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>
This commit is contained in:
parent
739037a60b
commit
d93c91c386
3 changed files with 283 additions and 40 deletions
90
app/(app)/insights/components/backlog-health.tsx
Normal file
90
app/(app)/insights/components/backlog-health.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
app/(app)/insights/components/velocity-chart.tsx
Normal file
92
app/(app)/insights/components/velocity-chart.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,32 +5,53 @@ import { prisma } from '@/lib/prisma'
|
||||||
import { productAccessFilter } from '@/lib/product-access'
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
import { getBurndownData } from '@/lib/insights/burndown'
|
import { getBurndownData } from '@/lib/insights/burndown'
|
||||||
import { getSprintStatusBreakdown } from '@/lib/insights/sprint-status'
|
import { getSprintStatusBreakdown } from '@/lib/insights/sprint-status'
|
||||||
|
import { getVerifyResultStats, getAlignmentTrend } from '@/lib/insights/verify-stats'
|
||||||
|
import { getJobsPerDay } from '@/lib/insights/agent-throughput'
|
||||||
|
import { getVelocity } from '@/lib/insights/velocity'
|
||||||
|
import { getBacklogHealth } from '@/lib/insights/backlog-health'
|
||||||
import { SprintInfoStrip } from './components/sprint-info-strip'
|
import { SprintInfoStrip } from './components/sprint-info-strip'
|
||||||
import { BurndownChart } from './components/burndown-chart'
|
import { BurndownChart } from './components/burndown-chart'
|
||||||
import { SprintStatusDonut } from './components/sprint-status-donut'
|
import { SprintStatusDonut } from './components/sprint-status-donut'
|
||||||
|
import { PlanQualityCard } from './components/plan-quality'
|
||||||
|
import { AlignmentTrend } from './components/alignment-trend'
|
||||||
|
import { AgentThroughputCard } from './components/agent-throughput'
|
||||||
|
import { VelocityChart } from './components/velocity-chart'
|
||||||
|
import { BacklogHealthCard } from './components/backlog-health'
|
||||||
|
|
||||||
const DAY_MS = 86_400_000
|
const DAY_MS = 86_400_000
|
||||||
const ASSUMED_SPRINT_DAYS = 14
|
const ASSUMED_SPRINT_DAYS = 14
|
||||||
|
|
||||||
|
interface InsightsPageProps {
|
||||||
|
searchParams: Promise<{ product?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) {
|
function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) {
|
||||||
return (
|
return (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{productName} — sprint heeft geen datums.{' '}
|
{productName} — sprint heeft geen datums.{' '}
|
||||||
<a
|
<a href={`/products/${productId}/sprint`} className="underline text-primary">
|
||||||
href={`/products/${productId}/sprint`}
|
|
||||||
className="underline text-primary"
|
|
||||||
>
|
|
||||||
Stel datums in
|
Stel datums in
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function InsightsPage() {
|
export default async function InsightsPage({ searchParams }: InsightsPageProps) {
|
||||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
const userId = session.userId!
|
const userId = session.userId!
|
||||||
|
const { product: filterProductId } = await searchParams
|
||||||
|
|
||||||
const [burndownSprints, statusBreakdown, activeSprints] = await Promise.all([
|
const [
|
||||||
|
burndownSprints,
|
||||||
|
statusBreakdown,
|
||||||
|
activeSprints,
|
||||||
|
productList,
|
||||||
|
verifyStats,
|
||||||
|
alignmentTrend,
|
||||||
|
jobsPerDay,
|
||||||
|
velocity,
|
||||||
|
backlogHealth,
|
||||||
|
] = await Promise.all([
|
||||||
getBurndownData(userId),
|
getBurndownData(userId),
|
||||||
getSprintStatusBreakdown(userId),
|
getSprintStatusBreakdown(userId),
|
||||||
prisma.sprint.findMany({
|
prisma.sprint.findMany({
|
||||||
|
|
@ -43,20 +64,21 @@ export default async function InsightsPage() {
|
||||||
tasks: { select: { id: true } },
|
tasks: { select: { id: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
prisma.product.findMany({
|
||||||
|
where: productAccessFilter(userId),
|
||||||
|
select: { id: true, name: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
}),
|
||||||
|
getVerifyResultStats(userId, 30),
|
||||||
|
getAlignmentTrend(userId, 5),
|
||||||
|
getJobsPerDay(userId, 14, filterProductId),
|
||||||
|
getVelocity(userId, 5),
|
||||||
|
getBacklogHealth(userId),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (activeSprints.length === 0) {
|
// Date.now is an impure call but used once per request — safe in a Server Component.
|
||||||
return (
|
// eslint-disable-next-line react-hooks/purity
|
||||||
<div className="p-6 max-w-4xl mx-auto w-full">
|
const nowMs = Date.now()
|
||||||
<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 => ({
|
const sprintInfos = activeSprints.map(s => ({
|
||||||
sprintId: s.id,
|
sprintId: s.id,
|
||||||
productId: s.product.id,
|
productId: s.product.id,
|
||||||
|
|
@ -65,33 +87,72 @@ export default async function InsightsPage() {
|
||||||
taskCount: s.tasks.length,
|
taskCount: s.tasks.length,
|
||||||
daysLeft: ASSUMED_SPRINT_DAYS - Math.floor((nowMs - s.created_at.getTime()) / DAY_MS),
|
daysLeft: ASSUMED_SPRINT_DAYS - Math.floor((nowMs - s.created_at.getTime()) / DAY_MS),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const burndownMap = new Map(burndownSprints.map(b => [b.sprintId, b]))
|
const burndownMap = new Map(burndownSprints.map(b => [b.sprintId, b]))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 max-w-4xl mx-auto w-full">
|
<div className="p-6 space-y-8 max-w-6xl mx-auto w-full">
|
||||||
<h1 className="text-xl font-medium text-foreground">Sprint Health</h1>
|
<h1 className="text-2xl font-semibold text-foreground">Insights</h1>
|
||||||
|
|
||||||
<SprintInfoStrip sprints={sprintInfos} />
|
{/* Sprint Health */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-medium text-foreground">Sprint Health</h2>
|
||||||
|
{activeSprints.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Geen active sprints — start er een via /products/[id]/sprint.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SprintInfoStrip sprints={sprintInfos} />
|
||||||
|
<div className="grid grid-cols-1 md: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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{/* Plan-quality */}
|
||||||
<div className="space-y-4">
|
<section className="space-y-3">
|
||||||
{sprintInfos.map(s => {
|
<h2 className="text-lg font-medium text-foreground">Plan-quality</h2>
|
||||||
const burndown = burndownMap.get(s.sprintId)
|
<PlanQualityCard stats={verifyStats} nowMs={nowMs} />
|
||||||
if (!burndown || burndown.days.length === 0) {
|
{alignmentTrend.length > 0 && <AlignmentTrend trend={alignmentTrend} />}
|
||||||
return (
|
</section>
|
||||||
<MissingDatesNotice
|
|
||||||
key={s.sprintId}
|
{/* Agent throughput */}
|
||||||
productId={s.productId}
|
<section className="space-y-3">
|
||||||
productName={s.productName}
|
<h2 className="text-lg font-medium text-foreground">Agent throughput</h2>
|
||||||
/>
|
<AgentThroughputCard
|
||||||
)
|
data={jobsPerDay}
|
||||||
}
|
productList={productList}
|
||||||
return <BurndownChart key={s.sprintId} sprint={burndown} />
|
currentProductId={filterProductId}
|
||||||
})}
|
/>
|
||||||
</div>
|
</section>
|
||||||
<SprintStatusDonut data={statusBreakdown} />
|
|
||||||
</div>
|
{/* Velocity */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-medium text-foreground">Velocity</h2>
|
||||||
|
<VelocityChart data={velocity} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Backlog health */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-medium text-foreground">Backlog health</h2>
|
||||||
|
<BacklogHealthCard data={backlogHealth} />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue