From 0454eede74bc58ac6418aa6601fa4df4439d6cfc Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 16:44:53 +0200 Subject: [PATCH] feat(insights): port unique files from closed bundle-PRs (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-introduce the 3 unique files from closed PRs #37 and #40 that overlap-merged with already-landed sub-PRs (#34, #35, #36, #38, #39): - app/(app)/insights/page.tsx — Server Component dat alle helpers parallel aanroept en de 5 sectie-Cards rendert (Sprint Health, Plan-quality, Agent throughput, Velocity, Backlog health) - app/(app)/insights/components/sprint-info-strip.tsx — chips per active sprint met productname + goal + dagen-over + taakcount - app/(app)/insights/components/alignment-trend.tsx — Recharts LineChart die % ALIGNED jobs per sprint over laatste 5 sprints toont - lib/insights/verify-stats.ts — TrendPoint type + getAlignmentTrend helper (uitgebreid van PR #38) Plus dependency: recharts (was in package.json van #37/#40 die we sloten). Tests: 290/290 groen, tsc clean, lint clean. Co-authored-by: Claude Opus 4.7 (1M context) --- .../insights/components/alignment-trend.tsx | 73 ++++++++++++++ .../insights/components/sprint-info-strip.tsx | 45 +++++++++ app/(app)/insights/page.tsx | 97 +++++++++++++++++++ lib/insights/verify-stats.ts | 54 +++++++++++ 4 files changed, 269 insertions(+) create mode 100644 app/(app)/insights/components/alignment-trend.tsx create mode 100644 app/(app)/insights/components/sprint-info-strip.tsx create mode 100644 app/(app)/insights/page.tsx diff --git a/app/(app)/insights/components/alignment-trend.tsx b/app/(app)/insights/components/alignment-trend.tsx new file mode 100644 index 0000000..45375d1 --- /dev/null +++ b/app/(app)/insights/components/alignment-trend.tsx @@ -0,0 +1,73 @@ +'use client' + +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from 'recharts' +import type { TrendPoint } from '@/lib/insights/verify-stats' + +interface Props { + trend: TrendPoint[] +} + +interface TooltipPayload { + payload?: { total: number; alignedRatio: number; sprintGoal: string } +} + +function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) { + if (!active || !payload?.length) return null + const d = payload[0].payload + if (!d) return null + const aligned = Math.round((d.alignedRatio / 100) * d.total) + return ( +
+

{d.sprintGoal}

+

+ {aligned} / {d.total} aligned ({d.alignedRatio}%) +

+
+ ) +} + +function sprintLabel(goal: string): string { + return goal.length > 20 ? goal.slice(0, 18) + '…' : goal +} + +export function AlignmentTrend({ trend }: Props) { + if (trend.length === 0) { + return ( +

+ Geen voltooide sprints met verify-data gevonden. +

+ ) + } + + const data = trend.map(p => ({ + ...p, + label: sprintLabel(p.sprintGoal), + })) + + return ( +
+

+ % Aligned per sprint (laatste {trend.length}) +

+ + + + `${v}%`} tick={{ fontSize: 11 }} /> + } /> + + + +
+ ) +} diff --git a/app/(app)/insights/components/sprint-info-strip.tsx b/app/(app)/insights/components/sprint-info-strip.tsx new file mode 100644 index 0000000..3d85a33 --- /dev/null +++ b/app/(app)/insights/components/sprint-info-strip.tsx @@ -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 ( +
+ {sprints.map(s => ( +
+ {s.productName} + {truncate(s.sprintGoal, 60)} + + {s.daysLeft > 0 ? `${s.daysLeft}d over` : `${Math.abs(s.daysLeft)}d over tijd`} + + {s.taskCount} tasks +
+ ))} +
+ ) +} diff --git a/app/(app)/insights/page.tsx b/app/(app)/insights/page.tsx new file mode 100644 index 0000000..64e16af --- /dev/null +++ b/app/(app)/insights/page.tsx @@ -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 ( +

+ {productName} — sprint heeft geen datums.{' '} + + Stel datums in + +

+ ) +} + +export default async function InsightsPage() { + const session = await getIronSession(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 ( +
+

Sprint Health

+

+ Geen active sprints — start er een via /products/[id]/sprint +

+
+ ) + } + + 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 ( +
+

Sprint Health

+ + + +
+
+ {sprintInfos.map(s => { + const burndown = burndownMap.get(s.sprintId) + if (!burndown || burndown.days.length === 0) { + return ( + + ) + } + return + })} +
+ +
+
+ ) +} diff --git a/lib/insights/verify-stats.ts b/lib/insights/verify-stats.ts index 5f3772e..c19140f 100644 --- a/lib/insights/verify-stats.ts +++ b/lib/insights/verify-stats.ts @@ -1,4 +1,5 @@ import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' export type VerifyResultKey = 'ALIGNED' | 'PARTIAL' | 'EMPTY' | 'DIVERGENT' @@ -17,6 +18,14 @@ export interface VerifyResultStats { topDivergent: TopJob[] } +export interface TrendPoint { + sprintId: string + sprintGoal: string + productName: string + alignedRatio: number + total: number +} + const RESULT_ORDER: VerifyResultKey[] = ['ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT'] export async function getVerifyResultStats( @@ -90,3 +99,48 @@ export async function getVerifyResultStats( topDivergent: rawDivergent.map(toTopJob), } } + +export async function getAlignmentTrend( + userId: string, + sprintsBack = 5, +): Promise { + const sprints = await prisma.sprint.findMany({ + where: { + status: 'COMPLETED', + product: productAccessFilter(userId), + }, + orderBy: { completed_at: 'desc' }, + take: sprintsBack, + select: { + id: true, + sprint_goal: true, + completed_at: true, + product: { select: { name: true } }, + }, + }) + + const points = await Promise.all( + sprints.map(async sprint => { + const jobs = await prisma.claudeJob.findMany({ + where: { + user_id: userId, + status: 'DONE', + verify_result: { not: null }, + task: { story: { sprint_id: sprint.id } }, + }, + select: { verify_result: true }, + }) + const aligned = jobs.filter(j => j.verify_result === 'ALIGNED').length + return { + sprintId: sprint.id, + sprintGoal: sprint.sprint_goal, + productName: sprint.product.name, + alignedRatio: jobs.length > 0 ? Math.round((aligned / jobs.length) * 100) : 0, + total: jobs.length, + } + }), + ) + + // chronologisch oplopend (we fetched desc, so reverse) + return points.reverse() +}