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>
90 lines
2.9 KiB
TypeScript
90 lines
2.9 KiB
TypeScript
'use client'
|
|
|
|
import Link from 'next/link'
|
|
import { CheckCircle2, AlertTriangle } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import type { BacklogHealth, StuckTask } from '@/lib/insights/backlog-health'
|
|
|
|
interface Props {
|
|
data: BacklogHealth
|
|
}
|
|
|
|
function Counter({ count, label }: { count: number; label: string }) {
|
|
const ok = count === 0
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
|
|
{ok ? (
|
|
<CheckCircle2 className="size-4 text-status-done shrink-0" />
|
|
) : (
|
|
<AlertTriangle className="size-4 text-priority-medium shrink-0" />
|
|
)}
|
|
<span className={cn('font-semibold', ok && 'text-status-done')}>{count}</span>
|
|
<span className="text-muted-foreground">{label}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StuckRow({ task }: { task: StuckTask }) {
|
|
const severity =
|
|
task.daysStuck >= 14
|
|
? 'bg-priority-critical/10 text-priority-critical'
|
|
: task.daysStuck >= 7
|
|
? 'bg-priority-high/10 text-priority-high'
|
|
: ''
|
|
return (
|
|
<tr className="border-b border-border last:border-0">
|
|
<td className="py-1 pr-2">
|
|
<Link
|
|
href={`/products/${task.productId}/solo?task=${task.taskId}`}
|
|
className="text-primary underline line-clamp-1"
|
|
>
|
|
{task.title}
|
|
</Link>
|
|
</td>
|
|
<td className="py-1 pr-2 text-muted-foreground whitespace-nowrap">
|
|
{task.productName}
|
|
</td>
|
|
<td className={cn('py-1 px-2 text-right whitespace-nowrap rounded font-medium', severity)}>
|
|
{task.daysStuck}d
|
|
</td>
|
|
<td className="py-1 pl-2 text-muted-foreground whitespace-nowrap">
|
|
{task.sprintGoal ?? '—'}
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
export function BacklogHealthCard({ data }: Props) {
|
|
const { storiesWithoutAc, tasksWithoutPlan, stuckTasks } = data
|
|
const allClear =
|
|
storiesWithoutAc === 0 && tasksWithoutPlan === 0 && stuckTasks.length === 0
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border p-4 space-y-3">
|
|
<h2 className="text-sm font-medium">Backlog health</h2>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Counter count={storiesWithoutAc} label="stories zonder AC" />
|
|
<Counter count={tasksWithoutPlan} label="tasks zonder plan" />
|
|
<Counter count={stuckTasks.length} label="stuck > 7d" />
|
|
</div>
|
|
|
|
{allClear ? (
|
|
<p className="text-sm text-status-done">Geen stuck tasks 🎉</p>
|
|
) : stuckTasks.length > 0 ? (
|
|
<div>
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">
|
|
Stuck tasks (top {stuckTasks.length})
|
|
</p>
|
|
<table className="w-full text-sm">
|
|
<tbody>
|
|
{stuckTasks.map(t => (
|
|
<StuckRow key={t.taskId} task={t} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|