Scrum4Me/app/(app)/insights/components/velocity-chart.tsx
Janpeter Visser d93c91c386
fix(insights): integrate all 5 sections in /insights + add missing components (#50)
The /insights page was rendering only Sprint Health (2 charts).
PRs #47/#48/#49 delivered helpers + tests for Velocity, Backlog health
and Agent throughput, but Velocity and Backlog never produced their
UI components, and none of the four new sections were wired into
page.tsx. Result: user sees 2 charts where 5 sections were promised.

This PR fills the gaps:

- New `app/(app)/insights/components/velocity-chart.tsx` — Recharts
  grouped BarChart with optional ReferenceLine for the average. Empty
  state when <2 completed sprints.
- New `app/(app)/insights/components/backlog-health.tsx` — counters
  (stories sans AC / tasks sans plan / stuck>7d) + stuck-tasks table
  with severity-coded days-stuck cell.
- `app/(app)/insights/page.tsx` rewritten as 5 sections:
  Sprint Health, Plan-quality (donut + alignment-trend), Agent
  throughput, Velocity, Backlog health. Helpers run in one
  Promise.all so the page renders in a single tick.

Tests: 314/314 green, tsc clean, lint 0 errors.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:23:03 +02:00

92 lines
2.8 KiB
TypeScript

'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
ReferenceLine,
ResponsiveContainer,
} from 'recharts'
import type { VelocityData } from '@/lib/insights/velocity'
import { SERIES_COLORS } from '@/lib/chart-colors'
interface Props {
data: VelocityData
}
export function VelocityChart({ data }: Props) {
const { sprints, productNames } = data
if (sprints.length < 2) {
return (
<div className="rounded-lg border border-border p-4 space-y-2">
<h2 className="text-sm font-medium">Velocity</h2>
<p className="text-sm text-muted-foreground">
Velocity wordt zichtbaar na 2+ voltooide sprints (nu: {sprints.length}).
</p>
</div>
)
}
// Reshape: [{ sprintLabel, [productName1]: count, [productName2]: count, ... }]
type Row = { sprintLabel: string } & Record<string, number | string>
const grouped = new Map<string, Row>()
for (const s of sprints) {
const label =
s.sprintGoal.length > 14 ? s.sprintGoal.slice(0, 14) + '…' : s.sprintGoal
const key = `${s.sprintId}`
if (!grouped.has(key)) {
grouped.set(key, { sprintLabel: label })
}
grouped.get(key)![s.productName] = s.doneCount
}
const rows = Array.from(grouped.values())
// Average across all bars (used for ReferenceLine)
const allCounts = sprints.map(s => s.doneCount)
const avg = allCounts.length > 0 ? allCounts.reduce((a, b) => a + b, 0) / allCounts.length : 0
return (
<div className="rounded-lg border border-border p-4 space-y-2">
<h2 className="text-sm font-medium">Velocity (laatste {sprints.length} sprints)</h2>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={rows}>
<XAxis
dataKey="sprintLabel"
tick={{ fontSize: 11 }}
stroke="var(--muted-foreground)"
/>
<YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<Tooltip
contentStyle={{
background: 'var(--popover)',
border: '1px solid var(--border)',
borderRadius: 6,
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
{productNames.map((p, i) => (
<Bar
key={p.id}
dataKey={p.name}
fill={SERIES_COLORS[i % SERIES_COLORS.length]}
radius={[2, 2, 0, 0]}
/>
))}
{avg > 0 && (
<ReferenceLine
y={avg}
stroke="var(--muted-foreground)"
strokeDasharray="3 3"
label={{ value: `avg ${avg.toFixed(1)}`, fontSize: 10, fill: 'var(--muted-foreground)' }}
/>
)}
</BarChart>
</ResponsiveContainer>
</div>
)
}