feat: /insights page — SprintInfoStrip, BurndownChart, SprintStatusDonut integratie

Sprint Health pagina met info-strip per sprint (daysLeft gekleurd), gestackte burndown charts
en status-donut. Empty-state bij geen active sprints, MissingDatesNotice deeplink.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-01 15:56:55 +02:00
parent fe89368848
commit c0646ec039
4 changed files with 239 additions and 0 deletions

View file

@ -0,0 +1,49 @@
'use client'
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import type { BurndownSprint } from '@/lib/insights/burndown'
interface Props {
sprint: BurndownSprint
}
export function BurndownChart({ sprint }: Props) {
if (sprint.days.length === 0) {
return <p className="text-muted-foreground text-sm">Geen sprint-data</p>
}
return (
<div className="space-y-2">
<h3 className="text-sm font-medium text-foreground">
{sprint.productName} {sprint.sprintGoal}
</h3>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={sprint.days}>
<XAxis dataKey="day" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Legend />
<Line
dataKey="ideal"
stroke="var(--muted-foreground)"
strokeDasharray="4 4"
dot={false}
/>
<Line
dataKey="remaining"
stroke="var(--status-in-progress)"
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)
}

View file

@ -0,0 +1,45 @@
'use client'
interface SprintInfo {
sprintId: string
productName: string
sprintGoal: string
taskCount: number
daysLeft: number
}
interface Props {
sprints: SprintInfo[]
}
function daysLeftColor(daysLeft: number): string {
if (daysLeft >= 3) return 'text-[color:var(--status-done)]'
if (daysLeft >= 1) return 'text-[color:var(--priority-medium)]'
return 'text-[color:var(--priority-critical)]'
}
function truncate(text: string, max: number): string {
return text.length > max ? text.slice(0, max) + '…' : text
}
export function SprintInfoStrip({ sprints }: Props) {
if (sprints.length === 0) return null
return (
<div className="flex flex-wrap gap-2">
{sprints.map(s => (
<div
key={s.sprintId}
className="flex items-center gap-3 rounded-lg border border-border bg-surface-container px-3 py-2 text-sm"
>
<span className="font-medium text-foreground">{s.productName}</span>
<span className="text-muted-foreground">{truncate(s.sprintGoal, 60)}</span>
<span className={`font-mono tabular-nums ${daysLeftColor(s.daysLeft)}`}>
{s.daysLeft > 0 ? `${s.daysLeft}d over` : `${Math.abs(s.daysLeft)}d over tijd`}
</span>
<span className="text-muted-foreground">{s.taskCount} tasks</span>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,48 @@
'use client'
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import type { StatusCount } from '@/lib/insights/sprint-status'
interface Props {
data: StatusCount[]
}
const STATUS_COLORS: Record<string, string> = {
TO_DO: 'var(--status-todo)',
IN_PROGRESS: 'var(--status-in-progress)',
DONE: 'var(--status-done)',
}
const STATUS_LABELS: Record<string, string> = {
TO_DO: 'To do',
IN_PROGRESS: 'In progress',
DONE: 'Done',
}
export function SprintStatusDonut({ data }: Props) {
if (data.length === 0) {
return <p className="text-muted-foreground text-sm">Geen actieve sprint-taken</p>
}
const labeled = data.map(d => ({ ...d, name: STATUS_LABELS[d.status] ?? d.status }))
return (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={labeled}
dataKey="count"
nameKey="name"
innerRadius={50}
outerRadius={80}
>
{labeled.map(entry => (
<Cell key={entry.status} fill={STATUS_COLORS[entry.status]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
)
}

View file

@ -0,0 +1,97 @@
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { getBurndownData } from '@/lib/insights/burndown'
import { getSprintStatusBreakdown } from '@/lib/insights/sprint-status'
import { SprintInfoStrip } from './components/sprint-info-strip'
import { BurndownChart } from './components/burndown-chart'
import { SprintStatusDonut } from './components/sprint-status-donut'
const DAY_MS = 86_400_000
const ASSUMED_SPRINT_DAYS = 14
function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) {
return (
<p className="text-muted-foreground text-sm">
{productName} sprint heeft geen datums.{' '}
<a
href={`/products/${productId}/sprint`}
className="underline text-primary"
>
Stel datums in
</a>
</p>
)
}
export default async function InsightsPage() {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
const userId = session.userId!
const [burndownSprints, statusBreakdown, activeSprints] = await Promise.all([
getBurndownData(userId),
getSprintStatusBreakdown(userId),
prisma.sprint.findMany({
where: { status: 'ACTIVE', product: productAccessFilter(userId) },
select: {
id: true,
sprint_goal: true,
created_at: true,
product: { select: { id: true, name: true } },
tasks: { select: { id: true } },
},
}),
])
if (activeSprints.length === 0) {
return (
<div className="p-6 max-w-4xl mx-auto w-full">
<h1 className="text-xl font-medium text-foreground mb-6">Sprint Health</h1>
<p className="text-muted-foreground">
Geen active sprints start er een via /products/[id]/sprint
</p>
</div>
)
}
const nowMs = new Date().getTime()
const sprintInfos = activeSprints.map(s => ({
sprintId: s.id,
productId: s.product.id,
productName: s.product.name,
sprintGoal: s.sprint_goal,
taskCount: s.tasks.length,
daysLeft: ASSUMED_SPRINT_DAYS - Math.floor((nowMs - s.created_at.getTime()) / DAY_MS),
}))
const burndownMap = new Map(burndownSprints.map(b => [b.sprintId, b]))
return (
<div className="p-6 space-y-6 max-w-4xl mx-auto w-full">
<h1 className="text-xl font-medium text-foreground">Sprint Health</h1>
<SprintInfoStrip sprints={sprintInfos} />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-4">
{sprintInfos.map(s => {
const burndown = burndownMap.get(s.sprintId)
if (!burndown || burndown.days.length === 0) {
return (
<MissingDatesNotice
key={s.sprintId}
productId={s.productId}
productName={s.productName}
/>
)
}
return <BurndownChart key={s.sprintId} sprint={burndown} />
})}
</div>
<SprintStatusDonut data={statusBreakdown} />
</div>
</div>
)
}