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