diff --git a/__tests__/actions/sprint-dates.test.ts b/__tests__/actions/sprint-dates.test.ts new file mode 100644 index 0000000..6cb59c2 --- /dev/null +++ b/__tests__/actions/sprint-dates.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), + getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1', user_id: 'user-1' }), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { createSprintAction, updateSprintDatesAction } from '@/actions/sprints' + +const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType; create: ReturnType; update: ReturnType } } + +function makeFormData(data: Record) { + const fd = new FormData() + for (const [k, v] of Object.entries(data)) { + if (v !== null) fd.append(k, v) + } + return fd +} + +describe('createSprintAction — date validation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSprint.sprint.findFirst.mockResolvedValue(null) + mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' }) + }) + + it('accepts valid start_date + end_date', async () => { + const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-01', end_date: '2026-05-14' }) + const result = await createSprintAction(undefined, fd) + expect(result.success).toBe(true) + expect(mockSprint.sprint.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ start_date: new Date('2026-05-01'), end_date: new Date('2026-05-14') }) }) + ) + }) + + it('rejects end_date before start_date', async () => { + const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-14', end_date: '2026-05-01' }) + const result = await createSprintAction(undefined, fd) + expect(result.error).toBeTruthy() + const errors = result.error as Record + expect(errors.end_date?.[0]).toContain('Einddatum') + }) + + it('accepts no dates (both optional)', async () => { + const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '', end_date: '' }) + const result = await createSprintAction(undefined, fd) + expect(result.success).toBe(true) + }) +}) + +describe('updateSprintDatesAction — date validation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSprint.sprint.findFirst.mockResolvedValue({ id: 'sprint-1', product_id: 'product-1' }) + mockSprint.sprint.update.mockResolvedValue({}) + }) + + it('saves valid dates', async () => { + const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-01', end_date: '2026-05-14' }) + const result = await updateSprintDatesAction(undefined, fd) + expect(result.success).toBe(true) + }) + + it('rejects end_date before start_date', async () => { + const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-10', end_date: '2026-05-05' }) + const result = await updateSprintDatesAction(undefined, fd) + expect(result.error).toBeTruthy() + const errors = result.error as Record + expect(errors.end_date?.[0]).toContain('Einddatum') + }) + + it('blocks demo users', async () => { + const { getIronSession } = await import('iron-session') + vi.mocked(getIronSession).mockResolvedValueOnce({ userId: 'user-1', isDemo: true } as never) + const fd = makeFormData({ id: 'sprint-1', start_date: '', end_date: '' }) + const result = await updateSprintDatesAction(undefined, fd) + expect(result.error).toBe('Niet beschikbaar in demo-modus') + }) +}) diff --git a/__tests__/components/solo/task-detail-dialog.test.tsx b/__tests__/components/solo/task-detail-dialog.test.tsx index 0a6d34f..3b767fc 100644 --- a/__tests__/components/solo/task-detail-dialog.test.tsx +++ b/__tests__/components/solo/task-detail-dialog.test.tsx @@ -60,6 +60,7 @@ const baseTask: SoloTask = { sort_order: 1, status: 'TO_DO', verify_only: false, + verify_required: 'ALIGNED_OR_PARTIAL', story_id: 'story-1', story_code: 'ST-100', story_title: 'Test Story', diff --git a/__tests__/lib/chart-colors.test.ts b/__tests__/lib/chart-colors.test.ts new file mode 100644 index 0000000..b8d0be2 --- /dev/null +++ b/__tests__/lib/chart-colors.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { + STATUS_COLORS, + PRIORITY_COLORS, + VERIFY_COLORS, + JOB_STATUS_COLORS, + SERIES_COLORS, +} from '@/lib/chart-colors' + +describe('chart-colors', () => { + it('STATUS_COLORS has all TaskStatus keys and non-empty values', () => { + const keys: (keyof typeof STATUS_COLORS)[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE'] + for (const key of keys) { + expect(STATUS_COLORS[key]).toBeTruthy() + expect(typeof STATUS_COLORS[key]).toBe('string') + } + }) + + it('PRIORITY_COLORS has keys 1-4 with non-empty values', () => { + const keys = [1, 2, 3, 4] as const + for (const key of keys) { + expect(PRIORITY_COLORS[key]).toBeTruthy() + expect(typeof PRIORITY_COLORS[key]).toBe('string') + } + }) + + it('VERIFY_COLORS has all VerifyResult keys and non-empty values', () => { + const keys: (keyof typeof VERIFY_COLORS)[] = ['ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT'] + for (const key of keys) { + expect(VERIFY_COLORS[key]).toBeTruthy() + expect(typeof VERIFY_COLORS[key]).toBe('string') + } + }) + + it('JOB_STATUS_COLORS has all ClaudeJobStatus keys and non-empty values', () => { + const keys: (keyof typeof JOB_STATUS_COLORS)[] = [ + 'queued', 'claimed', 'running', 'done', 'failed', 'cancelled', + ] + for (const key of keys) { + expect(JOB_STATUS_COLORS[key]).toBeTruthy() + expect(typeof JOB_STATUS_COLORS[key]).toBe('string') + } + }) + + it('SERIES_COLORS has 5 non-empty entries', () => { + expect(SERIES_COLORS).toHaveLength(5) + for (const color of SERIES_COLORS) { + expect(color).toBeTruthy() + expect(typeof color).toBe('string') + } + }) +}) diff --git a/__tests__/lib/insights/agent-throughput.test.ts b/__tests__/lib/insights/agent-throughput.test.ts new file mode 100644 index 0000000..3465dd4 --- /dev/null +++ b/__tests__/lib/insights/agent-throughput.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockQueryRaw } = vi.hoisted(() => ({ mockQueryRaw: vi.fn() })) + +vi.mock('@/lib/prisma', () => ({ + prisma: { $queryRaw: mockQueryRaw }, +})) + +import { getJobsPerDay } from '@/lib/insights/agent-throughput' + +// Build a date string for N days ago (UTC) +function daysAgo(n: number): Date { + const d = new Date() + d.setUTCDate(d.getUTCDate() - n) + return d +} + +function toUTCDate(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('getJobsPerDay', () => { + it('returns a 14-day array zero-filled for missing days', async () => { + // Only 3 days have data; the rest should be 0 + const day0 = toUTCDate(daysAgo(0)) + const day3 = toUTCDate(daysAgo(3)) + const day7 = toUTCDate(daysAgo(7)) + + const dayRows = [ + { day: day0, status: 'done', count: BigInt(2) }, + { day: day3, status: 'failed', count: BigInt(1) }, + { day: day7, status: 'done', count: BigInt(5) }, + ] + + const kpiRows = [ + { today_count: BigInt(2), done_7d: BigInt(7), terminal_7d: BigInt(10), avg_seconds: 120 }, + ] + + mockQueryRaw.mockResolvedValueOnce(dayRows).mockResolvedValueOnce(kpiRows) + + const result = await getJobsPerDay('user-1') + + expect(result.perDay).toHaveLength(14) + + // All days should have zero counts except the three we seeded + const nonZero = result.perDay.filter( + d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled > 0, + ) + expect(nonZero).toHaveLength(3) + + // Today's done count should be 2 + const today = result.perDay[result.perDay.length - 1] + expect(today.done).toBe(2) + }) + + it('calculates KPIs correctly', async () => { + mockQueryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { today_count: BigInt(3), done_7d: BigInt(7), terminal_7d: BigInt(10), avg_seconds: 90 }, + ]) + + const result = await getJobsPerDay('user-1') + + expect(result.kpi.todayCount).toBe(3) + expect(result.kpi.successRate7d).toBe(0.7) + expect(result.kpi.avgDurationSeconds7d).toBe(90) + }) + + it('returns zero successRate and null avgDuration when no terminal jobs', async () => { + mockQueryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { today_count: BigInt(0), done_7d: BigInt(0), terminal_7d: BigInt(0), avg_seconds: null }, + ]) + + const result = await getJobsPerDay('user-1') + + expect(result.kpi.successRate7d).toBe(0) + expect(result.kpi.avgDurationSeconds7d).toBeNull() + }) +}) diff --git a/__tests__/lib/insights/backlog-health.test.ts b/__tests__/lib/insights/backlog-health.test.ts new file mode 100644 index 0000000..f74bfe3 --- /dev/null +++ b/__tests__/lib/insights/backlog-health.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockStoryCount, mockTaskCount, mockTaskFindMany } = vi.hoisted(() => ({ + mockStoryCount: vi.fn(), + mockTaskCount: vi.fn(), + mockTaskFindMany: vi.fn(), +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + story: { count: mockStoryCount }, + task: { count: mockTaskCount, findMany: mockTaskFindMany }, + }, +})) + +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: () => ({ some: 'filter' }), +})) + +import { getBacklogHealth } from '@/lib/insights/backlog-health' + +function makeTask(id: string, daysAgo: number) { + const updatedAt = new Date(Date.now() - daysAgo * 86_400_000) + return { + id, + title: `Task ${id}`, + updated_at: updatedAt, + story: { + product: { id: 'prod-1', name: 'My Product' }, + sprint: { sprint_goal: 'Sprint goal' }, + }, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('getBacklogHealth', () => { + it('returns all zeros when backlog is healthy', async () => { + mockStoryCount.mockResolvedValue(0) + mockTaskCount.mockResolvedValue(0) + mockTaskFindMany.mockResolvedValue([]) + + const result = await getBacklogHealth('user-1') + + expect(result.storiesWithoutAc).toBe(0) + expect(result.tasksWithoutPlan).toBe(0) + expect(result.stuckTasks).toEqual([]) + }) + + it('returns counts and stuck tasks when everything is flagged', async () => { + mockStoryCount.mockResolvedValue(5) + mockTaskCount.mockResolvedValue(3) + mockTaskFindMany.mockResolvedValue([makeTask('t1', 10), makeTask('t2', 8)]) + + const result = await getBacklogHealth('user-1') + + expect(result.storiesWithoutAc).toBe(5) + expect(result.tasksWithoutPlan).toBe(3) + expect(result.stuckTasks).toHaveLength(2) + expect(result.stuckTasks[0].taskId).toBe('t1') + expect(result.stuckTasks[0].daysStuck).toBeGreaterThanOrEqual(10) + expect(result.stuckTasks[0].productName).toBe('My Product') + expect(result.stuckTasks[0].sprintGoal).toBe('Sprint goal') + }) + + it('mixed: some counters non-zero, one stuck task, no sprint', async () => { + mockStoryCount.mockResolvedValue(2) + mockTaskCount.mockResolvedValue(0) + const task = makeTask('t3', 14) + task.story.sprint = null as unknown as { sprint_goal: string } + mockTaskFindMany.mockResolvedValue([task]) + + const result = await getBacklogHealth('user-1') + + expect(result.storiesWithoutAc).toBe(2) + expect(result.tasksWithoutPlan).toBe(0) + expect(result.stuckTasks[0].sprintGoal).toBeNull() + expect(result.stuckTasks[0].daysStuck).toBeGreaterThanOrEqual(14) + }) +}) diff --git a/__tests__/lib/insights/velocity.test.ts b/__tests__/lib/insights/velocity.test.ts new file mode 100644 index 0000000..535c1fa --- /dev/null +++ b/__tests__/lib/insights/velocity.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest' + +const { mockFindMany } = vi.hoisted(() => ({ mockFindMany: vi.fn() })) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { findMany: mockFindMany }, + }, +})) + +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: () => ({ some: 'filter' }), +})) + +import { getVelocity } from '@/lib/insights/velocity' + +const completedAt = (iso: string) => new Date(iso) + +function makeSprint(id: string, goal: string, productId: string, productName: string, doneCounts: number, completedIso: string) { + const tasks = Array.from({ length: doneCounts }, () => ({ status: 'DONE' })) + return { + id, + sprint_goal: goal, + completed_at: completedAt(completedIso), + product: { id: productId, name: productName }, + tasks, + } +} + +describe('getVelocity', () => { + it('returns 3 sprints in chronological order with correct done counts', async () => { + // DB returns newest-first (orderBy: completed_at desc), getVelocity reverses to oldest-first + mockFindMany.mockResolvedValue([ + makeSprint('s3', 'Sprint C', 'p1', 'Prod A', 3, '2024-03-01T00:00:00.000Z'), + makeSprint('s2', 'Sprint B', 'p1', 'Prod A', 5, '2024-02-01T00:00:00.000Z'), + makeSprint('s1', 'Sprint A', 'p1', 'Prod A', 2, '2024-01-01T00:00:00.000Z'), + ]) + + const result = await getVelocity('user-1') + + expect(result.sprints).toHaveLength(3) + expect(result.sprints.map(s => s.doneCount)).toEqual([2, 5, 3]) + expect(result.sprints.map(s => s.sprintId)).toEqual(['s1', 's2', 's3']) + }) + + it('deduplicates productNames from sprints', async () => { + mockFindMany.mockResolvedValue([ + makeSprint('s2', 'Sprint B', 'p2', 'Prod B', 1, '2024-02-01T00:00:00.000Z'), + makeSprint('s1', 'Sprint A', 'p1', 'Prod A', 2, '2024-01-01T00:00:00.000Z'), + ]) + + const result = await getVelocity('user-1') + + const ids = result.productNames.map(p => p.id) + expect(new Set(ids).size).toBe(ids.length) + expect(result.productNames).toHaveLength(2) + }) + + it('returns empty sprints and productNames when no completed sprints exist', async () => { + mockFindMany.mockResolvedValue([]) + + const result = await getVelocity('user-1') + + expect(result.sprints).toEqual([]) + expect(result.productNames).toEqual([]) + }) + + it('passes sprintsBack as take parameter', async () => { + mockFindMany.mockResolvedValue([]) + + await getVelocity('user-1', 3) + + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 3 }), + ) + }) +}) diff --git a/__tests__/realtime/payload-contract.test.ts b/__tests__/realtime/payload-contract.test.ts new file mode 100644 index 0000000..b36bc09 --- /dev/null +++ b/__tests__/realtime/payload-contract.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useBacklogStore } from '@/stores/backlog-store' +import type { BacklogPbi, BacklogStory, BacklogTask } from '@/stores/backlog-store' + +const PBI: BacklogPbi = { + id: 'pbi-1', + code: 'PBI-1', + title: 'Realtime PBI', + priority: 2, + description: 'desc', + created_at: new Date('2024-01-01T00:00:00Z'), + status: 'ready', +} + +const STORY: BacklogStory = { + id: 'story-1', + code: 'ST-1', + title: 'Realtime story', + description: null, + acceptance_criteria: null, + priority: 2, + status: 'OPEN', + pbi_id: 'pbi-1', + created_at: new Date('2024-01-01T00:00:00Z'), +} + +const TASK: BacklogTask = { + id: 'task-1', + title: 'Realtime task', + description: null, + priority: 2, + status: 'TO_DO', + sort_order: 1, + story_id: 'story-1', + created_at: new Date('2024-01-01T00:00:00Z'), +} + +beforeEach(() => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) +}) + +// --------------------------------------------------------------------------- +// PBI +// --------------------------------------------------------------------------- + +describe('PBI payload contract', () => { + it('INSERT: entity appears in pbis with correct title and status', () => { + useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) + const state = useBacklogStore.getState() + expect(state.pbis).toHaveLength(1) + expect(state.pbis[0].id).toBe('pbi-1') + expect(state.pbis[0].title).toBe('Realtime PBI') + expect(state.pbis[0].status).toBe('ready') + }) + + it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { + useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) + useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) + expect(useBacklogStore.getState().pbis).toHaveLength(1) + }) + + it('UPDATE: changed_fields partial merges into existing entity', () => { + useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'Updated PBI', status: 'in_sprint' as const }) + const pbi = useBacklogStore.getState().pbis[0] + expect(pbi.title).toBe('Updated PBI') + expect(pbi.status).toBe('in_sprint') + expect(pbi.priority).toBe(2) // unchanged field retained + }) + + it('DELETE: entity is removed from pbis', () => { + useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' }) + expect(useBacklogStore.getState().pbis).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// Story +// --------------------------------------------------------------------------- + +describe('Story payload contract', () => { + it('INSERT: entity appears in storiesByPbi[pbi_id] with correct title and status', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) + const bucket = useBacklogStore.getState().storiesByPbi['pbi-1'] + expect(bucket).toHaveLength(1) + expect(bucket[0].id).toBe('story-1') + expect(bucket[0].title).toBe('Realtime story') + expect(bucket[0].status).toBe('OPEN') + }) + + it('INSERT: creates bucket when pbi_id was not yet in storiesByPbi', () => { + useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) + }) + + it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { + useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) + useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) + }) + + it('UPDATE: changed_fields partial merges into existing story', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'U', { id: 'story-1', title: 'Updated story', status: 'IN_SPRINT' }) + const story = useBacklogStore.getState().storiesByPbi['pbi-1'][0] + expect(story.title).toBe('Updated story') + expect(story.status).toBe('IN_SPRINT') + expect(story.priority).toBe(2) // unchanged field retained + }) + + it('DELETE: entity is removed from its pbi bucket', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// Task +// --------------------------------------------------------------------------- + +describe('Task payload contract', () => { + it('INSERT: entity appears in tasksByStory[story_id] with correct title and status', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [] } }) + useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) + const bucket = useBacklogStore.getState().tasksByStory['story-1'] + expect(bucket).toHaveLength(1) + expect(bucket[0].id).toBe('task-1') + expect(bucket[0].title).toBe('Realtime task') + expect(bucket[0].status).toBe('TO_DO') + }) + + it('INSERT: creates bucket when story_id was not yet in tasksByStory', () => { + useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) + expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1) + }) + + it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { + useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) + useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) + expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1) + }) + + it('UPDATE: changed_fields partial merges into existing task', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } }) + useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', title: 'Updated task', status: 'IN_PROGRESS' }) + const task = useBacklogStore.getState().tasksByStory['story-1'][0] + expect(task.title).toBe('Updated task') + expect(task.status).toBe('IN_PROGRESS') + expect(task.sort_order).toBe(1) // unchanged field retained + }) + + it('DELETE: entity is removed from its story bucket', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } }) + useBacklogStore.getState().applyChange('task', 'D', { id: 'task-1' }) + expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(0) + }) +}) diff --git a/__tests__/stores/solo-store-realtime.test.ts b/__tests__/stores/solo-store-realtime.test.ts index b2f42cf..f61a7f8 100644 --- a/__tests__/stores/solo-store-realtime.test.ts +++ b/__tests__/stores/solo-store-realtime.test.ts @@ -12,6 +12,7 @@ const baseTask = (id: string, overrides: Partial = {}): SoloTask => ({ sort_order: 1, status: 'TO_DO', verify_only: false, + verify_required: 'ALIGNED_OR_PARTIAL', story_id: 'story-1', story_code: 'ST-100', story_title: 'Original Story', diff --git a/actions/sprints.ts b/actions/sprints.ts index 7eb7229..8eb2292 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -16,6 +16,14 @@ function hasDuplicateIds(ids: string[]) { return new Set(ids).size !== ids.length } +const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null)) + +function validateDateOrder(data: { start_date: Date | null; end_date: Date | null }, ctx: z.RefinementCtx) { + if (data.start_date && data.end_date && data.end_date < data.start_date) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Einddatum moet na startdatum liggen' }) + } +} + export async function createSprintAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } @@ -24,9 +32,13 @@ export async function createSprintAction(_prevState: unknown, formData: FormData const parsed = z.object({ productId: z.string(), sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500), - }).safeParse({ + start_date: dateField, + end_date: dateField, + }).superRefine(validateDateOrder).safeParse({ productId: formData.get('productId'), sprint_goal: formData.get('sprint_goal'), + start_date: formData.get('start_date'), + end_date: formData.get('end_date'), }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } @@ -43,6 +55,8 @@ export async function createSprintAction(_prevState: unknown, formData: FormData product_id: parsed.data.productId, sprint_goal: parsed.data.sprint_goal, status: 'ACTIVE', + start_date: parsed.data.start_date, + end_date: parsed.data.end_date, }, }) @@ -50,6 +64,35 @@ export async function createSprintAction(_prevState: unknown, formData: FormData return { success: true, sprintId: sprint.id } } +export async function updateSprintDatesAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = z.object({ + id: z.string(), + start_date: dateField, + end_date: dateField, + }).superRefine(validateDateOrder).safeParse({ + id: formData.get('id'), + start_date: formData.get('start_date'), + end_date: formData.get('end_date'), + }) + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + + const sprint = await prisma.sprint.findFirst({ + where: { id: parsed.data.id, product: productAccessFilter(session.userId) }, + }) + if (!sprint) return { error: 'Sprint niet gevonden' } + + await prisma.sprint.update({ + where: { id: parsed.data.id }, + data: { start_date: parsed.data.start_date, end_date: parsed.data.end_date }, + }) + revalidatePath(`/products/${sprint.product_id}/sprint`) + return { success: true } +} + export async function updateSprintGoalAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } diff --git a/app/(app)/insights/components/agent-throughput.tsx b/app/(app)/insights/components/agent-throughput.tsx new file mode 100644 index 0000000..820e64f --- /dev/null +++ b/app/(app)/insights/components/agent-throughput.tsx @@ -0,0 +1,133 @@ +'use client' + +import { useRouter, usePathname, useSearchParams } from 'next/navigation' +import { useTransition } from 'react' +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from 'recharts' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import type { JobsPerDayResult } from '@/lib/insights/agent-throughput' +import { JOB_STATUS_COLORS } from '@/lib/chart-colors' + +interface Props { + data: JobsPerDayResult + productList: { id: string; name: string }[] + currentProductId?: string +} + +function formatDuration(seconds: number | null): string { + if (seconds === null) return '—' + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return m > 0 ? `${m}m ${s}s` : `${s}s` +} + +const STACKED_STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled'] as const + +export function AgentThroughputCard({ data, productList, currentProductId }: Props) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const [isPending, startTransition] = useTransition() + + const { perDay, kpi } = data + + const isEmpty = perDay.every( + d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled === 0, + ) + + function handleProductChange(value: string | null) { + if (value === null) return + startTransition(() => { + const params = new URLSearchParams(searchParams.toString()) + if (value === '__all__') { + params.delete('product') + } else { + params.set('product', value) + } + router.replace(`${pathname}?${params.toString()}`) + }) + } + + return ( +
+ {/* KPI strip + product filter */} +
+
+
+
{kpi.todayCount}
+
Jobs vandaag
+
+
+
+ {kpi.successRate7d === 0 ? '—' : `${Math.round(kpi.successRate7d * 100)}%`} +
+
Success-rate (7d)
+
+
+
+ {formatDuration(kpi.avgDurationSeconds7d)} +
+
Avg duration (7d)
+
+
+ + {productList.length > 0 && ( + + )} +
+ + {/* Chart */} + {isEmpty ? ( +

+ Geen agent-activiteit in de laatste 2 weken +

+ ) : ( + + + (v as string).slice(5)} + /> + + + {STACKED_STATUSES.map(status => ( + + ))} + + + )} +
+ ) +} diff --git a/app/(app)/insights/components/backlog-health.tsx b/app/(app)/insights/components/backlog-health.tsx new file mode 100644 index 0000000..193efe9 --- /dev/null +++ b/app/(app)/insights/components/backlog-health.tsx @@ -0,0 +1,90 @@ +'use client' + +import Link from 'next/link' +import { CheckCircle2, AlertTriangle } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { BacklogHealth, StuckTask } from '@/lib/insights/backlog-health' + +interface Props { + data: BacklogHealth +} + +function Counter({ count, label }: { count: number; label: string }) { + const ok = count === 0 + return ( +
+ {ok ? ( + + ) : ( + + )} + {count} + {label} +
+ ) +} + +function StuckRow({ task }: { task: StuckTask }) { + const severity = + task.daysStuck >= 14 + ? 'bg-priority-critical/10 text-priority-critical' + : task.daysStuck >= 7 + ? 'bg-priority-high/10 text-priority-high' + : '' + return ( + + + + {task.title} + + + + {task.productName} + + + {task.daysStuck}d + + + {task.sprintGoal ?? '—'} + + + ) +} + +export function BacklogHealthCard({ data }: Props) { + const { storiesWithoutAc, tasksWithoutPlan, stuckTasks } = data + const allClear = + storiesWithoutAc === 0 && tasksWithoutPlan === 0 && stuckTasks.length === 0 + + return ( +
+

Backlog health

+ +
+ + + +
+ + {allClear ? ( +

Geen stuck tasks 🎉

+ ) : stuckTasks.length > 0 ? ( +
+

+ Stuck tasks (top {stuckTasks.length}) +

+ + + {stuckTasks.map(t => ( + + ))} + +
+
+ ) : null} +
+ ) +} diff --git a/app/(app)/insights/components/velocity-chart.tsx b/app/(app)/insights/components/velocity-chart.tsx new file mode 100644 index 0000000..7cd2d9e --- /dev/null +++ b/app/(app)/insights/components/velocity-chart.tsx @@ -0,0 +1,92 @@ +'use client' + +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, + ReferenceLine, + ResponsiveContainer, +} from 'recharts' +import type { VelocityData } from '@/lib/insights/velocity' +import { SERIES_COLORS } from '@/lib/chart-colors' + +interface Props { + data: VelocityData +} + +export function VelocityChart({ data }: Props) { + const { sprints, productNames } = data + + if (sprints.length < 2) { + return ( +
+

Velocity

+

+ Velocity wordt zichtbaar na 2+ voltooide sprints (nu: {sprints.length}). +

+
+ ) + } + + // Reshape: [{ sprintLabel, [productName1]: count, [productName2]: count, ... }] + type Row = { sprintLabel: string } & Record + const grouped = new Map() + for (const s of sprints) { + const label = + s.sprintGoal.length > 14 ? s.sprintGoal.slice(0, 14) + '…' : s.sprintGoal + const key = `${s.sprintId}` + if (!grouped.has(key)) { + grouped.set(key, { sprintLabel: label }) + } + grouped.get(key)![s.productName] = s.doneCount + } + const rows = Array.from(grouped.values()) + + // Average across all bars (used for ReferenceLine) + const allCounts = sprints.map(s => s.doneCount) + const avg = allCounts.length > 0 ? allCounts.reduce((a, b) => a + b, 0) / allCounts.length : 0 + + return ( +
+

Velocity (laatste {sprints.length} sprints)

+ + + + + + + {productNames.map((p, i) => ( + + ))} + {avg > 0 && ( + + )} + + +
+ ) +} diff --git a/app/(app)/insights/page.tsx b/app/(app)/insights/page.tsx index 64e16af..77164d5 100644 --- a/app/(app)/insights/page.tsx +++ b/app/(app)/insights/page.tsx @@ -5,32 +5,53 @@ 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 { getVerifyResultStats, getAlignmentTrend } from '@/lib/insights/verify-stats' +import { getJobsPerDay } from '@/lib/insights/agent-throughput' +import { getVelocity } from '@/lib/insights/velocity' +import { getBacklogHealth } from '@/lib/insights/backlog-health' import { SprintInfoStrip } from './components/sprint-info-strip' import { BurndownChart } from './components/burndown-chart' import { SprintStatusDonut } from './components/sprint-status-donut' +import { PlanQualityCard } from './components/plan-quality' +import { AlignmentTrend } from './components/alignment-trend' +import { AgentThroughputCard } from './components/agent-throughput' +import { VelocityChart } from './components/velocity-chart' +import { BacklogHealthCard } from './components/backlog-health' const DAY_MS = 86_400_000 const ASSUMED_SPRINT_DAYS = 14 +interface InsightsPageProps { + searchParams: Promise<{ product?: string }> +} + function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) { return (

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

) } -export default async function InsightsPage() { +export default async function InsightsPage({ searchParams }: InsightsPageProps) { const session = await getIronSession(await cookies(), sessionOptions) const userId = session.userId! + const { product: filterProductId } = await searchParams - const [burndownSprints, statusBreakdown, activeSprints] = await Promise.all([ + const [ + burndownSprints, + statusBreakdown, + activeSprints, + productList, + verifyStats, + alignmentTrend, + jobsPerDay, + velocity, + backlogHealth, + ] = await Promise.all([ getBurndownData(userId), getSprintStatusBreakdown(userId), prisma.sprint.findMany({ @@ -43,20 +64,21 @@ export default async function InsightsPage() { tasks: { select: { id: true } }, }, }), + prisma.product.findMany({ + where: productAccessFilter(userId), + select: { id: true, name: true }, + orderBy: { name: 'asc' }, + }), + getVerifyResultStats(userId, 30), + getAlignmentTrend(userId, 5), + getJobsPerDay(userId, 14, filterProductId), + getVelocity(userId, 5), + getBacklogHealth(userId), ]) - if (activeSprints.length === 0) { - return ( -
-

Sprint Health

-

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

-
- ) - } - - const nowMs = new Date().getTime() + // Date.now is an impure call but used once per request — safe in a Server Component. + // eslint-disable-next-line react-hooks/purity + const nowMs = Date.now() const sprintInfos = activeSprints.map(s => ({ sprintId: s.id, productId: s.product.id, @@ -65,33 +87,72 @@ export default async function InsightsPage() { 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

+
+

Insights

- + {/* Sprint Health */} +
+

Sprint Health

+ {activeSprints.length === 0 ? ( +

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

+ ) : ( + <> + +
+
+ {sprintInfos.map(s => { + const burndown = burndownMap.get(s.sprintId) + if (!burndown || burndown.days.length === 0) { + return ( + + ) + } + return + })} +
+ +
+ + )} +
-
-
- {sprintInfos.map(s => { - const burndown = burndownMap.get(s.sprintId) - if (!burndown || burndown.days.length === 0) { - return ( - - ) - } - return - })} -
- -
+ {/* Plan-quality */} +
+

Plan-quality

+ + {alignmentTrend.length > 0 && } +
+ + {/* Agent throughput */} +
+

Agent throughput

+ +
+ + {/* Velocity */} +
+

Velocity

+ +
+ + {/* Backlog health */} +
+

Backlog health

+ +
) } diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 3c9cf86..3ad8a25 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -83,6 +83,7 @@ export default async function SoloProductPage({ params }: Props) { sort_order: t.sort_order, status: t.status as SoloTask['status'], verify_only: t.verify_only, + verify_required: t.verify_required as SoloTask['verify_required'], story_id: t.story.id, story_code: t.story.code, story_title: t.story.title, diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index 3b16d5f..e8a6b9e 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -33,6 +33,13 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { const sprint = await prisma.sprint.findFirst({ where: { product_id: id, status: 'ACTIVE' }, + select: { + id: true, + sprint_goal: true, + status: true, + start_date: true, + end_date: true, + }, }) if (!sprint) redirect(`/products/${id}`) diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index dd38055..b80a811 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -10,18 +10,22 @@ import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' // drive tasks into a state the shared UI can't display. const PATCHABLE_TASK_STATUS = TASK_STATUS_API_VALUES.filter((s) => s !== 'review') +const VERIFY_REQUIRED_VALUES = ['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'] as const + const patchSchema = z .object({ status: z.enum(PATCHABLE_TASK_STATUS as [string, ...string[]]).optional(), implementation_plan: z.string().optional(), verify_only: z.boolean().optional(), + verify_required: z.enum(VERIFY_REQUIRED_VALUES).optional(), }) .refine( (data) => data.status !== undefined || data.implementation_plan !== undefined || - data.verify_only !== undefined, - { message: 'Geef minimaal status, implementation_plan of verify_only mee' }, + data.verify_only !== undefined || + data.verify_required !== undefined, + { message: 'Geef minimaal status, implementation_plan, verify_only of verify_required mee' }, ) export async function PATCH( @@ -88,19 +92,21 @@ export async function PATCH( } } - // Combine simple field writes (plan, verify_only) into one update call - const simpleData: { implementation_plan?: string; verify_only?: boolean } = {} + // Combine simple field writes (plan, verify_only, verify_required) into one update call + const simpleData: { implementation_plan?: string; verify_only?: boolean; verify_required?: typeof VERIFY_REQUIRED_VALUES[number] } = {} if (parsed.data.implementation_plan !== undefined) simpleData.implementation_plan = parsed.data.implementation_plan if (parsed.data.verify_only !== undefined) simpleData.verify_only = parsed.data.verify_only + if (parsed.data.verify_required !== undefined) + simpleData.verify_required = parsed.data.verify_required const updated = await prisma.$transaction(async (tx) => { const simpleUpdate = Object.keys(simpleData).length > 0 ? await tx.task.update({ where: { id }, data: simpleData, - select: { id: true, status: true, implementation_plan: true, verify_only: true }, + select: { id: true, status: true, implementation_plan: true, verify_only: true, verify_required: true }, }) : null @@ -111,6 +117,7 @@ export async function PATCH( status: result.task.status, implementation_plan: result.task.implementation_plan, verify_only: simpleUpdate?.verify_only, + verify_required: simpleUpdate?.verify_required, } } @@ -125,5 +132,6 @@ export async function PATCH( status: taskStatusToApi(updated.status), implementation_plan: updated.implementation_plan, ...(updated.verify_only !== undefined && { verify_only: updated.verify_only }), + ...(updated.verify_required !== undefined && { verify_required: updated.verify_required }), }) } diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index f039e67..2a7e97e 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -137,6 +137,7 @@ export function NavBar({ pathname.includes('/solo') ) : disabledSpan('Solo')} + {navLink('/insights', 'Insights', pathname.startsWith('/insights'))} {navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 4524d9b..54ee48e 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -26,6 +26,7 @@ export interface SoloTask { sort_order: number status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE' verify_only: boolean + verify_required: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY' story_id: string story_code: string | null story_title: string diff --git a/components/solo/task-detail-dialog.tsx b/components/solo/task-detail-dialog.tsx index 953c614..a7f3147 100644 --- a/components/solo/task-detail-dialog.tsx +++ b/components/solo/task-detail-dialog.tsx @@ -55,16 +55,24 @@ const VERIFY_RESULT_CONFIG: Record type SaveState = 'idle' | 'saving' | 'saved' +const VERIFY_REQUIRED_LABELS: Record = { + ALIGNED: 'Strikt — alleen ALIGNED', + ALIGNED_OR_PARTIAL: 'Standaard — ALIGNED of PARTIAL met uitleg', + ANY: 'Vrij — geen verify-eis', +} + function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDetailContentProps) { - const { updatePlan, updateVerifyOnly } = useSoloStore() + const { updatePlan, updateVerifyOnly, updateVerifyRequired } = useSoloStore() const job = useSoloStore(s => s.claudeJobsByTaskId[task.id]) const connectedWorkers = useSoloStore(s => s.connectedWorkers) const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '') const [localVerifyOnly, setLocalVerifyOnly] = useState(task.verify_only) + const [localVerifyRequired, setLocalVerifyRequired] = useState(task.verify_required) const [saveState, setSaveState] = useState('idle') const [, startTransition] = useTransition() const [jobPending, startJobTransition] = useTransition() const [verifyOnlyPending, startVerifyOnlyTransition] = useTransition() + const [verifyRequiredPending, startVerifyRequiredTransition] = useTransition() const fadeTimer = useRef | null>(null) const savedPlanRef = useRef(task.implementation_plan ?? '') @@ -145,6 +153,32 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe }) } + function handleVerifyRequiredChange(e: React.ChangeEvent) { + if (isDemo) return + const newValue = e.target.value as typeof localVerifyRequired + const prevValue = localVerifyRequired + setLocalVerifyRequired(newValue) + startVerifyRequiredTransition(async () => { + try { + const res = await fetch(`/api/tasks/${task.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ verify_required: newValue }), + }) + if (!res.ok) { + setLocalVerifyRequired(prevValue) + toast.error('Verify-required bijwerken mislukt') + return + } + updateVerifyRequired(task.id, newValue) + } catch { + setLocalVerifyRequired(prevValue) + toast.error('Verify-required bijwerken mislukt') + } + }) + } + return ( <> @@ -220,6 +254,22 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe Alleen verifiëren (niet implementeren) +
+ Verify-gate: + + + +
+
{pending ? 'Opslaan…' : 'Opslaan'} } +function toDateInputValue(d: Date | null) { + if (!d) return '' + return d.toISOString().slice(0, 10) +} + export function SprintHeader({ productId: _productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) { const [editingGoal, setEditingGoal] = useState(false) + const [editingDates, setEditingDates] = useState(false) const [completeOpen, setCompleteOpen] = useState(false) const [decisions, setDecisions] = useState>({}) const [isCompleting, startCompleting] = useTransition() @@ -50,6 +58,16 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem undefined ) + const [datesState, datesFormAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await updateSprintDatesAction(_prev, fd) + if (result?.success) { setEditingDates(false); toast.success('Sprint datums opgeslagen') } + else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt') + return result + }, + undefined + ) + function setDecision(storyId: string, value: 'DONE' | 'OPEN') { setDecisions(prev => ({ ...prev, [storyId]: value })) } @@ -96,13 +114,57 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem )}
- - - +
+ + + + + + +
+ {/* Dates edit dialog */} + + + + Sprint datums instellen + +
+ +
+
+ + + {typeof datesState?.error === 'object' && (datesState.error as Record).start_date && ( +

{(datesState.error as Record).start_date[0]}

+ )} +
+
+ + + {typeof datesState?.error === 'object' && (datesState.error as Record).end_date && ( +

{(datesState.error as Record).end_date[0]}

+ )} +
+
+ {typeof datesState?.error === 'string' && ( +

{datesState.error}

+ )} +
+ + +
+
+
+
+ {/* Complete sprint dialog */} diff --git a/components/sprint/start-sprint-button.tsx b/components/sprint/start-sprint-button.tsx index f0b951c..f9c18d6 100644 --- a/components/sprint/start-sprint-button.tsx +++ b/components/sprint/start-sprint-button.tsx @@ -75,6 +75,23 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) { )} +
+
+ + + {typeof state?.error === 'object' && (state.error as Record).start_date && ( +

{(state.error as Record).start_date[0]}

+ )} +
+
+ + + {typeof state?.error === 'object' && (state.error as Record).end_date && ( +

{(state.error as Record).end_date[0]}

+ )} +
+
+ {globalError && (
{globalError} diff --git a/docs/INDEX.md b/docs/INDEX.md index 43169b8..54ab159 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -2,7 +2,7 @@ # Documentation Index -Auto-generated on 2026-05-02 from front-matter and headings. +Auto-generated on 2026-05-03 from front-matter and headings. ## Architecture Decision Records @@ -17,6 +17,7 @@ Auto-generated on 2026-05-02 from front-matter and headings. | 0006 | [ADR-0006: Demo-user write protection enforced in three layers](./adr/0006-demo-user-three-layer-policy.md) | accepted | | 0007 | [ADR-0007: Agent ↔ user question channel via persistent table + LISTEN/NOTIFY](./adr/0007-claude-question-channel-design.md) | accepted | | 0008 | [ADR-0008: Agent instructions in CLAUDE.md + topical runbooks](./adr/0008-agent-instructions-in-claude-md.md) | accepted | +| 0009 | [ADR-0009: Three-phase agent pipeline for feature ideation → plan → implementation](./adr/0009-three-phase-feature-pipeline.md) | proposed | ## Specifications @@ -36,6 +37,7 @@ Auto-generated on 2026-05-02 from front-matter and headings. | [PBI Bulk-Create Spec — Docs-Restructure for AI-Optimized Lookup](./plans/docs-restructure-pbi-spec.md) | — | — | | [M10 — Password-loze inlog via QR-pairing](./plans/M10-qr-pairing-login.md) | active | 2026-05-03 | | [M11 — Claude vraagt, gebruiker antwoordt](./plans/M11-claude-questions.md) | active | 2026-05-03 | +| [M12 — Drie-fase agent-pipeline voor feature-ideatie](./plans/M12-three-phase-feature-pipeline.md) | proposal | 2026-05-03 | | [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | active | 2026-05-03 | | [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 | | [ST-1110 — Demo gebruiker read-only](./plans/ST-1110-demo-readonly.md) | active | 2026-05-03 | @@ -64,13 +66,16 @@ Auto-generated on 2026-05-02 from front-matter and headings. | [Route Handler (REST API)](./patterns/route-handler.md) | active | 2026-05-03 | | [Server Action](./patterns/server-action.md) | active | 2026-05-03 | | [Float sort_order (drag-and-drop volgorde)](./patterns/sort-order.md) | active | 2026-05-03 | +| [Patroon: Story met UI-component](./patterns/story-with-ui-component.md) | — | — | | [Zustand optimistische update + rollback](./patterns/zustand-optimistic.md) | active | 2026-05-03 | ## Other Docs | Title | Path | Status | Updated | |---|---|---|---| +| [Scrum4Me REST API](./api.md) | `api.md` | active | 2026-05-03 | | [Scrum4Me REST API](./api/rest-contract.md) | `api/rest-contract.md` | active | 2026-05-03 | +| [route-handlers](./app/getting-started/route-handlers.md) | `app/getting-started/route-handlers.md` | — | — | | [Scrum4Me — Technische Architectuur (breadcrumb)](./architecture.md) | `architecture.md` | active | 2026-05-03 | | [Authentication, Sessions & Demo Policy](./architecture/auth-and-sessions.md) | `architecture/auth-and-sessions.md` | active | 2026-05-03 | | [Claude ↔ User Question Channel](./architecture/claude-question-channel.md) | `architecture/claude-question-channel.md` | active | 2026-05-03 | @@ -78,13 +83,25 @@ Auto-generated on 2026-05-02 from front-matter and headings. | [Scrum4Me — Architecture Overview](./architecture/overview.md) | `architecture/overview.md` | active | 2026-05-03 | | [Project Structure, Stores, Realtime & Job Queue](./architecture/project-structure.md) | `architecture/project-structure.md` | active | 2026-05-03 | | [QR-pairing Login Flow](./architecture/qr-pairing.md) | `architecture/qr-pairing.md` | active | 2026-05-03 | +| [Scrum4Me — Implementatie Backlog](./backlog.md) | `backlog.md` | active | 2026-05-03 | | [Scrum4Me — Implementatie Backlog](./backlog/index.md) | `backlog/index.md` | active | 2026-05-03 | | [DevPlanner — Product Backlog](./backlog/product-historical.md) | `backlog/product-historical.md` | active | 2026-05-03 | | [Agent Instruction Audit](./decisions/agent-instructions-history.md) | `decisions/agent-instructions-history.md` | active | 2026-05-03 | | [Scrum4Me — Styling & Design System](./design/styling.md) | `design/styling.md` | active | 2026-05-03 | +| [Docker smoke test — task 1](./docker-smoke/2-mei-task-1.md) | `docker-smoke/2-mei-task-1.md` | — | — | +| [Docker smoke test — task 2](./docker-smoke/2-mei-task-2.md) | `docker-smoke/2-mei-task-2.md` | — | — | +| [Scrum4Me — Functionele Specificatie](./functional.md) | `functional.md` | active | 2026-05-03 | | [Scrum4Me — Glossary](./glossary.md) | `glossary.md` | active | 2026-05-03 | +| [Scrum4Me — Styling & Design System](./md3-color-scheme.md) | `md3-color-scheme.md` | active | 2026-05-03 | | [Obsidian as Personal Authoring Layer](./obsidian-authoring.md) | `obsidian-authoring.md` | active | 2026-05-02 | +| [PbiDialog Profiel](./pbi-dialog.md) | `pbi-dialog.md` | — | — | +| [DevPlanner — User Personas](./personas.md) | `personas.md` | active | 2026-05-03 | +| [DevPlanner — Product Backlog](./product-backlog.md) | `product-backlog.md` | active | 2026-05-03 | | [Scrum4Me — API Test Plan](./qa/api-test-plan.md) | `qa/api-test-plan.md` | active | 2026-05-03 | +| [Realtime smoke-checklist — PBI / Story / Task](./realtime-smoke.md) | `realtime-smoke.md` | — | — | | [Branch, PR & Commit Strategy](./runbooks/branch-and-commit.md) | `runbooks/branch-and-commit.md` | active | 2026-05-03 | | [Vercel Deployment](./runbooks/deploy-vercel.md) | `runbooks/deploy-vercel.md` | active | 2026-05-03 | | [MCP Integration — Scrum4Me Tools](./runbooks/mcp-integration.md) | `runbooks/mcp-integration.md` | active | 2026-05-03 | +| [StoryDialog Profiel](./story-dialog.md) | `story-dialog.md` | — | — | +| [TaskDialog Profiel](./task-dialog.md) | `task-dialog.md` | — | — | +| [Scrum4Me — API Test Plan](./test-plan.md) | `test-plan.md` | active | 2026-05-03 | diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..4065a47 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,528 @@ +--- +title: "Scrum4Me REST API" +status: active +audience: [ai-agent, contributor] +language: en +last_updated: 2026-05-03 +--- + +# Scrum4Me REST API + +REST-API contract voor Claude Code en andere clients. + +## Authenticatie + +Alle endpoints behalve `GET /api/health` vereisen een Bearer-token: + +``` +Authorization: Bearer +``` + +Tokens beheer je via Instellingen → Tokens (`/settings/tokens`). Een token is gekoppeld aan één gebruiker; een demo-account-token kan lezen maar niet schrijven (`403`). + +## Status-enums + +De API gebruikt **lowercase** statussen. De database gebruikt UPPER_SNAKE; de vertaling gebeurt op de boundary. + +| Entiteit | Waarden | +|---|---| +| Task status | `todo`, `in_progress`, `review`, `done` | +| Story status | `open`, `in_sprint`, `done` | + +## Foutcodes + +| Code | Betekenis | +|---|---| +| `200` | OK | +| `201` | Created | +| `400` | Malformed body (bv. ongeldige JSON) | +| `401` | Token ontbreekt of ongeldig | +| `403` | Token heeft geen toegang (demo-account, geen lid van product) | +| `404` | Resource niet gevonden | +| `422` | Validatiefout — body is wel-gevormd maar niet acceptabel | +| `500` | Onverwachte serverfout | + +--- + +## Endpoints + +### `GET /api/health` + +Health-probe. Geen authenticatie vereist. + +**Query params:** `?db=1` voegt een DB-ping toe. + +**Response (200):** +```json +{ "status": "ok", "version": "0.3.x", "time": "2026-04-26T20:00:00Z" } +``` + +Met `?db=1`: +```json +{ "status": "ok", "version": "0.3.x", "time": "...", "database": "ok" } +``` + +`database` is `"ok"` of `"down"`. De endpoint zelf retourneert altijd `200`. + +```bash +curl https://scrum4me.app/api/health?db=1 +``` + +--- + +### `GET /api/products` + +Lijst van actieve producten waar de tokengebruiker eigenaar of lid van is. + +**Response (200):** +```json +[ + { + "id": "cmofu...", + "code": "SCRUM4ME", + "name": "Scrum4Me", + "description": "...", + "repo_url": "https://github.com/...", + "definition_of_done": "..." + } +] +``` + +```bash +curl -H "Authorization: Bearer $TOKEN" https://scrum4me.app/api/products +``` + +--- + +### `GET /api/products/:id/claude-context` + +Bundled context voor Claude Code: product, actieve sprint, volgende story (met tasks) en open todos van de tokengebruiker — in één call. + +**Response (200):** +```json +{ + "product": { "id", "code", "name", "description", "repo_url", "definition_of_done" }, + "active_sprint": { "id": "...", "sprint_goal": "...", "status": "ACTIVE" } | null, + "next_story": { + "id", "code", "title", "description", "acceptance_criteria", + "priority", "status", + "tasks": [ + { "id", "code", "title", "description", "implementation_plan", + "priority", "sort_order", "status" } + ] + } | null, + "open_todos": [ + { "id", "title", "description", "created_at" } + ] +} +``` + +`open_todos` is gelimiteerd op 50 items, gesorteerd op `created_at` asc. Demo-tokens kunnen dit endpoint lezen. + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + https://scrum4me.app/api/products/$PRODUCT_ID/claude-context +``` + +--- + +### `GET /api/products/:id/next-story` + +Hoogst geprioriteerde open story in de actieve sprint. + +**Response (200):** +```json +{ + "id": "...", + "code": "ST-356", + "title": "Solo Kanban-bord met DnD en Zustand", + "description": "...", + "acceptance_criteria": "...", + "status": "in_sprint", + "tasks": [ + { + "id": "...", + "code": "ST-356.1", + "title": "Store stores/solo-store.ts", + "description": "...", + "implementation_plan": null, + "priority": 2, + "sort_order": 1, + "status": "todo" + } + ] +} +``` + +**Foutcodes:** `404` als geen actieve sprint of geen open stories. + +--- + +### `GET /api/sprints/:id/tasks` + +Lijst taken van de sprint, geordend op `(story.sort_order, task.priority, task.sort_order)`. + +**Query params:** `?limit=N` (default 10, max 50) + +**Response (200):** +```json +[ + { + "id": "...", + "code": "ST-356.1", + "title": "...", + "description": "...", + "implementation_plan": null, + "story_id": "...", + "story_code": "ST-356", + "priority": 2, + "sort_order": 1, + "status": "todo" + } +] +``` + +--- + +### `PATCH /api/stories/:id/tasks/reorder` + +Volgorde van taken binnen een story aanpassen. + +**Body:** +```json +{ "task_ids": ["task-id-a", "task-id-b", "task-id-c"] } +``` + +Alle IDs moeten bij de story horen. **Foutcodes:** `422` bij Zod-fouten of als een task_id niet tot de story behoort. + +--- + +### `PATCH /api/tasks/:id` + +Status of implementation_plan bijwerken. Minstens één van beide is verplicht. +Toegestane status-waarden zijn `todo`, `in_progress` en `done`. `review` +wordt door deze endpoint geweigerd zolang de sprint-UI die state niet +rendert — gebruik de Kanban-board voor REVIEW-overgangen. + +**Body:** +```json +{ "status": "in_progress", "implementation_plan": "..." } +``` + +**Response (200):** +```json +{ + "id": "...", + "status": "in_progress", + "implementation_plan": "..." +} +``` + +**Foutcodes:** `422` bij ongeldige body of onbekende status. `403` bij demo-token. + +--- + +### `POST /api/stories/:id/log` + +Activiteit vastleggen op een story. + +**Body — IMPLEMENTATION_PLAN:** +```json +{ + "type": "IMPLEMENTATION_PLAN", + "content": "Plan: ...", + "metadata": { "branch": "feat/x" } +} +``` + +**Body — TEST_RESULT:** +```json +{ + "type": "TEST_RESULT", + "content": "Alle tests groen", + "status": "PASSED", + "metadata": { "ci_run": "..." } +} +``` + +**Body — COMMIT:** +```json +{ + "type": "COMMIT", + "content": "Werk afgerond", + "commit_hash": "abc123", + "commit_message": "feat(ST-XXX): ...", + "metadata": { "branch": "feat/x" } +} +``` + +`metadata` is optioneel, vrij JSON-object. **Response (201):** +```json +{ "id": "...", "created_at": "..." } +``` + +--- + +### `POST /api/todos` + +Nieuwe todo voor de tokengebruiker. + +**Body:** +```json +{ + "title": "Een ding doen", + "description": "Optionele uitleg, max 2000 tekens", + "product_id": "cmof..." +} +``` + +**Response (201):** +```json +{ "id": "...", "title": "...", "description": "...", "created_at": "..." } +``` + +--- + +### `GET /api/realtime/solo?product_id=...` + +Server-Sent Events stream voor het Solo Paneel. Wordt gebruikt door de browser-UI (`useSoloRealtime`); voor Claude Code zelden relevant, maar gedocumenteerd voor volledigheid. + +**Auth:** iron-session cookie of Bearer-token. Demo-tokens mogen lezen. +**Query params:** `product_id` (verplicht). +**Response:** `text/event-stream`. Stream blijft open tot de client sluit of de server na 240s een hard-close doet (client herconnect dan transparant). + +**Events:** +- `event: ready` — eenmalig direct na connect, met `{ product_id, sprint_id }` als payload. +- `event: error` — bij interne fouten (pg connect mislukt e.d.). +- `data: {...}` — task/story mutaties die binnen scope vallen (zie hieronder). Payload-shape: + + ```json + { + "op": "I" | "U" | "D", + "entity": "task" | "story", + "id": "cmof...", + "story_id": "cmof...", + "product_id": "cmof...", + "sprint_id": "cmog..." , + "assignee_id": "cmof..." , + "task_status": "TO_DO" | "IN_PROGRESS" | "REVIEW" | "DONE", + "task_title": "...", + "task_sort_order": 1, + "changed_fields": ["status", "updated_at"] + } + ``` + + Niet alle velden zijn altijd aanwezig — `task_*` alleen voor `entity: "task"`, idem `story_*`. `task_status` gebruikt de **DB-enum** (UPPER_SNAKE), niet de lowercase API-vorm. + +- `: heartbeat` — SSE-comment elke 25s, om proxies keep-alive te houden. Kan genegeerd worden. + +**Server-side filter:** +- `product_id` matcht de query-param +- `sprint_id` matcht de actieve sprint van het product +- `assignee_id` is gelijk aan de ingelogde user (of `null` voor unassigned-story claims) + +Niet-matchende events worden gedropt — clients ontvangen geen irrelevante data. + +**Voorbeeld (browser):** +```js +const source = new EventSource('/api/realtime/solo?product_id=cmof...') +source.onmessage = (e) => console.log(JSON.parse(e.data)) +``` + +--- + +## Auth — QR-pairing (M10) + +Drie anonieme/cookie-geauthenticeerde endpoints voor de password-loze inlog +via QR-pairing. Worden door de browser gebruikt (niet door Claude Code) — +gedocumenteerd voor volledigheid en voor handmatige curl-tests. + +**Cookie-mechaniek:** `pair/start` zet een korte `s4m_pair`-HttpOnly-cookie +(`Path=/api/auth/pair`, `Max-Age=300`, `SameSite=Lax`, `Secure` in productie). +`pair/stream` en `pair/claim` authenticeren tegen die cookie. Geheim materiaal +zit nooit in URL-paden of querystrings — `mobileSecret` reist alleen via QR- +fragment (`#s=…`) en POST-body, `desktopToken` alleen via cookie. + +### `POST /api/auth/pair/start` + +Anon. Maakt een nieuwe `LoginPairing` aan en zet de pre-auth cookie. + +**Auth:** geen. +**Body:** geen. +**Rate-limit:** 10 per IP per minuut (zelfde patroon als `/login`). + +**Response 200:** +```json +{ + "pairingId": "cmoh...", + "mobileSecret": "<43-char base64url>", + "expiresAt": "2026-04-27T20:30:00.000Z", + "qrUrl": "https://.../m/pair#id=cmoh...&s=" +} +``` +Plus `Set-Cookie: s4m_pair=; HttpOnly; Path=/api/auth/pair; Max-Age=300; SameSite=Lax`. + +**Foutcodes:** `429` bij rate-limit overschreden. + +**Voorbeeld:** +```bash +curl -i -X POST -c /tmp/jar http://localhost:3000/api/auth/pair/start +``` + +--- + +### `GET /api/auth/pair/stream/:pairingId` + +Server-Sent Events stream die de desktop opent direct na `pair/start` om op +de approve-bevestiging van de mobiel te wachten. + +**Auth:** `s4m_pair`-cookie. Werkt vanuit `EventSource` met `withCredentials: true`. +**Path:** `pairingId` is niet vertrouwelijk; cookie is het bewijs. +**Stream-duur:** maximaal 240s (Vercel-buffer onder de 300s `maxDuration`); sluit +zodra status `consumed` of `cancelled` doorkomt. + +**Events:** +- `event: state` — eenmalig direct na connect, met `{ pairing_id, status }` (status van pairing op moment van connecten — voorkomt race wanneer approve net vóór SSE-open landt). +- `data: {...}` — bij elke status-overgang. Payload: + ```json + { "op": "I" | "U", "pairing_id": "cmoh...", "status": "pending" | "approved" | "consumed" | "cancelled" } + ``` +- `: heartbeat` — SSE-comment elke 25s. + +**Foutcodes:** `401` zonder/foute cookie, `404` als pairing onbekend, `410` als pairing verlopen. + +**Voorbeeld:** +```bash +curl -N -i -b /tmp/jar http://localhost:3000/api/auth/pair/stream/ +``` + +--- + +### `POST /api/auth/pair/claim` + +Cookie-auth. Atomisch consume van een approved pairing → schrijft de echte +`session` cookie zodat de desktop is ingelogd. + +**Auth:** `s4m_pair`-cookie. +**Body:** `{ "pairingId": "cmoh..." }`. + +**Response 200:** `{ "ok": true }` plus +- `Set-Cookie: session=...; HttpOnly; SameSite=Lax` — paired-sessie met `paired: true` en `pairedExpiresAt = now + 8h` payload-velden. +- `Set-Cookie: s4m_pair=...; Max-Age=0` — pre-auth cookie wordt gewist. + +**Foutcodes:** +- `400` bij ontbrekende of malformed body +- `401` zonder cookie of bij hash-mismatch (cookie matcht geen pairing) +- `410` als pairing al consumed/cancelled is (replay) of verlopen + +**Voorbeeld:** +```bash +curl -i -X POST -b /tmp/jar -c /tmp/jar \ + -H "Content-Type: application/json" \ + -d '{"pairingId":""}' \ + http://localhost:3000/api/auth/pair/claim +``` + +--- + +## Notifications — Vraag-antwoord-kanaal (M11) + +Endpoints voor de Claude vraag-antwoord-flow. De **MCP-tools** in de mcp-repo (`ask_user_question`, `get_question_answer`, `list_open_questions`, `cancel_question`) zijn de primaire schrijf-interface; de endpoints hieronder zijn voor de browser-UI en cron. + +### `GET /api/realtime/notifications` + +Server-Sent Events stream voor de notifications-bell in de NavBar. **User-scoped** — geen `product_id`-param; filtert server-side op alle producten waar de gebruiker eigenaar of teamlid is. + +**Auth:** iron-session cookie. Demo-gebruikers mogen lezen. +**Response:** `text/event-stream`. Stream blijft open tot client sluit of server na 240s een hard-close doet (client herconnect). + +**Events:** +- `event: state` — eenmalig direct na connect, met `{ questions: [...] }` als payload (zelfde shape als de live updates). +- `data: {...}` — bij elke status-overgang in `claude_questions`. Payload-shape: + ```json + { + "op": "I" | "U", + "entity": "question", + "id": "cmoh...", + "product_id": "cmoh...", + "story_id": "cmoh...", + "task_id": "cmoh..." | null, + "assignee_id": "cmoh..." | null, + "status": "open" | "answered" | "cancelled" | "expired" + } + ``` + Het is een delta — voor de volledige vraag-tekst en options reconnect de client (initial-state-event levert ze opnieuw). +- `: heartbeat` — SSE-comment elke 25s. + +**Server-side filter:** +- `payload.entity === 'question'` (`task` en `story` events horen op `/api/realtime/solo`) +- `payload.product_id` zit in de set producten met user-access (productAccessFilter) + +**Voorbeeld:** +```js +const source = new EventSource('/api/realtime/notifications', { withCredentials: true }) +``` + +--- + +## Cron — Expire questions + +### `POST /api/cron/expire-questions` + +Vercel cron handler die dagelijks draait. Markeert verlopen open vragen als `expired` en verlopen pending login_pairings als `cancelled`. + +**Auth:** `Authorization: Bearer ${CRON_SECRET}` — header die Vercel automatisch injecteert wanneer de env-var op de project-omgeving staat. Zonder secret of bij mismatch: 401. + +**Schedule:** `0 4 * * *` (dagelijks om 04:00 UTC; Vercel Hobby-plan staat alleen daily crons toe — Pro ondersteunt fijnmazigere schedules). + +**Response 200:** +```json +{ + "expired_questions": 0, + "expired_pairings": 0, + "ran_at": "2026-04-28T00:00:00.000Z" +} +``` + +**Voorbeeld (handmatige trigger):** +```bash +curl -X POST -H "Authorization: Bearer $CRON_SECRET" \ + https://your-app.vercel.app/api/cron/expire-questions +``` + +--- + +## Cron — Cleanup agent artifacts + +### `POST /api/cron/cleanup-agent-artifacts` + +Vercel cron handler die dagelijks draait. Verwijdert `FAILED` en `CANCELLED` claude_jobs waarvan `finished_at` ouder is dan 7 dagen. Hard-delete — geen historische waarde; audit-trail zit in git-commits. + +**Auth:** `Authorization: Bearer ${CRON_SECRET}` — zelfde mechanisme als `/api/cron/expire-questions`. Zonder secret of bij mismatch: 401. + +**Schedule:** `0 3 * * *` (dagelijks om 03:00 UTC). + +**Response 200:** +```json +{ + "deleted": 3, + "ran_at": "2026-05-01T03:00:00.000Z" +} +``` + +**Voorbeeld (handmatige trigger):** +```bash +curl -X POST -H "Authorization: Bearer $CRON_SECRET" \ + https://your-app.vercel.app/api/cron/cleanup-agent-artifacts +``` + +--- + +## Voorbeeldworkflow voor Claude Code + +1. **Probe:** `GET /api/health?db=1` — bevestig dat de service en DB bereikbaar zijn. +2. **Context:** `GET /api/products/$ID/claude-context` — haal product, sprint, volgende story en todos op in één call. +3. **Plan vastleggen:** `POST /api/stories/$STORY_ID/log` met `type: IMPLEMENTATION_PLAN`. +4. **Per task:** `PATCH /api/tasks/$TASK_ID` met `status: "in_progress"`, daarna met `status: "done"` plus eventueel `implementation_plan`. +5. **Test:** `POST /api/stories/$STORY_ID/log` met `type: TEST_RESULT` en `status: PASSED|FAILED`. +6. **Commit:** `POST /api/stories/$STORY_ID/log` met `type: COMMIT`, `commit_hash`, `commit_message`, optioneel `metadata: { branch }`. diff --git a/docs/api/.gitkeep b/docs/api/.gitkeep index e69de29..94f7de9 100644 --- a/docs/api/.gitkeep +++ b/docs/api/.gitkeep @@ -0,0 +1 @@ +# placeholder — remove when first file is added diff --git a/docs/architecture/.gitkeep b/docs/architecture/.gitkeep new file mode 100644 index 0000000..94f7de9 --- /dev/null +++ b/docs/architecture/.gitkeep @@ -0,0 +1 @@ +# placeholder — remove when first file is added diff --git a/docs/assets/.gitkeep b/docs/assets/.gitkeep index e69de29..94f7de9 100644 --- a/docs/assets/.gitkeep +++ b/docs/assets/.gitkeep @@ -0,0 +1 @@ +# placeholder — remove when first file is added diff --git a/docs/backlog.md b/docs/backlog.md new file mode 100644 index 0000000..3891334 --- /dev/null +++ b/docs/backlog.md @@ -0,0 +1,784 @@ +--- +title: "Scrum4Me — Implementatie Backlog" +status: active +audience: [maintainer, contributor] +language: nl +last_updated: 2026-05-03 +--- + +# Scrum4Me — Implementatie Backlog + +**Versie:** 0.1 — april 2026 +**Volgt op:** Functionele Specificatie v0.2, Architectuur v0.1 + +--- + +## MVP-definitie + +De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan doorlopen: een product aanmaken, een Product Backlog opbouwen met PBI's en stories, een Sprint plannen, taken aanmaken, en Claude Code de volgende story laten ophalen, implementeren en vastleggen — allemaal zonder hulp of handleiding. De app draait stabiel op Vercel en is volledig lokaal opzetbaar via één README. + +--- + +## Milestone-overzicht + +| Milestone | Doel | Tasks | +|---|---|---| +| M0: Foundation | Project, database, auth, navigatieshell | ST-001 – ST-008 | +| M1: Producten & Product Backlog | Producten, PBI's, gesplitst scherm | ST-101 – ST-110 | +| M2: Stories & Drag-and-drop | Stories als blokken, dnd-kit, Zustand | ST-201 – ST-210 | +| M3: Sprint Backlog & Sprint Planning | Sprint aanmaken, stories slepen, taken | ST-301 – ST-313 | +| M3.5: Solo Paneel & Story Assignment | Story-claim, persoonlijk Kanban-bord per product | ST-350 – ST-360 | +| M4: Claude Code REST API | Alle endpoints, tokenbeheer | ST-401 – ST-410 | +| M5: Todo-lijst | Todo CRUD, promotie naar PBI/story; Data Table + detail-kaart | ST-501 – ST-506, ST-509 – ST-510 | +| M6: Polish & Launch-ready | Foutafhandeling, toegankelijkheid, CI/CD, beveiliging | ST-601 – ST-612 | +| M7: MCP-server voor Claude Code | Native MCP-laag bovenop Scrum4Me-DB (aparte repo `mcp`) | ST-701 – ST-710 | +| M8: Realtime Solo Paneel | Live updates voor stories/tasks via SSE + Postgres LISTEN/NOTIFY | ST-801 – ST-806 | +| M9: Actief Product Backlog | Persistente actieve PB-keuze, gesplitste navigatie, disabled-states | ST-901 – ST-907 | +| M10: Password-loze inlog via QR-pairing | Mobiel als bevestigingskanaal voor desktop-login zonder wachtwoord | ST-1001 – ST-1008 | +| M11: Claude vraagt, gebruiker antwoordt | Persistent vraag-antwoord-kanaal tussen Claude (MCP) en de actieve gebruiker | ST-1101 – ST-1108 | +--- + +## Backlog + +### M0: Foundation + +- [x] **ST-001** Project scaffolding + - `create-next-app` met TypeScript strict, Tailwind CSS, App Router; installeer shadcn/ui, Zustand, dnd-kit, iron-session, bcrypt, Zod; configureer path aliases (`@/`) + - Done when: `npm run dev` start zonder fouten; `npm run lint` geeft geen errors; shadcn `Button` rendert op een testpagina + +- [x] **ST-002** Prisma v7 setup + `prisma.config.ts` + - Installeer Prisma v7 + `@prisma/adapter-pg`; schrijf `prisma.config.ts` met `DATABASE_URL` via Zod-gevalideerde env; schrijf `lib/prisma.ts` singleton + - Done when: `npx prisma db push` slaagt; Prisma Client importeerbaar in een testbestand zonder fouten + +- [x] **ST-003** Database schema migratie (volledige initiële migratie) + - Schrijf het volledige `schema.prisma` op basis van het architectuurdocument: `User`, `UserRole`, `ApiToken`, `Product`, `Pbi`, `Story`, `StoryLog`, `Sprint`, `Task`, `Todo`; alle enums, indexes, cascade deletes + - Done when: `npx prisma migrate dev --name init` slaagt; alle tabellen zichtbaar in DB-client; `npx prisma validate` geeft geen fouten + +- [x] **ST-004** Seed met testdata + - Schrijf `prisma/seed.ts` op basis van het Product Backlog document (devplanner-product-backlog.md); seed één gebruiker, één product (Scrum4Me zelf), alle PBI's en stories als testdata; voeg demo-gebruiker toe + - Done when: `npx prisma db seed` slaagt; DB bevat alle PBI's en stories uit het backlog-document; demo-gebruiker aanwezig + +- [x] **ST-005** Environment variabelen + `lib/env.ts` + - Schrijf Zod-schema voor alle env vars (`DATABASE_URL`, `DIRECT_URL`, `SESSION_SECRET`, `NODE_ENV`); exporteer gevalideerd `env` object; schrijf `.env.example` met instructies + - Done when: app gooit een begrijpelijke fout bij ontbrekende env var; `.env.example` volledig gedocumenteerd + +- [x] **ST-006** Authenticatie — registratie en inloggen + - Schrijf `lib/auth.ts` (registreer met bcrypt hash, verifieer bij inloggen); schrijf `lib/session.ts` (iron-session config); implementeer `/register` en `/login` pagina's met Server Actions; sla `{ userId, isDemo }` op in sessiecookie + - Done when: registreren → ingelogde sessie → redirect `/dashboard`; inloggen met verkeerde credentials geeft generieke foutmelding; sessie blijft actief na paginaverversing + +- [x] **ST-007** Route-beveiliging via `proxy.ts` + - Schrijf `proxy.ts` die sessiecookie-aanwezigheid controleert; redirect naar `/login` bij alle `/dashboard`, `/products/*`, `/todos`, `/settings/*` routes zonder sessiecookie; authenticated users worden van `/login` en `/register` doorgestuurd naar `/dashboard`; volledige sessievalidatie gebeurt server-side in de app layout + - Done when: directe navigatie naar `/dashboard` zonder sessie redirect naar `/login`; ingelogde gebruiker op `/login` redirect naar `/dashboard` + +- [x] **ST-008** Navigatieshell + dashboard-layout + - Schrijf `app/(app)/layout.tsx` met navigatiebalk (logo, productenlink, todolink, instellingen, uitlogknop); implementeer uitlog Server Action; implementeer `/dashboard` als lege productenlijstpagina met "Maak je eerste product aan" lege staat; zet demo-badge zichtbaar als `isDemo === true` + - Done when: volledige auth-flow (register → login → dashboard → logout → login) werkt end-to-end; demo-gebruiker ziet badge in navigatie + +--- + +### M1: Producten & Product Backlog + +- [x] **ST-101** Product aanmaken + - `/products/new` pagina met formulier (naam, beschrijving, repo URL, definition of done); `createProduct` Server Action met Zod-validatie; uniekheidscontrole op naam per gebruiker; redirect naar `/products/[id]` na aanmaken + - Done when: product aangemaakt en zichtbaar op dashboard; dubbele naam geeft inline validatiefout; lege naam blokkeert submit + +- [x] **ST-102** Productenlijst op dashboard + - Haal actieve producten op via Prisma Server Component; toon naam, beschrijving (ingekort 80 tekens), repo-link; lege staat met CTA; klikken opent Product Backlog + - Done when: twee producten zichtbaar na aanmaken; gearchiveerd product niet zichtbaar in standaardlijst + +- [x] **ST-103** Product bewerken en archiveren + - Bewerkformulier (naam, beschrijving, repo URL, DoD) via Server Action; archiveerknop met bevestigingsdialoog; hersteloptie voor gearchiveerde producten; "toon gearchiveerd"-filter op dashboard + - Done when: naam bijwerken persisteert; archiveren verbergt product; herstel maakt het weer zichtbaar + +- [x] **ST-104** Gesplitst scherm layout component (`SplitPane`) + - Bouw herbruikbaar `` Client Component met versleepbare horizontale splitter; sla splitter-positie op in `localStorage` per sleutel; standaard 40/60 verhouding; minimale panelbreedte 200px; responsive fallback naar tabs op < 1024px + - Done when: splitter versleepbaar en positie behouden na paginaverversing; tabs getoond op smal scherm + +- [x] **ST-105** Navigatiebar-component per paneel + - Bouw herbruikbaar `` component met slots voor knoppen (aanmaken, filter, verwijderen); consistent design voor linker- en rechterpaneel + - Done when: navigatiebar herbruikt in minimaal twee gesplitste schermen zonder duplicatie + +- [x] **ST-106** PBI aanmaken en weergeven + - Linkerpaneel van `/products/[id]`: haal PBI's op gegroepeerd op prioriteit en sort_order; "PBI aanmaken" knop opent inline formulier (titel, prioriteit); `createPbi` Server Action; nieuw PBI verschijnt onderaan de juiste prioriteitsgroep + - Done when: PBI aangemaakt en zichtbaar in juiste prioriteitsgroep; lege staat toont prompt + +- [x] **ST-107** PBI prioriteitsgroepen met visuele scheiding + - Render PBI's gegroepeerd per prioriteit (1–4) met gelabelde scheidingslijn per groep (bijv. "Kritiek", "Hoog"); lege groepen zijn niet zichtbaar; prioriteitsbadge per PBI + - Done when: vier prioriteitsgroepen correct gerenderd met labels; PBI met prioriteit 1 staat boven prioriteit 4 + +- [x] **ST-108** PBI bewerken en verwijderen + - Inline bewerkingsmodus via dubbelklik of contextmenu (titel, omschrijving, prioriteit); `updatePbi` Server Action; verwijderen met bevestigingsdialoog inclusief waarschuwing cascade; `deletePbi` Server Action + - Done when: titelbewering opgeslagen zonder paginaverversing; verwijderen cascade-verwijdert stories (verifieerbaar in DB) + +- [x] **ST-109** PBI selecteren → stories laden + - Klikken op PBI in linkerpaneel toont bijbehorende stories rechts via `useSelectionStore`; geselecteerd PBI visueel gemarkeerd; lege staat rechts als geen stories + - Done when: klikken op PBI A toont stories van A rechts; klikken op PBI B schakelt direct over + +- [x] **ST-110** PBI filter + - Filterknop in linkerpaneel navigatiebar; dropdown voor prioriteit (1–4, alle); filter werkt realtime op gerenderde lijst; actief filter zichtbaar als badge; wissen via ×-knop + - Done when: filter op prioriteit 1 verbergt alle andere PBI's; wissen herstelt volledige lijst + +--- + +### M2: Stories & Drag-and-drop + +- [x] **ST-201** `usePlannerStore` Zustand-store + - Schrijf `stores/planner-store.ts` met `pbiOrder`, `storyOrder`, `taskOrder`; `init*`, `reorder*`, `rollback*` actions; TypeScript strict types + - Done when: store importeerbaar in een Client Component; `initPbis` vult order; `reorderPbis` muteert order; `rollbackPbis` herstelt vorige staat + +- [x] **ST-202** `useSelectionStore` Zustand-store + - Schrijf `stores/selection-store.ts` met `selectedPbiId`, `selectedStoryId`, setters en `clearSelection` + - Done when: selectie in linkerpaneel via store zichtbaar in rechterpaneel zonder prop drilling + +- [x] **ST-203** dnd-kit setup + PBI drag-and-drop + - Installeer dnd-kit; wrap linkerpaneel in `DndContext` + `SortableContext`; implementeer `useSortable` per PBI-rij; `onDragEnd`: bereken nieuwe `sort_order` via float-gemiddelde; optimistisch updaten via `usePlannerStore`; `reorderPbisAction` Server Action; rollback bij fout + - Done when: PBI versleepbaar binnen prioriteitsgroep; volgorde opgeslagen na loslaten; UI rollback bij gesimuleerde server-fout + +- [x] **ST-204** PBI drag-and-drop over prioriteitsgrens + - Uitbreiding ST-203: slepen over een prioriteitsgrens wijzigt `priority` van het PBI; `sort_order` wordt onderaan de doelgroep geplaatst; `updatePbiPriority` Server Action + - Done when: PBI naar prioriteit 2 slepen vanuit prioriteit 3 wijzigt zowel prioriteit als volgorde + +- [x] **ST-205** Story aanmaken en weergeven als blokken + - Rechterpaneel van Product Backlog: haal stories op voor geselecteerd PBI; render als blokken (~10% schermbreedte, horizontaal); elk blok toont titel (ingekort), prioriteitsbadge, statusbadge; "Story aanmaken" knop; `createStory` Server Action + - Done when: drie stories zichtbaar als blokken; nieuw blok verschijnt in juiste prioriteitsgroep + +- [x] **ST-206** Story prioriteitsgroepen met visuele scheiding + - Groepeer story-blokken per prioriteit; gekleurde band of scheidingslijn per groep; blokken horizontaal gerangschikt per rij; nieuwe rij bij overloop + - Done when: stories van vier prioriteiten correct gescheiden weergegeven + +- [x] **ST-207** Story drag-and-drop (horizontaal, binnen en tussen groepen) + - dnd-kit horizontale `SortableContext` per prioriteitsgroep; `onDragEnd`: herrangschikking via float-gemiddelde in `storyOrder`; slepen naar andere groep wijzigt prioriteit; optimistisch via `usePlannerStore`; `reorderStoriesAction` Server Action; rollback bij fout + - Done when: story versleepbaar binnen groep en naar andere groep; volgorde en prioriteit persistent na loslaten + +- [x] **ST-208** Story detail-modal / slide-over + - Klikken op storyblok opent slide-over of modal met titel, omschrijving, acceptatiecriteria, statusbadge, activiteitenlog (leeg bij nieuwe story); bewerkformulier voor titel/omschrijving/acceptatiecriteria; `updateStory` Server Action + - Done when: klikken op blok opent detail; bewerken persisteert; sluiten keert terug naar backlog + +- [x] **ST-209** Story verwijderen + - Verwijderknop in story-detail of contextmenu; bevestigingsdialoog met waarschuwing cascade (taken); `deleteStory` Server Action; blok verdwijnt optimistisch uit het rechterpaneel + - Done when: story verwijderd incl. cascade-taken (verifieerbaar in DB); blok direct verdwenen uit UI + +- [x] **ST-210** Story filter in rechterpaneel + - Filterknop in rechterpaneel navigatiebar; filter op status (OPEN, IN_SPRINT, DONE) en prioriteit; realtime; actief filter als badge; wissbaar + - Done when: filter op OPEN verbergt IN_SPRINT stories + +--- + +### M3: Sprint Backlog & Sprint Planning + +- [x] **ST-301** `useSprintStore` Zustand-store + - Schrijf `stores/sprint-store.ts`; `initSprint`, `addStoryToSprint`, `removeStoryFromSprint`, `reorderSprintStories`, `rollbackSprint` + - Done when: store beheert sprint-story-volgorde onafhankelijk van planner-store + +- [x] **ST-302** Sprint aanmaken + - "Sprint starten" knop op productpagina (zichtbaar als geen actieve Sprint); modal met Sprint Goal invoerveld; `createSprint` Server Action; max. 1 actieve Sprint per product afgedwongen in service-laag + - Done when: Sprint aangemaakt met Goal; tweede sprint aanmaken terwijl eerste actief is geeft foutmelding + +- [x] **ST-303** Sprint Backlog scherm — layout + - `/products/[id]/sprint` pagina; `SplitPane` met Sprint Backlog links (stories in Sprint op volgorde) en rechts de Product Backlog stories gegroepeerd per PBI (inklapbaar); Sprint Goal zichtbaar bovenaan; lege staat links met instructie + - Done when: pagina rendert correct; Sprint Goal zichtbaar; beide panelen tonen juiste data + +- [x] **ST-304** Story vanuit Product Backlog naar Sprint slepen + - dnd-kit drag vanuit rechterpaneel naar linkerpaneel; `onDragEnd`: `addStoryToSprint` in store; story krijgt badge "In Sprint" in Product Backlog; `addStoryToSprintAction` Server Action (zet `sprint_id` + status `IN_SPRINT`); rollback bij fout + - Done when: story gesleept naar Sprint verschijnt links en toont "In Sprint" badge rechts; persistent na herlaad + +- [x] **ST-305** Sprint Backlog story volgorde aanpassen + - dnd-kit verticale `SortableContext` in linkerpaneel; herrangschikking via float-gemiddelde in `useSprintStore`; `reorderSprintStoriesAction` Server Action + - Done when: volgorde in Sprint Backlog persistent na loslaten en na paginaverversing + +- [x] **ST-306** Story uit Sprint verwijderen + - Verwijderknop per story in Sprint Backlog; `removeStoryFromSprintAction` Server Action (wist `sprint_id`, zet status terug op `OPEN`); story verdwijnt links en badge verdwijnt rechts + - Done when: verwijderen persistent; story beschikbaar in Product Backlog rechterpaneel + +- [x] **ST-307** Sprint Planning scherm — layout + - `/products/[id]/sprint/planning` pagina; `SplitPane` met Sprint Backlog stories links (op volgorde) en taken van geselecteerde story rechts; Sprint Goal zichtbaar; lege staat rechts als geen story geselecteerd + - Done when: pagina rendert; story selecteren links toont taken rechts + +- [x] **ST-308** Taak aanmaken + - "Taak aanmaken" knop in rechterpaneel navigatiebar; inline formulier (titel, omschrijving, prioriteit); `createTask` Server Action; voortgangsindicator per story (bijv. "0/0 Done") + - Done when: taak aangemaakt en zichtbaar in takenlijst; voortgangsindicator toont "0/1 Done" + +- [x] **ST-309** Taak drag-and-drop (verticaal) + - dnd-kit verticale `SortableContext` in rechterpaneel; herrangschikking via float-gemiddelde in `usePlannerStore.taskOrder`; `reorderTasksAction` Server Action + - Done when: taken versleepbaar; volgorde persistent na loslaten + +- [x] **ST-310** Taakstatus bijhouden + - Status-toggle per taak (TO_DO → IN_PROGRESS → DONE) via klikbare badge of dropdown; `updateTaskStatus` Server Action; voortgangsindicator op story updatet optimistisch + - Done when: taak op DONE zetten verhoogt teller in voortgangsindicator; persistent na herlaad + +- [x] **ST-311** Taak bewerken en verwijderen + - Inline bewerken van titel, omschrijving en prioriteit; `updateTask` Server Action; verwijderen met bevestiging; `deleteTask` Server Action + - Done when: titelwijziging persisteert; verwijderde taak verdwijnt uit lijst + +- [x] **ST-312** Sprint afronden + - "Sprint afronden" knop op Sprint-pagina; dialoog toont per story de status en vraagt: "Markeer als Done of terug naar Backlog?"; `completeSprint` Server Action zet Sprint op COMPLETED, verwerkt keuzes per story + - Done when: Sprint afgerond; stories correct verplaatst naar DONE of OPEN; nieuwe Sprint aanmaakbaar + +- [x] **ST-313** Sprint Board — drie-panelen layout (vervangt ST-303 + ST-307) + - **Doel:** `/products/[id]/sprint` wordt één scherm met drie panelen van links naar rechts: Product Backlog · Sprint Backlog · Taken. De losse `/sprint/planning` route wordt verwijderd (redirect → `/sprint`). + - **Panelen:** + - *Links — Product Backlog:* PBIs met stories gegroepeerd en inklapbaar; stories die al in sprint zijn grijs/disabled; klikken of slepen voegt story toe aan Sprint Backlog (midden) + - *Midden — Sprint Backlog:* stories in sprint op volgorde; klikken selecteert story → taken laden rechts; versleepbaar om te sorteren; trash-knop verwijdert uit sprint + - *Rechts — Taken:* `TaskList` voor de geselecteerde story; lege staat "Selecteer een story" als niets geselecteerd; "+ Taak" knop zoals huidig + - **Layout:** `TriplePane` component — drie verticale panelen met twee versleepbare scheidingslijnen; opslaan in `localStorage` per product (key: `sprint-triple-${productId}`) + - **DnD:** één `DndContext` omhult alle drie panelen; drag van links naar midden werkt via `DragOverlay`; reorder binnen midden via `SortableContext`; taken-reorder in eigen geneste `DndContext` + - **State:** `SprintBoardClient` beheert sprint stories, product backlog data, `selectedStoryId`, en taken per story (vanuit server props); `useSelectionStore.selectedStoryId` voor story-selectie + - **Navigatie:** "Sprint Planning →" link onderaan Sprint Backlog pagina verwijderd; `SprintHeader` blijft bovenaan met "Sprint afronden" + - **Route cleanup:** `/sprint/planning/page.tsx` vervangt door redirect naar `/products/[id]/sprint`; `PlanningLeft`, `PlanningRightClient` components verwijderen + - Done when: één `/sprint` pagina toont alle drie panelen; story slepen van links naar midden werkt; story selecteren toont taken rechts; taak aanmaken en sorteren werkt; pagina hervat na herlaad met juiste data; `/sprint/planning` redirect werkt + +--- + +### M3.5: Solo Paneel & Story Assignment + +> **Doel:** een persoonlijk Kanban-bord per product dat de taken toont van stories die geclaimd zijn door de ingelogde developer. Story-level assignment volgt het Scrum self-organizing principe: developers claimen vrijwillig stories (pull, niet push). Volledige technische specificatie in `specs/functional.md#solo-panel`. + +- [x] **ST-350** Story.assignee_id schema-migratie + auth-helpers + - **Schema:** voeg `assignee_id String?` + `assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)` toe aan `Story`; voeg `assigned_stories Story[] @relation("StoryAssignee")` toe aan `User`; voeg index `@@index([sprint_id, assignee_id])` toe; migratie via `prisma migrate dev --name add_story_assignee` + - **Auth-helpers:** schrijf `lib/auth.ts` met `getSession`, `requireUser`, `requireWriter`, `requireProductAccess`, `requireProductWriter` — laatste twee doen membership-check via owner (`Product.user_id`) OF lid (`ProductMember`); demo-check op basis van `session.isDemo` (uit ST-006); throwt *"Niet beschikbaar in demo-modus"* bij demo-write-poging + - Done when: migratie slaagt; `requireProductWriter` blokkeert demo-user; `requireProductAccess` accepteert zowel owner als member + +- [x] **ST-351** `` herbruikbare component + - Wrapper rond shadcn `Avatar`; props: `userId`, `username`, `size` ('xs' | 'sm' | 'md' | 'lg'), `className`; `` met fallback naar initialen (eerste 2 tekens username) op `bg-primary-container`; vier groottes via Tailwind classes + - Done when: avatar rendert in 4 sizes; bij ontbrekende avatar-data (404) fallback naar initialen zichtbaar; component bruikbaar in story-kaart, sprint board, instellingen + +- [x] **ST-352** Story-claim Server Actions + - Vier acties in `actions/stories.ts`: `claimStoryAction` (zet `assignee_id = currentUserId`), `unclaimStoryAction` (null), `reassignStoryAction` (valideert dat target user lid van product is), `claimAllUnassignedInActiveSprintAction` (bulk via `updateMany` voor ongeclaimde stories in actieve sprint); allemaal Zod-gevalideerd, achter `requireProductWriter`, met `revalidatePath` voor `/sprint` én `/solo`; tenant-guard via `where: { id, product_id }` + - Done when: alle vier acties testbaar via testbestand; demo-user krijgt foutmelding; reassignment naar niet-lid faalt met foutmelding; bulk claimt alleen ongeclaimde + +- [x] **ST-353** Sprint Board: assignee-chip + dropdown menu op story-kaart + - Op story-kaart in middenpaneel van ST-313 Sprint Board: assignee-chip onderaan met `` + username (of muted "Niet geclaimd" badge als `assignee_id === null`); shadcn `DropdownMenu` (3-dots rechtsboven) met items "Pak op" / "Geef terug aan team" / "Wijs toe aan ▶" (submenu met members); items conditioneel zichtbaar op basis van huidige assignee; demo-modus: dropdown disabled met tooltip "Niet beschikbaar in demo-modus" + - Done when: chip toont juiste state; dropdown roept juiste acties aan; revalidatie ververst kaart; toast "Story geclaimd" / "Toegewezen aan X" bij succes; demo-user ziet disabled-tooltip + +- [x] **ST-354** Sprint Board: bulk-claim knop "Claim alle ongeclaimde" + - Knop bovenaan Sprint Backlog paneel met telling: "Claim alle ongeclaimde stories (N)"; disabled als N=0 of `isDemo`; klik roept `claimAllUnassignedInActiveSprintAction` aan; Sonner success-toast "{count} stories geclaimd"; pending state via `useTransition` + - Done when: telling correct; claimen werkt; knop disabled bij 0 ongeclaimd of demo; toast verschijnt na succes + +- [x] **ST-355** Solo route — `/solo` redirect + `/products/[id]/solo` pagina + cookie + - **Cookie-helper:** schrijf `lib/cookies.ts` met `setLastProductCookie(productId)` (HTTP-only, sameSite lax, 30 dagen) + - **`/solo` page.tsx:** Server Component; leest cookie `lastProductId`; valideert toegang en redirect naar `/products/[id]/solo`, of toont `` als geen cookie of cookie ongeldig + - **`/products/[id]/solo` page.tsx:** Server Component; haalt active sprint op (404 → empty state ``); haalt taken op via `Task.findMany` met `where: { sprint_id, story: { assignee_id: session.userId } }` + count ongeclaimde stories parallel; geeft data door aan ``; zet `lastProductId` cookie bij elk bezoek + - **Empty state:** `` met titel, uitleg, link naar productpagina + - **``:** lijst van toegankelijke producten, klikken redirect naar `/products/[id]/solo` + - Done when: `/solo` zonder cookie toont picker; met geldige cookie redirect; pagina toont juiste taken; geen actieve sprint toont empty state; cookie persisteert tussen sessies + +- [x] **ST-356** Solo Kanban-bord met DnD en Zustand + - **Store `stores/solo-store.ts`:** `tasks`, `initTasks`, `optimisticMove(taskId, toStatus)` (returnt vorige status), `rollback(taskId, prevStatus)`, `updatePlan(taskId, plan)`; volgt patroon van `usePlannerStore` (ST-201) + - **`` Client Component:** root met `DndContext` (overslaan als `isDemo`), `PointerSensor` met `activationConstraint: { distance: 5 }`, `closestCorners` collision detection; header met productnaam, sprint goal, knop "Toon openstaande stories (N)"; grid met drie kolommen + - **``:** drop target per status (`TO_DO` / `IN_PROGRESS` / `DONE`); header met statuskleur via MD3 tokens (`bg-status-todo/15` etc.); count en lege staat + - **``:** hergebruik bestaande task-card (ST-310); draggable; toont prioriteit-indicator, taaktitel, story-titel; klik opent detail-dialoog (ST-357); demo: niet draggable + - **`onDragEnd` flow:** optimistische update via `optimisticMove`, dan `updateTaskStatusAction` aanroepen, op error rollback + Sonner error-toast "Status bijwerken mislukt — taak teruggeplaatst"; geen success-toast (te frequent) + - Done when: kaart sleepbaar tussen kolommen; status persisteert; gesimuleerde server-fout rollbackt UI; demo-user kan niet slepen + +- [x] **ST-357** Task detail-dialoog + `updateTaskPlanAction` + - **`updateTaskPlanAction`** in `actions/tasks.ts`: Zod-schema `{ taskId, productId, implementationPlan }`; `requireProductWriter`; tenant-guard via `where: { id: taskId, story: { product_id: productId } }`; `revalidatePath` + - **``** shadcn `Dialog`: header met taaktitel + statusbadge (MD3 tokens); sectie *Beschrijving* (read-only, volg bestaand patroon); sectie *Implementatieplan* met `