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