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>
92 lines
2.8 KiB
TypeScript
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>
|
|
)
|
|
}
|