From c0646ec0399a9912ba4fdd47384dc7ae12ca6a78 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 1 May 2026 15:56:55 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20/insights=20page=20=E2=80=94=20SprintIn?= =?UTF-8?q?foStrip,=20BurndownChart,=20SprintStatusDonut=20integratie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint Health pagina met info-strip per sprint (daysLeft gekleurd), gestackte burndown charts en status-donut. Empty-state bij geen active sprints, MissingDatesNotice deeplink. Co-Authored-By: Claude Sonnet 4.6 --- .../insights/components/burndown-chart.tsx | 49 ++++++++++ .../insights/components/sprint-info-strip.tsx | 45 +++++++++ .../components/sprint-status-donut.tsx | 48 +++++++++ app/(app)/insights/page.tsx | 97 +++++++++++++++++++ 4 files changed, 239 insertions(+) create mode 100644 app/(app)/insights/components/burndown-chart.tsx create mode 100644 app/(app)/insights/components/sprint-info-strip.tsx create mode 100644 app/(app)/insights/components/sprint-status-donut.tsx create mode 100644 app/(app)/insights/page.tsx diff --git a/app/(app)/insights/components/burndown-chart.tsx b/app/(app)/insights/components/burndown-chart.tsx new file mode 100644 index 0000000..14d355e --- /dev/null +++ b/app/(app)/insights/components/burndown-chart.tsx @@ -0,0 +1,49 @@ +'use client' + +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts' +import type { BurndownSprint } from '@/lib/insights/burndown' + +interface Props { + sprint: BurndownSprint +} + +export function BurndownChart({ sprint }: Props) { + if (sprint.days.length === 0) { + return

Geen sprint-data

+ } + + return ( +
+

+ {sprint.productName} — {sprint.sprintGoal} +

+ + + + + + + + + + +
+ ) +} 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/components/sprint-status-donut.tsx b/app/(app)/insights/components/sprint-status-donut.tsx new file mode 100644 index 0000000..ac4e433 --- /dev/null +++ b/app/(app)/insights/components/sprint-status-donut.tsx @@ -0,0 +1,48 @@ +'use client' + +import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts' +import type { StatusCount } from '@/lib/insights/sprint-status' + +interface Props { + data: StatusCount[] +} + +const STATUS_COLORS: Record = { + TO_DO: 'var(--status-todo)', + IN_PROGRESS: 'var(--status-in-progress)', + DONE: 'var(--status-done)', +} + +const STATUS_LABELS: Record = { + TO_DO: 'To do', + IN_PROGRESS: 'In progress', + DONE: 'Done', +} + +export function SprintStatusDonut({ data }: Props) { + if (data.length === 0) { + return

Geen actieve sprint-taken

+ } + + const labeled = data.map(d => ({ ...d, name: STATUS_LABELS[d.status] ?? d.status })) + + return ( + + + + {labeled.map(entry => ( + + ))} + + + + + + ) +} 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 + })} +
+ +
+
+ ) +}