From 8a8c94ee9df78985d85aa46c3a5e9964ce32221e Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 14:36:02 +0200 Subject: [PATCH 001/258] docs: update CLAUDE.md worker-presence batch-loop notitie (#27) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 45952b8..04aba51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -302,7 +302,7 @@ Wanneer je als agent draait (na een instructie als *"pak de volgende job uit de Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaan — die afsluiting hoort bij de individuele job, niet bij het einde van de batch. **Code koppelen aan app** -- 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — geeft in claude_workers een record en start de batch-loop hierboven. +- 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — scrum4me-mcp registreert bij startup een ClaudeWorker-record + heartbeat (5s); SIGTERM/SIGINT ruimt 'm op. UI telt actieve workers via `last_seen_at < now() - 15s`. ### Prompt From 765177a81c1e204579ce5cef640f9530e8456358 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 15:14:17 +0200 Subject: [PATCH 002/258] Worker presence docs + lokale config opschoning (#28) * docs: update CLAUDE.md worker-presence batch-loop notitie * chore: voeg .mcp.json, .codex/ en Brainstro toe aan .gitignore Voorkomt dat lokale MCP-credentials en scratch-bestanden per ongeluk gecommit worden. Co-Authored-By: Claude Sonnet 4.6 * chore: update allowed-tools in settings.local.json Voegt MCP- en bash-permissies toe die tijdens M13-implementatie gebruikt zijn. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .claude/settings.local.json | 24 ++++++++++++++++++++++-- .gitignore | 11 ++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dd2c1d5..41bd3a1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -55,7 +55,27 @@ "Bash(npx ctx7@latest docs /websites/github_en_rest \"How to fetch Copilot bot pull request reviews and identify them by author\")", "Bash(npm i *)", "Bash(curl *)", - "Bash(grep -E \"\\\\.\\(tsx|ts\\)$\")" + "Bash(grep -E \"\\\\.\\(tsx|ts\\)$\")", + "mcp__scrum4me__update_job_status", + "Bash(node --env-file=.env.local node_modules/tsx/dist/cli.mjs ./scripts/check-jobs-tmp.ts)", + "Bash(node --env-file=.env.local node_modules/tsx/dist/cli.mjs ./scripts/check-workers-tmp.ts)", + "Bash(node --env-file=.env.local node_modules/prisma/build/index.js migrate deploy)", + "Bash(xargs grep *)", + "Bash(node --env-file=.env.local node_modules/prisma/build/index.js migrate status)", + "Bash(gh run *)", + "Bash(dir \"C:\\\\Users\\\\Madhu\\\\Projects\")", + "Bash(Get-ChildItem -Path \"C:\\\\Users\\\\Madhu\\\\Projects\\\\scrum4me-mcp\" -Recurse -Include \"*wait*\" -ErrorAction SilentlyContinue)", + "Bash(Select-Object FullName)", + "Bash(Get-ChildItem -Path \"C:\\\\Users\\\\Madhu\\\\Projects\\\\scrum4me-mcp\" -Force)", + "Bash(Format-Table -Property Name, PSIsContainer)", + "Bash(Sort-Object)", + "PowerShell(Push-Location \"C:\\\\Users\\\\Madhu\\\\Projects\\\\scrum4me-mcp\"; npx tsc --noEmit; $result = $?; Pop-Location; Write-Output \"typecheck ok: $result\")", + "PowerShell(git *)", + "mcp__scrum4me__verify_task_against_plan" ] - } + }, + "enableAllProjectMcpServers": true, + "enabledMcpjsonServers": [ + "scrum4me" + ] } diff --git a/.gitignore b/.gitignore index 4ff2246..e3c508b 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,13 @@ screenshots/ !public/screenshots/ # Testomgeving -jp.sh \ No newline at end of file +jp.sh + +# MCP config (bevat credentials) +.mcp.json + +# Codex local config +.codex/ + +# Lokale scratch-bestanden +Brainstro \ No newline at end of file From 79c9661ebd845d06fe7a42e637fbf2d790a97733 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 16:41:09 +0200 Subject: [PATCH 003/258] docs: update CLAUDE.md worker-presence beschrijving conform implementatieplan (#33) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 04aba51..65be9e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -302,7 +302,7 @@ Wanneer je als agent draait (na een instructie als *"pak de volgende job uit de Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaan — die afsluiting hoort bij de individuele job, niet bij het einde van de batch. **Code koppelen aan app** -- 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — scrum4me-mcp registreert bij startup een ClaudeWorker-record + heartbeat (5s); SIGTERM/SIGINT ruimt 'm op. UI telt actieve workers via `last_seen_at < now() - 15s`. +- 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — Server-startup registreert een ClaudeWorker-record + heartbeat (5s); SIGTERM/SIGINT ruimt 'm op. UI in NavBar telt actieve workers via `last_seen_at < now() - 15s`. ### Prompt From fb3e55b9c032b9c6128cb2ae4de4aee60927da1a Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 16:41:12 +0200 Subject: [PATCH 004/258] feat: getBurndownData helper + computeBurndownDays (lib/insights/burndown.ts) (#34) Server-side aggregatie per active sprint: bouwt time-series met remaining en ideal per dag. Inclusief 4 Vitest-unit-tests voor de pure computeBurndownDays functie. Co-authored-by: Claude Sonnet 4.6 --- __tests__/lib/insights/burndown.test.ts | 57 ++++++++++++++++ lib/insights/burndown.ts | 87 +++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 __tests__/lib/insights/burndown.test.ts create mode 100644 lib/insights/burndown.ts diff --git a/__tests__/lib/insights/burndown.test.ts b/__tests__/lib/insights/burndown.test.ts new file mode 100644 index 0000000..a85b9e9 --- /dev/null +++ b/__tests__/lib/insights/burndown.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ prisma: {} })) + +import { computeBurndownDays } from '@/lib/insights/burndown' + +describe('computeBurndownDays', () => { + it('5-day sprint: remaining and ideal match spec', () => { + const start = new Date('2024-01-01T00:00:00.000Z') + const end = new Date('2024-01-05T00:00:00.000Z') + + const tasks = [ + { status: 'DONE', updated_at: new Date('2024-01-02T12:00:00.000Z') }, + { status: 'DONE', updated_at: new Date('2024-01-04T12:00:00.000Z') }, + { status: 'IN_PROGRESS', updated_at: new Date('2024-01-05T12:00:00.000Z') }, + ] + + const days = computeBurndownDays(tasks, start, end) + + expect(days).toHaveLength(5) + expect(days.map(d => d.remaining)).toEqual([3, 2, 2, 1, 1]) + expect(days.map(d => d.ideal)).toEqual([3, 2.25, 1.5, 0.75, 0]) + expect(days.map(d => d.day)).toEqual([ + '2024-01-01', + '2024-01-02', + '2024-01-03', + '2024-01-04', + '2024-01-05', + ]) + }) + + it('returns empty array when end is before start', () => { + const start = new Date('2024-01-05T00:00:00.000Z') + const end = new Date('2024-01-01T00:00:00.000Z') + expect(computeBurndownDays([], start, end)).toEqual([]) + }) + + it('single-day sprint has ideal = 0', () => { + const day = new Date('2024-01-01T00:00:00.000Z') + const tasks = [{ status: 'TO_DO', updated_at: new Date('2024-01-01T08:00:00.000Z') }] + const days = computeBurndownDays(tasks, day, day) + expect(days).toHaveLength(1) + expect(days[0].ideal).toBe(0) + expect(days[0].remaining).toBe(1) + }) + + it('all tasks done on first day: remaining drops to 0', () => { + const start = new Date('2024-01-01T00:00:00.000Z') + const end = new Date('2024-01-03T00:00:00.000Z') + const tasks = [ + { status: 'DONE', updated_at: new Date('2024-01-01T10:00:00.000Z') }, + { status: 'DONE', updated_at: new Date('2024-01-01T11:00:00.000Z') }, + ] + const days = computeBurndownDays(tasks, start, end) + expect(days.map(d => d.remaining)).toEqual([0, 0, 0]) + }) +}) diff --git a/lib/insights/burndown.ts b/lib/insights/burndown.ts new file mode 100644 index 0000000..551d216 --- /dev/null +++ b/lib/insights/burndown.ts @@ -0,0 +1,87 @@ +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' + +export interface BurndownDay { + day: string + remaining: number + ideal: number +} + +export interface BurndownSprint { + sprintId: string + productId: string + productName: string + sprintGoal: string + days: BurndownDay[] +} + +const DAY_MS = 86_400_000 + +function toUTCMidnight(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())) +} + +export function computeBurndownDays( + tasks: { status: string; updated_at: Date }[], + startDate: Date, + endDate: Date, +): BurndownDay[] { + const start = toUTCMidnight(startDate) + const end = toUTCMidnight(endDate) + const total = tasks.length + // n = number of intervals (end - start in days) + const n = Math.round((end.getTime() - start.getTime()) / DAY_MS) + + const days: BurndownDay[] = [] + + for (let i = 0; ; i++) { + const dayStart = new Date(start.getTime() + i * DAY_MS) + if (dayStart > end) break + + const nextDay = new Date(dayStart.getTime() + DAY_MS) + const done = tasks.filter(t => t.status === 'DONE' && t.updated_at < nextDay).length + const ideal = n === 0 ? 0 : Math.round((total * (n - i) / n) * 100) / 100 + + days.push({ + day: dayStart.toISOString().slice(0, 10), + remaining: total - done, + ideal, + }) + } + + return days +} + +export async function getBurndownData(userId: string): Promise { + const now = new Date() + + const sprints = await prisma.sprint.findMany({ + where: { + status: 'ACTIVE', + product: productAccessFilter(userId), + }, + select: { + id: true, + sprint_goal: true, + created_at: true, + completed_at: true, + product: { select: { id: true, name: true } }, + tasks: { select: { status: true, updated_at: true } }, + }, + }) + + return sprints + .map(sprint => { + const endDate = sprint.completed_at ?? now + if (endDate <= sprint.created_at) return null + + return { + sprintId: sprint.id, + productId: sprint.product.id, + productName: sprint.product.name, + sprintGoal: sprint.sprint_goal, + days: computeBurndownDays(sprint.tasks, sprint.created_at, endDate), + } + }) + .filter((s): s is BurndownSprint => s !== null) +} From 1df1ea48ad7ad8521f36f540018338a9b63890f0 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 16:41:15 +0200 Subject: [PATCH 005/258] Component: BurndownChart (Recharts LineChart) (#35) * feat: getBurndownData helper + computeBurndownDays (lib/insights/burndown.ts) Server-side aggregatie per active sprint: bouwt time-series met remaining en ideal per dag. Inclusief 4 Vitest-unit-tests voor de pure computeBurndownDays functie. Co-Authored-By: Claude Sonnet 4.6 * chore: voeg recharts toe aan dependencies Vereist door BurndownChart component in lib/insights. Co-Authored-By: Claude Sonnet 4.6 * feat: BurndownChart component (Recharts LineChart, ideal vs remaining) LineChart met two series: ideal (grijs, dashed) en remaining (status-in-progress blauw). ResponsiveContainer 100% x 240px, empty-state bij lege days-array. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../insights/components/burndown-chart.tsx | 49 +++++ package-lock.json | 173 +++++++++++++++--- package.json | 1 + 3 files changed, 199 insertions(+), 24 deletions(-) create mode 100644 app/(app)/insights/components/burndown-chart.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/package-lock.json b/package-lock.json index 50921e6..af110cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "react-hook-form": "^7.74.0", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", + "recharts": "^3.8.1", "remark-gfm": "^4.0.1", "shadcn": "^4.4.0", "sharp": "^0.34.5", @@ -3537,6 +3538,42 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", @@ -4506,7 +4543,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-axis": { @@ -4540,7 +4576,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-contour": { @@ -4589,7 +4624,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-fetch": { @@ -4637,7 +4671,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-color": "*" @@ -4647,7 +4680,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-polygon": { @@ -4675,7 +4707,6 @@ "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-time": "*" @@ -4699,7 +4730,6 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -4709,7 +4739,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-time-format": { @@ -4723,7 +4752,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-transition": { @@ -4891,6 +4919,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -7865,7 +7899,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "dev": true, "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -7918,7 +7951,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -8027,7 +8059,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=12" @@ -8065,7 +8096,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -8098,7 +8128,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3" @@ -8111,7 +8140,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -8196,7 +8224,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", @@ -8237,7 +8264,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "dev": true, "license": "ISC", "dependencies": { "d3-path": "^3.1.0" @@ -8250,7 +8276,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "2 - 3" @@ -8263,7 +8288,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "dev": true, "license": "ISC", "dependencies": { "d3-time": "1 - 3" @@ -8276,7 +8300,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -8455,6 +8478,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -9089,6 +9118,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -9644,7 +9683,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, "license": "MIT" }, "node_modules/events": { @@ -10866,7 +10904,6 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "devOptional": true, "license": "MIT", "funding": { "type": "opencollective", @@ -10951,7 +10988,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -15708,7 +15744,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-markdown": { @@ -15738,6 +15773,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-stately": { "version": "3.46.0", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz", @@ -15839,6 +15897,36 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -15853,6 +15941,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -18337,6 +18440,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", diff --git a/package.json b/package.json index 2d3b7df..b1cfef4 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "react-hook-form": "^7.74.0", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", + "recharts": "^3.8.1", "remark-gfm": "^4.0.1", "shadcn": "^4.4.0", "sharp": "^0.34.5", From 253936178433f2242df909369298025d976c2e9e Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 16:41:18 +0200 Subject: [PATCH 006/258] Component: SprintStatusDonut (Recharts PieChart) (#36) * chore: voeg recharts toe aan dependencies Vereist door SprintStatusDonut component. Co-Authored-By: Claude Sonnet 4.6 * feat: SprintStatusDonut + getSprintStatusBreakdown helper PieChart (donut) met TO_DO/IN_PROGRESS/DONE verdeling over alle active sprints. REVIEW wordt samengevoegd in IN_PROGRESS. MD3 status-kleuren via CSS-variabelen. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../components/sprint-status-donut.tsx | 48 +++++++++++++++++++ lib/insights/sprint-status.ts | 39 +++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 app/(app)/insights/components/sprint-status-donut.tsx create mode 100644 lib/insights/sprint-status.ts 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/lib/insights/sprint-status.ts b/lib/insights/sprint-status.ts new file mode 100644 index 0000000..51b619a --- /dev/null +++ b/lib/insights/sprint-status.ts @@ -0,0 +1,39 @@ +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' + +export type SprintStatusGroup = 'TO_DO' | 'IN_PROGRESS' | 'DONE' + +export interface StatusCount { + status: SprintStatusGroup + count: number +} + +// Maps REVIEW → IN_PROGRESS so donut shows 3 buckets only +function toGroup(status: string): SprintStatusGroup { + if (status === 'DONE') return 'DONE' + if (status === 'TO_DO') return 'TO_DO' + return 'IN_PROGRESS' +} + +export async function getSprintStatusBreakdown(userId: string): Promise { + const tasks = await prisma.task.findMany({ + where: { + story: { + sprint: { + status: 'ACTIVE', + product: productAccessFilter(userId), + }, + }, + }, + select: { status: true }, + }) + + const counts: Record = { TO_DO: 0, IN_PROGRESS: 0, DONE: 0 } + for (const t of tasks) { + counts[toGroup(t.status)]++ + } + + return (Object.entries(counts) as [SprintStatusGroup, number][]) + .filter(([, count]) => count > 0) + .map(([status, count]) => ({ status, count })) +} From ddd9b8b39ba6096f8ce4f425625af884afa18d1b Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 16:41:22 +0200 Subject: [PATCH 007/258] feat: getVerifyResultStats helper + 5 Vitest-tests (lib/insights/verify-stats.ts) (#38) Aggregeert verify_result counts (ALIGNED/PARTIAL/EMPTY/DIVERGENT) en top-5 EMPTY/DIVERGENT jobs over de laatste N dagen voor de ingelogde gebruiker. Co-authored-by: Claude Sonnet 4.6 --- __tests__/lib/insights/verify-stats.test.ts | 112 ++++++++++++++++++++ lib/insights/verify-stats.ts | 92 ++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 __tests__/lib/insights/verify-stats.test.ts create mode 100644 lib/insights/verify-stats.ts diff --git a/__tests__/lib/insights/verify-stats.test.ts b/__tests__/lib/insights/verify-stats.test.ts new file mode 100644 index 0000000..bc96de1 --- /dev/null +++ b/__tests__/lib/insights/verify-stats.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGroupBy, mockFindMany } = vi.hoisted(() => ({ + mockGroupBy: vi.fn(), + mockFindMany: vi.fn(), +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + claudeJob: { + groupBy: mockGroupBy, + findMany: mockFindMany, + }, + }, +})) + +import { getVerifyResultStats } from '@/lib/insights/verify-stats' + +const USER_ID = 'user-1' + +const makeJob = (id: string, verifyResult: string, daysAgo: number) => { + const finishedAt = new Date() + finishedAt.setDate(finishedAt.getDate() - daysAgo) + return { + id, + finished_at: finishedAt, + task: { id: `task-${id}`, title: `Task ${id}` }, + product: { id: 'prod-1', name: 'Scrum4Me' }, + verify_result: verifyResult, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('getVerifyResultStats', () => { + it('returns counts in ALIGNED→PARTIAL→EMPTY→DIVERGENT order', async () => { + mockGroupBy.mockResolvedValue([ + { verify_result: 'DIVERGENT', _count: { _all: 2 } }, + { verify_result: 'ALIGNED', _count: { _all: 10 } }, + { verify_result: 'EMPTY', _count: { _all: 3 } }, + { verify_result: 'PARTIAL', _count: { _all: 1 } }, + ]) + mockFindMany.mockResolvedValue([]) + + const stats = await getVerifyResultStats(USER_ID) + + expect(stats.counts.map(c => c.result)).toEqual([ + 'ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT', + ]) + expect(stats.counts.map(c => c.count)).toEqual([10, 1, 3, 2]) + }) + + it('omits results with zero count from groupBy', async () => { + mockGroupBy.mockResolvedValue([ + { verify_result: 'ALIGNED', _count: { _all: 5 } }, + ]) + mockFindMany.mockResolvedValue([]) + + const stats = await getVerifyResultStats(USER_ID) + + expect(stats.counts).toHaveLength(1) + expect(stats.counts[0]).toEqual({ result: 'ALIGNED', count: 5 }) + }) + + it('maps topEmpty jobs correctly', async () => { + mockGroupBy.mockResolvedValue([]) + const job = makeJob('j1', 'EMPTY', 2) + // First findMany call → topEmpty, second → topDivergent + mockFindMany + .mockResolvedValueOnce([job]) + .mockResolvedValueOnce([]) + + const stats = await getVerifyResultStats(USER_ID) + + expect(stats.topEmpty).toHaveLength(1) + expect(stats.topEmpty[0]).toMatchObject({ + jobId: 'j1', + taskId: 'task-j1', + taskTitle: 'Task j1', + productId: 'prod-1', + productName: 'Scrum4Me', + }) + }) + + it('topDivergent is ordered most-recent first (from DB order)', async () => { + mockGroupBy.mockResolvedValue([]) + const jobs = [ + makeJob('jOld', 'DIVERGENT', 10), + makeJob('jNew', 'DIVERGENT', 1), + ] + mockFindMany + .mockResolvedValueOnce([]) // topEmpty + .mockResolvedValueOnce(jobs) // topDivergent (already sorted by Prisma orderBy) + + const stats = await getVerifyResultStats(USER_ID) + + expect(stats.topDivergent.map(j => j.jobId)).toEqual(['jOld', 'jNew']) + }) + + it('returns empty stats when no jobs found', async () => { + mockGroupBy.mockResolvedValue([]) + mockFindMany.mockResolvedValue([]) + + const stats = await getVerifyResultStats(USER_ID) + + expect(stats.counts).toEqual([]) + expect(stats.topEmpty).toEqual([]) + expect(stats.topDivergent).toEqual([]) + }) +}) diff --git a/lib/insights/verify-stats.ts b/lib/insights/verify-stats.ts new file mode 100644 index 0000000..5f3772e --- /dev/null +++ b/lib/insights/verify-stats.ts @@ -0,0 +1,92 @@ +import { prisma } from '@/lib/prisma' + +export type VerifyResultKey = 'ALIGNED' | 'PARTIAL' | 'EMPTY' | 'DIVERGENT' + +export interface TopJob { + jobId: string + taskId: string + taskTitle: string + productId: string + productName: string + finishedAt: Date +} + +export interface VerifyResultStats { + counts: { result: VerifyResultKey; count: number }[] + topEmpty: TopJob[] + topDivergent: TopJob[] +} + +const RESULT_ORDER: VerifyResultKey[] = ['ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT'] + +export async function getVerifyResultStats( + userId: string, + daysBack = 30, +): Promise { + const cutoff = new Date() + cutoff.setDate(cutoff.getDate() - daysBack) + + const baseWhere = { + user_id: userId, + status: 'DONE' as const, + verify_result: { not: null as null }, + finished_at: { gt: cutoff }, + } + + const [grouped, rawEmpty, rawDivergent] = await Promise.all([ + prisma.claudeJob.groupBy({ + by: ['verify_result'], + where: baseWhere, + _count: { _all: true }, + }), + prisma.claudeJob.findMany({ + where: { ...baseWhere, verify_result: 'EMPTY' }, + orderBy: { finished_at: 'desc' }, + take: 5, + select: { + id: true, + finished_at: true, + task: { select: { id: true, title: true } }, + product: { select: { id: true, name: true } }, + }, + }), + prisma.claudeJob.findMany({ + where: { ...baseWhere, verify_result: 'DIVERGENT' }, + orderBy: { finished_at: 'desc' }, + take: 5, + select: { + id: true, + finished_at: true, + task: { select: { id: true, title: true } }, + product: { select: { id: true, name: true } }, + }, + }), + ]) + + const countMap = new Map( + grouped + .filter(g => g.verify_result !== null) + .map(g => [g.verify_result as VerifyResultKey, g._count._all]), + ) + + const counts = RESULT_ORDER + .filter(r => countMap.has(r)) + .map(r => ({ result: r, count: countMap.get(r)! })) + + function toTopJob(j: { id: string; finished_at: Date | null; task: { id: string; title: string }; product: { id: string; name: string } }): TopJob { + return { + jobId: j.id, + taskId: j.task.id, + taskTitle: j.task.title, + productId: j.product.id, + productName: j.product.name, + finishedAt: j.finished_at!, + } + } + + return { + counts, + topEmpty: rawEmpty.map(toTopJob), + topDivergent: rawDivergent.map(toTopJob), + } +} From 8c0941804c03ec49c360b4c8950466524c715665 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 16:41:25 +0200 Subject: [PATCH 008/258] Component: VerifyResultDonut + Top-5 tabellen (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: voeg recharts toe aan dependencies Vereist door PlanQualityCard component. Co-Authored-By: Claude Sonnet 4.6 * feat: getVerifyResultStats helper (lib/insights/verify-stats.ts) Aggregeert verify_result counts en top-5 EMPTY/DIVERGENT jobs over de laatste N dagen. Co-Authored-By: Claude Sonnet 4.6 * feat: PlanQualityCard — verify_result donut + top-5 EMPTY/DIVERGENT tabellen PieChart (donut) met ALIGNED/PARTIAL/EMPTY/DIVERGENT verdeling, MD3-kleuren. Twee tabellen rechts met Next.js Link deeplinks naar TaskDetailDialog. Empty-state met link naar Plan-verify gating story. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../insights/components/plan-quality.tsx | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 app/(app)/insights/components/plan-quality.tsx diff --git a/app/(app)/insights/components/plan-quality.tsx b/app/(app)/insights/components/plan-quality.tsx new file mode 100644 index 0000000..2a592b4 --- /dev/null +++ b/app/(app)/insights/components/plan-quality.tsx @@ -0,0 +1,105 @@ +'use client' + +import Link from 'next/link' +import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts' +import type { VerifyResultStats, VerifyResultKey, TopJob } from '@/lib/insights/verify-stats' + +interface Props { + stats: VerifyResultStats +} + +const VERIFY_COLORS: Record = { + ALIGNED: 'var(--status-done)', + PARTIAL: 'var(--priority-medium)', + EMPTY: 'var(--priority-critical)', + DIVERGENT: 'var(--priority-high)', +} + +const VERIFY_LABELS: Record = { + ALIGNED: 'Aligned', + PARTIAL: 'Partial', + EMPTY: 'Empty', + DIVERGENT: 'Divergent', +} + +function daysAgo(date: Date, nowMs: number): string { + const diff = Math.floor((nowMs - new Date(date).getTime()) / 86_400_000) + return diff === 0 ? 'vandaag' : `${diff}d geleden` +} + +function TopTable({ title, jobs, nowMs }: { title: string; jobs: TopJob[]; nowMs: number }) { + if (jobs.length === 0) return null + return ( +
+

{title}

+ + + {jobs.map(j => ( + + + + + + ))} + +
+ + {j.taskTitle} + + {j.productName}{daysAgo(j.finishedAt, nowMs)}
+
+ ) +} + +export function PlanQualityCard({ stats, nowMs }: Props & { nowMs: number }) { + if (stats.counts.length === 0) { + return ( +
+

+ Plan-verify gating moet eerst geactiveerd worden.{' '} + + Bekijk de Plan-verify gating story + +

+
+ ) + } + + const labeled = stats.counts.map(c => ({ + ...c, + name: VERIFY_LABELS[c.result], + })) + + return ( +
+ + + + {labeled.map(entry => ( + + ))} + + + + + + +
+ + +
+
+ ) +} From 0454eede74bc58ac6418aa6601fa4df4439d6cfc Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 16:44:53 +0200 Subject: [PATCH 009/258] 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() +} From 9fa0093336137d0a8897763e4a238306a27fc1db Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 18:19:15 +0200 Subject: [PATCH 010/258] SMOKE-TEST: branch-per-story flow met 3 simpele tasks (#42) * docs(ST-smoke): add smoke-test-1.md * docs(ST-smoke): add smoke-test-2.md * docs(ST-smoke): add smoke-test-3.md --- docs/smoke-test-1.md | 4 ++++ docs/smoke-test-2.md | 4 ++++ docs/smoke-test-3.md | 4 ++++ 3 files changed, 12 insertions(+) create mode 100644 docs/smoke-test-1.md create mode 100644 docs/smoke-test-2.md create mode 100644 docs/smoke-test-3.md diff --git a/docs/smoke-test-1.md b/docs/smoke-test-1.md new file mode 100644 index 0000000..3fd435c --- /dev/null +++ b/docs/smoke-test-1.md @@ -0,0 +1,4 @@ +# Smoke test 1 + +Dit bestand is gemaakt door task 1 van de branch-per-story smoke-test. +Auto-gegenereerd op merge-tijd; mag na verificatie verwijderd worden. diff --git a/docs/smoke-test-2.md b/docs/smoke-test-2.md new file mode 100644 index 0000000..9a81c33 --- /dev/null +++ b/docs/smoke-test-2.md @@ -0,0 +1,4 @@ +# Smoke test 2 + +Dit bestand is gemaakt door task 2 van de branch-per-story smoke-test. +Auto-gegenereerd op merge-tijd; mag na verificatie verwijderd worden. diff --git a/docs/smoke-test-3.md b/docs/smoke-test-3.md new file mode 100644 index 0000000..b1f58d1 --- /dev/null +++ b/docs/smoke-test-3.md @@ -0,0 +1,4 @@ +# Smoke test 3 + +Dit bestand is gemaakt door task 3 van de branch-per-story smoke-test. +Auto-gegenereerd op merge-tijd; mag na verificatie verwijderd worden. From 070e1d9ea218174f1c354ae349af501a5f4cdb8a Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 19:37:39 +0200 Subject: [PATCH 011/258] chore: remove smoke-test files (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These three docs/smoke-test-*.md files were created by the branch-per-story flow smoke-test (PR #42, story cmon1q0do0023bortliq2tae9). The flow worked correctly — confirmed in DB: 1 branch + 1 PR for 3 tasks, verify_result populated, worktree-cleanup deferred until the last sub-task. Files served their purpose and can now be removed. Co-authored-by: Claude Opus 4.7 (1M context) --- docs/smoke-test-1.md | 4 ---- docs/smoke-test-2.md | 4 ---- docs/smoke-test-3.md | 4 ---- 3 files changed, 12 deletions(-) delete mode 100644 docs/smoke-test-1.md delete mode 100644 docs/smoke-test-2.md delete mode 100644 docs/smoke-test-3.md diff --git a/docs/smoke-test-1.md b/docs/smoke-test-1.md deleted file mode 100644 index 3fd435c..0000000 --- a/docs/smoke-test-1.md +++ /dev/null @@ -1,4 +0,0 @@ -# Smoke test 1 - -Dit bestand is gemaakt door task 1 van de branch-per-story smoke-test. -Auto-gegenereerd op merge-tijd; mag na verificatie verwijderd worden. diff --git a/docs/smoke-test-2.md b/docs/smoke-test-2.md deleted file mode 100644 index 9a81c33..0000000 --- a/docs/smoke-test-2.md +++ /dev/null @@ -1,4 +0,0 @@ -# Smoke test 2 - -Dit bestand is gemaakt door task 2 van de branch-per-story smoke-test. -Auto-gegenereerd op merge-tijd; mag na verificatie verwijderd worden. diff --git a/docs/smoke-test-3.md b/docs/smoke-test-3.md deleted file mode 100644 index b1f58d1..0000000 --- a/docs/smoke-test-3.md +++ /dev/null @@ -1,4 +0,0 @@ -# Smoke test 3 - -Dit bestand is gemaakt door task 3 van de branch-per-story smoke-test. -Auto-gegenereerd op merge-tijd; mag na verificatie verwijderd worden. From 6c6c8b96b7efff67f07d4339debc46c31e27a186 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 20:04:22 +0200 Subject: [PATCH 012/258] fix(realtime): force-destroy pg socket on cleanup timeout (SSE leak) (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three SSE-routes (solo, backlog, notifications) each create a long- running pg.Client that LISTENs on scrum4me_changes. On abrupt close (Fast Refresh, browser refresh, Vercel function recycle) the pgClient.end()-await sometimes hangs silently, leaving the underlying socket connected to Postgres. The connection stays in 'idle' on Neon's side and after ~10-20 reconnects the connection-pool fills up — new SSE connects fail with ERR_INCOMPLETE_CHUNKED_ENCODING in the browser. Fix: shared `closePgClientSafely` helper that races client.end() against a 2 s timeout; on timeout it force-destroys the underlying socket so the OS releases the FD and Postgres notices the disconnect. Validated by direct DB inspection: 18 stale 'idle LISTEN'-connections were piled up before the fix; after manual pg_terminate_backend cleanup the SSE-stream stabilised. This change makes the pile-up impossible going forward. - new lib/realtime/pg-client-cleanup.ts - 3 routes use the helper instead of bare `await pgClient.end()` - 3 unit tests for the helper (timely-end, hang-falls-back-to-destroy, end-rejection-is-swallowed) Co-authored-by: Claude Opus 4.7 (1M context) --- .../lib/realtime/pg-client-cleanup.test.ts | 66 +++++++++++++++++++ app/api/realtime/backlog/route.ts | 3 +- app/api/realtime/notifications/route.ts | 7 +- app/api/realtime/solo/route.ts | 7 +- lib/realtime/pg-client-cleanup.ts | 55 ++++++++++++++++ 5 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 __tests__/lib/realtime/pg-client-cleanup.test.ts create mode 100644 lib/realtime/pg-client-cleanup.ts diff --git a/__tests__/lib/realtime/pg-client-cleanup.test.ts b/__tests__/lib/realtime/pg-client-cleanup.test.ts new file mode 100644 index 0000000..099032b --- /dev/null +++ b/__tests__/lib/realtime/pg-client-cleanup.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Client } from 'pg' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' + +function makeFakeClient(opts: { + endResolves?: Promise + destroy?: ReturnType +}): Client { + const handlers = new Map void>>() + const fake = { + end: vi.fn().mockReturnValue(opts.endResolves ?? Promise.resolve()), + on: vi.fn((event: string, fn: (...args: unknown[]) => void) => { + const list = handlers.get(event) ?? [] + list.push(fn) + handlers.set(event, list) + return fake + }), + removeAllListeners: vi.fn((event: string) => { + handlers.delete(event) + return fake + }), + connection: { + stream: { destroy: opts.destroy ?? vi.fn() }, + }, + } + return fake as unknown as Client +} + +describe('closePgClientSafely', () => { + beforeEach(() => { + vi.useRealTimers() + }) + + it('drops listeners and awaits client.end() when it resolves quickly', async () => { + const destroy = vi.fn() + const client = makeFakeClient({ destroy }) + + await closePgClientSafely(client, 'test') + + expect(client.removeAllListeners).toHaveBeenCalledWith('notification') + expect(client.removeAllListeners).toHaveBeenCalledWith('error') + expect(client.end).toHaveBeenCalledOnce() + expect(destroy).not.toHaveBeenCalled() // ended in time + }) + + it('falls back to socket-destroy when client.end() hangs past the timeout', async () => { + const destroy = vi.fn() + // .end() never resolves + const client = makeFakeClient({ endResolves: new Promise(() => {}), destroy }) + + vi.useFakeTimers() + const promise = closePgClientSafely(client, 'test-hang') + await vi.advanceTimersByTimeAsync(2_001) + await promise + + expect(destroy).toHaveBeenCalledOnce() + const arg = destroy.mock.calls[0][0] + expect(arg).toBeInstanceOf(Error) + }) + + it('does not throw when client.end() rejects', async () => { + const client = makeFakeClient({ endResolves: Promise.reject(new Error('boom')) }) + + await expect(closePgClientSafely(client, 'test-reject')).resolves.toBeUndefined() + }) +}) diff --git a/app/api/realtime/backlog/route.ts b/app/api/realtime/backlog/route.ts index 1736710..dfbd835 100644 --- a/app/api/realtime/backlog/route.ts +++ b/app/api/realtime/backlog/route.ts @@ -6,6 +6,7 @@ import { NextRequest } from 'next/server' import { Client } from 'pg' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -68,7 +69,7 @@ export async function GET(request: NextRequest) { closed = true if (heartbeatTimer) clearInterval(heartbeatTimer) if (hardCloseTimer) clearTimeout(hardCloseTimer) - try { await pgClient.end() } catch { /* ignore */ } + await closePgClientSafely(pgClient, 'realtime/backlog') try { controller.close() } catch { /* already closed */ } if (process.env.NODE_ENV !== 'production') { console.log(`[realtime/backlog] closed: ${reason}`) diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index f31c6d5..907898a 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -16,6 +16,7 @@ import { Client } from 'pg' import { getSession } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -83,11 +84,7 @@ export async function GET(request: NextRequest) { closed = true if (heartbeatTimer) clearInterval(heartbeatTimer) if (hardCloseTimer) clearTimeout(hardCloseTimer) - try { - await pgClient.end() - } catch { - // ignore - } + await closePgClientSafely(pgClient, 'realtime/notifications') try { controller.close() } catch { diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index 112e0cc..0553cf6 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -14,6 +14,7 @@ import { NextRequest } from 'next/server' import { Client } from 'pg' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -145,11 +146,7 @@ export async function GET(request: NextRequest) { closed = true if (heartbeatTimer) clearInterval(heartbeatTimer) if (hardCloseTimer) clearTimeout(hardCloseTimer) - try { - await pgClient.end() - } catch { - // ignore - } + await closePgClientSafely(pgClient, 'realtime/solo') try { controller.close() } catch { diff --git a/lib/realtime/pg-client-cleanup.ts b/lib/realtime/pg-client-cleanup.ts new file mode 100644 index 0000000..6021320 --- /dev/null +++ b/lib/realtime/pg-client-cleanup.ts @@ -0,0 +1,55 @@ +// Robust pg.Client cleanup for SSE-routes that hold a long-running LISTEN- +// connection. Without this helper, `pgClient.end()` can hang silently when +// the underlying socket is in a weird state (Fast Refresh, abrupt browser +// close, Vercel function recycle), leaving the connection in 'idle' on the +// Postgres server. After ~10-20 reconnects the Neon connection-pool fills +// up and new SSE-connections fail with ERR_INCOMPLETE_CHUNKED_ENCODING. +// +// Strategy: race `pgClient.end()` against a short timeout; if the timeout +// wins, force-destroy the underlying socket so the OS releases the FD and +// Neon notices the disconnect. + +import type { Client } from 'pg' + +const END_TIMEOUT_MS = 2_000 + +interface PgClientWithStream { + connection?: { stream?: { destroy?: (err?: Error) => void } } +} + +export async function closePgClientSafely( + client: Client, + label: string, +): Promise { + // Drop notification/error handlers so a late event from the dying + // connection cannot trigger downstream cleanup again. + client.removeAllListeners('notification') + client.removeAllListeners('error') + client.on('error', () => { + // Swallow: connection is being torn down on purpose. + }) + + let timer: ReturnType | null = null + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), END_TIMEOUT_MS) + }) + + const result = await Promise.race([ + client.end().then(() => 'ended' as const), + timeout, + ]).catch(() => 'error' as const) + + if (timer) clearTimeout(timer) + + if (result !== 'ended') { + if (process.env.NODE_ENV !== 'production') { + console.warn(`[${label}] pgClient.end() did not finish in ${END_TIMEOUT_MS}ms — forcing socket destroy`) + } + const stream = (client as unknown as PgClientWithStream).connection?.stream + try { + stream?.destroy?.(new Error(`forced socket destroy from ${label}`)) + } catch { + // best-effort + } + } +} From 55a1ee035c8e528ee4d3d262d4e5e28b4e9a18a3 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Sat, 2 May 2026 13:09:25 +0200 Subject: [PATCH 013/258] docs: introduce generic entity-dialog pattern + entity-profiles (#45) * docs(dialog-pattern): add generic entity-dialog spec Introduceert docs/patterns/dialog.md als bron-of-truth voor elke create/edit/detail-dialog in Scrum4Me, ongeacht het achterliggende dataobject. Bevat 14 secties: uitgangspunten, stack, component- architectuur, layout, validatie, drielaagse demo-policy, submission, dialog-gedrag, theming, footer, triggers/URL-state, per-entiteit profile-template, out-of-scope, en een verificatie-checklist. Registreert het patroon in CLAUDE.md "Implementatiepatronen"-tabel zodat Claude (en mensen) de spec verplicht raadplegen voor elke nieuwe dialog. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(dialog-pattern): convert task spec + add pbi/story entity-profiles Reduceert docs/scrum4me-task-dialog.md van 507 naar ~140 regels: alle gedeelde regels verhuisd naar docs/patterns/dialog.md, dit document bevat nu alleen Task-specifieke velden, URL-pattern, status-veld, server actions, triggers en bewuste out-of-scope-keuzes. Voegt twee nieuwe entity-profielen toe voor bestaande dialogen: - docs/scrum4me-pbi-dialog.md (PbiDialog: state-based, code+title-rij, PbiStatusSelect, geen delete in v1) - docs/scrum4me-story-dialog.md (StoryDialog: state-based, header met status/priority badges, inline activity-log, demo-readonly-fallback, inline-delete-confirm i.p.v. AlertDialog) Beide profielen documenteren expliciet de "Bekende gaps t.o.v. generieke spec" zodat opvolgende PR's de afwijkingen kunnen rechtzetten of bewust kunnen accorderen. Co-Authored-By: Claude Opus 4.7 (1M context) * Added pdevelopment docs --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .gitignore | 3 +- CLAUDE.md | 1 + docs/patterns/dialog.md | 387 ++++++++++++ docs/plans/ST-1114-copilot-reviews.md | 133 +++++ .../Tweede Claude Agent — Planning Agent.md | 346 +++++++++++ docs/scrum4me-pbi-dialog.md | 120 ++++ docs/scrum4me-story-dialog.md | 163 +++++ docs/scrum4me-task-dialog.md | 557 +++--------------- 8 files changed, 1241 insertions(+), 469 deletions(-) create mode 100644 docs/patterns/dialog.md create mode 100644 docs/plans/ST-1114-copilot-reviews.md create mode 100644 docs/plans/Tweede Claude Agent — Planning Agent.md create mode 100644 docs/scrum4me-pbi-dialog.md create mode 100644 docs/scrum4me-story-dialog.md diff --git a/.gitignore b/.gitignore index e3c508b..9c8093c 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,5 @@ jp.sh .codex/ # Lokale scratch-bestanden -Brainstro \ No newline at end of file +Brainstro +/graphify-out \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 65be9e6..010083b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,6 +111,7 @@ Lees het relevante patroon vóór je begint. Nooit uit het hoofd schrijven. | Middleware (route protection) | `docs/patterns/middleware.md` | | QR-pairing (unauth-SSE + pre-auth cookie) | `docs/patterns/qr-login.md` | | Bidirectionele async-comms MCP-agent ↔ user | `docs/patterns/claude-question-channel.md` | +| **Entity Dialog (verplicht voor élke create/edit/detail-dialog)** | `docs/patterns/dialog.md` — bron-of-truth; per entiteit één profile-doc (bv. `docs/scrum4me-task-dialog.md`) | | Status-enum mapping (DB ↔ API) | `lib/task-status.ts` | | Client/server module-boundary | `*-server.ts` bevat DB-calls of node-only deps; `*.ts` is pure (client-safe). Nooit `import { ... } from '@/lib/foo-server'` in een client-component, anders krijg je `Module not found: 'dns'`/`'pg'`-style runtime fouten | diff --git a/docs/patterns/dialog.md b/docs/patterns/dialog.md new file mode 100644 index 0000000..9bf4682 --- /dev/null +++ b/docs/patterns/dialog.md @@ -0,0 +1,387 @@ +# Pattern — Entity Dialog + +Deze pagina is **bindend** voor elke create/edit/detail-dialog in Scrum4Me, ongeacht het achterliggende dataobject (PBI, Story, Task, Todo, Sprint, Product, User, of toekomstige entiteiten). Een nieuwe dialog die hier niet aan voldoet, hoort niet gemerged te worden. + +> **Doel:** elke dialog voelt identiek aan voor de gebruiker, hergebruikt dezelfde primitives, en heeft de drielaagse demo-policy + auth-scoping standaard ingebakken. + +Voor entity-specifieke afwijkingen of velden: schrijf één begeleidende doc per entiteit (zie sectie [§ Per-entiteit profile](#per-entiteit-profile-verplicht)). Voorbeeld: `docs/scrum4me-task-dialog.md` is het Task-profiel. + +--- + +## 1 — Verplichte uitgangspunten + +| # | Regel | Bron / waarom | +|---|---|---| +| 1.1 | Bouw op `components/ui/dialog.tsx` (de bestaande shadcn/`@base-ui/react`-wrapper). **Geen** directe imports van dialog-primitives uit `@base-ui/react`. | Voorkomt twee parallelle dialog-implementaties met inconsistente animatie/focus-trap/theming | +| 1.2 | Gebruik composition via de **`render`-prop** (zie `CLAUDE.md` "UI Library Conventions"). Nooit Radix' `asChild`. | Project gebruikt `@base-ui/react`, niet Radix | +| 1.3 | Mode (`create` vs `edit` vs `detail`) wordt afgeleid uit één input — een prop, een `state`-object of een `searchParam`. **Niet** twee aparte componenten. | Voorkomt code-duplicatie en inconsistente labels/footer-layouts | +| 1.4 | Auth-scoping op elke server action via `productAccessFilter(userId)` (of het scope-helper-equivalent). Cross-tenant writes mogen onmogelijk zijn. | `CLAUDE.md` "Toegangsmodel" + `docs/patterns/server-action.md` | +| 1.5 | **Drielaagse demo-policy** (verplicht — zie § 6) op elke write-actie. | `CLAUDE.md` "Demo-check" + `docs/scrum4me-architecture.md#demo-user-policy` | +| 1.6 | Validatie via één gedeeld zod-schema (`lib/schemas/.ts`) — gebruikt door zowel form als server action. | `CLAUDE.md` "Validatie" | +| 1.7 | Foutcodes volgen de project-conventie (§ 5). | `CLAUDE.md` "Foutcodes API" | +| 1.8 | Geen willekeurige Tailwind-kleuren (`bg-blue-500` etc.). Alleen MD3-tokens uit `app/styles/theme.css`. | `docs/scrum4me-styling.md` | + +--- + +## 2 — Stack & dependencies + +Toegestane runtime-deps voor dialog-werk (al aanwezig of standaard pattern): + +| Doel | Voorkeur | Acceptabele alternatief | +|---|---|---| +| Form-state | `react-hook-form` + `@hookform/resolvers/zod` | `useActionState` + `useFormStatus` (Server Actions, native React) | +| Auto-grow textarea | `react-textarea-autosize` | — | +| Markdown-rendering (preview) | `react-markdown` + `remark-gfm` (via bestaande ``-wrapper) | — | +| Toasts | `sonner` | — | +| Iconen | `lucide-react` | — | + +> **Per-dialog mag je kiezen tussen `react-hook-form` of `useActionState`.** Beide patronen draaien al in deze codebase. Kies één per dialog en blijf consistent binnen dat bestand. Mix ze niet. + +Verboden in dialog-context (v1): +- `material-color-utilities` (dynamic color valt buiten v1) +- Nieuwe form-libraries — geen `formik`, `final-form`, etc. + +--- + +## 3 — Component-architectuur + +### 3.1 Reusables (`components/entity-dialog/` of `components/shared/`) + +Deze primitives kennen géén entity-specifieke types en mogen door élke dialog gebruikt worden: + +| Primitive | Locatie | Verantwoordelijkheid | +|---|---|---| +| `` + `` etc. | `components/ui/dialog.tsx` | Shell, motion, focus-trap, backdrop | +| `` / `` | `components/shared/priority-select.tsx` | P1-P4 — identiek over alle entiteiten | +| `` | `components/shared/demo-tooltip.tsx` | Wrapper rond write-knoppen voor demo-modus (laag 3 van 3) | +| Auto-grow textarea | (toe te voegen wanneer nodig in `components/shared/`) | Wrapper rond `react-textarea-autosize` met char-counter + markdown-hint | +| Dirty-close-guard | (gedeelde AlertDialog-flow) | "Wijzigingen niet opgeslagen — weggooien?" | +| `` | `components/markdown.tsx` | `react-markdown` + `remark-gfm` voor description/criteria-preview | + +> Wanneer je een primitive twee keer kopieert tussen entity-dialogs, **promote 'm meteen** naar `components/shared/` (of `components/entity-dialog/`). Drie keer is te laat. + +### 3.2 Entity-specifieke laag (`components//-dialog.tsx`) + +Per entiteit één wrapper-bestand dat: +1. De juiste form/body rendert +2. De juiste server actions koppelt (`saveAction`, `deleteAction`) +3. Entiteit-specifieke labels levert ("Story bewerken", "PBI aanmaken", etc.) + +Een entity-dialog bevat **geen** layout-mechanica, motion-config of dirty-check zelf — die komen uit § 3.1. + +--- + +## 4 — Layout & responsive gedrag + +Identiek voor élke dialog (geen entity-specifieke variaties tenzij expliciet beargumenteerd in het entity-profile): + +| Breakpoint | Breedte | Hoogte | +|---|---|---| +| Mobiel (< 640px) | full-screen | full-screen | +| Tablet (640–1024px) | `90vw` | `max-h-[85vh]` | +| Desktop (≥ 1024px) | `max-w-[50vw]`, `min-w-[480px]` | `max-h-[85vh]` | + +Verplicht: +- Padding `p-6` rondom (24px) +- Veld-spacing in body `space-y-6` (24px) +- **Sticky** header (titel + close) en **sticky** footer (knoppen) +- Body scrollt onafhankelijk; geneste scrolls vermijden +- Footer heeft `border-t` in `outline-variant` + +--- + +## 5 — Validatie & foutcodes + +### 5.1 zod-schema + +Eén `lib/schemas/.ts` per entiteit. Geïmporteerd door zowel form als server action — geen aparte definities. + +### 5.2 Foutcodes (verplicht) + +| Code | Wanneer | UI-respons | +|---|---|---| +| **422** | zod-validatiefout (server-side dubbelcheck) | `fieldErrors` mappen naar `form.setError()`, géén toast | +| **403** | demo-sessie probeert te schrijven, of cross-tenant write geblokkeerd | toast "Niet toegestaan in demo-modus" of "Geen toegang", form blijft open | +| **400** | malformed JSON-body (`request.json()` faalt) — alleen bij REST-route-handlers | toast "Ongeldige aanvraag" | +| **500** | onverwachte serverfout | toast met "Opnieuw proberen"-knop, form-state behouden | + +> Field-level errors zijn **alleen** geldig bij `code: 422`. Bij andere codes is `fieldErrors` ongedefinieerd. + +### 5.3 Field-level rendering + +- Errors **onder** het veld, in `text-error`, met `border-error` op het input-element +- Géén toast voor field-level errors +- Submit-knop **blijft enabled** bij errors — klik scrollt naar eerste error-veld + focus +- react-hook-form mode: `onTouched` (eerste validatie bij blur, daarna onChange) + +--- + +## 6 — Drielaagse demo-policy (verplicht voor write-dialogs) + +Elke dialog die schrijft (create / edit / delete) MOET door alle drie de lagen heen: + +1. **Middleware-guard** in `proxy.ts` — blokkeert demo-sessies op write-routes vóór de server action loopt. Returnt **403**. +2. **`session.isDemo`-check** binnen elke `saveAction` / `deleteAction` zelf — defense-in-depth voor het geval een actie buiten een proxy-route loopt. Returnt **403**. +3. **``** rond de submit- en delete-knoppen — UI-laag: knoppen `disabled` met tooltip "Demo-modus: opslaan uitgeschakeld". + +> Eén laag missen = bug. Reviewers moeten alle drie de lagen kunnen aanwijzen in de PR. + +--- + +## 7 — Submission-flow + +### 7.1 Server Action (template) + +```ts +// actions/.ts +'use server' + +export async function saveAction( + input: Input, + context: { /* ids voor revalidatePath en scope */ }, +): PromiseResult> { + const session = await getSession() + if (!session.userId) return { ok: false, code: 403, error: 'forbidden' } + if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' } + + const scope = await productAccessFilter(session.userId) // verplicht + const parsed = Schema.safeParse(input) + if (!parsed.success) { + return { ok: false, code: 422, error: 'validation', fieldErrors: parsed.error.flatten().fieldErrors } + } + // ... Prisma write binnen `scope` ... + // revalidatePath(...) op de context-route + return { ok: true, : row } +} + +type SaveResult = + | { ok: true; : } + | { ok: false; code: 422; error: 'validation'; fieldErrors: Record } + | { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' } + | { ok: false; code: 500; error: 'server_error' } +``` + +### 7.2 Revalidation + +`revalidatePath` op de **context-route** waarin de dialog werd geopend, niet op een statisch path. Context wordt door de aanroepende client meegegeven (geen hard-coded paths in de action). + +### 7.3 Submit-flow + +- Synchroon (geen optimistic update in v1, behalve waar het store-patroon `usePlannerStore` al bestaat) +- Tijdens submit: cancel- en submit-knop disabled, spinner of "…" in submit-knop, velden **blijven enabled** +- Server saniteert en valideert opnieuw met hetzelfde zod-schema + +--- + +## 8 — Dialog-gedrag (UX-regels) + +### 8.1 Sluiten met dirty state + +- Form niet aangeraakt → Esc / backdrop-klik / Cancel sluiten **direct** +- Form `isDirty` → Esc / backdrop-klik / Cancel triggeren `AlertDialog`: *"Wijzigingen niet opgeslagen — weggooien?"* + +### 8.2 Keyboard shortcuts + +| Toets | Actie | +|---|---| +| **Esc** | Sluiten (met dirty-check) | +| **Cmd/Ctrl + Enter** | Submit vanuit elk veld | +| **Enter in ``** | **Geen** submit (alleen Cmd/Ctrl+Enter) | +| **Enter in `