Merge remote-tracking branch 'origin/main' into pr-61
# Conflicts: # .gitignore # AGENTS.md # CLAUDE.md # README.md # docs/API.md # docs/MD3_Color_Scheme_Documentation.md # docs/api.md # docs/api/rest-contract.md # docs/architecture.md # docs/backlog.md # docs/backlog/index.md # docs/backlog/product-historical.md # docs/decisions/agent-instructions-history.md # docs/design/styling.md # docs/functional.md # docs/md3-color-scheme.md # docs/patterns/claude-question-channel.md # docs/patterns/dialog.md # docs/patterns/qr-login.md # docs/personas.md # docs/plans/M10-qr-pairing-login.md # docs/plans/M11-claude-questions.md # docs/plans/M9-active-product-backlog.md # docs/plans/ST-1114-copilot-reviews.md # docs/plans/tweede-claude-agent-planning.md # docs/product-backlog.md # docs/qa/api-test-plan.md # docs/scrum4me-backlog.md # docs/scrum4me-functional-spec.md # docs/scrum4me-personas.md # docs/scrum4me-product-backlog.md # docs/scrum4me-test-plan.md # docs/solo-paneel-spec.md # docs/specs/functional.md # docs/specs/personas.md # docs/styling.md # docs/test-plan.md
This commit is contained in:
commit
422df4f9fb
58 changed files with 7587 additions and 59 deletions
97
__tests__/actions/sprint-dates.test.ts
Normal file
97
__tests__/actions/sprint-dates.test.ts
Normal file
|
|
@ -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<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } }
|
||||||
|
|
||||||
|
function makeFormData(data: Record<string, string | null>) {
|
||||||
|
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<string, string[]>
|
||||||
|
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<string, string[]>
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -60,6 +60,7 @@ const baseTask: SoloTask = {
|
||||||
sort_order: 1,
|
sort_order: 1,
|
||||||
status: 'TO_DO',
|
status: 'TO_DO',
|
||||||
verify_only: false,
|
verify_only: false,
|
||||||
|
verify_required: 'ALIGNED_OR_PARTIAL',
|
||||||
story_id: 'story-1',
|
story_id: 'story-1',
|
||||||
story_code: 'ST-100',
|
story_code: 'ST-100',
|
||||||
story_title: 'Test Story',
|
story_title: 'Test Story',
|
||||||
|
|
|
||||||
52
__tests__/lib/chart-colors.test.ts
Normal file
52
__tests__/lib/chart-colors.test.ts
Normal file
|
|
@ -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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
82
__tests__/lib/insights/agent-throughput.test.ts
Normal file
82
__tests__/lib/insights/agent-throughput.test.ts
Normal file
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
82
__tests__/lib/insights/backlog-health.test.ts
Normal file
82
__tests__/lib/insights/backlog-health.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
77
__tests__/lib/insights/velocity.test.ts
Normal file
77
__tests__/lib/insights/velocity.test.ts
Normal file
|
|
@ -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 }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
160
__tests__/realtime/payload-contract.test.ts
Normal file
160
__tests__/realtime/payload-contract.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -12,6 +12,7 @@ const baseTask = (id: string, overrides: Partial<SoloTask> = {}): SoloTask => ({
|
||||||
sort_order: 1,
|
sort_order: 1,
|
||||||
status: 'TO_DO',
|
status: 'TO_DO',
|
||||||
verify_only: false,
|
verify_only: false,
|
||||||
|
verify_required: 'ALIGNED_OR_PARTIAL',
|
||||||
story_id: 'story-1',
|
story_id: 'story-1',
|
||||||
story_code: 'ST-100',
|
story_code: 'ST-100',
|
||||||
story_title: 'Original Story',
|
story_title: 'Original Story',
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@ function hasDuplicateIds(ids: string[]) {
|
||||||
return new Set(ids).size !== ids.length
|
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) {
|
export async function createSprintAction(_prevState: unknown, formData: FormData) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
|
@ -24,9 +32,13 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
||||||
const parsed = z.object({
|
const parsed = z.object({
|
||||||
productId: z.string(),
|
productId: z.string(),
|
||||||
sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500),
|
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'),
|
productId: formData.get('productId'),
|
||||||
sprint_goal: formData.get('sprint_goal'),
|
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 }
|
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,
|
product_id: parsed.data.productId,
|
||||||
sprint_goal: parsed.data.sprint_goal,
|
sprint_goal: parsed.data.sprint_goal,
|
||||||
status: 'ACTIVE',
|
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 }
|
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) {
|
export async function updateSprintGoalAction(_prevState: unknown, formData: FormData) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
|
|
||||||
133
app/(app)/insights/components/agent-throughput.tsx
Normal file
133
app/(app)/insights/components/agent-throughput.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* KPI strip + product filter */}
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold text-foreground">{kpi.todayCount}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Jobs vandaag</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold text-foreground">
|
||||||
|
{kpi.successRate7d === 0 ? '—' : `${Math.round(kpi.successRate7d * 100)}%`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Success-rate (7d)</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold text-foreground">
|
||||||
|
{formatDuration(kpi.avgDurationSeconds7d)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Avg duration (7d)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{productList.length > 0 && (
|
||||||
|
<Select
|
||||||
|
value={currentProductId ?? '__all__'}
|
||||||
|
onValueChange={handleProductChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-44" disabled={isPending}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">Alle producten</SelectItem>
|
||||||
|
{productList.map(p => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
{isEmpty ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
Geen agent-activiteit in de laatste 2 weken
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart data={perDay}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickFormatter={v => (v as string).slice(5)}
|
||||||
|
/>
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
{STACKED_STATUSES.map(status => (
|
||||||
|
<Bar
|
||||||
|
key={status}
|
||||||
|
dataKey={status}
|
||||||
|
stackId="status"
|
||||||
|
fill={JOB_STATUS_COLORS[status]}
|
||||||
|
name={status}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
app/(app)/insights/components/backlog-health.tsx
Normal file
90
app/(app)/insights/components/backlog-health.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
|
||||||
|
{ok ? (
|
||||||
|
<CheckCircle2 className="size-4 text-status-done shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="size-4 text-priority-medium shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className={cn('font-semibold', ok && 'text-status-done')}>{count}</span>
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<tr className="border-b border-border last:border-0">
|
||||||
|
<td className="py-1 pr-2">
|
||||||
|
<Link
|
||||||
|
href={`/products/${task.productId}/solo?task=${task.taskId}`}
|
||||||
|
className="text-primary underline line-clamp-1"
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="py-1 pr-2 text-muted-foreground whitespace-nowrap">
|
||||||
|
{task.productName}
|
||||||
|
</td>
|
||||||
|
<td className={cn('py-1 px-2 text-right whitespace-nowrap rounded font-medium', severity)}>
|
||||||
|
{task.daysStuck}d
|
||||||
|
</td>
|
||||||
|
<td className="py-1 pl-2 text-muted-foreground whitespace-nowrap">
|
||||||
|
{task.sprintGoal ?? '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BacklogHealthCard({ data }: Props) {
|
||||||
|
const { storiesWithoutAc, tasksWithoutPlan, stuckTasks } = data
|
||||||
|
const allClear =
|
||||||
|
storiesWithoutAc === 0 && tasksWithoutPlan === 0 && stuckTasks.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||||
|
<h2 className="text-sm font-medium">Backlog health</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Counter count={storiesWithoutAc} label="stories zonder AC" />
|
||||||
|
<Counter count={tasksWithoutPlan} label="tasks zonder plan" />
|
||||||
|
<Counter count={stuckTasks.length} label="stuck > 7d" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allClear ? (
|
||||||
|
<p className="text-sm text-status-done">Geen stuck tasks 🎉</p>
|
||||||
|
) : stuckTasks.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">
|
||||||
|
Stuck tasks (top {stuckTasks.length})
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{stuckTasks.map(t => (
|
||||||
|
<StuckRow key={t.taskId} task={t} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
app/(app)/insights/components/velocity-chart.tsx
Normal file
92
app/(app)/insights/components/velocity-chart.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="rounded-lg border border-border p-4 space-y-2">
|
||||||
|
<h2 className="text-sm font-medium">Velocity</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Velocity wordt zichtbaar na 2+ voltooide sprints (nu: {sprints.length}).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reshape: [{ sprintLabel, [productName1]: count, [productName2]: count, ... }]
|
||||||
|
type Row = { sprintLabel: string } & Record<string, number | string>
|
||||||
|
const grouped = new Map<string, Row>()
|
||||||
|
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 (
|
||||||
|
<div className="rounded-lg border border-border p-4 space-y-2">
|
||||||
|
<h2 className="text-sm font-medium">Velocity (laatste {sprints.length} sprints)</h2>
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart data={rows}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="sprintLabel"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--popover)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
{productNames.map((p, i) => (
|
||||||
|
<Bar
|
||||||
|
key={p.id}
|
||||||
|
dataKey={p.name}
|
||||||
|
fill={SERIES_COLORS[i % SERIES_COLORS.length]}
|
||||||
|
radius={[2, 2, 0, 0]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{avg > 0 && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={avg}
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
label={{ value: `avg ${avg.toFixed(1)}`, fontSize: 10, fill: 'var(--muted-foreground)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,32 +5,53 @@ import { prisma } from '@/lib/prisma'
|
||||||
import { productAccessFilter } from '@/lib/product-access'
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
import { getBurndownData } from '@/lib/insights/burndown'
|
import { getBurndownData } from '@/lib/insights/burndown'
|
||||||
import { getSprintStatusBreakdown } from '@/lib/insights/sprint-status'
|
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 { SprintInfoStrip } from './components/sprint-info-strip'
|
||||||
import { BurndownChart } from './components/burndown-chart'
|
import { BurndownChart } from './components/burndown-chart'
|
||||||
import { SprintStatusDonut } from './components/sprint-status-donut'
|
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 DAY_MS = 86_400_000
|
||||||
const ASSUMED_SPRINT_DAYS = 14
|
const ASSUMED_SPRINT_DAYS = 14
|
||||||
|
|
||||||
|
interface InsightsPageProps {
|
||||||
|
searchParams: Promise<{ product?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) {
|
function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) {
|
||||||
return (
|
return (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{productName} — sprint heeft geen datums.{' '}
|
{productName} — sprint heeft geen datums.{' '}
|
||||||
<a
|
<a href={`/products/${productId}/sprint`} className="underline text-primary">
|
||||||
href={`/products/${productId}/sprint`}
|
|
||||||
className="underline text-primary"
|
|
||||||
>
|
|
||||||
Stel datums in
|
Stel datums in
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function InsightsPage() {
|
export default async function InsightsPage({ searchParams }: InsightsPageProps) {
|
||||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
const userId = session.userId!
|
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),
|
getBurndownData(userId),
|
||||||
getSprintStatusBreakdown(userId),
|
getSprintStatusBreakdown(userId),
|
||||||
prisma.sprint.findMany({
|
prisma.sprint.findMany({
|
||||||
|
|
@ -43,20 +64,21 @@ export default async function InsightsPage() {
|
||||||
tasks: { select: { id: true } },
|
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) {
|
// Date.now is an impure call but used once per request — safe in a Server Component.
|
||||||
return (
|
// eslint-disable-next-line react-hooks/purity
|
||||||
<div className="p-6 max-w-4xl mx-auto w-full">
|
const nowMs = Date.now()
|
||||||
<h1 className="text-xl font-medium text-foreground mb-6">Sprint Health</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Geen active sprints — start er een via /products/[id]/sprint
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowMs = new Date().getTime()
|
|
||||||
const sprintInfos = activeSprints.map(s => ({
|
const sprintInfos = activeSprints.map(s => ({
|
||||||
sprintId: s.id,
|
sprintId: s.id,
|
||||||
productId: s.product.id,
|
productId: s.product.id,
|
||||||
|
|
@ -65,33 +87,72 @@ export default async function InsightsPage() {
|
||||||
taskCount: s.tasks.length,
|
taskCount: s.tasks.length,
|
||||||
daysLeft: ASSUMED_SPRINT_DAYS - Math.floor((nowMs - s.created_at.getTime()) / DAY_MS),
|
daysLeft: ASSUMED_SPRINT_DAYS - Math.floor((nowMs - s.created_at.getTime()) / DAY_MS),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const burndownMap = new Map(burndownSprints.map(b => [b.sprintId, b]))
|
const burndownMap = new Map(burndownSprints.map(b => [b.sprintId, b]))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 max-w-4xl mx-auto w-full">
|
<div className="p-6 space-y-8 max-w-6xl mx-auto w-full">
|
||||||
<h1 className="text-xl font-medium text-foreground">Sprint Health</h1>
|
<h1 className="text-2xl font-semibold text-foreground">Insights</h1>
|
||||||
|
|
||||||
<SprintInfoStrip sprints={sprintInfos} />
|
{/* Sprint Health */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-medium text-foreground">Sprint Health</h2>
|
||||||
|
{activeSprints.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Geen active sprints — start er een via /products/[id]/sprint.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SprintInfoStrip sprints={sprintInfos} />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sprintInfos.map(s => {
|
||||||
|
const burndown = burndownMap.get(s.sprintId)
|
||||||
|
if (!burndown || burndown.days.length === 0) {
|
||||||
|
return (
|
||||||
|
<MissingDatesNotice
|
||||||
|
key={s.sprintId}
|
||||||
|
productId={s.productId}
|
||||||
|
productName={s.productName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <BurndownChart key={s.sprintId} sprint={burndown} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<SprintStatusDonut data={statusBreakdown} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{/* Plan-quality */}
|
||||||
<div className="space-y-4">
|
<section className="space-y-3">
|
||||||
{sprintInfos.map(s => {
|
<h2 className="text-lg font-medium text-foreground">Plan-quality</h2>
|
||||||
const burndown = burndownMap.get(s.sprintId)
|
<PlanQualityCard stats={verifyStats} nowMs={nowMs} />
|
||||||
if (!burndown || burndown.days.length === 0) {
|
{alignmentTrend.length > 0 && <AlignmentTrend trend={alignmentTrend} />}
|
||||||
return (
|
</section>
|
||||||
<MissingDatesNotice
|
|
||||||
key={s.sprintId}
|
{/* Agent throughput */}
|
||||||
productId={s.productId}
|
<section className="space-y-3">
|
||||||
productName={s.productName}
|
<h2 className="text-lg font-medium text-foreground">Agent throughput</h2>
|
||||||
/>
|
<AgentThroughputCard
|
||||||
)
|
data={jobsPerDay}
|
||||||
}
|
productList={productList}
|
||||||
return <BurndownChart key={s.sprintId} sprint={burndown} />
|
currentProductId={filterProductId}
|
||||||
})}
|
/>
|
||||||
</div>
|
</section>
|
||||||
<SprintStatusDonut data={statusBreakdown} />
|
|
||||||
</div>
|
{/* Velocity */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-medium text-foreground">Velocity</h2>
|
||||||
|
<VelocityChart data={velocity} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Backlog health */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-medium text-foreground">Backlog health</h2>
|
||||||
|
<BacklogHealthCard data={backlogHealth} />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ export default async function SoloProductPage({ params }: Props) {
|
||||||
sort_order: t.sort_order,
|
sort_order: t.sort_order,
|
||||||
status: t.status as SoloTask['status'],
|
status: t.status as SoloTask['status'],
|
||||||
verify_only: t.verify_only,
|
verify_only: t.verify_only,
|
||||||
|
verify_required: t.verify_required as SoloTask['verify_required'],
|
||||||
story_id: t.story.id,
|
story_id: t.story.id,
|
||||||
story_code: t.story.code,
|
story_code: t.story.code,
|
||||||
story_title: t.story.title,
|
story_title: t.story.title,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,13 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
||||||
|
|
||||||
const sprint = await prisma.sprint.findFirst({
|
const sprint = await prisma.sprint.findFirst({
|
||||||
where: { product_id: id, status: 'ACTIVE' },
|
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}`)
|
if (!sprint) redirect(`/products/${id}`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,22 @@ import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
|
||||||
// drive tasks into a state the shared UI can't display.
|
// drive tasks into a state the shared UI can't display.
|
||||||
const PATCHABLE_TASK_STATUS = TASK_STATUS_API_VALUES.filter((s) => s !== 'review')
|
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
|
const patchSchema = z
|
||||||
.object({
|
.object({
|
||||||
status: z.enum(PATCHABLE_TASK_STATUS as [string, ...string[]]).optional(),
|
status: z.enum(PATCHABLE_TASK_STATUS as [string, ...string[]]).optional(),
|
||||||
implementation_plan: z.string().optional(),
|
implementation_plan: z.string().optional(),
|
||||||
verify_only: z.boolean().optional(),
|
verify_only: z.boolean().optional(),
|
||||||
|
verify_required: z.enum(VERIFY_REQUIRED_VALUES).optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) =>
|
(data) =>
|
||||||
data.status !== undefined ||
|
data.status !== undefined ||
|
||||||
data.implementation_plan !== undefined ||
|
data.implementation_plan !== undefined ||
|
||||||
data.verify_only !== undefined,
|
data.verify_only !== undefined ||
|
||||||
{ message: 'Geef minimaal status, implementation_plan of verify_only mee' },
|
data.verify_required !== undefined,
|
||||||
|
{ message: 'Geef minimaal status, implementation_plan, verify_only of verify_required mee' },
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
|
|
@ -88,19 +92,21 @@ export async function PATCH(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine simple field writes (plan, verify_only) into one update call
|
// Combine simple field writes (plan, verify_only, verify_required) into one update call
|
||||||
const simpleData: { implementation_plan?: string; verify_only?: boolean } = {}
|
const simpleData: { implementation_plan?: string; verify_only?: boolean; verify_required?: typeof VERIFY_REQUIRED_VALUES[number] } = {}
|
||||||
if (parsed.data.implementation_plan !== undefined)
|
if (parsed.data.implementation_plan !== undefined)
|
||||||
simpleData.implementation_plan = parsed.data.implementation_plan
|
simpleData.implementation_plan = parsed.data.implementation_plan
|
||||||
if (parsed.data.verify_only !== undefined)
|
if (parsed.data.verify_only !== undefined)
|
||||||
simpleData.verify_only = parsed.data.verify_only
|
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 updated = await prisma.$transaction(async (tx) => {
|
||||||
const simpleUpdate = Object.keys(simpleData).length > 0
|
const simpleUpdate = Object.keys(simpleData).length > 0
|
||||||
? await tx.task.update({
|
? await tx.task.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: simpleData,
|
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
|
: null
|
||||||
|
|
||||||
|
|
@ -111,6 +117,7 @@ export async function PATCH(
|
||||||
status: result.task.status,
|
status: result.task.status,
|
||||||
implementation_plan: result.task.implementation_plan,
|
implementation_plan: result.task.implementation_plan,
|
||||||
verify_only: simpleUpdate?.verify_only,
|
verify_only: simpleUpdate?.verify_only,
|
||||||
|
verify_required: simpleUpdate?.verify_required,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,5 +132,6 @@ export async function PATCH(
|
||||||
status: taskStatusToApi(updated.status),
|
status: taskStatusToApi(updated.status),
|
||||||
implementation_plan: updated.implementation_plan,
|
implementation_plan: updated.implementation_plan,
|
||||||
...(updated.verify_only !== undefined && { verify_only: updated.verify_only }),
|
...(updated.verify_only !== undefined && { verify_only: updated.verify_only }),
|
||||||
|
...(updated.verify_required !== undefined && { verify_required: updated.verify_required }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ export function NavBar({
|
||||||
pathname.includes('/solo')
|
pathname.includes('/solo')
|
||||||
)
|
)
|
||||||
: disabledSpan('Solo')}
|
: disabledSpan('Solo')}
|
||||||
|
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
|
||||||
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
|
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export interface SoloTask {
|
||||||
sort_order: number
|
sort_order: number
|
||||||
status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
|
status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
|
||||||
verify_only: boolean
|
verify_only: boolean
|
||||||
|
verify_required: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY'
|
||||||
story_id: string
|
story_id: string
|
||||||
story_code: string | null
|
story_code: string | null
|
||||||
story_title: string
|
story_title: string
|
||||||
|
|
|
||||||
|
|
@ -55,16 +55,24 @@ const VERIFY_RESULT_CONFIG: Record<string, { label: string; className: string }>
|
||||||
|
|
||||||
type SaveState = 'idle' | 'saving' | 'saved'
|
type SaveState = 'idle' | 'saving' | 'saved'
|
||||||
|
|
||||||
|
const VERIFY_REQUIRED_LABELS: Record<string, string> = {
|
||||||
|
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) {
|
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 job = useSoloStore(s => s.claudeJobsByTaskId[task.id])
|
||||||
const connectedWorkers = useSoloStore(s => s.connectedWorkers)
|
const connectedWorkers = useSoloStore(s => s.connectedWorkers)
|
||||||
const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '')
|
const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '')
|
||||||
const [localVerifyOnly, setLocalVerifyOnly] = useState(task.verify_only)
|
const [localVerifyOnly, setLocalVerifyOnly] = useState(task.verify_only)
|
||||||
|
const [localVerifyRequired, setLocalVerifyRequired] = useState(task.verify_required)
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle')
|
const [saveState, setSaveState] = useState<SaveState>('idle')
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
const [jobPending, startJobTransition] = useTransition()
|
const [jobPending, startJobTransition] = useTransition()
|
||||||
const [verifyOnlyPending, startVerifyOnlyTransition] = useTransition()
|
const [verifyOnlyPending, startVerifyOnlyTransition] = useTransition()
|
||||||
|
const [verifyRequiredPending, startVerifyRequiredTransition] = useTransition()
|
||||||
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const savedPlanRef = useRef(task.implementation_plan ?? '')
|
const savedPlanRef = useRef(task.implementation_plan ?? '')
|
||||||
|
|
||||||
|
|
@ -145,6 +153,32 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleVerifyRequiredChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
@ -220,6 +254,22 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe
|
||||||
<span className="text-xs text-muted-foreground">Alleen verifiëren (niet implementeren)</span>
|
<span className="text-xs text-muted-foreground">Alleen verifiëren (niet implementeren)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">Verify-gate:</span>
|
||||||
|
<DemoTooltip show={isDemo}>
|
||||||
|
<select
|
||||||
|
value={localVerifyRequired}
|
||||||
|
onChange={handleVerifyRequiredChange}
|
||||||
|
disabled={isDemo || verifyRequiredPending}
|
||||||
|
className="text-xs rounded-md border border-border bg-surface-container px-2 py-1 text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{(['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'] as const).map(v => (
|
||||||
|
<option key={v} value={v}>{VERIFY_REQUIRED_LABELS[v]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</DemoTooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="-mx-4 -mb-4 flex flex-wrap items-center gap-2 border-t bg-muted/50 px-4 py-3 rounded-b-xl">
|
<div className="-mx-4 -mb-4 flex flex-wrap items-center gap-2 border-t bg-muted/50 px-4 py-3 rounded-b-xl">
|
||||||
<Link
|
<Link
|
||||||
href={`/products/${productId}/sprint/planning`}
|
href={`/products/${productId}/sprint/planning`}
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,15 @@ import {
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { updateSprintGoalAction, completeSprintAction } from '@/actions/sprints'
|
import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction } from '@/actions/sprints'
|
||||||
import type { SprintStory } from './sprint-backlog'
|
import type { SprintStory } from './sprint-backlog'
|
||||||
|
|
||||||
interface Sprint {
|
interface Sprint {
|
||||||
id: string
|
id: string
|
||||||
sprint_goal: string
|
sprint_goal: string
|
||||||
status: string
|
status: string
|
||||||
|
start_date: Date | null
|
||||||
|
end_date: Date | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SprintHeaderProps {
|
interface SprintHeaderProps {
|
||||||
|
|
@ -34,8 +36,14 @@ function SaveGoalButton() {
|
||||||
return <Button type="submit" size="sm" disabled={pending}>{pending ? 'Opslaan…' : 'Opslaan'}</Button>
|
return <Button type="submit" size="sm" disabled={pending}>{pending ? 'Opslaan…' : 'Opslaan'}</Button>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toDateInputValue(d: Date | null) {
|
||||||
|
if (!d) return ''
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
export function SprintHeader({ productId: _productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) {
|
export function SprintHeader({ productId: _productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) {
|
||||||
const [editingGoal, setEditingGoal] = useState(false)
|
const [editingGoal, setEditingGoal] = useState(false)
|
||||||
|
const [editingDates, setEditingDates] = useState(false)
|
||||||
const [completeOpen, setCompleteOpen] = useState(false)
|
const [completeOpen, setCompleteOpen] = useState(false)
|
||||||
const [decisions, setDecisions] = useState<Record<string, 'DONE' | 'OPEN'>>({})
|
const [decisions, setDecisions] = useState<Record<string, 'DONE' | 'OPEN'>>({})
|
||||||
const [isCompleting, startCompleting] = useTransition()
|
const [isCompleting, startCompleting] = useTransition()
|
||||||
|
|
@ -50,6 +58,16 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
||||||
undefined
|
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') {
|
function setDecision(storyId: string, value: 'DONE' | 'OPEN') {
|
||||||
setDecisions(prev => ({ ...prev, [storyId]: value }))
|
setDecisions(prev => ({ ...prev, [storyId]: value }))
|
||||||
}
|
}
|
||||||
|
|
@ -96,13 +114,57 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DemoTooltip show={isDemo}>
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button size="sm" variant="outline" disabled={isDemo} className="shrink-0 border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
|
<DemoTooltip show={isDemo}>
|
||||||
Sprint afronden
|
<Button size="sm" variant="ghost" disabled={isDemo} className="text-muted-foreground" onClick={() => !isDemo && setEditingDates(true)}>
|
||||||
</Button>
|
{sprint.start_date && sprint.end_date
|
||||||
</DemoTooltip>
|
? `${toDateInputValue(sprint.start_date)} → ${toDateInputValue(sprint.end_date)}`
|
||||||
|
: 'Datums instellen'}
|
||||||
|
</Button>
|
||||||
|
</DemoTooltip>
|
||||||
|
<DemoTooltip show={isDemo}>
|
||||||
|
<Button size="sm" variant="outline" disabled={isDemo} className="border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
|
||||||
|
Sprint afronden
|
||||||
|
</Button>
|
||||||
|
</DemoTooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dates edit dialog */}
|
||||||
|
<Dialog open={editingDates} onOpenChange={setEditingDates}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Sprint datums instellen</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form action={datesFormAction} className="space-y-4 p-1">
|
||||||
|
<input type="hidden" name="id" value={sprint.id} />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">Startdatum</label>
|
||||||
|
<input type="date" name="start_date" defaultValue={toDateInputValue(sprint.start_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
|
||||||
|
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).start_date && (
|
||||||
|
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).start_date[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">Einddatum</label>
|
||||||
|
<input type="date" name="end_date" defaultValue={toDateInputValue(sprint.end_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
|
||||||
|
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).end_date && (
|
||||||
|
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).end_date[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{typeof datesState?.error === 'string' && (
|
||||||
|
<p className="text-xs text-error">{datesState.error}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => setEditingDates(false)}>Annuleren</Button>
|
||||||
|
<Button type="submit">Opslaan</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Complete sprint dialog */}
|
{/* Complete sprint dialog */}
|
||||||
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
|
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,23 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">Startdatum</label>
|
||||||
|
<input type="date" name="start_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
|
||||||
|
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).start_date && (
|
||||||
|
<p className="text-xs text-error">{(state.error as Record<string, string[]>).start_date[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">Einddatum</label>
|
||||||
|
<input type="date" name="end_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
|
||||||
|
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).end_date && (
|
||||||
|
<p className="text-xs text-error">{(state.error as Record<string, string[]>).end_date[0]}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{globalError && (
|
{globalError && (
|
||||||
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
||||||
{globalError}
|
{globalError}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
# Documentation Index
|
# 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
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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) | — | — |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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-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 |
|
| [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 |
|
| [Route Handler (REST API)](./patterns/route-handler.md) | active | 2026-05-03 |
|
||||||
| [Server Action](./patterns/server-action.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 |
|
| [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 |
|
| [Zustand optimistische update + rollback](./patterns/zustand-optimistic.md) | active | 2026-05-03 |
|
||||||
|
|
||||||
## Other Docs
|
## Other Docs
|
||||||
|
|
||||||
| Title | Path | Status | Updated |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 — 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
||||||
|
|
|
||||||
528
docs/api.md
Normal file
528
docs/api.md
Normal file
|
|
@ -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 <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
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=<mobileSecret>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Plus `Set-Cookie: s4m_pair=<desktopToken>; 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/<pairingId>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `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":"<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 }`.
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# placeholder — remove when first file is added
|
||||||
1
docs/architecture/.gitkeep
Normal file
1
docs/architecture/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# placeholder — remove when first file is added
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# placeholder — remove when first file is added
|
||||||
784
docs/backlog.md
Normal file
784
docs/backlog.md
Normal file
|
|
@ -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 `<SplitPane>` 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 `<PanelNavBar>` 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** `<UserAvatar>` herbruikbare component
|
||||||
|
- Wrapper rond shadcn `Avatar`; props: `userId`, `username`, `size` ('xs' | 'sm' | 'md' | 'lg'), `className`; `<AvatarImage src="/api/users/{userId}/avatar">` 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 `<UserAvatar size="xs">` + 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 `<ProductPicker>` als geen cookie of cookie ongeldig
|
||||||
|
- **`/products/[id]/solo` page.tsx:** Server Component; haalt active sprint op (404 → empty state `<NoActiveSprint>`); haalt taken op via `Task.findMany` met `where: { sprint_id, story: { assignee_id: session.userId } }` + count ongeclaimde stories parallel; geeft data door aan `<SoloBoard>`; zet `lastProductId` cookie bij elk bezoek
|
||||||
|
- **Empty state:** `<NoActiveSprint>` met titel, uitleg, link naar productpagina
|
||||||
|
- **`<ProductPicker>`:** 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)
|
||||||
|
- **`<SoloBoard>` 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
|
||||||
|
- **`<SoloColumn>`:** drop target per status (`TO_DO` / `IN_PROGRESS` / `DONE`); header met statuskleur via MD3 tokens (`bg-status-todo/15` etc.); count en lege staat
|
||||||
|
- **`<SoloTaskCard>`:** 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`
|
||||||
|
- **`<TaskDetailDialog>`** shadcn `Dialog`: header met taaktitel + statusbadge (MD3 tokens); sectie *Beschrijving* (read-only, volg bestaand patroon); sectie *Implementatieplan* met `<Textarea>` save-on-blur; on-blur roept `updateTaskPlanAction`, indicator rechtsonder ("Bezig met opslaan…" → "Opgeslagen", vervaagt na 2s); error-toast bij fout; footer-link "Open in Sprint Board ↗"; demo-modus: textarea `readOnly` met tooltip
|
||||||
|
- Done when: edit + blur + refresh persisteert; gesimuleerde server-fout toont error-toast; demo-user kan dialoog openen maar niet bewerken
|
||||||
|
|
||||||
|
- [x] **ST-358** Openstaande stories sheet
|
||||||
|
- Knop "Toon openstaande stories (N)" bovenaan Solo bord opent shadcn `<Sheet>` (slide-out van rechts); inhoud: lijst van ongeclaimde stories in actieve sprint met titel, taakaantal, "Pak op"-knop per item; klik roept `claimStoryAction`, sheet blijft open (zodat meerdere achter elkaar claimen kan); Sonner success-toast per claim; lege staat "Geen ongeclaimde stories. Lekker bezig!"; pending state via `useFormStatus`; demo: knoppen disabled met tooltip
|
||||||
|
- Done when: sheet opent met N items; claimen verwijdert item uit lijst en verlaagt teller; lege staat correct; demo-user ziet sheet maar kan niet claimen
|
||||||
|
|
||||||
|
- [x] **ST-359** Navbar-link "Solo"
|
||||||
|
- Voeg `<NavLink href="/solo" icon={<UserSquare />}>Solo</NavLink>` toe aan navigatieshell (ST-008); altijd zichtbaar voor ingelogde users (geen product-context); plek tussen "Producten" en "Todos"
|
||||||
|
- Done when: link altijd zichtbaar in nav; klik gaat naar `/solo` en redirect verder
|
||||||
|
|
||||||
|
- [x] **ST-360** Demo-seed uitbreiden met geclaimde stories
|
||||||
|
- Update `prisma/seed.ts` (ST-004): demo-user (`is_demo = true`) heeft minimaal één product met ACTIVE sprint; minimaal 3 stories met `assignee_id = demoUser.id` (variërend over taakstatussen TO_DO, IN_PROGRESS, DONE); minimaal 1 ongeclaimde story (om "Toon openstaande" te demonstreren — demo-user kan niet claimen, ziet wel hoe het werkt)
|
||||||
|
- Done when: login als demo → Solo bord toont werkend Kanban met taken in alle drie kolommen; "Toon openstaande" sheet toont ten minste 1 story (claim-knoppen disabled)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M4: Claude Code REST API
|
||||||
|
|
||||||
|
- [x] **ST-401** API-token infrastructuur
|
||||||
|
- Schrijf `lib/api-auth.ts`: parseer `Authorization: Bearer` header; bereken SHA-256 hash; zoek op in `api_tokens`; controleer `revoked_at`; retourneer `userId` of 401; retourneer 403 als `is_demo`
|
||||||
|
- Done when: geldige token geeft userId terug; ongeldige token geeft 401; ingetrokken token geeft 401; demo-token op schrijf-endpoint geeft 403
|
||||||
|
|
||||||
|
- [x] **ST-402** API-tokenbeheer UI
|
||||||
|
- `/settings/tokens` pagina; token aanmaken (label optioneel); token eenmalig getoond in kopieerbaar veld na aanmaken; tokenoverzicht (label, datum, actief/ingetrokken); intrekken via Server Action; max. 10 actieve tokens
|
||||||
|
- Done when: token aangemaakt en waarde zichtbaar; na sluiten dialoog niet meer te zien; intrekken maakt token onbruikbaar (getest via curl)
|
||||||
|
|
||||||
|
- [x] **ST-403** `GET /api/products` — productenlijst
|
||||||
|
- Route Handler; authenticatie via `api-auth.ts`; retourneer actieve producten `[{ id, name, repo_url }]` als JSON voor producten waar de tokengebruiker eigenaar of teamlid is
|
||||||
|
- Done when: `curl -H "Authorization: Bearer <token>" /api/products` retourneert correct JSON inclusief gedeelde product backlogs; 401 zonder token
|
||||||
|
|
||||||
|
- [x] **ST-404** `GET /api/products/:id/next-story` — volgende story ophalen
|
||||||
|
- Route Handler; haal hoogst geprioriteerde OPEN story op van actieve Sprint van het product (priority ASC, sort_order ASC); retourneer `{ id, title, description, acceptance_criteria, tasks[] }`; 404 als geen open stories
|
||||||
|
- Done when: endpoint retourneert eerste story van Sprint; 404 als Sprint leeg; 404 als geen actieve Sprint
|
||||||
|
|
||||||
|
- [x] **ST-405** `GET /api/sprints/:id/tasks` — taken ophalen
|
||||||
|
- Route Handler met `?limit=N` query param (default 10, max 50); retourneer taken van actieve Sprint op `(story.sort_order, task.priority, task.sort_order)` volgorde; retourneer `{ id, title, story_id, priority, sort_order, status }`
|
||||||
|
- Done when: endpoint retourneert max N taken in juiste volgorde; `?limit=5` retourneert max 5
|
||||||
|
|
||||||
|
- [x] **ST-406** `PATCH /api/stories/:id/tasks/reorder` — taakvolgorde aanpassen
|
||||||
|
- Route Handler; body: `{ task_ids: string[] }`; valideer alle IDs behoren tot de story; update `sort_order` via float-verdeling; retourneer `{ success: true }`
|
||||||
|
- Done when: volgorde in DB veranderd na PATCH; gewijzigde volgorde zichtbaar in Sprint Planning UI na herlaad; ongeldige task_id geeft 400
|
||||||
|
|
||||||
|
- [x] **ST-407** `POST /api/stories/:id/log` — activiteit vastleggen
|
||||||
|
- Route Handler; body: `{ type, content, status?, commit_hash?, commit_message? }`; Zod-validatie per type; schrijf naar `story_logs`; retourneer `{ id, created_at }`
|
||||||
|
- Done when: drie typen werken (IMPLEMENTATION_PLAN, TEST_RESULT, COMMIT); log-entry zichtbaar in story-detail UI na aanmaken via API; ontbrekend verplicht veld geeft 400
|
||||||
|
|
||||||
|
- [x] **ST-408** `PATCH /api/tasks/:id` — taakstatus en implementatieplan bijwerken
|
||||||
|
- Route Handler; body: `{ status?: "TO_DO" | "IN_PROGRESS" | "DONE", implementation_plan?: string }`; minimaal één veld verplicht; valideer dat taak aan requester's product behoort; retourneer `{ id, status, implementation_plan }`
|
||||||
|
- Done when: status update via API zichtbaar in Sprint Planning UI; implementation_plan opgeslagen en opvraagbaar; lege body geeft 400; andere gebruikers taak geeft 403
|
||||||
|
|
||||||
|
- [x] **ST-409** `POST /api/todos` — todo aanmaken
|
||||||
|
- Route Handler; body: `{ title: string, product_id: string }`; valideer dat product bij de geverifieerde gebruiker hoort; schrijf naar `todos`; retourneer `{ id, title, created_at }`
|
||||||
|
- Done when: todo aangemaakt via API met product_id verschijnt in todo-lijst UI gekoppeld aan het juiste product; lege titel of ontbrekend product_id geeft 400; onbekend product geeft 404
|
||||||
|
|
||||||
|
- [x] **ST-410** Story-activiteitenlog UI
|
||||||
|
- Activiteitenlog sectie in story-detail slide-over; haal `story_logs` op via Server Component; render chronologisch; visuele stijl per type (IMPLEMENTATION_PLAN = blauw, TEST_RESULT passed = groen, failed = rood, COMMIT = paars); commit-hash klikbaar als `repo_url` ingesteld; lege staat
|
||||||
|
- Done when: drie log-entries (plan, test, commit) correct gestyled; commit-hash link opent in nieuw tabblad
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M5: Todo-lijst
|
||||||
|
|
||||||
|
> **Herontwerp (april 2026):** ST-501–505 beschreven de oorspronkelijke QuickInput-aanpak. Die is geïmplementeerd maar vervangen door een Data Table + detail-kaart ontwerp (ST-509–510). ST-501–505 zijn als referentie bewaard; de functionele eisen zijn ongewijzigd.
|
||||||
|
|
||||||
|
- [x] **ST-501** Todo-lijst pagina *(vervangen door ST-509)*
|
||||||
|
- `/todos` pagina; haal actieve (niet-gearchiveerde) todos op inclusief productnaam; snel-invoerveld bovenaan met product-dropdown (verplicht) en titel (Enter om op te slaan); `createTodo` Server Action; lege staat met instructie; productnaam-badge per todo-item
|
||||||
|
- Done when: todo aanmaken via Enter persisteert en verschijnt in lijst met productnaam; aanmaken zonder product geblokkeerd; lege staat zichtbaar bij geen todos
|
||||||
|
|
||||||
|
- [x] **ST-502** Todo afvinken *(vervangen door ST-509)*
|
||||||
|
- Checkbox per todo; `toggleTodo` Server Action; afgevinkte todos visueel doorgestreept; afgevinkte todos blijven zichtbaar onderaan de lijst
|
||||||
|
- Done when: afvinken persistent na herlaad; visuele doorstreping correct
|
||||||
|
|
||||||
|
- [x] **ST-503** Afgevinkte todos archiveren *(vervangen door ST-510)*
|
||||||
|
- "Archiveer afgeronde items" knop; `archiveCompletedTodos` Server Action; gearchiveerde todos verdwijnen uit standaardweergave
|
||||||
|
- Done when: archiveren verbergt alle afgevinkte todos; telling correct
|
||||||
|
|
||||||
|
- [x] **ST-504** Todo promoveren naar PBI *(vervangen door ST-510)*
|
||||||
|
- "Promoveer naar PBI" contextmenu of knop per todo; dialoog: product dropdown (actieve producten), prioriteit dropdown; titel vooringevuld (bewerkbaar); bevestigingswaarschuwing; `promoteTodeToPbi` Server Action (maak PBI aan, verwijder todo)
|
||||||
|
- Done when: gepromoveerde todo verdwijnt; PBI zichtbaar in juist product met juiste prioriteit
|
||||||
|
|
||||||
|
- [x] **ST-505** Todo promoveren naar story *(vervangen door ST-510)*
|
||||||
|
- "Promoveer naar story" knop per todo; dialoog: product dropdown → PBI dropdown (gefilterd op product), prioriteit; titel vooringevuld; `promoteTodoToStory` Server Action (maak story aan, verwijder todo)
|
||||||
|
- Done when: gepromoveerde todo verdwijnt; story zichtbaar in juist PBI met juiste prioriteit
|
||||||
|
|
||||||
|
- [x] **ST-509** Todo Data Table
|
||||||
|
- Installeer `@tanstack/react-table`; voeg shadcn `data-table`-patroon toe
|
||||||
|
- **Kolommen:**
|
||||||
|
- Selectie-checkbox (kolom 1): multi-select voor bulk-archivering; header-checkbox selecteert/deselecteert alle zichtbare rijen
|
||||||
|
- Titel (kolom 2): max 2 regels, `line-clamp-2 truncate`; afgevinkte todos doorgestreept; klik op rij (buiten checkbox) opent detail-kaart
|
||||||
|
- Productnaam-badge (kolom 3)
|
||||||
|
- Aanmaakdatum (kolom 4)
|
||||||
|
- **Toolbar boven de tabel:**
|
||||||
|
- Product-filter dropdown (Alles / Geen product / per product)
|
||||||
|
- "+" knop: opent lege detail-kaart voor nieuw aanmaken (erft geselecteerd filter-product)
|
||||||
|
- "Archiveer geselecteerde (N)" knop: actief zodra ≥ 1 checkbox aangevinkt; roept `archiveSelectedTodosAction` aan met de geselecteerde IDs; resettet selectie na afloop
|
||||||
|
- **Paginering:** max 10 rijen per pagina; vorige/volgende knoppen; paginatelling ("1–10 van 23")
|
||||||
|
- **Lege staat:** "Geen todo's voor deze selectie." bij lege filter; "Nog geen todo's. Gebruik + om er een aan te maken." bij volledig lege lijst
|
||||||
|
- **`archiveSelectedTodosAction`** toevoegen aan `actions/todos.ts`: valideert dat alle meegegeven IDs bij de ingelogde gebruiker horen vóór schrijven; `archiveMany` via `updateMany`
|
||||||
|
- Done when: tabel toont alle actieve todos; paginering werkt; product-filter werkt; selectie-checkbox selecteert meerdere rijen; bulk-archiveren verwijdert geselecteerde rijen uit de weergave
|
||||||
|
|
||||||
|
- [x] **ST-510** Todo detail-kaart
|
||||||
|
- Kaart onder de tabel; altijd zichtbaar (leeg als geen todo geselecteerd of aangemaakt wordt)
|
||||||
|
- **Aanmaken:** "+" in toolbar zet kaart in aanmaak-modus; velden: product-dropdown (erft filter-product, of "Geen product" bij "Alles"), titel; opslaan via `createTodoAction`; na opslaan kaart leegmaken en tabel ververst
|
||||||
|
- **Bewerken:** klik op tabelrij (buiten checkbox) laadt todo in kaart; velden: product-dropdown, titel, done-toggle; opslaan via nieuwe `updateTodoAction` (title + product_id + done); annuleren deselecteert rij en leegt kaart
|
||||||
|
- **Promoveren:** knoppen "→ PBI" en "→ Story" in de kaart; openen de bestaande `PromotePbiDialog` en `PromoteStoryDialog`; alleen zichtbaar bij een bestaande geselecteerde todo
|
||||||
|
- **Demo-modus:** kaart-invoervelden uitgeschakeld; knoppen verborgen of disabled
|
||||||
|
- **`updateTodoAction`** toevoegen aan `actions/todos.ts`: valideert eigenaarschap; past `title`, `product_id` en/of `done` aan; `revalidatePath('/todos')`
|
||||||
|
- Done when: aanmaken via kaart persisteert; bewerken van titel, product en done-status werkt; promote vanuit kaart opent juist dialoog en verwijdert todo na bevestiging; kaart leeg bij geen selectie; demo-gebruiker ziet uitgeschakelde kaart
|
||||||
|
|
||||||
|
- [x] **ST-506** Rolbeheer in instellingen
|
||||||
|
- `/settings` pagina met roltoewijzing (checkbox per rol: Product Owner, Scrum Master, Developer); minimaal één rol verplicht; `updateRoles` Server Action; geselecteerde rollen zichtbaar in profielbalk
|
||||||
|
- Done when: rollen bijwerken persisterend; profielbalk toont gekozen rollen; uitvinken van alle rollen geeft validatiefout
|
||||||
|
|
||||||
|
- [x] **ST-507** Gebruikersprofiel (buiten originele backlog toegevoegd)
|
||||||
|
- Profielfoto-upload (JPEG/PNG/WebP, max 12 MB), server-side resizing naar max 700×700 WebP met Sharp, opgeslagen als bytea in Neon; bio (max 160) en bio_detail (max 2000) als aparte velden; `POST /api/profile/avatar` + `GET /api/profile/avatar` + `updateProfileAction`
|
||||||
|
- Done when: foto geüpload en zichtbaar in instellingen; bio opgeslagen; ongeldige bestanden geweigerd vóór verwerking
|
||||||
|
|
||||||
|
- [x] **ST-508** Product Backlog-overzicht in instellingen (buiten originele backlog toegevoegd)
|
||||||
|
- Gecombineerde lijst op `/settings` van eigen producten (badge "Eigenaar") en team-lidmaatschappen (badge "Developer" + eigenaarsnaam); klikbaar naar product; "Verlaten"-knop met bevestiging voor lidmaatschappen; lege staat met CTA
|
||||||
|
- Done when: eigenaar-producten en team-producten zichtbaar in één lijst; verlaten werkt en verwijdert rij
|
||||||
|
|
||||||
|
- [x] **ST-511** Entity codes voor Product, PBI en Story (buiten originele backlog toegevoegd)
|
||||||
|
- **Schema:** `code String? @db.VarChar(30)` op `Product`, `Pbi` en `Story`; unique per parent (`user_id` voor Product, `product_id` voor Pbi/Story); `Task` heeft geen DB-veld — code wordt afgeleid als `${story.code}.${index_in_story}`
|
||||||
|
- **Auto-generatie:** `lib/code-server.ts` met `generateNextStoryCode` (`ST-001`, `ST-002`, … 3-cijferig per product) en `generateNextPbiCode` (`PBI-1`, `PBI-2`, … per product); `createWithCodeRetry`-helper vangt P2002 op het code-veld op en probeert max 3× opnieuw zodat gelijktijdige inserts niet crashen
|
||||||
|
- **Validatie:** Zod max 30 tekens, regex `^[A-Za-z0-9._-]+$`; handmatige override mag elk format dat aan de basis-regex voldoet (geen format-enforcement op `ST-NNN`)
|
||||||
|
- **Forms:** code-input op Product/Pbi/Story dialogen; auto-default zichtbaar als placeholder `auto`; field-level error-rendering onder code-input voor zowel create- als edit-mode (uniciteits-conflict, ongeldig format)
|
||||||
|
- **Display:** `CodeBadge` (`components/shared/code-badge.tsx`) consistent op dashboard product-list, PBI-list, story-blocks (Product Backlog), sprint board (alle drie panelen incl. PBI-headers), solo-bord task-cards, task-detail-dialoog, sprint-afronden-dialoog en de story-dialoog title; task-card toont derived `${story.code}.${index}`-badge rechtsboven uitgelijnd
|
||||||
|
- **Seed:** parser strip `ST-XXX:`-prefix uit titles, vult `code` apart; product `Scrum4Me` krijgt `code: 'SCRUM4ME'`, milestones krijgen `M0`/`M3.5`/etc., stories krijgen `ST-001…ST-612`
|
||||||
|
- Done when: auto-toegekende codes per product oplopend en uniek; race-conflict wordt opgevangen door retry-helper i.p.v. te crashen; handmatige duplicate code toont inline error onder de input in zowel create- als edit-mode; codes zichtbaar als badge in alle lijsten/cards/dialogen; seed verdeelt codes correct (8 PBI's met `M*`, 84 stories met `ST-NNN`)
|
||||||
|
|
||||||
|
- [x] **ST-512** REST API uitbreidingen voor codes, todo-description en task implementation_plan (buiten originele backlog toegevoegd)
|
||||||
|
- **`GET /api/products`:** voeg `code` toe (naast `id`, `name`, `repo_url`); optioneel `description` en `definition_of_done`
|
||||||
|
- **`GET /api/products/:id/next-story`:** voeg `code` toe op story; voeg per task `code` (derived `${story.code}.${index_in_story}`) en `implementation_plan` toe
|
||||||
|
- **`GET /api/sprints/:id/tasks`:** voeg `description`, `implementation_plan` en `story_code` toe per task; voeg een derived `code`-veld per task toe (`${story.code}.${index_in_story}`)
|
||||||
|
- **`POST /api/todos`:** accepteer optionele `description` (max 2000 tekens); valideer en sla op; retourneer `description` in response
|
||||||
|
- Done when: alle vier endpoints retourneren / accepteren de nieuwe velden zoals beschreven; curl-test toont `code` op products, story en tasks; todo aanmaken via API met `description` slaat op
|
||||||
|
|
||||||
|
- [x] **ST-513** REST API hardening voor Claude Code (buiten originele backlog toegevoegd)
|
||||||
|
- **Health:** nieuwe `GET /api/health` zonder auth; retourneert `{ status, version, time }`; optioneel `?db=1` voor DB-ping (`{ database: 'ok' | 'down' }`)
|
||||||
|
- **Claude-context:** nieuwe `GET /api/products/:id/claude-context` (auth) die in één call `product`, `active_sprint`, `next_story` (met tasks), en `open_todos` van de gebruiker terugbrengt — voorkomt round-trips
|
||||||
|
- **Status-case op API-boundary:** nieuwe `lib/task-status.ts` mapper; API exposeert lowercase (`todo`/`in_progress`/`review`/`done` voor tasks; `open`/`in_sprint`/`done` voor stories); DB blijft UPPER_SNAKE; UI ongewijzigd
|
||||||
|
- **`PATCH /api/tasks/:id`:** accepteert lowercase `status` via mapper; retourneert lowercase
|
||||||
|
- **Story-log metadata:** nieuwe optionele `metadata Json?` kolom op `StoryLog`; `POST /api/stories/:id/log` accepteert per type een optioneel `metadata`-veld (bv. `{ branch: 'feat/x' }`); bestaande velden ongewijzigd → backwards-compatible
|
||||||
|
- **Foutcodes:** Zod-validatie geeft `422` (was `400`); `400` blijft voor malformed body; `401`/`403`/`404`/`500` ongewijzigd
|
||||||
|
<<<<<<<< HEAD:docs/backlog/index.md
|
||||||
|
- **API-documentatie:** nieuwe `docs/api/rest-contract.md` met endpoints, request/response, foutcodes, status-enums en curl-voorbeelden; `CLAUDE.md` verwijst ernaar
|
||||||
|
- Done when: `curl /api/health` werkt zonder auth; `curl /api/products/:id/claude-context` retourneert bundled JSON; PATCH/PUT routes accepteren lowercase status en geven 422 bij ongeldige body; story-log POST bewaart `metadata`; `docs/api/rest-contract.md` is gepubliceerd
|
||||||
|
========
|
||||||
|
- **API-documentatie:** nieuwe `docs/api.md` met endpoints, request/response, foutcodes, status-enums en curl-voorbeelden; `CLAUDE.md` verwijst ernaar
|
||||||
|
- Done when: `curl /api/health` werkt zonder auth; `curl /api/products/:id/claude-context` retourneert bundled JSON; PATCH/PUT routes accepteren lowercase status en geven 422 bij ongeldige body; story-log POST bewaart `metadata`; `docs/api.md` is gepubliceerd
|
||||||
|
>>>>>>>> origin/main:docs/backlog.md
|
||||||
|
- **`GET /api/products`:** voeg `code` toe (naast `id`, `name`, `repo_url`); optioneel `description` en `definition_of_done`
|
||||||
|
- **`GET /api/products/:id/next-story`:** voeg `code` toe op story; voeg per task `code` (derived `${story.code}.${index_in_story}`) en `implementation_plan` toe
|
||||||
|
- **`GET /api/sprints/:id/tasks`:** voeg `description`, `implementation_plan` en `story_code` toe per task; voeg een derived `code`-veld per task toe (`${story.code}.${index_in_story}`)
|
||||||
|
- **`POST /api/todos`:** accepteer optionele `description` (max 2000 tekens); valideer en sla op; retourneer `description` in response
|
||||||
|
- **Backwards-compat:** alle wijzigingen zijn additief — bestaande clients negeren onbekende keys; nieuwe input-velden zijn optioneel
|
||||||
|
- Done when: alle vier endpoints retourneren / accepteren de nieuwe velden zoals beschreven; curl-test toont `code` op products, story en tasks; todo aanmaken via API met `description` slaat op
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M6: Polish & Launch-ready
|
||||||
|
|
||||||
|
- [x] **ST-601** Loading states en skeletons
|
||||||
|
- `loading.tsx` voor alle zware routes (`/products/[id]`, `/sprint`, `/sprint/planning`); skeletoncomponenten voor PBI-lijst, story-blokken en takenlijst; pending states op alle form-submit-knoppen via `useFormStatus`
|
||||||
|
- Done when: navigeren naar een trage route toont skeleton; submit-knoppen disablen tijdens Server Action
|
||||||
|
|
||||||
|
- [x] **ST-602** Error boundaries
|
||||||
|
- `error.tsx` voor alle beschermde routes; toon gebruiksvriendelijke foutmelding met "Probeer opnieuw" knop; log fout naar console (Sentry in M6)
|
||||||
|
- Done when: gesimuleerde Server Action-fout toont error boundary zonder witte pagina
|
||||||
|
|
||||||
|
- [x] **ST-603** Toast-notificaties (Sonner)
|
||||||
|
- Installeer Sonner; success-toast na aanmaken/bewerken/verwijderen van producten, PBI's, stories, taken, todos; error-toast bij mislukte Server Actions; toast niet bij drag-and-drop (te frequent)
|
||||||
|
- Done when: aanmaken van PBI toont success-toast; gesimuleerde netwerk-fout toont error-toast
|
||||||
|
|
||||||
|
- [x] **ST-604** Demo-gebruiker write-protection in UI
|
||||||
|
- Alle aanmaak-, bewerk- en verwijderknoppen disabled + tooltip "Niet beschikbaar in demo-modus" voor demo-sessies; gebaseerd op `isDemo` in sessie
|
||||||
|
- Done when: demo-gebruiker ziet alle knoppen maar kan niets wijzigen; tooltip zichtbaar bij hover
|
||||||
|
|
||||||
|
- [x] **ST-605** Keyboard-navigatie
|
||||||
|
- Tab-volgorde logisch in alle formulieren; Enter submits formulieren; Escape sluit modals/slide-overs; dnd-kit keyboard-drag (Space om te pakken, pijltjestoetsen, Space om te laten vallen)
|
||||||
|
- Done when: volledige PBI aanmaken-flow keyboard-only uitvoerbaar; dnd-kit drag via keyboard werkt
|
||||||
|
|
||||||
|
- [x] **ST-606** Desktop-first UI-review
|
||||||
|
- Test alle flows op 1280px, 1440px en 1920px breedte; fix overflow, uitlijning en proportie-issues; controleer minimum schermbreedte 1024px (toon melding bij smaller)
|
||||||
|
- Done when: alle M0–M5 flows correct op drie schermbreedtes; melding bij < 1024px
|
||||||
|
|
||||||
|
- [x] **ST-607** Toegankelijkheid (WCAG AA)
|
||||||
|
- Kleurcontrast-check op alle tekst en badges; aria-labels op icon-only knoppen; focus-ring zichtbaar op alle interactieve elementen; `role` en `aria-selected` op geselecteerde PBI in linkerpaneel
|
||||||
|
- Done when: geen WCAG AA contrastfouten op primaire flows; alle knoppen hebben toegankelijke labels
|
||||||
|
|
||||||
|
- [x] **ST-608** Ratelimiting op auth-endpoints
|
||||||
|
- Max. 10 inlogpogingen per IP per minuut; max. 5 registraties per IP per uur; implementeer via in-memory counter (v1) of Vercel Edge middleware
|
||||||
|
- Done when: 11 snelle inlogpogingen leiden tot 429-respons met duidelijke melding
|
||||||
|
|
||||||
|
- [x] **ST-609** Beveiligingsreview API-endpoints
|
||||||
|
- Controleer alle Route Handlers: elke schrijfoperatie valideert dat de resource binnen de toegankelijke product-scope valt; cross-scope reads zijn onmogelijk tenzij de gebruiker via `product_members` gekoppeld is; voeg integratietests toe die cross-user toegang testen
|
||||||
|
- Done when: poging om een niet-gedeeld product van een andere gebruiker op te halen via API geeft 403 of 404; gedeelde producten zijn wel zichtbaar; getest met twee test-gebruikers
|
||||||
|
|
||||||
|
- [x] **ST-610** CI/CD via GitHub Actions
|
||||||
|
- Workflow: `lint` (ESLint), `typecheck` (tsc --noEmit), `prisma validate`, `build` (next build) op elke PR en push naar main; Vercel auto-deploy op main
|
||||||
|
- Done when: een TypeScript-fout in een PR blokkeert merge; succesvolle merge triggert Vercel-deploy
|
||||||
|
|
||||||
|
- [x] **ST-611** README en lokale setup-documentatie
|
||||||
|
- Schrijf `README.md` met: wat is Scrum4Me, quickstart lokaal (clone → env → prisma push → seed → dev), cloud deployment (Vercel + Neon stappenplan), API-documentatie (alle 7 endpoints met voorbeelden), Claude Code-integratie uitleg, Vercel Analytics status en directe dependencies zoals Sharp
|
||||||
|
- De in-app landingspagina (`/`) bevat al een gebruikershandleiding, Scrum-samenvatting en API-overzicht — de README richt zich op lokale setup en deployment
|
||||||
|
- Done when: iemand zonder context de app lokaal kan draaien op basis van alleen de README en `.env.example`
|
||||||
|
|
||||||
|
- [x] **ST-612** End-to-end acceptatietest
|
||||||
|
- Voer handmatig de volledige Lars-flow uit: product aanmaken → PBI's en stories aanmaken → Sprint starten → stories slepen → taken aanmaken → API-token aanmaken → curl `next-story` → curl `log` (plan, test, commit) → activiteitenlog controleren in UI
|
||||||
|
- Done when: volledige flow werkt zonder fouten of onverwacht gedrag; alle API-responses correct JSON
|
||||||
|
|
||||||
|
### M7: MCP-server voor Claude Code
|
||||||
|
|
||||||
|
Aparte repo: [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp). Native Prisma-toegang (geen REST-tussenlaag), stdio-transport, Scrum4Me-schema gevendord als git submodule. Tokens hergebruikt uit `api_tokens`. v1 is alleen dev-flow tools — geen PBI/sprint-creatie of profielbeheer.
|
||||||
|
|
||||||
|
- [x] **ST-701** Repo-skeleton mcp
|
||||||
|
- npm init, tsconfig strict, .gitignore, MCP SDK 1.29, Prisma 7, zod, tsx; lege `src/index.ts` die op stdio start
|
||||||
|
- Done when: `npx tsx src/index.ts` print `running on stdio` zonder crash; `tsc --noEmit` slaagt
|
||||||
|
|
||||||
|
- [x] **ST-702** Schema-sync via git submodule
|
||||||
|
- Submodule `vendor/scrum4me`, `scripts/sync-schema.sh` kopieert `schema.prisma` en strip de `generator erd`-block, `npm run prisma:generate` als postinstall
|
||||||
|
- Done when: `npm run sync-schema && npm run prisma:generate` werkt op een verse clone
|
||||||
|
|
||||||
|
- [x] **ST-703** Auth en Prisma-singleton
|
||||||
|
- `src/auth.ts` SHA-256 hash van `SCRUM4ME_TOKEN` → lookup in `api_tokens`, cached `{ userId, isDemo }`; `requireWriteAccess()` throwt `PermissionDeniedError` voor demo
|
||||||
|
- `src/prisma.ts` lazy proxy zodat bootstrap niet crasht zonder `DATABASE_URL`
|
||||||
|
- Done when: ongeldig token geeft `SCRUM4ME_TOKEN is invalid or revoked`; demo-tokens blokkeren writes
|
||||||
|
|
||||||
|
- [x] **ST-704** Status-mappers + error-helpers
|
||||||
|
- `src/status.ts` zelfde mappers als REST `lib/task-status.ts`
|
||||||
|
- `src/errors.ts` `formatZodError`, `toolError`, `toolJson`, `withToolErrors` wrapper
|
||||||
|
- Done when: zod-fouten en `PermissionDenied` worden als gestructureerde MCP-errors teruggegeven
|
||||||
|
|
||||||
|
- [x] **ST-705** Read-tools — `health`, `list_products`, `get_claude_context`
|
||||||
|
- `health` doet `SELECT 1`; `list_products` met product-access filter; `get_claude_context` bundelt product + active sprint + next story (met tasks) + 50 open todos
|
||||||
|
- Done when: smoke-test tegen live DB groen voor alle drie
|
||||||
|
|
||||||
|
- [x] **ST-706** Write-tools tasks — `update_task_status`, `update_task_plan`
|
||||||
|
- Status-input lowercase (`todo|in_progress|review|done`), conversie via mapper; access-check via story → product → membership/owner
|
||||||
|
- Done when: niet-eigenaar krijgt 'not accessible'; demo geeft `PERMISSION_DENIED`
|
||||||
|
|
||||||
|
- [x] **ST-707** Log-tools — `log_implementation`, `log_test_result`, `log_commit`
|
||||||
|
- Append `StoryLog` met juiste `type`; optioneel `metadata` JSONB
|
||||||
|
- Done when: drie logs verschijnen in story-activiteit met `type`/`status`/`commit_hash`/`metadata` zoals meegegeven
|
||||||
|
|
||||||
|
- [x] **ST-708** `create_todo`-tool
|
||||||
|
- Optionele `description` (max 2000) en `product_id` (gevalideerd via access-check)
|
||||||
|
- Done when: nieuwe todo verschijnt in `/todos` voor de tokengebruiker
|
||||||
|
|
||||||
|
- [x] **ST-709** Prompt `implement_next_story`
|
||||||
|
- Workflow: `get_claude_context` → plan → log_implementation → per task `in_progress`/`done` → tests → `log_test_result` → `log_commit`
|
||||||
|
- Done when: prompt zichtbaar in MCP-clients met argument `product_id`
|
||||||
|
|
||||||
|
- [x] **ST-710** README + Claude Code-config + smoke-test
|
||||||
|
- README beschrijft setup, tools-tabel, schema-sync, `~/.claude/mcp_servers.json` snippet, risico's
|
||||||
|
- `scripts/smoke-test.ts` valideert read-tools tegen live DB
|
||||||
|
- Done when: smoke-test groen; MCP Inspector toont 9 tools + 1 prompt
|
||||||
|
|
||||||
|
### M8: Realtime Solo Paneel
|
||||||
|
|
||||||
|
Live updates voor stories en tasks in het Solo Paneel zonder pagina-refresh. Wanneer Claude Code (via MCP), Codex (via REST) of een andere browser-tab een task/story muteert, ziet de gebruiker het binnen 1–2 seconden in zijn kanban-bord.
|
||||||
|
|
||||||
|
Transport: Server-Sent Events (Vercel ondersteunt geen stateful WebSockets). Bron: Postgres `LISTEN/NOTIFY` via row-level triggers op `tasks` en `stories`. Eén-richting (server → client) — mutaties blijven via Server Actions/REST/MCP.
|
||||||
|
|
||||||
|
Filtering server-side: alleen events binnen de actieve sprint van een product waar de gebruiker eigenaar of lid van is, plus `assignee_id == userId` (eigen kolommen) of `assignee_id IS NULL` (claim-lijst).
|
||||||
|
|
||||||
|
- [x] **ST-801** Postgres LISTEN/NOTIFY-infrastructuur
|
||||||
|
- Migratie met `notify_solo_change()`-functie + `AFTER INSERT/UPDATE/DELETE`-triggers op `tasks` en `stories`; payload bevat `op`, `entity`, `id`, `product_id`, `sprint_id`, `assignee_id`, `fields` (gewijzigde kolommen)
|
||||||
|
- Done when: `psql $DIRECT_URL -c "LISTEN scrum4me_solo;"` toont een payload bij een UI-mutatie
|
||||||
|
|
||||||
|
- [x] **ST-802** SSE-route `/api/realtime/solo`
|
||||||
|
- `app/api/realtime/solo/route.ts`, `runtime: 'nodejs'`, `maxDuration: 300`; auth via iron-session, query-param `product_id`, opent `pg.Client` op `DIRECT_URL` met `LISTEN`; heartbeat 25s; hard close 240s; in-handler filtering op product/sprint/assignee
|
||||||
|
- Done when: `curl -N` op localhost levert binnen 1s een event op na een task-mutatie via UI
|
||||||
|
|
||||||
|
- [x] **ST-803** Client hook `useSoloRealtime(productId)`
|
||||||
|
- `lib/realtime/use-solo-realtime.ts`; opent `EventSource`, exponential backoff reconnect (1s → 30s); Page Visibility API voor pauseren/hervatten; cleanup op unmount
|
||||||
|
- Done when: tab wisselen sluit/opent connectie zichtbaar in DevTools Network
|
||||||
|
|
||||||
|
- [x] **ST-804** Solo-store realtime-acties
|
||||||
|
- `applyTaskUpdate`, `applyTaskCreate`, `applyTaskDelete`, `applyStoryAssignment`, `markPending`/`clearPending` om eigen optimistic-echo te onderdrukken
|
||||||
|
- Done when: unit-test op solo-store met gesimuleerde events laat juiste eindstate zien
|
||||||
|
|
||||||
|
- [x] **ST-805** Wire-up in SoloBoard + UI-indicator
|
||||||
|
- `components/solo/solo-board.tsx` roept de hook aan; klein "live"/"verbinden..."-statusindicator; toast bij langer dan 5s disconnected
|
||||||
|
- Done when: twee tabs van Solo Paneel — mutatie in tab A komt binnen 1–2s in tab B zonder refresh
|
||||||
|
|
||||||
|
- [x] **ST-806** Documentatie + acceptatietest
|
||||||
|
<<<<<<<< HEAD:docs/backlog/index.md
|
||||||
|
- Sectie "Realtime updates" in `docs/architecture.md` met diagram en filtering-regels; vermelding in `CLAUDE.md`; korte note over `/api/realtime/solo` in `docs/api/rest-contract.md`; handmatig E2E-scenario's gedraaid (zelfde gebruiker twee tabs, MCP-write, REST-write, story-claim, network-flap)
|
||||||
|
========
|
||||||
|
- Sectie "Realtime updates" in `docs/architecture.md` met diagram en filtering-regels; vermelding in `CLAUDE.md`; korte note over `/api/realtime/solo` in `docs/api.md`; handmatig E2E-scenario's gedraaid (zelfde gebruiker twee tabs, MCP-write, REST-write, story-claim, network-flap)
|
||||||
|
>>>>>>>> origin/main:docs/backlog.md
|
||||||
|
- Done when: alle scenario's lopen door zonder onverwachte gedragingen
|
||||||
|
|
||||||
|
Volledig plan in `.Plans/2026-04-27-m8-realtime-solo.md` (lokaal, niet gecommit).
|
||||||
|
|
||||||
|
### M9: Actief Product Backlog
|
||||||
|
|
||||||
|
**Implementatieplan:** [docs/plans/M9-active-product-backlog.md](../plans/M9-active-product-backlog.md)
|
||||||
|
|
||||||
|
Eén "actief Product Backlog" per gebruiker — persistent in DB. De NavBar wordt gesplitst in **Producten** (lijst) en **Product Backlog** (PB-view van actief PB), met **Sprint** en **Solo** als aparte tabs die op het actieve PB werken. Geen actief PB → die drie tabs zijn disabled. Vervangt de bestaande `last_product`-cookieflow.
|
||||||
|
|
||||||
|
- [x] **ST-901** Database — `user.active_product_id`
|
||||||
|
- Voeg `active_product_id String? @db.Uuid` toe aan `User` met FK naar `Product.id` en `onDelete: SetNull`; migratie `add_user_active_product_id`; index op `active_product_id` voor join-performance
|
||||||
|
- Done when: `npx prisma migrate dev` slaagt; `prisma studio` toont kolom; `npx prisma validate` zonder fouten; submodule `vendor/scrum4me` in mcp draait `prisma generate` + `tsc --noEmit` zonder fouten
|
||||||
|
|
||||||
|
- [x] **ST-902** Server Actions — actief product zetten en wissen
|
||||||
|
- `actions/active-product.ts` met `setActiveProduct(productId)` en `clearActiveProduct()`; Zod + auth + `productAccessFilter`; demo-gebruikers mogen wisselen (sessie-effect alleen, geen DB-write); `archiveProduct` en `leaveProduct` zetten `active_product_id` op `null` als het hetzelfde product betreft
|
||||||
|
- Done when: setActive met onbekend/onbereikbaar product → 422; archiveren van actief product clearet de keuze; demo-flow geeft toast "Niet beschikbaar in demo-modus"
|
||||||
|
|
||||||
|
- [x] **ST-903** App-layout laadt actief product + redirects
|
||||||
|
- `app/(app)/layout.tsx` haalt `activeProduct` (id, name, archived) op naast user; geef door aan `NavBar`; `app/(app)/solo/page.tsx` gebruikt `user.active_product_id` i.p.v. `getLastProductCookie`; helper `lib/cookies.ts:getLastProductCookie` markeren deprecated of verwijderen plus call-sites opruimen
|
||||||
|
- Done when: ingelogd zonder actief PB toont NavBar zonder geactiveerde tabs; met actief PB redirect `/solo` → `/products/[active]/solo` zonder cookie te raadplegen
|
||||||
|
|
||||||
|
- [x] **ST-904** NavBar — splits + disabled-states + switcher
|
||||||
|
- Tabs worden: **Producten** (`/dashboard`) | **Product Backlog** (`/products/[active]`) | **Sprint** (`/products/[active]/sprint`) | **Solo** (`/products/[active]/solo`) | **Todo's** (`/todos`); zonder actief PB zijn de middelste drie disabled-spans (zelfde stijl als huidige Sprint-disabled); productnaam in midden wordt een dropdown-trigger (shadcn `DropdownMenu`) met je producten + "Producten beheren →"; Sprint krijgt `aria-disabled` + tooltip "Geen actieve sprint" als er geen sprint met status `ACTIVE` is
|
||||||
|
- Done when: handmatige test: zonder PB drie tabs grijs; activeer PB → tabs klikbaar; dropdown wisselt PB en redirect naar Product Backlog; Sprint-tab disabled tot sprint gestart
|
||||||
|
|
||||||
|
- [x] **ST-905** Producten-scherm — Activeer-knop per rij
|
||||||
|
- `components/dashboard/product-list.tsx`: per rij "Activeer"-knop (verborgen voor reeds actief PB); actieve rij krijgt badge "Actief" (MD3-token `bg-primary-container`); klik op Activeer → `setActiveProduct` + `router.push('/products/[id]')`; ook in `/products/[id]` header een Activeer-knop als dat product nog niet actief is
|
||||||
|
- Done when: activeer in dashboard markeert juiste rij + landt op Product Backlog; demo-gebruiker krijgt toast en geen DB-mutatie
|
||||||
|
|
||||||
|
- [x] **ST-906** Edge cases — toegangsverlies en archivering
|
||||||
|
- Wanneer een PB wordt gearchiveerd, ge-leaved, of een productmember wordt verwijderd: `active_product_id` automatisch `null` voor betroffen users (server actions van `archiveProduct`, `leaveProduct`, `removeMember`); guard in `app/(app)/layout.tsx`: als `active_product_id` is gezet maar product is archived/onbereikbaar, server-side clear + redirect naar `/dashboard` met toast "Je actieve product is niet meer beschikbaar"
|
||||||
|
- Done when: scenario test — eigenaar archiveert → membership-gebruikers landen op dashboard met toast en active is gecleared
|
||||||
|
|
||||||
|
- [x] **ST-907** Documentatie en tests
|
||||||
|
- Functional spec: nieuw hoofdstuk "Actief Product Backlog" (concept, menugedrag, edge cases); README: navigatie-screenshot bijwerken; `docs/patterns/` indien nieuwe patroon (n.v.t. tenzij dropdown-switcher een herbruikbaar component wordt); jest-tests in `__tests__/actions/active-product.test.ts` voor setActive (toegang, demo, archived); Playwright/manueel scenario: log in → activeer PB → wissel via dropdown → archiveer → verifieer auto-clear
|
||||||
|
- Done when: `npm run lint && npx tsc --noEmit && npm test && npm run build` groen; spec-secties geschreven; `vendor/scrum4me`-submodule in mcp gesynced
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M10: Password-loze inlog via QR-pairing
|
||||||
|
|
||||||
|
**Implementatieplan:** [docs/plans/M10-qr-pairing-login.md](../plans/M10-qr-pairing-login.md)
|
||||||
|
|
||||||
|
Inloggen op een (publieke) desktop zonder wachtwoord: de desktop toont een QR-code, de gebruiker scant met een telefoon waar hij al ingelogd is, bevestigt expliciet, en de desktop is binnen 1–2 seconden ingelogd. Bouwt voort op de Postgres LISTEN/NOTIFY-infra van M8 (eigen kanaal `scrum4me_pairing`). Geen wachtwoord ingetypt op het publieke apparaat, geen credentials op de draad, demo-accounts geblokkeerd, paired-sessie heeft eigen kortere TTL (8 u) + `paired`-vlag voor toekomstige remote-revoke.
|
||||||
|
|
||||||
|
**Beveiligingsuitgangspunt:** `mobileSecret` reist alleen via QR-fragment (`#s=…`) → `location.hash` op de mobiel → POST-body. Desktop-SSE en claim authenticeren via een **HttpOnly pre-auth cookie** (`s4m_pair`, `Path=/api/auth/pair`, `Max-Age=120`, `SameSite=Lax`). Twee gescheiden hashes in DB (`secret_hash` voor mobiel-bewijs, `desktop_token_hash` voor desktop-bewijs) zodat geheim materiaal niet in URL-paden, querystrings, access logs, reverse-proxy logs, observability of browsergeschiedenis kan belanden.
|
||||||
|
|
||||||
|
Volledige flow + threat-model: `docs/patterns/qr-login.md` (op te leveren in ST-1008).
|
||||||
|
|
||||||
|
- [ ] **ST-1001** LoginPairing schema + Postgres-trigger
|
||||||
|
- **Schema:** `LoginPairing { id, secret_hash, desktop_token_hash, status, user_id?, desktop_ua?, desktop_ip?, created_at, expires_at, approved_at?, consumed_at? }`; back-relation `User.login_pairings`; `@@index([expires_at])`, `@@index([status, expires_at])`; `status` als string (`pending|approved|consumed|cancelled`); twee hash-kolommen scheiden mobiel-bewijs van desktop-bewijs
|
||||||
|
- **Trigger:** `notify_pairing_change()` + `AFTER INSERT/UPDATE` op `login_pairings`; `pg_notify('scrum4me_pairing', payload)` met `{ pairing_id, status, op }`; analoog aan `notify_solo_change` uit ST-801
|
||||||
|
- **Migratie:** `prisma migrate dev --name add_login_pairing`
|
||||||
|
- Done when: migratie slaagt; `psql $DIRECT_URL -c "LISTEN scrum4me_pairing;"` levert payload bij INSERT op `login_pairings`; beide hash-kolommen zijn `NOT NULL`
|
||||||
|
|
||||||
|
- [ ] **ST-1002** Pairing-helpers + sessie-uitbreiding + pre-auth-cookie
|
||||||
|
- **`lib/auth/pairing.ts`:** `generateMobileSecret()` en `generateDesktopToken()` (beide 32 bytes → base64url, los gegenereerd zodat ze elkaar niet onthullen), `hashToken(t)` (sha256-hex), `verifyToken(t, hash)` (timing-safe compare)
|
||||||
|
- **`lib/auth/pair-cookie.ts`:** `setPairCookie(response, desktopToken)` (`HttpOnly`, `Secure` in prod, `SameSite=Lax`, `Path=/api/auth/pair`, `Max-Age=120`); `readPairCookie(request)` returnt `desktopToken | null`; `clearPairCookie(response)` op claim/cancel
|
||||||
|
- **`SessionData` in `lib/session.ts`:** voeg optionele `paired?: boolean` en `pairedExpiresAt?: number` toe
|
||||||
|
- **`app/(app)/layout.tsx`:** extra guard — als `session.paired && session.pairedExpiresAt < Date.now()` → `session.destroy()` + `redirect('/login')`
|
||||||
|
- Done when: helpers hebben unit-tests; paired-sessie verloopt zichtbaar na vervaltijd; cookie wordt nooit door client-JS gelezen (HttpOnly-test)
|
||||||
|
|
||||||
|
- [ ] **ST-1003** `POST /api/auth/pair/start` — pairing aanmaken (anon)
|
||||||
|
- Route Handler zonder auth; leest UA + best-effort IP (`x-forwarded-for`); genereert los `mobileSecret` + `desktopToken`; insert `LoginPairing` met beide hashes, `status='pending'`, `expires_at = now() + 2 min`
|
||||||
|
- **Response body:** `{ pairingId, mobileSecret, expiresAt, qrUrl }` — `qrUrl = ${origin}/m/pair#id=…&s=…` (fragment, geen querystring)
|
||||||
|
- **Response header:** `Set-Cookie: s4m_pair=<desktopToken>; HttpOnly; Secure; SameSite=Lax; Path=/api/auth/pair; Max-Age=120`
|
||||||
|
- **Rate-limit:** patroon ST-608 (max 10 starts per IP per minuut)
|
||||||
|
- Done when: curl POST levert pairingId+mobileSecret in body en `s4m_pair`-cookie in header; 11e call binnen 60s geeft 429; rij in `login_pairings` zonder plaintext secret of desktop-token
|
||||||
|
|
||||||
|
- [ ] **ST-1004** SSE-route `/api/auth/pair/stream/[pairingId]` (cookie-auth)
|
||||||
|
- `runtime: 'nodejs'`, `maxDuration: 300`; pairingId in pad (niet sensitief), auth via `s4m_pair`-cookie: sha256(cookie) matcht `desktop_token_hash` van pairing met `pairingId` en `expires_at > now()`; anders 401
|
||||||
|
- **Geen query-parameters met geheim materiaal.** Browser stuurt cookie automatisch mee.
|
||||||
|
- Hergebruik LISTEN/NOTIFY-pattern uit `app/api/realtime/solo/route.ts` op kanaal `scrum4me_pairing`; filter notifications op `pairing_id`
|
||||||
|
- Auto-close bij status `consumed`/`cancelled` of na 240 s; heartbeat 25 s
|
||||||
|
- Done when: SSE-verbinding zonder `s4m_pair`-cookie geeft 401; met geldige cookie levert event binnen 1s na approve; stream sluit na consume; pairingId in URL is OK (niet vertrouwelijk)
|
||||||
|
|
||||||
|
- [ ] **ST-1005** Server actions + mobiele bevestigingspagina
|
||||||
|
- **`actions/pairing.ts`:** `getPairingForApproval(pairingId, mobileSecret)`, `approvePairing(pairingId, mobileSecret)` (demo-blokkade, hash-vergelijk tegen `secret_hash`, status pending→approved, bumpt `expires_at` +5 min, zet `user_id` + `approved_at`), `cancelPairing(pairingId, mobileSecret)`
|
||||||
|
- **`app/(app)/m/pair/page.tsx`:** Server Component achter de bestaande `(app)/layout.tsx` auth-guard; **leest géén query-params** — alleen statische uitleg + een client-island
|
||||||
|
- **`app/(app)/m/pair/pair-confirmation.tsx`:** Client Component die bij mount `window.location.hash` parseert (`#id=…&s=…`), via Server Action `getPairingForApproval` de UA/IP/username ophaalt, dan toont *"Inloggen op {ua} ({ip}) als {jouw-username}?"* met Bevestig/Annuleer-knoppen die `approvePairing`/`cancelPairing` aanroepen; succes-state *"Klaar — je kunt deze tab sluiten"*. Wist `location.hash` na approve zodat back/forward de secret niet onthult
|
||||||
|
- Demo-modus: approve geeft Nederlandse foutmelding (consistent ST-604)
|
||||||
|
- Done when: ingelogde mobiel ziet bevestigingspagina met UA + IP; secret komt nooit in een GET-URL voor; tap "Bevestig" zet status approved; demo-user ziet foutmelding en pairing blijft `pending`
|
||||||
|
|
||||||
|
- [ ] **ST-1006** `POST /api/auth/pair/claim` — desktop-cookie zetten (cookie-auth)
|
||||||
|
- Auth via `s4m_pair`-cookie (geen body-secret nodig); atomic update: `UPDATE login_pairings SET status='consumed', consumed_at=now() WHERE id=$1 AND status='approved' AND desktop_token_hash=$2 AND expires_at > now() RETURNING user_id`
|
||||||
|
- Bij rij geretourneerd: `getIronSession` → `session.userId = user.id; session.isDemo = user.is_demo; session.paired = true; session.pairedExpiresAt = Date.now() + 8h`; clear `s4m_pair`-cookie; anders 410 (al consumed) / 404 / 401
|
||||||
|
- Logging alleen `pairingId`, nooit cookie-waarde of mobileSecret
|
||||||
|
- Done when: claim met geldige cookie schrijft iron-session cookie en retourneert 200; tweede claim 410; ontbrekende/foute cookie 401; `s4m_pair` is na succes geclear'd
|
||||||
|
|
||||||
|
- [ ] **ST-1007** Desktop UI: QR-render + SSE-listener op `/login`
|
||||||
|
- **Dependency:** `qrcode.react` (client SVG; mobileSecret blijft op desktop in JS-geheugen)
|
||||||
|
- **`app/login/qr-login-button.tsx`:** Client Component; klik → POST `pair/start` (`credentials: 'same-origin'` zodat `s4m_pair`-cookie wordt geaccepteerd) → render QR met `qrUrl` (fragment-URL) → open `EventSource('/api/auth/pair/stream/<pairingId>', { withCredentials: true })` → bij `approved` event POST `pair/claim` (cookie-only) → bij succes `router.push('/dashboard')`; aftellende timer (2 min); bij timeout "Vernieuwen"-knop; cleanup bij unmount/redirect
|
||||||
|
<<<<<<<< HEAD:docs/backlog/index.md
|
||||||
|
- **`app/login/page.tsx`:** knop "Inloggen via mobiel" naast bestaande wachtwoord-form (MD3-tokens uit `docs/design/styling.md`)
|
||||||
|
========
|
||||||
|
- **`app/login/page.tsx`:** knop "Inloggen via mobiel" naast bestaande wachtwoord-form (MD3-tokens uit `docs/styling.md`)
|
||||||
|
>>>>>>>> origin/main:docs/backlog.md
|
||||||
|
- A11y: QR heeft alt-tekst met de URL voor screenreaders/copy-paste (de hash-suffix is onderdeel van die alt-tekst, niet van de page-URL die in browsergeschiedenis komt)
|
||||||
|
- Done when: end-to-end happy path werkt op localhost (twee browsers): A toont QR → B scant + bevestigt → A redirect naar `/dashboard` met `session.paired === true`; QR vernieuwt na expiry; geen secret zichtbaar in DevTools Network-tab onder URL-kolommen
|
||||||
|
|
||||||
|
- [ ] **ST-1008** Documentatie + acceptatietest
|
||||||
|
<<<<<<<< HEAD:docs/backlog/index.md
|
||||||
|
- **`docs/api/rest-contract.md`:** drie nieuwe endpoints (start/stream/claim) met request/response, cookie-mechaniek, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief `--cookie-jar`
|
||||||
|
========
|
||||||
|
- **`docs/api.md`:** drie nieuwe endpoints (start/stream/claim) met request/response, cookie-mechaniek, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief `--cookie-jar`
|
||||||
|
>>>>>>>> origin/main:docs/backlog.md
|
||||||
|
- **`docs/architecture.md`:** sectie "QR-pairing flow" met sequence-diagram + threat-model; expliciete subsectie *"Waarom geen secret in URL"* — fragments worden niet naar server gestuurd; SSE/claim authenticeren via HttpOnly cookie zodat secret-materiaal niet in access logs / reverse-proxy logs / observability-tools / browsergeschiedenis kan belanden
|
||||||
|
- **`docs/patterns/qr-login.md`:** nieuw pattern-doc voor toekomstige features die hetzelfde unauth-SSE-via-pre-auth-cookie-patroon willen hergebruiken
|
||||||
|
- **`CLAUDE.md`:** verwijzing naar het nieuwe pattern-doc in de patterns-tabel
|
||||||
|
- **Acceptatietest:** zeven scenario's handmatig: happy path, demo-block, replay, expiry tijdens pending, expiry tussen approve+claim, ontbrekende cookie op SSE/claim, secret niet aanwezig in `nginx`/Vercel access logs (controle via runtime-logs MCP-tool)
|
||||||
|
- Done when: docs gepubliceerd; alle zeven scenario's groen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M11: Claude vraagt, gebruiker antwoordt
|
||||||
|
|
||||||
|
**Implementatieplan:** [docs/plans/M11-claude-questions.md](../plans/M11-claude-questions.md)
|
||||||
|
|
||||||
|
Persistent vraag-antwoord-kanaal tussen Claude Code (via MCP) en de actieve Scrum4Me-gebruiker. Claude schrijft een vraag naar `claude_questions` als hij vastloopt op een keuze; een Postgres-trigger emit op het bestaande `scrum4me_changes`-kanaal (uitgebreid met `entity: 'question'`); de Scrum4Me-app toont een notificatie-badge in de NavBar; iedereen met product-toegang kan antwoorden; Claude leest het antwoord (sync via polling met `wait_seconds`, of in een latere sessie via `get_question_answer`) en gaat door. Eerste concrete uitwerking van de AI-driven dev-flow-richting.
|
||||||
|
|
||||||
|
**Beveiligingsuitgangspunt:** atomic answer via `updateMany WHERE status='open'` voorkomt double-submit; demo-blok op zowel MCP-write-tools als Server Action; access-check via `productAccessFilter` in DB-query én SSE-filter; cron-endpoint voor expire-cleanup beveiligd met `Authorization: Bearer ${CRON_SECRET}`-header; logging alleen `question_id` (vraag/antwoord-tekst kan gevoelig materiaal bevatten).
|
||||||
|
|
||||||
|
- [ ] **ST-1101** `ClaudeQuestion` schema + Postgres-trigger
|
||||||
|
- **Schema:** `ClaudeQuestion { id, story_id, task_id?, product_id, asked_by, question, options?: Json, status, answer?, answered_by?, answered_at?, created_at, expires_at }`; relations op `User` (`asked_questions`, `answered_questions`), `Story`, `Task`, `Product`; indexes `(story_id, status)`, `(product_id, status)`, `(status, expires_at)`; `product_id` gedenormaliseerd voor SSE-filter
|
||||||
|
- **Trigger:** `notify_question_change()` `AFTER INSERT/UPDATE`; emit op `scrum4me_changes`-kanaal met payload `{ op, entity: 'question', id, product_id, story_id, task_id, assignee_id, status }`
|
||||||
|
- **Migratie:** `prisma migrate dev --name add_claude_questions`
|
||||||
|
- Done when: migratie slaagt; `psql LISTEN scrum4me_changes` toont nieuwe `entity: 'question'`-payload bij INSERT; bestaande solo-realtime-flow ongewijzigd; submodule sync na merge
|
||||||
|
|
||||||
|
- [ ] **ST-1102** MCP-tools voor Claude (in mcp-repo)
|
||||||
|
- **`ask_user_question`** (write): input `{ story_id, question, options?, task_id?, wait_seconds? }`; insert pairing + optioneel pollen tot `wait_seconds` (max 600); demo-blok via `requireWriteAccess`; access-check via `userCanAccessProduct(story.product_id, ...)`
|
||||||
|
- **`get_question_answer`** (read): haalt status + antwoord op een specifieke vraag op
|
||||||
|
- **`list_open_questions`** (read): lijst van eigen vragen (laatste 50, status open of answered)
|
||||||
|
- **`cancel_question`** (write): asker mag eigen vraag annuleren; status pending→cancelled
|
||||||
|
- Smoke-test in `scripts/smoke-test.ts`: `ask_user_question` met `wait_seconds=5` + parallel answer roundtrip
|
||||||
|
- Done when: MCP Inspector toont 4 nieuwe tools; smoke-test groen; demo-token op write-tools krijgt PERMISSION_DENIED; `tsc --noEmit` clean
|
||||||
|
|
||||||
|
- [ ] **ST-1103** Server Action `answerQuestion`
|
||||||
|
- `actions/questions.ts`: `answerQuestion(questionId, answer)` met getSession + Zod + demo-blok + `requireProductWriter` via `question.product_id`; atomic `updateMany WHERE status='open' AND expires_at>now`; `revalidatePath('/', 'layout')` voor badge-refresh
|
||||||
|
- Bij `count === 0`: disambigueer (al-answered/expired/access-fail) met begrijpelijke foutmelding
|
||||||
|
- Tests: 6 cases (happy, demo-block, geen access, race, expired, lege answer)
|
||||||
|
- Done when: `npm test` 6/6; handmatig: open vraag → antwoord → badge-count daalt met 1; demo-toast bij submit
|
||||||
|
|
||||||
|
- [ ] **ST-1104** User-scoped SSE-route `/api/realtime/notifications`
|
||||||
|
- Route Handler `runtime: 'nodejs'`, `maxDuration: 300`; auth via iron-session; **user-scoped** (geen product_id-param); filter `payload.entity === 'question'` én `payload.product_id` in user's accessible-product-ids
|
||||||
|
- Initial-state-event direct na connect (na LISTEN actief, conform M10 ST-1004 race-fix): summary-array van openstaande vragen voor deze user
|
||||||
|
- Update solo-route in `app/api/realtime/solo/route.ts`: in `shouldEmit` `if (payload.entity === 'question') return false` toevoegen — anders krijgen solo-clients ongewenst question-events
|
||||||
|
- Tests: 401 zonder cookie, filter op product-access, geen `entity:'question'`-events op solo-route
|
||||||
|
- Done when: `curl -N` levert events binnen 1s na INSERT; cross-product-test (user-A ziet user-B's vragen niet)
|
||||||
|
|
||||||
|
- [ ] **ST-1105** Notifications-UI (Bell + Sheet + Answer-modal + Zustand-store)
|
||||||
|
- **`stores/notifications-store.ts`** — Zustand store volgens `solo-store.ts`-patroon: `init`, `add`, `update`, `remove`, `optimisticAnswer`, `rollbackAnswer`; selectors `openCount`, `forYouCount`
|
||||||
|
- **`lib/realtime/use-notifications-realtime.ts`** — analoog aan `useSoloRealtime`; EventSource op `/api/realtime/notifications` met reconnect-backoff
|
||||||
|
- **`components/notifications/notifications-bridge.tsx`** — Server Component die initial-data fetcht en aan store geeft; mount in `app/(app)/layout.tsx` naast `<SoloRealtimeBridge />`
|
||||||
|
<<<<<<<< HEAD:docs/backlog/index.md
|
||||||
|
- **`components/shared/notifications-bell.tsx`** — Bell-icon (Lucide) met badge in NavBar (links van avatar); MD3-tokens uit `docs/design/styling.md`
|
||||||
|
========
|
||||||
|
- **`components/shared/notifications-bell.tsx`** — Bell-icon (Lucide) met badge in NavBar (links van avatar); MD3-tokens uit `docs/styling.md`
|
||||||
|
>>>>>>>> origin/main:docs/backlog.md
|
||||||
|
- **`components/notifications/notifications-sheet.tsx`** — shadcn Sheet van rechts; lijst gegroepeerd per product; story-assignee krijgt visuele *"wacht op jou"*-emphase
|
||||||
|
- **`components/notifications/answer-modal.tsx`** — shadcn Dialog; story-context-link, vraag-tekst, RadioGroup (als options) of Textarea (free-text), submit via `useTransition` + Server Action; demo-blok met tooltip
|
||||||
|
- Done when: bell + badge zichtbaar; klik opent Sheet met items; submit verwijdert item optimistisch; tweede tab van zelfde user ziet nieuwe vraag binnen 1-2s; demo-modus rendert maar Verstuur disabled
|
||||||
|
|
||||||
|
- [ ] **ST-1106** Demo-policy + access-tests
|
||||||
|
- Demo: Sheet rendert + Modal opent + Verstuur disabled met tooltip
|
||||||
|
- Access-isolation: cross-product test in `__tests__/api/notifications-stream.test.ts` (al gedeeltelijk in ST-1104)
|
||||||
|
- Story-assignee-emphase: visueel-only, toegang blijft product-membership-breed
|
||||||
|
- Done when: 4 access-tests groen; handmatige cross-product-verificatie
|
||||||
|
|
||||||
|
- [ ] **ST-1107** Vercel cron `expire-questions`
|
||||||
|
- **`app/api/cron/expire-questions/route.ts`** — POST handler beveiligd via `Authorization: Bearer ${CRON_SECRET}`; `updateMany WHERE status='open' AND expires_at<now → status='expired'`
|
||||||
|
- **`vercel.json`** — `crons` entry: `{ path: '/api/cron/expire-questions', schedule: '0 4 * * *' }` (dagelijks; Vercel Hobby-plan staat alleen daily crons toe)
|
||||||
|
- **`lib/env.ts`** + `.env.example` — `CRON_SECRET` via Zod
|
||||||
|
- Optioneel: ook M10's `login_pairings`-cleanup in dezelfde route opnemen
|
||||||
|
- Done when: handmatige `curl -X POST` met secret expireert oude rijen; Vercel-dashboard toont cron-config na deploy; onbevoegde call → 401
|
||||||
|
|
||||||
|
- [ ] **ST-1108** Documentatie + acceptatietest
|
||||||
|
<<<<<<<< HEAD:docs/backlog/index.md
|
||||||
|
- **`docs/api/rest-contract.md`:** secties "SSE — Notifications" + "Cron — Expire questions" met curl-voorbeelden
|
||||||
|
========
|
||||||
|
- **`docs/api.md`:** secties "SSE — Notifications" + "Cron — Expire questions" met curl-voorbeelden
|
||||||
|
>>>>>>>> origin/main:docs/backlog.md
|
||||||
|
- **`docs/architecture.md`:** sectie "Vraag-antwoord-kanaal Claude ↔ user" met Mermaid sequence-diagram + threat-model + "Waarom hergebruik scrum4me_changes-kanaal"
|
||||||
|
- **`docs/patterns/claude-question-channel.md`:** nieuw herbruikbaar pattern-doc voor toekomstige bidirectionele async-communicatie tussen MCP-agents en interactieve users
|
||||||
|
- **`CLAUDE.md`:** rij in Implementatiepatronen-tabel voor het nieuwe pattern
|
||||||
|
- **Acceptatietest** zes scenario's: sync happy (wait_seconds), async happy (geen wait), demo-block, access-isolation, expiry via cron, race op double-submit
|
||||||
|
- Done when: docs gepubliceerd; alle zes scenario's groen; backlog-parser-self-test toont M11 met ACTIVE-status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v2 Backlog (na MVP)
|
||||||
|
|
||||||
|
- [ ] Uitnodigingsflow voor teams — e-mailuitnodiging of link-gebaseerd; nu kunnen alleen admins met toegang tot het systeem Developers toevoegen via gebruikersnaam
|
||||||
|
- [ ] Daily Scrum scherm — voortgang per story bijhouden tijdens Sprint
|
||||||
|
- [ ] Sprint Review scherm — demo en feedback vastleggen per story
|
||||||
|
- [ ] Sprint Retrospective scherm — reflectie per Sprint
|
||||||
|
- [ ] Automatische story-statusupdate na commit via API
|
||||||
|
- [ ] Velocity tracking — statistieken over meerdere Sprints
|
||||||
|
- [ ] Definition of Done per product configureerbaar (nu vaste instelling)
|
||||||
|
- [ ] Notificaties / reminders
|
||||||
|
- [ ] Timeline / kalenderweergave per Sprint
|
||||||
|
- [ ] Integratie GitHub Issues / Linear
|
||||||
|
- [ ] Mobiele app — uitsluitend taken afvinken
|
||||||
|
- [ ] Export van Product Backlog en Sprint als markdown of CSV
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of MVP Done
|
||||||
|
|
||||||
|
- [x] Alle M0–M6 tasks afgerond
|
||||||
|
- [x] Volledige Lars-flow succesvol doorlopen (ST-612)
|
||||||
|
- [x] Alle 7 API-endpoints getest via curl (ST-403 t/m ST-409)
|
||||||
|
- [x] Demo-gebruiker kan inloggen en heeft geen schrijfrechten (ST-604)
|
||||||
|
- [x] App lokaal opzetbaar via README zonder extra hulp (ST-611)
|
||||||
|
- [x] CI/CD actief — falende build blokkeert merge (ST-610)
|
||||||
|
- [x] Beveiligingsreview API geslaagd (ST-609)
|
||||||
|
- [x] Geen bekende blocker-bugs
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# placeholder — remove when first file is added
|
||||||
|
|
@ -417,8 +417,13 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan
|
||||||
- **`PATCH /api/tasks/:id`:** accepteert lowercase `status` via mapper; retourneert lowercase
|
- **`PATCH /api/tasks/:id`:** accepteert lowercase `status` via mapper; retourneert lowercase
|
||||||
- **Story-log metadata:** nieuwe optionele `metadata Json?` kolom op `StoryLog`; `POST /api/stories/:id/log` accepteert per type een optioneel `metadata`-veld (bv. `{ branch: 'feat/x' }`); bestaande velden ongewijzigd → backwards-compatible
|
- **Story-log metadata:** nieuwe optionele `metadata Json?` kolom op `StoryLog`; `POST /api/stories/:id/log` accepteert per type een optioneel `metadata`-veld (bv. `{ branch: 'feat/x' }`); bestaande velden ongewijzigd → backwards-compatible
|
||||||
- **Foutcodes:** Zod-validatie geeft `422` (was `400`); `400` blijft voor malformed body; `401`/`403`/`404`/`500` ongewijzigd
|
- **Foutcodes:** Zod-validatie geeft `422` (was `400`); `400` blijft voor malformed body; `401`/`403`/`404`/`500` ongewijzigd
|
||||||
|
<<<<<<<< HEAD:docs/backlog/index.md
|
||||||
- **API-documentatie:** nieuwe `docs/api/rest-contract.md` met endpoints, request/response, foutcodes, status-enums en curl-voorbeelden; `CLAUDE.md` verwijst ernaar
|
- **API-documentatie:** nieuwe `docs/api/rest-contract.md` met endpoints, request/response, foutcodes, status-enums en curl-voorbeelden; `CLAUDE.md` verwijst ernaar
|
||||||
- Done when: `curl /api/health` werkt zonder auth; `curl /api/products/:id/claude-context` retourneert bundled JSON; PATCH/PUT routes accepteren lowercase status en geven 422 bij ongeldige body; story-log POST bewaart `metadata`; `docs/api/rest-contract.md` is gepubliceerd
|
- Done when: `curl /api/health` werkt zonder auth; `curl /api/products/:id/claude-context` retourneert bundled JSON; PATCH/PUT routes accepteren lowercase status en geven 422 bij ongeldige body; story-log POST bewaart `metadata`; `docs/api/rest-contract.md` is gepubliceerd
|
||||||
|
========
|
||||||
|
- **API-documentatie:** nieuwe `docs/api.md` met endpoints, request/response, foutcodes, status-enums en curl-voorbeelden; `CLAUDE.md` verwijst ernaar
|
||||||
|
- Done when: `curl /api/health` werkt zonder auth; `curl /api/products/:id/claude-context` retourneert bundled JSON; PATCH/PUT routes accepteren lowercase status en geven 422 bij ongeldige body; story-log POST bewaart `metadata`; `docs/api.md` is gepubliceerd
|
||||||
|
>>>>>>>> origin/main:docs/backlog.md
|
||||||
- **`GET /api/products`:** voeg `code` toe (naast `id`, `name`, `repo_url`); optioneel `description` en `definition_of_done`
|
- **`GET /api/products`:** voeg `code` toe (naast `id`, `name`, `repo_url`); optioneel `description` en `definition_of_done`
|
||||||
- **`GET /api/products/:id/next-story`:** voeg `code` toe op story; voeg per task `code` (derived `${story.code}.${index_in_story}`) en `implementation_plan` toe
|
- **`GET /api/products/:id/next-story`:** voeg `code` toe op story; voeg per task `code` (derived `${story.code}.${index_in_story}`) en `implementation_plan` toe
|
||||||
- **`GET /api/sprints/:id/tasks`:** voeg `description`, `implementation_plan` en `story_code` toe per task; voeg een derived `code`-veld per task toe (`${story.code}.${index_in_story}`)
|
- **`GET /api/sprints/:id/tasks`:** voeg `description`, `implementation_plan` en `story_code` toe per task; voeg een derived `code`-veld per task toe (`${story.code}.${index_in_story}`)
|
||||||
|
|
@ -555,7 +560,11 @@ Filtering server-side: alleen events binnen de actieve sprint van een product wa
|
||||||
- Done when: twee tabs van Solo Paneel — mutatie in tab A komt binnen 1–2s in tab B zonder refresh
|
- Done when: twee tabs van Solo Paneel — mutatie in tab A komt binnen 1–2s in tab B zonder refresh
|
||||||
|
|
||||||
- [x] **ST-806** Documentatie + acceptatietest
|
- [x] **ST-806** Documentatie + acceptatietest
|
||||||
|
<<<<<<<< HEAD:docs/backlog/index.md
|
||||||
- Sectie "Realtime updates" in `docs/architecture.md` met diagram en filtering-regels; vermelding in `CLAUDE.md`; korte note over `/api/realtime/solo` in `docs/api/rest-contract.md`; handmatig E2E-scenario's gedraaid (zelfde gebruiker twee tabs, MCP-write, REST-write, story-claim, network-flap)
|
- Sectie "Realtime updates" in `docs/architecture.md` met diagram en filtering-regels; vermelding in `CLAUDE.md`; korte note over `/api/realtime/solo` in `docs/api/rest-contract.md`; handmatig E2E-scenario's gedraaid (zelfde gebruiker twee tabs, MCP-write, REST-write, story-claim, network-flap)
|
||||||
|
========
|
||||||
|
- Sectie "Realtime updates" in `docs/architecture.md` met diagram en filtering-regels; vermelding in `CLAUDE.md`; korte note over `/api/realtime/solo` in `docs/api.md`; handmatig E2E-scenario's gedraaid (zelfde gebruiker twee tabs, MCP-write, REST-write, story-claim, network-flap)
|
||||||
|
>>>>>>>> origin/main:docs/backlog.md
|
||||||
- Done when: alle scenario's lopen door zonder onverwachte gedragingen
|
- Done when: alle scenario's lopen door zonder onverwachte gedragingen
|
||||||
|
|
||||||
Volledig plan in `.Plans/2026-04-27-m8-realtime-solo.md` (lokaal, niet gecommit).
|
Volledig plan in `.Plans/2026-04-27-m8-realtime-solo.md` (lokaal, niet gecommit).
|
||||||
|
|
@ -649,12 +658,20 @@ Volledige flow + threat-model: `docs/patterns/qr-login.md` (op te leveren in ST-
|
||||||
- [ ] **ST-1007** Desktop UI: QR-render + SSE-listener op `/login`
|
- [ ] **ST-1007** Desktop UI: QR-render + SSE-listener op `/login`
|
||||||
- **Dependency:** `qrcode.react` (client SVG; mobileSecret blijft op desktop in JS-geheugen)
|
- **Dependency:** `qrcode.react` (client SVG; mobileSecret blijft op desktop in JS-geheugen)
|
||||||
- **`app/login/qr-login-button.tsx`:** Client Component; klik → POST `pair/start` (`credentials: 'same-origin'` zodat `s4m_pair`-cookie wordt geaccepteerd) → render QR met `qrUrl` (fragment-URL) → open `EventSource('/api/auth/pair/stream/<pairingId>', { withCredentials: true })` → bij `approved` event POST `pair/claim` (cookie-only) → bij succes `router.push('/dashboard')`; aftellende timer (2 min); bij timeout "Vernieuwen"-knop; cleanup bij unmount/redirect
|
- **`app/login/qr-login-button.tsx`:** Client Component; klik → POST `pair/start` (`credentials: 'same-origin'` zodat `s4m_pair`-cookie wordt geaccepteerd) → render QR met `qrUrl` (fragment-URL) → open `EventSource('/api/auth/pair/stream/<pairingId>', { withCredentials: true })` → bij `approved` event POST `pair/claim` (cookie-only) → bij succes `router.push('/dashboard')`; aftellende timer (2 min); bij timeout "Vernieuwen"-knop; cleanup bij unmount/redirect
|
||||||
|
<<<<<<<< HEAD:docs/backlog/index.md
|
||||||
- **`app/login/page.tsx`:** knop "Inloggen via mobiel" naast bestaande wachtwoord-form (MD3-tokens uit `docs/design/styling.md`)
|
- **`app/login/page.tsx`:** knop "Inloggen via mobiel" naast bestaande wachtwoord-form (MD3-tokens uit `docs/design/styling.md`)
|
||||||
|
========
|
||||||
|
- **`app/login/page.tsx`:** knop "Inloggen via mobiel" naast bestaande wachtwoord-form (MD3-tokens uit `docs/styling.md`)
|
||||||
|
>>>>>>>> origin/main:docs/backlog.md
|
||||||
- A11y: QR heeft alt-tekst met de URL voor screenreaders/copy-paste (de hash-suffix is onderdeel van die alt-tekst, niet van de page-URL die in browsergeschiedenis komt)
|
- A11y: QR heeft alt-tekst met de URL voor screenreaders/copy-paste (de hash-suffix is onderdeel van die alt-tekst, niet van de page-URL die in browsergeschiedenis komt)
|
||||||
- Done when: end-to-end happy path werkt op localhost (twee browsers): A toont QR → B scant + bevestigt → A redirect naar `/dashboard` met `session.paired === true`; QR vernieuwt na expiry; geen secret zichtbaar in DevTools Network-tab onder URL-kolommen
|
- Done when: end-to-end happy path werkt op localhost (twee browsers): A toont QR → B scant + bevestigt → A redirect naar `/dashboard` met `session.paired === true`; QR vernieuwt na expiry; geen secret zichtbaar in DevTools Network-tab onder URL-kolommen
|
||||||
|
|
||||||
- [ ] **ST-1008** Documentatie + acceptatietest
|
- [ ] **ST-1008** Documentatie + acceptatietest
|
||||||
|
<<<<<<<< HEAD:docs/backlog/index.md
|
||||||
- **`docs/api/rest-contract.md`:** drie nieuwe endpoints (start/stream/claim) met request/response, cookie-mechaniek, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief `--cookie-jar`
|
- **`docs/api/rest-contract.md`:** drie nieuwe endpoints (start/stream/claim) met request/response, cookie-mechaniek, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief `--cookie-jar`
|
||||||
|
========
|
||||||
|
- **`docs/api.md`:** drie nieuwe endpoints (start/stream/claim) met request/response, cookie-mechaniek, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief `--cookie-jar`
|
||||||
|
>>>>>>>> origin/main:docs/backlog.md
|
||||||
- **`docs/architecture.md`:** sectie "QR-pairing flow" met sequence-diagram + threat-model; expliciete subsectie *"Waarom geen secret in URL"* — fragments worden niet naar server gestuurd; SSE/claim authenticeren via HttpOnly cookie zodat secret-materiaal niet in access logs / reverse-proxy logs / observability-tools / browsergeschiedenis kan belanden
|
- **`docs/architecture.md`:** sectie "QR-pairing flow" met sequence-diagram + threat-model; expliciete subsectie *"Waarom geen secret in URL"* — fragments worden niet naar server gestuurd; SSE/claim authenticeren via HttpOnly cookie zodat secret-materiaal niet in access logs / reverse-proxy logs / observability-tools / browsergeschiedenis kan belanden
|
||||||
- **`docs/patterns/qr-login.md`:** nieuw pattern-doc voor toekomstige features die hetzelfde unauth-SSE-via-pre-auth-cookie-patroon willen hergebruiken
|
- **`docs/patterns/qr-login.md`:** nieuw pattern-doc voor toekomstige features die hetzelfde unauth-SSE-via-pre-auth-cookie-patroon willen hergebruiken
|
||||||
- **`CLAUDE.md`:** verwijzing naar het nieuwe pattern-doc in de patterns-tabel
|
- **`CLAUDE.md`:** verwijzing naar het nieuwe pattern-doc in de patterns-tabel
|
||||||
|
|
@ -702,7 +719,11 @@ Persistent vraag-antwoord-kanaal tussen Claude Code (via MCP) en de actieve Scru
|
||||||
- **`stores/notifications-store.ts`** — Zustand store volgens `solo-store.ts`-patroon: `init`, `add`, `update`, `remove`, `optimisticAnswer`, `rollbackAnswer`; selectors `openCount`, `forYouCount`
|
- **`stores/notifications-store.ts`** — Zustand store volgens `solo-store.ts`-patroon: `init`, `add`, `update`, `remove`, `optimisticAnswer`, `rollbackAnswer`; selectors `openCount`, `forYouCount`
|
||||||
- **`lib/realtime/use-notifications-realtime.ts`** — analoog aan `useSoloRealtime`; EventSource op `/api/realtime/notifications` met reconnect-backoff
|
- **`lib/realtime/use-notifications-realtime.ts`** — analoog aan `useSoloRealtime`; EventSource op `/api/realtime/notifications` met reconnect-backoff
|
||||||
- **`components/notifications/notifications-bridge.tsx`** — Server Component die initial-data fetcht en aan store geeft; mount in `app/(app)/layout.tsx` naast `<SoloRealtimeBridge />`
|
- **`components/notifications/notifications-bridge.tsx`** — Server Component die initial-data fetcht en aan store geeft; mount in `app/(app)/layout.tsx` naast `<SoloRealtimeBridge />`
|
||||||
|
<<<<<<<< HEAD:docs/backlog/index.md
|
||||||
- **`components/shared/notifications-bell.tsx`** — Bell-icon (Lucide) met badge in NavBar (links van avatar); MD3-tokens uit `docs/design/styling.md`
|
- **`components/shared/notifications-bell.tsx`** — Bell-icon (Lucide) met badge in NavBar (links van avatar); MD3-tokens uit `docs/design/styling.md`
|
||||||
|
========
|
||||||
|
- **`components/shared/notifications-bell.tsx`** — Bell-icon (Lucide) met badge in NavBar (links van avatar); MD3-tokens uit `docs/styling.md`
|
||||||
|
>>>>>>>> origin/main:docs/backlog.md
|
||||||
- **`components/notifications/notifications-sheet.tsx`** — shadcn Sheet van rechts; lijst gegroepeerd per product; story-assignee krijgt visuele *"wacht op jou"*-emphase
|
- **`components/notifications/notifications-sheet.tsx`** — shadcn Sheet van rechts; lijst gegroepeerd per product; story-assignee krijgt visuele *"wacht op jou"*-emphase
|
||||||
- **`components/notifications/answer-modal.tsx`** — shadcn Dialog; story-context-link, vraag-tekst, RadioGroup (als options) of Textarea (free-text), submit via `useTransition` + Server Action; demo-blok met tooltip
|
- **`components/notifications/answer-modal.tsx`** — shadcn Dialog; story-context-link, vraag-tekst, RadioGroup (als options) of Textarea (free-text), submit via `useTransition` + Server Action; demo-blok met tooltip
|
||||||
- Done when: bell + badge zichtbaar; klik opent Sheet met items; submit verwijdert item optimistisch; tweede tab van zelfde user ziet nieuwe vraag binnen 1-2s; demo-modus rendert maar Verstuur disabled
|
- Done when: bell + badge zichtbaar; klik opent Sheet met items; submit verwijdert item optimistisch; tweede tab van zelfde user ziet nieuwe vraag binnen 1-2s; demo-modus rendert maar Verstuur disabled
|
||||||
|
|
@ -721,7 +742,11 @@ Persistent vraag-antwoord-kanaal tussen Claude Code (via MCP) en de actieve Scru
|
||||||
- Done when: handmatige `curl -X POST` met secret expireert oude rijen; Vercel-dashboard toont cron-config na deploy; onbevoegde call → 401
|
- Done when: handmatige `curl -X POST` met secret expireert oude rijen; Vercel-dashboard toont cron-config na deploy; onbevoegde call → 401
|
||||||
|
|
||||||
- [ ] **ST-1108** Documentatie + acceptatietest
|
- [ ] **ST-1108** Documentatie + acceptatietest
|
||||||
|
<<<<<<<< HEAD:docs/backlog/index.md
|
||||||
- **`docs/api/rest-contract.md`:** secties "SSE — Notifications" + "Cron — Expire questions" met curl-voorbeelden
|
- **`docs/api/rest-contract.md`:** secties "SSE — Notifications" + "Cron — Expire questions" met curl-voorbeelden
|
||||||
|
========
|
||||||
|
- **`docs/api.md`:** secties "SSE — Notifications" + "Cron — Expire questions" met curl-voorbeelden
|
||||||
|
>>>>>>>> origin/main:docs/backlog.md
|
||||||
- **`docs/architecture.md`:** sectie "Vraag-antwoord-kanaal Claude ↔ user" met Mermaid sequence-diagram + threat-model + "Waarom hergebruik scrum4me_changes-kanaal"
|
- **`docs/architecture.md`:** sectie "Vraag-antwoord-kanaal Claude ↔ user" met Mermaid sequence-diagram + threat-model + "Waarom hergebruik scrum4me_changes-kanaal"
|
||||||
- **`docs/patterns/claude-question-channel.md`:** nieuw herbruikbaar pattern-doc voor toekomstige bidirectionele async-communicatie tussen MCP-agents en interactieve users
|
- **`docs/patterns/claude-question-channel.md`:** nieuw herbruikbaar pattern-doc voor toekomstige bidirectionele async-communicatie tussen MCP-agents en interactieve users
|
||||||
- **`CLAUDE.md`:** rij in Implementatiepatronen-tabel voor het nieuwe pattern
|
- **`CLAUDE.md`:** rij in Implementatiepatronen-tabel voor het nieuwe pattern
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# placeholder — remove when first file is added
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# placeholder — remove when first file is added
|
||||||
5
docs/docker-smoke/2-mei-task-1.md
Normal file
5
docs/docker-smoke/2-mei-task-1.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Docker smoke test — task 1
|
||||||
|
|
||||||
|
Created by the agent running in a Docker container on the NAS.
|
||||||
|
Validates that the worktree-isolation + branch-per-story flow works
|
||||||
|
end-to-end from the containerised environment.
|
||||||
6
docs/docker-smoke/2-mei-task-2.md
Normal file
6
docs/docker-smoke/2-mei-task-2.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Docker smoke test — task 2
|
||||||
|
|
||||||
|
Second sub-task in the same story. The agent should land this commit
|
||||||
|
on the SAME `feat/story-<id>` branch as task 1 (sibling-reuse), and
|
||||||
|
the existing PR should accumulate this commit instead of opening a
|
||||||
|
new one.
|
||||||
1433
docs/functional.md
Normal file
1433
docs/functional.md
Normal file
File diff suppressed because it is too large
Load diff
1601
docs/md3-color-scheme.md
Normal file
1601
docs/md3-color-scheme.md
Normal file
File diff suppressed because it is too large
Load diff
64
docs/patterns/story-with-ui-component.md
Normal file
64
docs/patterns/story-with-ui-component.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Patroon: Story met UI-component
|
||||||
|
|
||||||
|
## Probleemstelling
|
||||||
|
|
||||||
|
Zonder een afsluitende Integration-task leverden agent-batches helpers en components die nooit in de UI verschenen. De Velocity-story (`cmomu4xpq001obortc9enpvfa`) van 2 mei 2026 is het concrete incident: Helper (`getVelocity`) en Component (`VelocityChart`) werden gebouwd en getest, maar zonder Integration-task bleef de `/insights`-pagina leeg.
|
||||||
|
|
||||||
|
## Verplicht patroon
|
||||||
|
|
||||||
|
Elke story die een `*-component.tsx` introduceert **moet** minimaal drie tasks bevatten:
|
||||||
|
|
||||||
|
| # | Type | Doel | Bestand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Helper** | Data-aggregatie of business-logic | `lib/<domain>/<helper>.ts` |
|
||||||
|
| 2 | **Component** | Presentational layer | `app/.../<component>.tsx` |
|
||||||
|
| 3 | **Integration** | Wire component in page + verify renders | `app/.../<route>/page.tsx` |
|
||||||
|
|
||||||
|
De Integration-task heeft `verify_required: ALIGNED` — de agent-verify moet de page.tsx in de diff terugzien voordat de job als `done` wordt geaccepteerd.
|
||||||
|
|
||||||
|
## Blueprint: Foundation Sprint Health-story
|
||||||
|
|
||||||
|
De story `cmomu0txi0016borthlautjjp` ("Foundation: route, recharts, sprint-dates migration, chart-colors helper") volgt dit patroon correct:
|
||||||
|
|
||||||
|
- **Tasks 1–3** — Helper-tasks (sprint-dates migration, chart-colors, route scaffolding)
|
||||||
|
- **Task 4** — "Sprint-info-strip + integratie in /insights page" — sluit de loop: component wordt in `page.tsx` geïmporteerd en gerenderd
|
||||||
|
|
||||||
|
Gebruik deze story als blueprint bij het aanmaken van nieuwe PBI's met UI-componenten.
|
||||||
|
|
||||||
|
## Anti-patroon
|
||||||
|
|
||||||
|
De Velocity-story had alleen Helper + Component. De Integration-task ontbrak:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ cmomu54pw001pbortedjhg2l8 Helper: getVelocity
|
||||||
|
✅ cmomu5bht001qbortlcq15arc Component: VelocityChart
|
||||||
|
❌ (ontbreekt) Integration: wire VelocityChart into /insights page
|
||||||
|
```
|
||||||
|
|
||||||
|
Resultaat: de component bestaat en compileert, maar is nergens te zien.
|
||||||
|
|
||||||
|
## Reviewchecklist bij PBI-creatie
|
||||||
|
|
||||||
|
Voordat een PBI met UI-features naar de backlog gaat:
|
||||||
|
|
||||||
|
- [ ] Elke story die een `*-component.tsx` introduceert heeft een Integration-task
|
||||||
|
- [ ] Integration-task raakt minimaal `app/(app)/<route>/page.tsx`
|
||||||
|
- [ ] Integration-task heeft `verify_required: ALIGNED` (of een equivalent verify-gate)
|
||||||
|
- [ ] Integration-task title volgt het formaat: `Integration: wire <ComponentName> into <route>`
|
||||||
|
|
||||||
|
## Template (kopieer bij elke nieuwe UI-story)
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1 — Helper: <beschrijving data-aggregatie>
|
||||||
|
Bestand: lib/<domain>/<helper>.ts
|
||||||
|
Tests: minimaal 3 scenario's
|
||||||
|
|
||||||
|
Task 2 — Component: <beschrijving component>
|
||||||
|
Bestand: app/(app)/<route>/components/<component>.tsx
|
||||||
|
Tests: TypeScript + render-smoke
|
||||||
|
|
||||||
|
Task 3 — Integration: wire <ComponentName> into /<route> page
|
||||||
|
Bestanden: app/(app)/<route>/page.tsx
|
||||||
|
verify_required: ALIGNED
|
||||||
|
Done when: component verschijnt in de browser zonder empty-state op dev-data
|
||||||
|
```
|
||||||
120
docs/pbi-dialog.md
Normal file
120
docs/pbi-dialog.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# PbiDialog Profiel
|
||||||
|
|
||||||
|
> Volgt **`docs/patterns/dialog.md`** (de generieke spec voor élke entity-dialog in Scrum4Me).
|
||||||
|
> Dit document beschrijft alleen de PBI-specifieke afwijkingen en keuzes — alle gedeelde regels (layout, motion, demo-policy, foutcodes, validatie, theming, dialog-gedrag) staan in de generieke spec en worden hier niet herhaald.
|
||||||
|
|
||||||
|
> **Belangrijk:** als een regel in dit profiel botst met de generieke spec, wint de generieke spec. Documenteer hier de afwijking + reden, of pas de generieke spec aan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Velden
|
||||||
|
|
||||||
|
| Veld | Type | Mode | Validatie |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `code` | `string \| null` | beide | optional, max 30 chars, mono-font, placeholder `auto` op create (server kent dan zelf een code toe) |
|
||||||
|
| `title` | `string` (required) | beide | trim, 1-200 chars |
|
||||||
|
| `priority` | `int` (1-4, P1 = hoogste) | beide | int 1-4, default 2 (kan via `defaultPriority`-prop bij create) |
|
||||||
|
| `status` | `PbiStatusApi` enum | beide | enum, default `'ready'` |
|
||||||
|
| `description` | `string \| null` | beide | optional, max 2000 chars, plain textarea (geen markdown rendering binnen de dialog) |
|
||||||
|
|
||||||
|
`PbiStatusApi` enum (lowercase, mapped via `lib/task-status.ts`): zie `<PbiStatusSelect>` voor de waarden.
|
||||||
|
|
||||||
|
### Veld-specifiek gedrag
|
||||||
|
|
||||||
|
- **Code + Titel** in één rij (`grid-cols-[6rem_1fr]`)
|
||||||
|
- **Prioriteit + Status** in één rij (`grid-cols-2`)
|
||||||
|
- **Prioriteit** via `<PrioritySelect>` (gedeelde primitive, géén segmented buttons in deze dialog)
|
||||||
|
- **Status** via `<PbiStatusSelect>` (PBI-specifieke wrapper rond gedeelde select)
|
||||||
|
- **Description** is `<Textarea rows={3} resize-none>` — géén auto-grow, géén markdown-hint, géén char-counter (afwijking van generieke spec; rationale: PBI-descriptions zijn doorgaans kort en richtinggevend)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL- of state-pattern
|
||||||
|
|
||||||
|
- **Gekozen:** state-based (`state: PbiDialogState | null` prop, gerendeerd binnen `PbiList`)
|
||||||
|
- **Reden:** PBI-dialog leeft altijd binnen `PbiList` op de product-backlog-pagina; deep-linking is niet vereist en zou een tweede edit-flow toevoegen.
|
||||||
|
- **State-shape:**
|
||||||
|
```ts
|
||||||
|
type PbiDialogState =
|
||||||
|
| { mode: 'create'; productId: string; defaultPriority?: number }
|
||||||
|
| { mode: 'edit'; pbi: PbiDialogPbi; productId: string }
|
||||||
|
```
|
||||||
|
- **Sluiten:** `onClose()` callback uit de parent — `setState(null)` in `PbiList`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status-veld
|
||||||
|
|
||||||
|
- **Default bij create:** `'ready'` (PBI-default state)
|
||||||
|
- **Geen verberging in create-mode** — anders dan TaskDialog wordt status hier wél getoond bij create, omdat een PBI zonder expliciete status onhandig is voor backlog-grooming
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server actions
|
||||||
|
|
||||||
|
| Actie | Locatie | Form-binding | Revalidatie |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `createPbiAction` | `actions/pbis.ts` | via `useActionState` + `<form action>` (FormData) | server-side `revalidatePath` op product-backlog |
|
||||||
|
| `updatePbiAction` | `actions/pbis.ts` | idem | idem |
|
||||||
|
| ~~`deletePbiAction`~~ | **(ontbreekt)** | n.v.t. | n.v.t. |
|
||||||
|
|
||||||
|
Beide acties moeten de drielaagse demo-policy volgen (zie § Bekende gaps).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Speciale gedragingen
|
||||||
|
|
||||||
|
### Form-state via `useActionState`
|
||||||
|
|
||||||
|
PbiDialog gebruikt het `useActionState` + `useFormStatus`-patroon (Server Actions / native React), niet `react-hook-form`. Dit is een toegestaan alternatief volgens de generieke spec § 2. Field-errors worden gemapt via een lokale `fieldError(field)`-helper die `result.error` als `Record<string, string[]>` interpreteert wanneer 'm geen string is.
|
||||||
|
|
||||||
|
### `key`-prop op `<form>`
|
||||||
|
|
||||||
|
Het `<form>`-element heeft `key={isEdit ? pbi!.id : 'create'}` — dit reset native form-state (defaultValues) wanneer de dialog tussen create en edit wisselt of wanneer een ander record bewerkt wordt.
|
||||||
|
|
||||||
|
### Hidden inputs voor server-binding
|
||||||
|
|
||||||
|
`priority` en `status` worden via `<input type="hidden">` doorgegeven aan de Server Action (de UI-controls zijn JS-state, niet directe form-fields).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Triggers
|
||||||
|
|
||||||
|
- **Create-trigger:** `+ PBI`-knop in `PanelNavBar` van `PbiList` → `setPbiDialogState({ mode: 'create', ... })`
|
||||||
|
- **Edit-trigger:** edit-icoon op een PBI-rij in `PbiList` → `setPbiDialogState({ mode: 'edit', pbi, ... })`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekende gaps t.o.v. generieke spec
|
||||||
|
|
||||||
|
> Deze items wijken af van `docs/patterns/dialog.md` en horen in een vervolg-PR rechtgezet (niet onderdeel van de huidige docs-introductie).
|
||||||
|
|
||||||
|
- ❌ **Geen `<DemoTooltip>`** rond submit-knop — laag 3 van de drielaagse demo-policy ontbreekt voor PBI-create/update. Dat betekent dat een demo-user de knop kan klikken; de server action blokkeert nog steeds (laag 2), maar de UX is suboptimaal.
|
||||||
|
- ❌ **Geen delete-knop / `deletePbiAction`** — alleen create + update. Of dat bewust is (PBI's worden nooit verwijderd, alleen status veranderd) of een gat, moet expliciet worden besloten en in dit profiel vastgelegd.
|
||||||
|
- ❌ **Geen dirty-close-guard** — Esc / backdrop / Cancel sluiten direct, ook met onopgeslagen wijzigingen. Generieke spec § 8.1 vereist een AlertDialog bij `isDirty`.
|
||||||
|
- ❌ **Geen Cmd/Ctrl+Enter shortcut** — alleen klik op submit-knop.
|
||||||
|
- ❌ **Geen char-counter / markdown-hint** op description — bewust weggelaten omdat PBI-descriptions kort zijn, maar verdient expliciete bevestiging.
|
||||||
|
- ⚠️ **Layout wijkt af** van de generieke responsive-tabel: `sm:max-w-md` i.p.v. de `max-w-[50vw]` / `90vw` / full-screen-progressie uit § 4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bewust NIET in v1
|
||||||
|
|
||||||
|
Specifiek voor PbiDialog (boven op de algemene out-of-scope-lijst in `docs/patterns/dialog.md` § 13):
|
||||||
|
|
||||||
|
- ❌ Inline aanmaken van child-stories binnen de PBI-dialog (gebeurt via StoryDialog vanuit `StoryPanel`)
|
||||||
|
- ❌ Bulk-status-update over meerdere PBI's
|
||||||
|
- ❌ PBI-templates / kopiëren
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenties
|
||||||
|
|
||||||
|
- `components/backlog/pbi-dialog.tsx` — implementatie
|
||||||
|
- `actions/pbis.ts` — server actions
|
||||||
|
- `components/shared/priority-select.tsx` — gedeelde priority-control
|
||||||
|
- `components/shared/pbi-status-select.tsx` — PBI-status-select
|
||||||
|
- `lib/task-status.ts` — `PbiStatusApi`-mapper
|
||||||
|
- `docs/patterns/dialog.md` — generieke spec (bron-of-truth)
|
||||||
|
- `docs/architecture.md` — datamodel `Pbi`
|
||||||
|
- `docs/styling.md` — MD3-tokens, status- en priority-kleuren
|
||||||
146
docs/personas.md
Normal file
146
docs/personas.md
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
---
|
||||||
|
title: "DevPlanner — User Personas"
|
||||||
|
status: active
|
||||||
|
audience: [maintainer]
|
||||||
|
language: nl
|
||||||
|
last_updated: 2026-05-03
|
||||||
|
---
|
||||||
|
|
||||||
|
# DevPlanner — User Personas
|
||||||
|
|
||||||
|
**Versie:** 0.1 — april 2026
|
||||||
|
**Volgt op:** Concept & Feature Brainstorm v0.3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persona-overzicht
|
||||||
|
|
||||||
|
| Naam | Leeftijd | Situatie | Primaire behoefte |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Lars | 34 | Solo developer, 4 eigen SaaS-projecten naast zijn dagtaak | Overzicht houden zonder planningssysteem te worden |
|
||||||
|
| Dina | 28 | Freelance developer, werkt voor 3 klanten tegelijk | Klantprojecten gescheiden houden en voortgang aantonen |
|
||||||
|
| Remi | 41 | Lead van een klein team (3 personen), geen dedicated PM | Scrum licht toepassen zonder Jira-overhead |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lars
|
||||||
|
|
||||||
|
**Leeftijd:** 34 — **Situatie:** Solo developer met 4 actieve side projects naast een fulltime baan
|
||||||
|
|
||||||
|
### Achtergrond
|
||||||
|
|
||||||
|
Lars werkt overdag als backend developer bij een middelgroot bedrijf en bouwt 's avonds en in het weekend aan zijn eigen projecten: een SaaS-tool voor factuurverwerking, een open-source CLI-utility, een persoonlijk dashboard en een experimenteel AI-project. Hij werkt alleen, heeft geen teamleden en geen deadlines die van buitenaf worden opgelegd. Alles loopt via zijn hoofd of verspreide markdown-bestanden in de repositories zelf.
|
||||||
|
|
||||||
|
### Een typische slechte avond
|
||||||
|
|
||||||
|
Hij opent zijn laptop na het werk en weet niet meer waar hij gebleven was. In welk project zat hij? Wat stond er open? Hij herinnert zich dat er vorige week een bug was gerapporteerd in de factuur-tool, maar hij weet niet meer of hij die heeft gefixt of alleen heeft opgeschreven. Hij besteedt twintig minuten aan het doorzoeken van commit-logs en README's voordat hij kan beginnen. Als Claude Code dan eindelijk een taak oppakt, is de context al kwijt.
|
||||||
|
|
||||||
|
### Hoe hij het nu oplost
|
||||||
|
|
||||||
|
Elke repository heeft een `TODO.md` die hij bijhoudt zolang een project actief is. Als hij een week niet heeft gekeken, is het bestand achterhaald. Prioritering is impliciet — hij werkt aan wat op dat moment het interessantst voelt, niet aan wat het belangrijkst is. Jira heeft hij één keer geprobeerd voor zijn side projects: duurde twee uur om in te richten en stopte na drie weken.
|
||||||
|
|
||||||
|
### Doelen met deze app
|
||||||
|
|
||||||
|
- Elke avond binnen één minuut weten welke taak het meest urgent is per project
|
||||||
|
- Claude Code laten oppakken wat open staat zonder zelf de context te hoeven herstellen
|
||||||
|
- Achteraf kunnen zien wat er gedaan is en hoe (implementatieplan, commit, testresultaat)
|
||||||
|
- Op klantbezoek of bij familie zijn side projects kunnen demonstreren op een geleende laptop, zonder dat hij zijn wachtwoord op een vreemd toetsenbord hoeft te typen — door zijn telefoon (waar hij al ingelogd is) een QR-code op het scherm te laten scannen
|
||||||
|
|
||||||
|
### Frustraties om te vermijden
|
||||||
|
|
||||||
|
- Verplichte velden en formulieren die langer duren dan de taak zelf
|
||||||
|
- Systemen die vragen om updates die hij toch niet bijhoudt (daily standup-achtige inputs)
|
||||||
|
- Alles wat aanvoelt als "project management voor grote teams" — hij is de enige gebruiker
|
||||||
|
|
||||||
|
### Relatie met technologie
|
||||||
|
|
||||||
|
Power user. Leeft in de terminal, gebruikt Claude Code dagelijks, bouwt zijn eigen tooling als iets hem niet bevalt. Wil een app die hij ook via een API kan aansturen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dina
|
||||||
|
|
||||||
|
**Leeftijd:** 28 — **Situatie:** Freelance full-stack developer, werkt parallel voor drie klanten
|
||||||
|
|
||||||
|
### Achtergrond
|
||||||
|
|
||||||
|
Dina werkt als zelfstandige en heeft op dit moment drie lopende klantprojecten: een e-commerceplatform in onderhoudsfase, een nieuw admin-dashboard voor een logistiek bedrijf en een kleine MVP voor een startup. Elk project heeft zijn eigen ritme, zijn eigen repo en zijn eigen verwachtingen. Ze werkt alleen maar heeft per klant een contactpersoon die wil weten wat er gedaan is.
|
||||||
|
|
||||||
|
### Een typische slechte dag
|
||||||
|
|
||||||
|
Ze heeft drie context-switches voor de lunch. Het logistiek-dashboard heeft een bugmelding binnengekomen, de startup wil een update, en de e-commerce klant heeft een vraag over iets wat twee Sprints geleden geleverd is. Ze heeft geen centraal overzicht — ze zoekt in Slack-threads, e-mails en commit-logs om te reconstrueren wat er wanneer gedaan is. Aan het eind van de dag heeft ze productief werk gedaan maar kan ze niet meer zeggen wat precies.
|
||||||
|
|
||||||
|
### Hoe ze het nu oplost
|
||||||
|
|
||||||
|
Elk klantproject heeft een Notion-pagina met een ruwe takenlijst. Notion is flexible maar heeft geen structuur die Scrum-begrippen begrijpt. Ze heeft geprobeerd Trello te gebruiken maar het mist de hiërarchie die ze nodig heeft (project → feature → taak). Ze voelt dat ze iets robuusters nodig heeft nu het derde project erbij is gekomen.
|
||||||
|
|
||||||
|
### Doelen met deze app
|
||||||
|
|
||||||
|
- Per klant een gestructureerde Product Backlog bijhouden zonder overlap
|
||||||
|
- Snel kunnen antwoorden op "wat heb je de afgelopen week gedaan?" met concrete verwijzingen naar commits en stories
|
||||||
|
- Claude Code gebruiken voor routinetaken zodat zij zich kan richten op complexere beslissingen
|
||||||
|
|
||||||
|
### Frustraties om te vermijden
|
||||||
|
|
||||||
|
- Systemen waarbij klantdata vermengd raakt (ze wil strikte projectscheiding)
|
||||||
|
- Dashboards die statistieken tonen die ze niet nodig heeft (velocity, burndown)
|
||||||
|
- Alles wat ze verplicht dagelijks bij te houden — ze heeft soms een dag vrij of ziek
|
||||||
|
|
||||||
|
### Relatie met technologie
|
||||||
|
|
||||||
|
Comfortabel maar niet fanatiek. Gebruikt VS Code, GitHub en een handvol SaaS-tools. Geen terminal-purist zoals Lars — ze gebruikt liever een goede UI dan een CLI als beide beschikbaar zijn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remi
|
||||||
|
|
||||||
|
**Leeftijd:** 41 — **Situatie:** Lead developer van een team van drie, zonder dedicated projectmanager
|
||||||
|
|
||||||
|
### Achtergrond
|
||||||
|
|
||||||
|
Remi werkt bij een klein softwarebedrijf met in totaal acht mensen. Zijn team bestaat uit hemzelf en twee juniordevelopers. Ze bouwen interne tools voor twee afdelingen en onderhouden drie legacy-systemen. Er is geen Scrum Master, geen Product Owner en geen projectmanager — Remi doet dat er allemaal bij. Hij heeft Scrum gelezen en wil het toepassen, maar Jira is te zwaar en te duur voor hun schaal. Ze werken nu met een gedeeld Excel-bestand dat niemand consequent bijhoudt.
|
||||||
|
|
||||||
|
### Een typische slechte week
|
||||||
|
|
||||||
|
Ze beginnen maandagochtend zonder duidelijk Sprint-doel. Iedereen werkt aan wat binnenkomt. Woensdag vraagt zijn manager naar de status van het rapportage-dashboard. Remi weet dat er aan gewerkt is maar niet hoe ver het is. Hij moet twee junioren ondervragen en drie feature-branches bekijken om een antwoord te kunnen geven. Vrijdagmiddag hebben ze een "retrospective" die eigenlijk een statusmeeting is.
|
||||||
|
|
||||||
|
### Hoe hij het nu oplost
|
||||||
|
|
||||||
|
Excel voor taakregistratie, Teams-kanalen per project voor communicatie, GitHub Issues voor bugs. Drie systemen die niet met elkaar praten. Hij heeft Linear geprobeerd maar de leercurve was te groot voor de junioren. Hij zoekt iets dat hij in een middag kan inrichten en dat zijn teamleden zonder training kunnen gebruiken.
|
||||||
|
|
||||||
|
### Doelen met deze app
|
||||||
|
|
||||||
|
- Elke Sprint starten met een duidelijk Sprint Goal dat iedereen ziet
|
||||||
|
- Wekelijks in één scherm kunnen zien wat open staat, wat in progress is en wat klaar is
|
||||||
|
- In de toekomst rollen kunnen toewijzen aan teamleden zodat de Product Owner (hijzelf) en de Developers (de junioren) gescheiden rechten hebben
|
||||||
|
|
||||||
|
### Frustraties om te vermijden
|
||||||
|
|
||||||
|
- Alles wat de junioren extra werk geeft zonder directe waarde voor hen
|
||||||
|
- Rapportages en grafieken die hij niet nodig heeft
|
||||||
|
- Systemen waarbij hij afhankelijk is van een externe SaaS die duur wordt bij groei
|
||||||
|
|
||||||
|
### Relatie met technologie
|
||||||
|
|
||||||
|
Ervaren developer, maar kiest bewust voor eenvoud. Wil een tool die hij zelf kan hosten als dat goedkoper uitpakt. Waardeert open source maar heeft geen tijd om een tool te onderhouden die hij zelf gebouwd heeft.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persona-spanningen
|
||||||
|
|
||||||
|
| Spanning | Lars wil | Dina wil | Remi wil | Oplossing |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| API vs. UI | Alles via API en Claude Code | Goede UI, API is bonus | UI eerst, team moet het kunnen gebruiken | UI is primair; API is volwaardige burgerklasse, niet bijzaak |
|
||||||
|
| Eén vs. meerdere gebruikers | Altijd solo | Solo maar met klantcontext | Team van 3 met rolscheiding | v1 is solo; rolbeheer is v1-fundament voor v2-teamuitbreiding |
|
||||||
|
| Scrum-striktheid | Licht, pragmatisch | Structuur helpt maar geen dogma | Wil Scrum leren toepassen | Scrum-terminologie consistent; events zijn optioneel, niet verplicht |
|
||||||
|
| Zichtbaarheid voortgang | Eigen overzicht, geen rapportages | Klantverantwoording via commits | Teamstatus in één scherm | Activiteitenlog per story volstaat voor alle drie; aparte rapportagelaag is v2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Primaire persona
|
||||||
|
|
||||||
|
**Lars** is de primaire designtarget voor v1.
|
||||||
|
|
||||||
|
Rationale: Lars vertegenwoordigt de meest veeleisende gebruiker in termen van API-integratie en Claude Code-koppeling — de kern-differentiator van DevPlanner. Als de app goed werkt voor Lars (solo, API-gedreven, meerdere projecten, minimale overhead), werkt het ook voor Dina (zelfde gebruik, lichtere techvoorkeur). Remi's behoeften — teamgebruik en rolscheiding — zijn bewust naar v2 verschoven; het fundament (rolmodel in de datastructuur) wordt in v1 al gelegd.
|
||||||
|
|
||||||
|
Een feature die Lars zou doen stoppen — verplichte velden, trage UI, geen API — wordt niet gebouwd, ook niet als Remi er baat bij zou hebben.
|
||||||
462
docs/product-backlog.md
Normal file
462
docs/product-backlog.md
Normal file
|
|
@ -0,0 +1,462 @@
|
||||||
|
---
|
||||||
|
title: "DevPlanner — Product Backlog"
|
||||||
|
status: active
|
||||||
|
audience: [maintainer]
|
||||||
|
language: nl
|
||||||
|
last_updated: 2026-05-03
|
||||||
|
---
|
||||||
|
|
||||||
|
# DevPlanner — Product Backlog
|
||||||
|
|
||||||
|
**Versie:** 0.1 — april 2026
|
||||||
|
**Product:** DevPlanner
|
||||||
|
**Beschrijving:** Een lichtgewicht Scrum-gebaseerde projectplanner voor solo developers en kleine Scrum Teams die meerdere softwareprojecten parallel beheren. De app organiseert werk hiërarchisch (product → PBI → story → taak), biedt een visuele planningslaag en integreert met Claude Code voor geautomatiseerde implementatieflows.
|
||||||
|
**Git repo:** https://github.com/devplanner/devplanner
|
||||||
|
**Definition of Done:** Feature is geïmplementeerd, getest (unit + integratie), gedocumenteerd in code, en gedeployed naar de staging-omgeving zonder regressies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prioriteiten
|
||||||
|
|
||||||
|
| Prioriteit | Betekenis |
|
||||||
|
|---|---|
|
||||||
|
| 1 — Kritiek | Blokkeert alle andere functionaliteit. Moet eerst. |
|
||||||
|
| 2 — Hoog | Core waarde van het product. MVP vereiste. |
|
||||||
|
| 3 — Middel | Verhoogt bruikbaarheid significant. v1 wenselijk. |
|
||||||
|
| 4 — Laag | Waardevol maar niet blokkerend. v2 kandidaat. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PBI-01 — Authenticatie & gebruikersbeheer
|
||||||
|
|
||||||
|
**Prioriteit:** 1 — Kritiek
|
||||||
|
**Omschrijving:** Het Scrum Team kan een account aanmaken en inloggen met gebruikersnaam en wachtwoord. Een demo-gebruiker heeft alleen leesrechten. Gebruikers kunnen één of meerdere Scrum-rollen aannemen.
|
||||||
|
|
||||||
|
### Stories
|
||||||
|
|
||||||
|
**S-01-01: Account aanmaken**
|
||||||
|
Als bezoeker wil ik een account aanmaken met gebruikersnaam en wachtwoord, zodat ik toegang krijg tot de app.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Gebruikersnaam en wachtwoord zijn verplicht
|
||||||
|
- Gebruikersnaam is uniek; dubbele aanmelding geeft foutmelding
|
||||||
|
- Wachtwoord heeft minimaal 8 tekens
|
||||||
|
- Na aanmaken wordt de gebruiker direct ingelogd
|
||||||
|
- Geen e-mailverificatie vereist in v1
|
||||||
|
|
||||||
|
**S-01-02: Inloggen**
|
||||||
|
Als geregistreerde gebruiker wil ik inloggen met gebruikersnaam en wachtwoord, zodat ik mijn projecten kan beheren.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Incorrecte combinatie geeft generieke foutmelding (geen onderscheid gebruikersnaam/wachtwoord)
|
||||||
|
- Na inloggen wordt de gebruiker doorgestuurd naar het dashboard
|
||||||
|
- Sessie blijft actief totdat de gebruiker uitlogt
|
||||||
|
|
||||||
|
**S-01-03: Uitloggen**
|
||||||
|
Als ingelogde gebruiker wil ik kunnen uitloggen, zodat mijn sessie veilig afgesloten wordt.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Uitlogknop altijd zichtbaar in de navigatie
|
||||||
|
- Na uitloggen wordt de gebruiker naar de loginpagina gestuurd
|
||||||
|
- Sessiedata wordt gewist
|
||||||
|
|
||||||
|
**S-01-04: Demo-gebruiker (read-only)**
|
||||||
|
Als bezoeker wil ik kunnen inloggen als demo-gebruiker, zodat ik de app kan verkennen zonder een account aan te maken.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Vaste inloggegevens voor de demo-gebruiker zijn beschikbaar op de loginpagina
|
||||||
|
- Demo-gebruiker ziet alle data maar kan niets aanmaken, aanpassen of verwijderen
|
||||||
|
- Alle actieknoppen (aanmaken, bewerken, verwijderen) zijn zichtbaar maar uitgeschakeld met tooltip "Niet beschikbaar in demo-modus"
|
||||||
|
- Demo-gebruiker kan niet van rol wisselen
|
||||||
|
|
||||||
|
**S-01-05: Roltoewijzing**
|
||||||
|
Als gebruiker wil ik één of meerdere Scrum-rollen kunnen aannemen (Product Owner, Scrum Master, Developer), zodat de app weet in welke context ik werk.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Gebruiker kan bij registratie of in instellingen rollen selecteren
|
||||||
|
- Minimaal één rol is verplicht
|
||||||
|
- Alle drie de rollen tegelijk zijn toegestaan
|
||||||
|
- Rolkeuze is zichtbaar in de navigatie/profielbalk
|
||||||
|
- Rolkeuze heeft in v1 geen effect op zichtbare functionaliteit (voorbereiding op v2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PBI-02 — Productbeheer
|
||||||
|
|
||||||
|
**Prioriteit:** 1 — Kritiek
|
||||||
|
**Omschrijving:** Het Scrum Team kan producten aanmaken, bekijken, bewerken en archiveren. Een product heeft een naam, beschrijving en koppeling naar een git-repository.
|
||||||
|
|
||||||
|
### Stories
|
||||||
|
|
||||||
|
**S-02-01: Product aanmaken**
|
||||||
|
Als Product Owner wil ik een nieuw product aanmaken met naam, beschrijving en git-repo URL, zodat ik een werkruimte heb voor de Product Backlog.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Naam is verplicht en uniek per gebruiker
|
||||||
|
- Beschrijving is optioneel (vrije tekst)
|
||||||
|
- Git-repo URL is optioneel maar wordt gevalideerd als geldige URL
|
||||||
|
- Product is direct zichtbaar in de productenlijst na aanmaken
|
||||||
|
|
||||||
|
**S-02-02: Product bewerken**
|
||||||
|
Als Product Owner wil ik de naam, beschrijving en git-repo URL van een product kunnen aanpassen, zodat de informatie actueel blijft.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Alle velden zijn bewerkbaar
|
||||||
|
- Wijzigingen worden opgeslagen zonder de pagina te verlaten
|
||||||
|
- Lege naam geeft validatiefout
|
||||||
|
|
||||||
|
**S-02-03: Product archiveren**
|
||||||
|
Als Product Owner wil ik een product kunnen archiveren, zodat het niet meer in het overzicht verschijnt maar de data bewaard blijft.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Gearchiveerde producten verschijnen niet in de standaardlijst
|
||||||
|
- Er is een optie om gearchiveerde producten te tonen
|
||||||
|
- Archiveren is omkeerbaar (product kan worden hersteld)
|
||||||
|
|
||||||
|
**S-02-04: Productenlijst bekijken**
|
||||||
|
Als gebruiker wil ik een overzicht zien van alle actieve producten, zodat ik snel naar het juiste product kan navigeren.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Lijst toont naam, beschrijving (ingekort) en git-repo link
|
||||||
|
- Klikken op een product opent de Product Backlog van dat product
|
||||||
|
- Lege staat toont een prompt om een product aan te maken
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PBI-03 — Product Backlog
|
||||||
|
|
||||||
|
**Prioriteit:** 1 — Kritiek
|
||||||
|
**Omschrijving:** Het Scrum Team kan de Product Backlog beheren via een gesplitst scherm: links de PBI's, rechts de bijbehorende stories. Items kunnen aangemaakt, bewerkt, geprioriteerd en gerangschikt worden via drag-and-drop (dnd-kit).
|
||||||
|
|
||||||
|
### Stories
|
||||||
|
|
||||||
|
**S-03-01: PBI aanmaken**
|
||||||
|
Als Product Owner wil ik een PBI aanmaken in de Product Backlog, zodat ik nieuwe functionaliteit kan definiëren.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- PBI heeft een titel (verplicht) en omschrijving (optioneel)
|
||||||
|
- PBI krijgt een prioriteit (1 t/m 4)
|
||||||
|
- Nieuw PBI verschijnt onderaan de lijst voor de gekozen prioriteit
|
||||||
|
- Aanmaken via knop in de navigatiebar van het linkerpaneel
|
||||||
|
|
||||||
|
**S-03-02: PBI bewerken**
|
||||||
|
Als Product Owner wil ik de titel, omschrijving en prioriteit van een PBI kunnen aanpassen.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Dubbelklikken of via contextmenu opent bewerkingsmodus
|
||||||
|
- Alle velden zijn inline bewerkbaar
|
||||||
|
- Prioriteitswijziging herplaatst het PBI visueel
|
||||||
|
|
||||||
|
**S-03-03: PBI verwijderen**
|
||||||
|
Als Product Owner wil ik een PBI kunnen verwijderen, zodat irrelevante items de backlog niet vervuilen.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Verwijderen vereist bevestiging
|
||||||
|
- Verwijderen van een PBI verwijdert ook alle bijbehorende stories (cascade)
|
||||||
|
- Actie is niet ongedaan te maken; bevestigingsdialoog waarschuwt hiervoor
|
||||||
|
|
||||||
|
**S-03-04: PBI prioriteit instellen**
|
||||||
|
Als Product Owner wil ik per PBI een prioriteit kunnen instellen (1 t/m 4), zodat de volgorde van de backlog de businesswaarde weerspiegelt.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Prioriteit is instelbaar via dropdown of inline label
|
||||||
|
- PBI's worden gegroepeerd per prioriteit in de lijst
|
||||||
|
- Visuele scheiding per prioriteitsgroep (kleurband of scheidingslijn)
|
||||||
|
|
||||||
|
**S-03-05: PBI volgorde aanpassen via drag-and-drop**
|
||||||
|
Als Product Owner wil ik de volgorde van PBI's binnen dezelfde prioriteit kunnen aanpassen via drag-and-drop, zodat ik fijnmazige prioritering kan doen.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Drag-and-drop werkt vloeiend (60fps) via dnd-kit
|
||||||
|
- Volgorde wordt direct opgeslagen na loslaten
|
||||||
|
- Drag over prioriteitsgrens wisselt de prioriteit van het PBI
|
||||||
|
- Visuele placeholder toont de doelpositie tijdens het slepen
|
||||||
|
|
||||||
|
**S-03-06: PBI filteren**
|
||||||
|
Als gebruiker wil ik PBI's kunnen filteren op prioriteit of status, zodat ik me kan focussen op het relevante werk.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Filteropties beschikbaar in de navigatiebar van het linkerpaneel
|
||||||
|
- Filter werkt realtime (geen herlaadactie)
|
||||||
|
- Actief filter is duidelijk zichtbaar; eenvoudig te wissen
|
||||||
|
|
||||||
|
**S-03-07: Gesplitst scherm Product Backlog**
|
||||||
|
Als gebruiker wil ik de Product Backlog bekijken als gesplitst scherm (PBI's links, stories rechts), zodat ik snel kan navigeren tussen PBI's en hun stories.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Scherm is standaard 50/50 verdeeld
|
||||||
|
- De splitter is horizontaal versleepbaar
|
||||||
|
- Elk paneel heeft een eigen navigatiebar met acties
|
||||||
|
- Selecteren van een PBI links toont de bijbehorende stories rechts
|
||||||
|
- Geselecteerde PBI is visueel gemarkeerd
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PBI-04 — Story-beheer
|
||||||
|
|
||||||
|
**Prioriteit:** 1 — Kritiek
|
||||||
|
**Omschrijving:** Stories kunnen worden aangemaakt, bewerkt, geprioriteerd en gerangschikt binnen een PBI. Stories worden weergegeven als blokken van circa 10% schermbreedte, gerangschikt op prioriteit.
|
||||||
|
|
||||||
|
### Stories
|
||||||
|
|
||||||
|
**S-04-01: Story aanmaken**
|
||||||
|
Als Product Owner wil ik een story aanmaken binnen een PBI, zodat ik de functionaliteit kan uitwerken in uitvoerbare eenheden.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Story heeft een titel (verplicht), omschrijving (optioneel) en prioriteit
|
||||||
|
- Aanmaken via navigatiebar van het rechterpaneel
|
||||||
|
- Nieuwe story verschijnt als blok rechts, in de juiste prioriteitsgroep
|
||||||
|
|
||||||
|
**S-04-02: Story weergave als blokken**
|
||||||
|
Als gebruiker wil ik stories zien als compacte blokken (~10% schermbreedte), zodat ik snel een overzicht heb van alle stories per PBI.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Elk blok toont: storytitel, prioriteit, status
|
||||||
|
- Blokken zijn gerangschikt op prioriteit (hoog naar laag, links naar rechts)
|
||||||
|
- Elke nieuwe prioriteitsgroep heeft een visuele scheiding (kleurband of lijn)
|
||||||
|
- Blokken zijn klikbaar voor detail/bewerking
|
||||||
|
|
||||||
|
**S-04-03: Story prioriteit instellen**
|
||||||
|
Als Product Owner wil ik per story een prioriteit instellen, zodat de Developer weet wat als eerste opgepakt moet worden.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Prioriteit instelbaar via het storyblok (dropdown of label)
|
||||||
|
- Prioriteitswijziging herplaatst het blok in de juiste groep
|
||||||
|
|
||||||
|
**S-04-04: Story volgorde aanpassen via drag-and-drop**
|
||||||
|
Als Product Owner wil ik de volgorde van stories binnen dezelfde prioriteit aanpassen via drag-and-drop, zodat ik de uitvoeringsvolgorde kan finetunen.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Drag-and-drop werkt via dnd-kit tussen en binnen prioriteitsgroepen
|
||||||
|
- Volgorde wordt direct opgeslagen
|
||||||
|
- Slepen over een prioriteitsgrens wijzigt de prioriteit
|
||||||
|
|
||||||
|
**S-04-05: Story bewerken**
|
||||||
|
Als Product Owner wil ik de titel, omschrijving en prioriteit van een story kunnen aanpassen.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Bewerkbaar via klikken op het storyblok
|
||||||
|
- Wijzigingen opgeslagen zonder paginaverversing
|
||||||
|
|
||||||
|
**S-04-06: Story verwijderen**
|
||||||
|
Als Product Owner wil ik een story kunnen verwijderen.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Verwijderen vereist bevestiging
|
||||||
|
- Cascade verwijdering van gekoppelde taken
|
||||||
|
- Niet ongedaan te maken; waarschuwing in dialoog
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PBI-05 — Todo-lijst
|
||||||
|
|
||||||
|
**Prioriteit:** 2 — Hoog
|
||||||
|
**Omschrijving:** Gebruikers kunnen een snelle todo-lijst bijhouden voor ongeplande of kortstondige taken. Een todo-item kan worden gepromoveerd naar een PBI of story.
|
||||||
|
|
||||||
|
### Stories
|
||||||
|
|
||||||
|
**S-05-01: Todo-item aanmaken**
|
||||||
|
Als gebruiker wil ik snel een todo-item aanmaken zonder het aan een product te koppelen, zodat ik losse gedachten kan vastleggen zonder de planningsflow te onderbreken.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Todo heeft alleen een titel (verplicht)
|
||||||
|
- Aanmaken via een snel-invoerveld (Enter om op te slaan)
|
||||||
|
- Todo's zijn zichtbaar in een aparte todo-sectie of zijpaneel
|
||||||
|
|
||||||
|
**S-05-02: Todo-item afvinken**
|
||||||
|
Als gebruiker wil ik een todo-item kunnen afvinken, zodat ik bij kan houden wat klaar is.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Afgevinkte items zijn visueel doorgestreept
|
||||||
|
- Afgevinkte items blijven zichtbaar maar kunnen worden gearchiveerd
|
||||||
|
|
||||||
|
**S-05-03: Todo promoveren naar PBI**
|
||||||
|
Als Product Owner wil ik een todo-item promoveren naar een PBI in een bestaand product, zodat losse ideeën in de formele backlog terechtkomen.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Promoten opent een dialoog om product en prioriteit te kiezen
|
||||||
|
- Het todo-item wordt omgezet naar een PBI en verdwijnt uit de todo-lijst
|
||||||
|
- De PBI-titel is gelijk aan de todo-titel (bewerkbaar in dialoog)
|
||||||
|
|
||||||
|
**S-05-04: Todo promoveren naar story**
|
||||||
|
Als Product Owner wil ik een todo-item promoveren naar een story binnen een bestaand PBI, zodat ik snel nieuwe stories kan toevoegen vanuit losse notities.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Promoten opent een dialoog om product, PBI en prioriteit te kiezen
|
||||||
|
- Todo wordt omgezet naar een story en verdwijnt uit de todo-lijst
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PBI-06 — Sprint Backlog & Sprint Planning
|
||||||
|
|
||||||
|
**Prioriteit:** 2 — Hoog
|
||||||
|
**Omschrijving:** Het Scrum Team kan een Sprint aanmaken met een Sprint Goal, stories uit de Product Backlog naar de Sprint Backlog slepen, en de volgorde bepalen.
|
||||||
|
|
||||||
|
### Stories
|
||||||
|
|
||||||
|
**S-06-01: Sprint aanmaken**
|
||||||
|
Als Scrum Master wil ik een nieuwe Sprint aanmaken met een Sprint Goal, zodat het Scrum Team een duidelijk doel heeft voor de komende Sprint.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Sprint heeft een Sprint Goal (verplicht, vrije tekst)
|
||||||
|
- Sprint is gekoppeld aan een product
|
||||||
|
- Er kan maar één actieve Sprint per product zijn
|
||||||
|
|
||||||
|
**S-06-02: Sprint Backlog scherm (gesplitst)**
|
||||||
|
Als gebruiker wil ik de Sprint Backlog kunnen beheren via een gesplitst scherm (Sprint Backlog links, stories per PBI rechts), zodat ik snel stories kan toevoegen aan de Sprint.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Links: Sprint Backlog met geselecteerde stories in volgorde
|
||||||
|
- Rechts: stories uit de Product Backlog, gegroepeerd per PBI
|
||||||
|
- Splitter is horizontaal versleepbaar
|
||||||
|
- Elk paneel heeft eigen navigatiebar
|
||||||
|
|
||||||
|
**S-06-03: Story naar Sprint slepen**
|
||||||
|
Als Developer wil ik een story vanuit de Product Backlog naar de Sprint Backlog kunnen slepen, zodat ik bepaal wat we deze Sprint gaan oppakken.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Drag-and-drop werkt via dnd-kit tussen rechterpaneel en linkerpaneel
|
||||||
|
- Story verschijnt in de Sprint Backlog op de gesleepte positie
|
||||||
|
- Story in de Product Backlog krijgt visuele markering "In Sprint"
|
||||||
|
- Een story kan maar aan één actieve Sprint gekoppeld zijn
|
||||||
|
|
||||||
|
**S-06-04: Volgorde stories in Sprint bepalen**
|
||||||
|
Als Developer wil ik de volgorde van stories in de Sprint Backlog kunnen aanpassen via drag-and-drop, zodat ik de uitvoeringsvolgorde bepaal.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Drag-and-drop werkt binnen de Sprint Backlog
|
||||||
|
- Volgorde wordt direct opgeslagen
|
||||||
|
- Volgorde is onafhankelijk van de prioriteit in de Product Backlog
|
||||||
|
|
||||||
|
**S-06-05: Story uit Sprint verwijderen**
|
||||||
|
Als Developer wil ik een story uit de Sprint Backlog kunnen verwijderen (terugplaatsen in de Product Backlog), zodat we de Sprint scope kunnen aanpassen.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Story verdwijnt uit de Sprint Backlog
|
||||||
|
- Story is weer beschikbaar in de Product Backlog
|
||||||
|
- Actie vereist geen bevestiging (is niet destructief)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PBI-07 — Sprint Planning (taken per story)
|
||||||
|
|
||||||
|
**Prioriteit:** 2 — Hoog
|
||||||
|
**Omschrijving:** Tijdens Sprint Planning worden stories opgedeeld in taken. Hetzelfde gesplitste scherm wordt gebruikt: stories links, taken rechts. Taken kunnen geprioriteerd en gerangschikt worden.
|
||||||
|
|
||||||
|
### Stories
|
||||||
|
|
||||||
|
**S-07-01: Sprint Planning scherm**
|
||||||
|
Als Developer wil ik een Sprint Planning scherm zien met stories links en taken rechts, zodat ik per story taken kan aanmaken en rangschikken.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Links: stories in de Sprint Backlog in volgorde
|
||||||
|
- Rechts: taken van de geselecteerde story
|
||||||
|
- Selecteren van een story links toont de bijbehorende taken rechts
|
||||||
|
- Gesplitst scherm is horizontaal versleepbaar
|
||||||
|
|
||||||
|
**S-07-02: Taak aanmaken**
|
||||||
|
Als Developer wil ik een taak aanmaken onder een story, zodat ik het uitvoerbare werk kan definiëren.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Taak heeft een titel (verplicht), omschrijving (optioneel) en prioriteit
|
||||||
|
- Aanmaken via navigatiebar van het rechterpaneel
|
||||||
|
- Nieuwe taak verschijnt onderaan de takenlijst van de story
|
||||||
|
|
||||||
|
**S-07-03: Taak prioriteit instellen**
|
||||||
|
Als Developer wil ik per taak een prioriteit instellen, zodat de uitvoeringsvolgorde duidelijk is.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Prioriteit instelbaar via taakregel (dropdown of label)
|
||||||
|
- Taken gegroepeerd en gerangschikt op prioriteit
|
||||||
|
|
||||||
|
**S-07-04: Taak volgorde aanpassen via drag-and-drop**
|
||||||
|
Als Developer wil ik de volgorde van taken binnen een story kunnen aanpassen via drag-and-drop, zodat de uitvoeringsvolgorde precies klopt.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Drag-and-drop via dnd-kit binnen de takenlijst
|
||||||
|
- Volgorde direct opgeslagen na loslaten
|
||||||
|
|
||||||
|
**S-07-05: Taakstatus bijhouden**
|
||||||
|
Als Developer wil ik de status van een taak kunnen bijhouden (To Do, In Progress, Done), zodat de voortgang van de Sprint zichtbaar is.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Status is instelbaar via de UI (dropdown of knoppen)
|
||||||
|
- Statuswijziging is direct zichtbaar in het Sprint Planning scherm
|
||||||
|
- Story toont een voortgangsindicator op basis van taakstatussen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PBI-08 — Claude Code integratie
|
||||||
|
|
||||||
|
**Prioriteit:** 2 — Hoog
|
||||||
|
**Omschrijving:** Claude Code kan via een REST API (en later MCP) stories en taken ophalen, de volgorde beoordelen, een implementatieplan opstellen, tests uitvoeren en committen. Elk resultaat wordt vastgelegd in de story.
|
||||||
|
|
||||||
|
### Stories
|
||||||
|
|
||||||
|
**S-08-01: REST API — story ophalen**
|
||||||
|
Als Developer (via Claude Code) wil ik de hoogst geprioriteerde open story van een product kunnen ophalen via een API-endpoint, zodat Claude Code weet wat er gedaan moet worden.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Endpoint: `GET /api/products/:id/next-story`
|
||||||
|
- Retourneert: story-id, titel, omschrijving, acceptatiecriteria, gekoppelde taken
|
||||||
|
- Authentiseerd via API-token
|
||||||
|
- Geeft 404 als er geen open stories zijn
|
||||||
|
|
||||||
|
**S-08-02: REST API — eerste 10 taken ophalen**
|
||||||
|
Als Developer (via Claude Code) wil ik de eerste 10 taken van de Sprint Backlog kunnen ophalen, zodat Claude Code de volgorde kan beoordelen en zo nodig aanpassen.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Endpoint: `GET /api/sprints/:id/tasks?limit=10`
|
||||||
|
- Retourneert taken in huidige volgorde met id, titel, prioriteit, status
|
||||||
|
- Claude Code kan de volgorde aanpassen via een apart endpoint
|
||||||
|
|
||||||
|
**S-08-03: REST API — taakvolgorde aanpassen**
|
||||||
|
Als Developer (via Claude Code) wil ik de volgorde van taken kunnen aanpassen via de API, zodat Claude Code een optimale uitvoeringsvolgorde kan bepalen.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Endpoint: `PATCH /api/stories/:id/tasks/reorder`
|
||||||
|
- Accepteert een geordende lijst van taak-id's
|
||||||
|
- Volgorde wordt direct weerspiegeld in de UI
|
||||||
|
|
||||||
|
**S-08-04: Implementatieplan vastleggen**
|
||||||
|
Als Developer (via Claude Code) wil ik een implementatieplan kunnen schrijven naar een story, zodat de ontwerpbeslissingen traceerbaar zijn.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Endpoint: `POST /api/stories/:id/log`
|
||||||
|
- Veld: `type: "implementation_plan"`, `content: string`
|
||||||
|
- Log-entry verschijnt in de story-activiteitenlog in de UI
|
||||||
|
|
||||||
|
**S-08-05: Teststatus vastleggen**
|
||||||
|
Als Developer (via Claude Code) wil ik de uitkomst van testruns kunnen vastleggen in een story, zodat kwaliteitsbewijs per story bewaard blijft.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Endpoint: `POST /api/stories/:id/log`
|
||||||
|
- Veld: `type: "test_result"`, `content: string`, `status: "passed" | "failed"`
|
||||||
|
- Teststatus zichtbaar in de story-activiteitenlog
|
||||||
|
|
||||||
|
**S-08-06: Commit-hash vastleggen**
|
||||||
|
Als Developer (via Claude Code) wil ik de commit-hash na een succesvolle commit kunnen vastleggen in een story, zodat code en planning direct gekoppeld zijn.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Endpoint: `POST /api/stories/:id/log`
|
||||||
|
- Veld: `type: "commit"`, `hash: string`, `message: string`
|
||||||
|
- Commit-hash is klikbaar en linkt naar de git-repo (indien geconfigureerd)
|
||||||
|
|
||||||
|
**S-08-07: Story activiteitenlog in UI**
|
||||||
|
Als gebruiker wil ik per story een activiteitenlog zien met alle vastgelegde implementatieplannen, testresultaten en commits, zodat ik de volledige uitvoeringsgeschiedenis op één plek heb.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Log toont alle entries in chronologische volgorde
|
||||||
|
- Elk type entry heeft een eigen visuele stijl (plan, test, commit)
|
||||||
|
- Log is zichtbaar in de story-detailweergave
|
||||||
|
- Log is read-only in de UI (schrijven gebeurt via API)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PBI-09 — Infrastructuur & deployment
|
||||||
|
|
||||||
|
**Prioriteit:** 1 — Kritiek
|
||||||
|
**Omschrijving:** De app is deployable op Vercel + Neon (cloud) én volledig lokaal draaibaar zonder externe afhankelijkheden.
|
||||||
|
|
||||||
|
### Stories
|
||||||
|
|
||||||
|
**S-09-01: Cloud deployment (Vercel + Neon)**
|
||||||
|
Als Developer wil ik de app deployen op Vercel met een Neon PostgreSQL-database, zodat de app beschikbaar is via een URL.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- `next build` slaagt zonder fouten
|
||||||
|
- Database-migraties worden uitgevoerd via Prisma
|
||||||
|
- Environment variables zijn gedocumenteerd in `.env.example`
|
||||||
|
|
||||||
|
**S-09-02: Lokale modus**
|
||||||
|
Als Developer wil ik de app lokaal kunnen draaien met een Neon PostgreSQL-database, zodat de lokale setup overeenkomt met productie.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- `npm run dev` start de app lokaal zonder Vercel of Neon account
|
||||||
|
- Database wordt aangemaakt via `prisma db push`
|
||||||
|
- README bevat stap-voor-stap instructies voor lokale setup
|
||||||
|
|
||||||
|
**S-09-03: API-token authenticatie**
|
||||||
|
Als Developer wil ik een API-token kunnen genereren in de app, zodat Claude Code veilig kan communiceren met de REST API.
|
||||||
|
Acceptatiecriteria:
|
||||||
|
- Gebruiker kan een API-token aanmaken in de instellingenpagina
|
||||||
|
- Token wordt eenmalig getoond en daarna niet meer zichtbaar
|
||||||
|
- Token kan worden ingetrokken
|
||||||
|
- Alle API-endpoints vereisen een geldig token via `Authorization: Bearer`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backlog — v2 kandidaten (niet in v1)
|
||||||
|
|
||||||
|
| PBI | Omschrijving |
|
||||||
|
|---|---|
|
||||||
|
| Daily Scrum scherm | Voortgang per story bijhouden tijdens de Sprint |
|
||||||
|
| Sprint Review scherm | Demo en feedback vastleggen per story |
|
||||||
|
| Sprint Retrospective scherm | Reflectie vastleggen per Sprint |
|
||||||
|
| Meerdere gebruikers per Scrum Team | Uitgebreide auth met rol-gebaseerde permissies |
|
||||||
|
| Automatische statusupdate na commit | Story op Done zetten via API-aanroep |
|
||||||
|
| Velocity tracking | Statistieken over meerdere Sprints |
|
||||||
|
| Notificaties / reminders | Push of e-mailmeldingen |
|
||||||
|
| Timeline / kalenderweergave | Sprint-planning op kalender |
|
||||||
|
| Definition of Done per product configureerbaar | Nu vaste instelling; later flexibel |
|
||||||
|
| Integratie GitHub Issues / Linear | Import/export van PBI's en stories |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Dit document dient als testdata voor de eerste implementatie van de datastructuur.*
|
||||||
|
*Versie 0.1 — te updaten na Sprint 1 Review.*
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# placeholder — remove when first file is added
|
||||||
79
docs/realtime-smoke.md
Normal file
79
docs/realtime-smoke.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Realtime smoke-checklist — PBI / Story / Task
|
||||||
|
|
||||||
|
Manuele checklist voor story "End-to-end smoke-test: PBI/Story/Task verschijnen zonder refresh".
|
||||||
|
Uitvoeren na deployment van de realtime-feature (SSE-keten: DB-trigger → pg NOTIFY → SSE → store → render).
|
||||||
|
|
||||||
|
## Voorbereiding
|
||||||
|
|
||||||
|
1. Open twee browser-tabs op hetzelfde product:
|
||||||
|
- **Tab A** — `/backlog?product=<product_id>` (read-only observatie)
|
||||||
|
- **Tab B** — zelfde URL; gebruik dit tabblad voor create/edit/delete-acties
|
||||||
|
2. Zorg dat je **niet** refresht in tab A tijdens het testen.
|
||||||
|
3. Controleer in de DevTools-console van tab A dat de SSE-verbinding actief is
|
||||||
|
(`EventSource` connected, geen foutmeldingen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
### PBI
|
||||||
|
|
||||||
|
- [ ] **1. PBI aanmaken in tab B**
|
||||||
|
Maak een nieuwe PBI aan (bijv. titel "Smoke PBI").
|
||||||
|
→ Tab A toont de nieuwe PBI **binnen 1 seconde** zonder refresh.
|
||||||
|
→ Geen dubbele entry (PBI verschijnt precies één keer).
|
||||||
|
|
||||||
|
- [ ] **2. PBI titel bewerken in tab B**
|
||||||
|
Pas de titel van de PBI aan naar "Smoke PBI — updated".
|
||||||
|
→ Tab A reflecteert de nieuwe titel **binnen 1 seconde**.
|
||||||
|
→ Geen dubbele entry, geen flickering.
|
||||||
|
|
||||||
|
- [ ] **3. PBI verwijderen in tab B**
|
||||||
|
Verwijder de PBI.
|
||||||
|
→ Tab A verwijdert de PBI **binnen 1 seconde**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Story
|
||||||
|
|
||||||
|
- [ ] **4. Story aanmaken in tab B**
|
||||||
|
Maak een story aan onder een bestaande PBI (bijv. "Smoke Story").
|
||||||
|
→ Tab A toont de story in de juiste PBI-rij **binnen 1 seconde**.
|
||||||
|
→ Geen dubbele entry.
|
||||||
|
|
||||||
|
- [ ] **5. Story titel bewerken in tab B**
|
||||||
|
Pas de titel aan naar "Smoke Story — updated".
|
||||||
|
→ Tab A reflecteert de nieuwe titel.
|
||||||
|
→ Geen dubbele entry, geen flickering.
|
||||||
|
|
||||||
|
- [ ] **6. Story verwijderen in tab B**
|
||||||
|
Verwijder de story.
|
||||||
|
→ Tab A verwijdert de story uit de lijst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task (solo-paneel + backlog drie-paneel)
|
||||||
|
|
||||||
|
- [ ] **7. Task aanmaken via solo-paneel**
|
||||||
|
Maak een task aan onder een story in het solo-paneel.
|
||||||
|
→ De task verschijnt in het solo-paneel **en** in het backlog drie-paneel **binnen 1 seconde**.
|
||||||
|
→ Geen dubbele entry.
|
||||||
|
|
||||||
|
- [ ] **8. Task status bijwerken**
|
||||||
|
Verander de status van de task (bijv. TO_DO → IN_PROGRESS).
|
||||||
|
→ Beide panelen reflecteren de nieuwe status.
|
||||||
|
|
||||||
|
- [ ] **9. Task verwijderen**
|
||||||
|
Verwijder de task.
|
||||||
|
→ Verdwijnt uit beide panelen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pass-criteria
|
||||||
|
|
||||||
|
Alle 9 items afgevinkt = smoke geslaagd.
|
||||||
|
|
||||||
|
Bij een mislukking: noteer welk item faalde en controleer:
|
||||||
|
1. De SSE-stream (`/api/realtime/backlog?product_id=...`) in de DevTools-netwerktab.
|
||||||
|
2. De Zustand-store via Redux DevTools of een debug-breakpoint in `applyChange`.
|
||||||
|
3. De DB-trigger op de relevante tabel (`pg_notify` op INSERT/UPDATE/DELETE).
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# placeholder — remove when first file is added
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# placeholder — remove when first file is added
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# placeholder — remove when first file is added
|
||||||
163
docs/story-dialog.md
Normal file
163
docs/story-dialog.md
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
# StoryDialog Profiel
|
||||||
|
|
||||||
|
> Volgt **`docs/patterns/dialog.md`** (de generieke spec voor élke entity-dialog in Scrum4Me).
|
||||||
|
> Dit document beschrijft alleen de Story-specifieke afwijkingen en keuzes — alle gedeelde regels (layout, motion, demo-policy, foutcodes, validatie, theming, dialog-gedrag) staan in de generieke spec en worden hier niet herhaald.
|
||||||
|
|
||||||
|
> **Belangrijk:** als een regel in dit profiel botst met de generieke spec, wint de generieke spec. Documenteer hier de afwijking + reden, of pas de generieke spec aan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Velden
|
||||||
|
|
||||||
|
| Veld | Type | Mode | Validatie |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `code` | `string \| null` | beide | optional, max 30 chars, mono-font, placeholder `auto` op create |
|
||||||
|
| `title` | `string` (required) | beide | trim, 1-200 chars |
|
||||||
|
| `priority` | `int` (1-4, P1 = hoogste) | beide | int 1-4, default 2 (overschrijfbaar via `defaultPriority`-prop bij create) |
|
||||||
|
| `description` | `string \| null` | beide | optional, plain textarea, placeholder `Als… wil ik… zodat…` (user-story-template) |
|
||||||
|
| `acceptance_criteria` | `string \| null` | beide | optional, plain textarea, placeholder `- Gegeven… Als… Dan…` (Gherkin-template) |
|
||||||
|
| `status` | `StoryStatus` enum | alleen edit | read-only badge in header, niet bewerkbaar in deze dialog |
|
||||||
|
|
||||||
|
`StoryStatus` enum: `OPEN | IN_SPRINT | DONE` (uppercase in DB).
|
||||||
|
|
||||||
|
### Veld-specifiek gedrag
|
||||||
|
|
||||||
|
- **Code + Titel** in één rij (`grid-cols-[6rem_1fr]`)
|
||||||
|
- **Prioriteit** via `<PrioritySelect>` (gedeelde primitive)
|
||||||
|
- **Description** als `<Textarea rows={3} resize-none>` — géén auto-grow, géén markdown-hint binnen de dialog (afwijking van generieke spec; rationale: stories zijn meestal één zin)
|
||||||
|
- **Acceptatiecriteria** idem — géén auto-grow, géén char-counter
|
||||||
|
- **Status** wordt **niet bewerkt** vanuit deze dialog. Status verandert via lijst-acties (sleep naar sprint = IN_SPRINT, taak-completion = DONE). Read-only badge in dialog-header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL- of state-pattern
|
||||||
|
|
||||||
|
- **Gekozen:** state-based (`state: StoryDialogState | null` prop, gerendeerd binnen `StoryPanel`)
|
||||||
|
- **Reden:** StoryDialog leeft binnen `StoryPanel` met live-store-data (geselecteerde PBI bepaalt zichtbare stories); deep-linking zou parallelle data-fetch-paden vereisen.
|
||||||
|
- **State-shape:**
|
||||||
|
```ts
|
||||||
|
type StoryDialogState =
|
||||||
|
| { mode: 'create'; pbiId: string; productId: string; defaultPriority?: number }
|
||||||
|
| { mode: 'edit'; story: Story; productId: string }
|
||||||
|
```
|
||||||
|
- **Sluiten:** `onClose()` callback uit de parent — `setState(null)` in `StoryPanel`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status-veld
|
||||||
|
|
||||||
|
- **Niet bewerkbaar in deze dialog** — alleen weergegeven als badge in de header (edit-mode)
|
||||||
|
- **Default bij create:** `OPEN` (server-default, niet expliciet gezet vanuit form)
|
||||||
|
- Status-overgangen lopen via:
|
||||||
|
- `OPEN → IN_SPRINT` — drag-and-drop naar een sprint of `sprint-id` zetten via story-actions
|
||||||
|
- `IN_SPRINT → DONE` — alle taken op `DONE` zetten triggert auto-promotion (zie story-status-logic in `actions/stories.ts`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server actions
|
||||||
|
|
||||||
|
| Actie | Locatie | Form-binding | Revalidatie |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `createStoryAction` | `actions/stories.ts` | via `useActionState` + `<form action>` (FormData) | server-side `revalidatePath` op product-backlog |
|
||||||
|
| `updateStoryAction` | `actions/stories.ts` | idem | idem |
|
||||||
|
| `deleteStoryAction` | `actions/stories.ts` | aangeroepen vanuit `useTransition` (geen form) | server-side `revalidatePath` |
|
||||||
|
| `getStoryLogsAction` | `actions/stories.ts` | aangeroepen on-mount in edit-mode | n.v.t. (read-only) |
|
||||||
|
|
||||||
|
Alle write-acties zijn drielaags afgedekt (proxy-guard + server-action-check + DemoTooltip op submit-knop).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Speciale gedragingen
|
||||||
|
|
||||||
|
### Header-presentatie (afwijking van generieke spec)
|
||||||
|
|
||||||
|
In edit-mode toont de dialog-header **drie elementen** boven op de standaard titel:
|
||||||
|
|
||||||
|
1. Story-titel als dialog-title (groot)
|
||||||
|
2. Story-code als monospace-badge rechtsboven (klein)
|
||||||
|
3. Twee badges direct onder de titel: priority-badge (kleur via `PRIORITY_COLORS`) en status-badge (kleur via `STATUS_COLORS`)
|
||||||
|
|
||||||
|
Generieke spec gaat uit van een sobere header met alleen `headline-small` titel + optioneel een `created_at`-meta-string. StoryDialog wijkt hier bewust van af omdat status + priority belangrijke context zijn voor de gebruiker bij het openen van een story (vaak wisselt iemand vlot tussen meerdere stories).
|
||||||
|
|
||||||
|
### Demo-modus = read-only weergave
|
||||||
|
|
||||||
|
Wanneer `isDemo === true` én `isEdit === true`, wordt het form **vervangen** door een read-only weergave:
|
||||||
|
|
||||||
|
- `description` via gedeelde `<Markdown>`-wrapper (`react-markdown` + `remark-gfm`)
|
||||||
|
- `acceptance_criteria` als plain whitespace-pre-line tekst
|
||||||
|
|
||||||
|
In create-mode is er voor demo-users niets te tonen — de dialog wordt alsnog geopend maar de submit-knop is `disabled` met `<DemoTooltip>`.
|
||||||
|
|
||||||
|
> Dit "read-only-fallback"-patroon is uniek voor StoryDialog tot nu toe. Het zou geadopteerd kunnen worden door andere edit-dialogs zodra demo-flow-vereisten dat rechtvaardigen.
|
||||||
|
|
||||||
|
### Activity-log (StoryLog) inline
|
||||||
|
|
||||||
|
In edit-mode wordt onder het form een `<StoryLog>`-paneel getoond met de chronologische logs van deze story (commit-hashes, status-transitions, etc.). Logs worden lazy-fetched via `getStoryLogsAction(story.id)` zodra de dialog opent.
|
||||||
|
|
||||||
|
Dit is een **read-only side-panel** en valt binnen de uitzondering die de generieke spec § 13 maakt voor `<StoryLog>`-style activity-rendering.
|
||||||
|
|
||||||
|
### Delete-flow (afwijking van generieke spec)
|
||||||
|
|
||||||
|
Generieke spec § 10.4 vereist een **`AlertDialog`** voor delete-confirmatie. StoryDialog gebruikt in plaats daarvan een **inline-confirm** in dezelfde footer-rij:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Weet je het zeker? Taken worden ook verwijderd. [Verwijderen] [Annuleren] ]
|
||||||
|
```
|
||||||
|
|
||||||
|
Een `AlertDialog` zou een tweede modale laag toevoegen die in deze context onhandig voelt (de dialog zelf is al een interruptive overlay). De inline-confirm is een **bewuste afwijking** van de generieke spec.
|
||||||
|
|
||||||
|
### Form-state via `useActionState`
|
||||||
|
|
||||||
|
Net als PbiDialog gebruikt StoryDialog `useActionState` + `useFormStatus`, niet `react-hook-form`. Dit is een toegestaan alternatief volgens de generieke spec § 2.
|
||||||
|
|
||||||
|
### `key`-prop op `<form>`
|
||||||
|
|
||||||
|
Het `<form>` heeft `key={isEdit ? story!.id : 'create'}` — reset native form-state bij record-wissel of mode-switch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Triggers
|
||||||
|
|
||||||
|
- **Create-trigger:** `+ Story`-knop in `PanelNavBar` van `StoryPanel` → `setStoryDialogState({ mode: 'create', pbiId, productId, defaultPriority: 2 })`
|
||||||
|
- **Edit-trigger:** edit-icoon op een story-card in `StoryPanel` → `setStoryDialogState({ mode: 'edit', story, productId })`
|
||||||
|
- **Empty-state-trigger:** `Maak je eerste story aan`-knop in `EmptyPanel` (zelfde state als create-trigger)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekende gaps t.o.v. generieke spec
|
||||||
|
|
||||||
|
> Deze items wijken af van `docs/patterns/dialog.md` en horen in een vervolg-PR rechtgezet (niet onderdeel van de huidige docs-introductie).
|
||||||
|
|
||||||
|
- ❌ **Geen dirty-close-guard** — Esc / backdrop / Cancel sluiten direct, ook met onopgeslagen wijzigingen. Generieke spec § 8.1 vereist een AlertDialog bij `isDirty`.
|
||||||
|
- ❌ **Geen Cmd/Ctrl+Enter shortcut** — alleen klik op submit-knop.
|
||||||
|
- ❌ **Geen char-counter / markdown-hint** op description / acceptance_criteria — bewust weggelaten, maar verdient expliciete bevestiging als design-keuze.
|
||||||
|
- ⚠️ **Inline-delete-confirm** in plaats van AlertDialog (zie § Speciale gedragingen). Bewuste afwijking; de generieke spec mag deze variant expliciet toestaan, of dit profile moet als precedent gelden voor toekomstige dialogen.
|
||||||
|
- ⚠️ **Header-layout** met meerdere badges wijkt af van de sobere header in § 4. Bewuste afwijking — context-zwaar bij story-wisselen.
|
||||||
|
- ⚠️ **Layout wijkt af** van de generieke responsive-tabel: `sm:max-w-lg` met eigen `max-h-[90vh]` + `flex flex-col` i.p.v. de exacte `max-w-[50vw]` / `90vw` / full-screen-progressie uit § 4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bewust NIET in v1
|
||||||
|
|
||||||
|
Specifiek voor StoryDialog (boven op de algemene out-of-scope-lijst in `docs/patterns/dialog.md` § 13):
|
||||||
|
|
||||||
|
- ❌ Status bewerken vanuit de dialog (gebeurt via lijst-acties / drag-and-drop / auto-promotion)
|
||||||
|
- ❌ Inline aanmaken van child-tasks (gebeurt via TaskDialog vanuit `TaskPanel`)
|
||||||
|
- ❌ Bulk-edit over meerdere stories
|
||||||
|
- ❌ Story-templates
|
||||||
|
- ❌ Linking aan externe issues (GitHub / Linear) — staat op v1.1+ roadmap
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenties
|
||||||
|
|
||||||
|
- `components/backlog/story-dialog.tsx` — implementatie
|
||||||
|
- `actions/stories.ts` — server actions (incl. `getStoryLogsAction`)
|
||||||
|
- `components/shared/priority-select.tsx` — gedeelde priority-control
|
||||||
|
- `components/shared/story-log.tsx` — activity-log paneel
|
||||||
|
- `components/shared/demo-tooltip.tsx` — demo-policy laag 3
|
||||||
|
- `components/markdown.tsx` — gedeelde markdown-wrapper
|
||||||
|
- `lib/task-status.ts` — status-enum-mapper
|
||||||
|
- `docs/patterns/dialog.md` — generieke spec (bron-of-truth)
|
||||||
|
- `docs/architecture.md` — datamodel `Story`
|
||||||
|
- `docs/styling.md` — MD3-tokens, status- en priority-kleuren
|
||||||
127
docs/task-dialog.md
Normal file
127
docs/task-dialog.md
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# TaskDialog Profiel
|
||||||
|
|
||||||
|
> Volgt **`docs/patterns/dialog.md`** (de generieke spec voor élke entity-dialog in Scrum4Me).
|
||||||
|
> Dit document beschrijft alleen de Task-specifieke afwijkingen en keuzes — alle gedeelde regels (layout, motion, demo-policy, foutcodes, validatie, theming, dialog-gedrag) staan in de generieke spec en worden hier niet herhaald.
|
||||||
|
|
||||||
|
> **Belangrijk:** als een regel in dit profiel botst met de generieke spec, wint de generieke spec. Documenteer hier de afwijking + reden, of pas de generieke spec aan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Velden
|
||||||
|
|
||||||
|
| Veld | Type | Mode | Validatie |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `title` | `string` (required) | beide | trim, 1-120 chars |
|
||||||
|
| `description` | `string \| null` | beide | optional, max 2.000 chars, markdown |
|
||||||
|
| `implementation_plan` | `string \| null` | beide | optional, max 10.000 chars, markdown |
|
||||||
|
| `priority` | `int` (1-4, P1 = hoogste) | beide | int 1-4, default 3 |
|
||||||
|
| `status` | `TaskStatus` enum | alleen edit (default `TO_DO` op create, niet getoond) | enum |
|
||||||
|
| `created_at` | `Date` | alleen edit | read-only metadata in header |
|
||||||
|
|
||||||
|
`TaskStatus` enum: `TO_DO | IN_PROGRESS | REVIEW | DONE`.
|
||||||
|
|
||||||
|
### Veld-specifiek gedrag
|
||||||
|
|
||||||
|
- **Auto-grow textareas** (`description`, `implementation_plan`) via `react-textarea-autosize`. Max 6 regels (description) / 12 regels (implementation_plan), daarna `overflow-y-auto`.
|
||||||
|
- **Karakter-counter** vanaf 75% van de limiet, klein, rechtsonder, `text-muted-foreground`. Bv. `1547 / 2000`.
|
||||||
|
- **Markdown-hint** onder elk textarea: `Markdown ondersteund (lijstjes, **vet**, \`code\`)`.
|
||||||
|
- **Priority** als segmented buttons via `<PrioritySelect>` / `<PrioritySegmented>`. Default P3 (Medium).
|
||||||
|
- **Status** met gekleurde dot:
|
||||||
|
- `TO_DO` — grijs
|
||||||
|
- `IN_PROGRESS` — `status-in-progress` (blauw)
|
||||||
|
- `REVIEW` — paars
|
||||||
|
- `DONE` — `status-done` (groen)
|
||||||
|
- **`created_at` als header-metadata** in edit-mode, naast de titel: `Aangemaakt: 23 apr 2026`. Klein, `muted-foreground`, géén form-veld.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL- of state-pattern
|
||||||
|
|
||||||
|
- **Gekozen:** URL-based (`searchParams`)
|
||||||
|
- **Reden:** TaskDialog wordt geopend vanuit twee context-pagina's (sprint-detail en product-backlog) en moet deep-linkable zijn voor share/refresh-scenario's. Suspense + skeleton voor edit-mode loading is gewenst.
|
||||||
|
- **Routes:**
|
||||||
|
```
|
||||||
|
/sprint/<sprintId>?newTask=1 → create
|
||||||
|
/sprint/<sprintId>?editTask=<taskId> → edit
|
||||||
|
/products/<productId>/backlog?newTask=1 → create
|
||||||
|
/products/<productId>/backlog?editTask=<taskId> → edit
|
||||||
|
```
|
||||||
|
- **Sluiten:** `router.push(<base-route>)` zonder query-params.
|
||||||
|
- **Server-side fetch in edit-mode:** server component fetcht de taak vóór render mét `productAccessFilter(userId)`. Bestaat de taak niet of valt 'm buiten scope → toast + redirect naar de context-route.
|
||||||
|
- Optioneel: `nuqs` als de query-state-handling te omslachtig wordt — pas introduceren als losse refactor-task, niet inline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status-veld
|
||||||
|
|
||||||
|
Verberg `status` in **create-mode** (default = `TO_DO` is genoeg). Toon alleen in edit-mode als `<Select>` met gekleurde dot per optie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server actions
|
||||||
|
|
||||||
|
| Actie | Locatie | Context-arg | Revalidatie |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `saveTask` | `app/actions/tasks.ts` | `{ sprintId?: string; productId?: string }` | `revalidatePath('/sprint/<sprintId>')` óf `revalidatePath('/products/<productId>/backlog')` afhankelijk van context |
|
||||||
|
| `deleteTask` | `app/actions/tasks.ts` | idem | idem |
|
||||||
|
|
||||||
|
Beide acties volgen de drielaagse demo-policy + auth-scoping uit `docs/patterns/dialog.md` § 6–7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Speciale gedragingen
|
||||||
|
|
||||||
|
### Triggers (bestaande UI vervangen)
|
||||||
|
|
||||||
|
Deze TaskDialog is de **enige** create/edit-flow voor taken in beide contexten (sprint én backlog). Bestaande inline-edit-paden in `components/sprint/task-list.tsx` en het backlog-equivalent worden vervangen, niet ernaast geplaatst.
|
||||||
|
|
||||||
|
- **Create-trigger:** filled button `+ Nieuwe taak` in tasklist-header → zet `?newTask=1` op huidige route
|
||||||
|
- **Edit-trigger:** klik op de hele rij in de tasklist (geen apart edit-icoon) → zet `?editTask=<id>` op huidige route
|
||||||
|
- **Loading edit-mode:** Suspense met minimale skeleton (3 grijze balken), `200ms`-delay zodat snelle fetches geen flicker tonen
|
||||||
|
|
||||||
|
### Markdown-rendering elders
|
||||||
|
|
||||||
|
`description` en `implementation_plan` worden buiten de dialog (taakdetail, hover-card) gerenderd via de gedeelde `<Markdown>`-wrapper (`react-markdown` + `remark-gfm`). Niet in de dialog zelf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementatie-volgorde (suggestie)
|
||||||
|
|
||||||
|
Hergebruik dit als checklist bij het bouwen of refactoren van TaskDialog:
|
||||||
|
|
||||||
|
1. Dependencies in `package.json` (zie `docs/patterns/dialog.md` § 2)
|
||||||
|
2. zod-schema in `lib/schemas/task.ts` — gedeeld door form en action
|
||||||
|
3. `productAccessFilter`-helper checken in `lib/auth/`
|
||||||
|
4. `saveTask` / `deleteTask` in `app/actions/tasks.ts` met auth-scoping + demo-check (laag 2)
|
||||||
|
5. `proxy.ts`-guard voor demo-write-routes (laag 1) — alleen als nog niet aanwezig
|
||||||
|
6. Eventueel ontbrekende MD3-tokens in `app/styles/theme.css` aanvullen
|
||||||
|
7. `<DemoTooltip>` rond submit/delete-knoppen (laag 3)
|
||||||
|
8. TaskDialog — create-mode eerst (minder edge cases)
|
||||||
|
9. Edit-mode toevoegen (status, delete, `created_at`-metadata)
|
||||||
|
10. URL-state via native `searchParams` op beide context-pagina's
|
||||||
|
11. Bestaande task-row trigger refactoren (klikbaar maken naar dialog)
|
||||||
|
12. Suspense + skeleton voor edit-mode + scope-check op fetch
|
||||||
|
13. Dirty-close-guard
|
||||||
|
14. Keyboard shortcuts (Cmd+Enter)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bewust NIET in v1
|
||||||
|
|
||||||
|
Specifiek voor TaskDialog (boven op de algemene out-of-scope-lijst in `docs/patterns/dialog.md` § 13):
|
||||||
|
|
||||||
|
- ❌ Sub-tasks / parent-child relaties tussen taken
|
||||||
|
- ❌ Tags / labels / categorieën op taken
|
||||||
|
- ❌ Due dates / reminders per taak
|
||||||
|
- ❌ Time tracking (uren-registratie) — wel relevant voor inspannings-monitor, eigen feature
|
||||||
|
- ❌ Sharing / collaboration per taak
|
||||||
|
- ❌ Templates voor terugkerende taken
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenties
|
||||||
|
|
||||||
|
- `docs/patterns/dialog.md` — generieke spec (bron-of-truth voor alles wat hier niet beschreven is)
|
||||||
|
- `docs/architecture.md` — datamodel `Task`
|
||||||
|
- `docs/styling.md` — MD3-tokens, status- en priority-kleuren
|
||||||
|
- `lib/task-status.ts` — enum-mapper DB ↔ API
|
||||||
462
docs/test-plan.md
Normal file
462
docs/test-plan.md
Normal file
|
|
@ -0,0 +1,462 @@
|
||||||
|
---
|
||||||
|
title: "Scrum4Me — API Test Plan"
|
||||||
|
status: active
|
||||||
|
audience: [maintainer, contributor]
|
||||||
|
language: nl
|
||||||
|
last_updated: 2026-05-03
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scrum4Me — API Test Plan
|
||||||
|
|
||||||
|
**Versie:** 1.0
|
||||||
|
**Datum:** 25 april 2026
|
||||||
|
**Auteur:** Jan Peter Visser
|
||||||
|
**Status:** Draft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Introduction
|
||||||
|
|
||||||
|
This document describes the test plan and test planning for the Scrum4Me API endpoints — the integration surface that Claude Code and external agents use to interact with the application. It covers strategy, scope, test cases, tooling, exit criteria, and a phased execution schedule.
|
||||||
|
|
||||||
|
The API consists of 7 endpoints that form the Definition of Done checkpoint: all 7 must pass curl-level verification before the project ships.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Objectives
|
||||||
|
|
||||||
|
| # | Objective |
|
||||||
|
|---|---|
|
||||||
|
| O-1 | Verify that all 7 API endpoints return correct responses for valid input |
|
||||||
|
| O-2 | Verify that unauthenticated requests are rejected with 401 |
|
||||||
|
| O-3 | Verify that demo users cannot perform write operations (403) |
|
||||||
|
| O-4 | Verify that cross-user access is impossible at every endpoint |
|
||||||
|
| O-5 | Verify that all Zod validation schemas reject malformed input with 400 |
|
||||||
|
| O-6 | Verify edge cases: resource-not-found (404), empty result sets, boundary values |
|
||||||
|
| O-7 | Produce executable curl scripts that satisfy the DoD requirement |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Scope
|
||||||
|
|
||||||
|
### 3.1 In scope
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth type | Write | Demo check |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `/api/products` | GET | Bearer token | No | No |
|
||||||
|
| `/api/products/:id/next-story` | GET | Bearer token | No | No |
|
||||||
|
| `/api/sprints/:id/tasks` | GET | Bearer token | No | No |
|
||||||
|
| `/api/stories/:id/tasks/reorder` | PATCH | Bearer token | Yes | Yes |
|
||||||
|
| `/api/stories/:id/log` | POST | Bearer token | Yes | Yes |
|
||||||
|
| `/api/tasks/:id` | PATCH | Bearer token | Yes | Yes |
|
||||||
|
| `/api/todos` | POST | Bearer token | Yes | Yes |
|
||||||
|
|
||||||
|
### 3.2 Out of scope
|
||||||
|
|
||||||
|
- `/api/profile/avatar` (GET/POST) — session-cookie auth, separate concern
|
||||||
|
- Server Actions (`actions/*.ts`) — UI-layer, covered by separate acceptance testing
|
||||||
|
- Frontend components and pages
|
||||||
|
- Database migrations and schema changes
|
||||||
|
- Performance and load testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Test Strategy
|
||||||
|
|
||||||
|
### 4.1 Layers
|
||||||
|
|
||||||
|
The strategy uses two complementary test layers. Together they satisfy both the automated regression requirement and the DoD curl requirement.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Layer 1 — Vitest Unit Tests (automated, mocked) │
|
||||||
|
│ Fast, deterministic, no external DB required │
|
||||||
|
│ Covers: auth, demo block, cross-user isolation, input validation │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Layer 2 — Curl Scripts (manual, real DB) │
|
||||||
|
│ Executable against localhost:3000 with seeded test data │
|
||||||
|
│ Covers: happy paths, response shape, DoD compliance │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
No Playwright, no Jest — Vitest is already configured and the existing `__tests__/api/security.test.ts` establishes the mock pattern to follow.
|
||||||
|
|
||||||
|
### 4.2 Vitest approach
|
||||||
|
|
||||||
|
All unit tests mock two dependencies only:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: { /* model methods as vi.fn() */ }
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-auth', () => ({
|
||||||
|
authenticateApiRequest: vi.fn()
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
This approach:
|
||||||
|
- Tests route handler logic in isolation
|
||||||
|
- Keeps tests fast (no network/DB)
|
||||||
|
- Follows the pattern already validated in `security.test.ts`
|
||||||
|
- Does not fall into the mock/prod divergence trap because the mocked boundary (`authenticateApiRequest` + `prisma`) is stable and narrow
|
||||||
|
|
||||||
|
### 4.3 Curl approach
|
||||||
|
|
||||||
|
A single shell script `scripts/test-api.sh` with:
|
||||||
|
- A `TOKEN` variable set at the top (obtained from a seeded user via the UI)
|
||||||
|
- One function per endpoint, each printing pass/fail based on HTTP status
|
||||||
|
- Run order that follows the Lars flow (read → write → verify)
|
||||||
|
|
||||||
|
### 4.4 Test data
|
||||||
|
|
||||||
|
The existing seed (`prisma/seed.ts`) creates:
|
||||||
|
- `lars` — full-permission user, used as the primary test actor
|
||||||
|
- `demo` — read-only user, used for demo-block tests
|
||||||
|
|
||||||
|
A second regular user (`tester`) must be created manually (or added to the seed) to test cross-user isolation scenarios.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Test Cases
|
||||||
|
|
||||||
|
The following tables list every test case by endpoint. Each case has an ID, description, input, expected HTTP status, and which layer covers it.
|
||||||
|
|
||||||
|
### TC format
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| ID | Unique identifier, e.g. `TC-P-01` (P = products) |
|
||||||
|
| Layer | V = Vitest, C = Curl |
|
||||||
|
| Input | What is sent |
|
||||||
|
| Expected | HTTP status + key response fields |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.1 GET /api/products
|
||||||
|
|
||||||
|
| ID | Layer | Scenario | Input | Expected |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| TC-P-01 | V | No token | No Authorization header | 401 |
|
||||||
|
| TC-P-02 | V | Invalid token | `Bearer invalid123` | 401 |
|
||||||
|
| TC-P-03 | V | Revoked token | Valid hash but `revoked_at` set | 401 |
|
||||||
|
| TC-P-04 | V | Valid token, owns 2 products | Valid token, 2 products in DB | 200, array of 2 |
|
||||||
|
| TC-P-05 | V | Valid token, is team member | Valid token, member of product owned by other user | 200, includes that product |
|
||||||
|
| TC-P-06 | V | Valid token, no products | Valid token, no products in DB | 200, empty array |
|
||||||
|
| TC-P-07 | V | Archived products excluded | 1 active + 1 archived product | 200, array of 1 |
|
||||||
|
| TC-P-08 | V | Cross-user: other user's products not returned | Token for user A, products owned by user B | 200, empty array |
|
||||||
|
| TC-P-09 | C | Happy path (lars) | Lars' token | 200, ≥1 product |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 GET /api/products/:id/next-story
|
||||||
|
|
||||||
|
| ID | Layer | Scenario | Input | Expected |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| TC-NS-01 | V | No token | No Authorization header | 401 |
|
||||||
|
| TC-NS-02 | V | Invalid token | `Bearer invalid` | 401 |
|
||||||
|
| TC-NS-03 | V | Product not found / not accessible | Valid token, unknown product id | 404 |
|
||||||
|
| TC-NS-04 | V | No active sprint | Valid token, product with no ACTIVE sprint | 404 |
|
||||||
|
| TC-NS-05 | V | Active sprint, no IN_SPRINT stories | Valid token, sprint exists but 0 stories | 404 |
|
||||||
|
| TC-NS-06 | V | Returns highest-priority story | Valid token, 3 IN_SPRINT stories with tasks | 200, story with tasks array |
|
||||||
|
| TC-NS-07 | V | Cross-user: other user's product | Token for user A, product owned by user B | 404 |
|
||||||
|
| TC-NS-08 | C | Happy path (lars, active sprint) | Lars' token + DevPlanner product id | 200, story object |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 GET /api/sprints/:id/tasks?limit=10
|
||||||
|
|
||||||
|
| ID | Layer | Scenario | Input | Expected |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| TC-ST-01 | V | No token | No Authorization header | 401 |
|
||||||
|
| TC-ST-02 | V | Invalid token | `Bearer invalid` | 401 |
|
||||||
|
| TC-ST-03 | V | Sprint not found / not accessible | Valid token, unknown sprint id | 404 |
|
||||||
|
| TC-ST-04 | V | Cross-user: other user's sprint | Token for user A, sprint in user B's product | 404 |
|
||||||
|
| TC-ST-05 | V | Default limit applied | No `?limit` param | 200, ≤10 tasks |
|
||||||
|
| TC-ST-06 | V | Custom limit respected | `?limit=3`, sprint has 5 tasks | 200, exactly 3 tasks |
|
||||||
|
| TC-ST-07 | V | Limit boundary: limit=1 | Sprint has multiple tasks | 200, exactly 1 task |
|
||||||
|
| TC-ST-08 | V | Sprint with 0 tasks | Valid sprint, no tasks | 200, empty array |
|
||||||
|
| TC-ST-09 | C | Happy path (lars) | Lars' token + active sprint id + `?limit=10` | 200, tasks array |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 PATCH /api/stories/:id/tasks/reorder
|
||||||
|
|
||||||
|
| ID | Layer | Scenario | Input | Expected |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| TC-RO-01 | V | No token | No Authorization header | 401 |
|
||||||
|
| TC-RO-02 | V | Invalid token | `Bearer invalid` | 401 |
|
||||||
|
| TC-RO-03 | V | Demo user | Valid token, `isDemo: true` | 403 |
|
||||||
|
| TC-RO-04 | V | Story not found / not accessible | Valid token, unknown story id | 404 |
|
||||||
|
| TC-RO-05 | V | Cross-user: other user's story | Token for user A, story in user B's product | 404 |
|
||||||
|
| TC-RO-06 | V | Empty task_ids array | `{ "task_ids": [] }` | 400 |
|
||||||
|
| TC-RO-07 | V | task_ids not array | `{ "task_ids": "abc" }` | 400 |
|
||||||
|
| TC-RO-08 | V | task_ids contains IDs from different story | Valid token, mixed-story task IDs | 400 |
|
||||||
|
| TC-RO-09 | V | Happy path | Valid token + valid task_ids in new order | 200 |
|
||||||
|
| TC-RO-10 | C | Happy path (lars) | Lars' token + story id + ordered task ids | 200 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.5 POST /api/stories/:id/log
|
||||||
|
|
||||||
|
| ID | Layer | Scenario | Input | Expected |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| TC-L-01 | V | No token | No Authorization header | 401 |
|
||||||
|
| TC-L-02 | V | Invalid token | `Bearer invalid` | 401 |
|
||||||
|
| TC-L-03 | V | Demo user | Valid token, `isDemo: true` | 403 |
|
||||||
|
| TC-L-04 | V | Story not found / not accessible | Valid token, unknown story id | 404 |
|
||||||
|
| TC-L-05 | V | Cross-user: other user's story | Token for user A, story in user B's product | 404 |
|
||||||
|
| TC-L-06 | V | Missing `type` field | `{ "content": "..." }` | 400 |
|
||||||
|
| TC-L-07 | V | Unknown `type` value | `{ "type": "UNKNOWN", "content": "..." }` | 400 |
|
||||||
|
| TC-L-08 | V | IMPLEMENTATION_PLAN — missing content | `{ "type": "IMPLEMENTATION_PLAN" }` | 400 |
|
||||||
|
| TC-L-09 | V | IMPLEMENTATION_PLAN — happy path | `{ "type": "IMPLEMENTATION_PLAN", "content": "Approach: ..." }` | 201 |
|
||||||
|
| TC-L-10 | V | TEST_RESULT — missing status | `{ "type": "TEST_RESULT", "content": "..." }` | 400 |
|
||||||
|
| TC-L-11 | V | TEST_RESULT — invalid status | `{ "type": "TEST_RESULT", "content": "...", "status": "UNKNOWN" }` | 400 |
|
||||||
|
| TC-L-12 | V | TEST_RESULT — happy path PASSED | `{ "type": "TEST_RESULT", "content": "...", "status": "PASSED" }` | 201 |
|
||||||
|
| TC-L-13 | V | TEST_RESULT — happy path FAILED | `{ "type": "TEST_RESULT", "content": "...", "status": "FAILED" }` | 201 |
|
||||||
|
| TC-L-14 | V | COMMIT — missing commit_hash | `{ "type": "COMMIT", "content": "...", "commit_message": "..." }` | 400 |
|
||||||
|
| TC-L-15 | V | COMMIT — missing commit_message | `{ "type": "COMMIT", "content": "...", "commit_hash": "abc1234" }` | 400 |
|
||||||
|
| TC-L-16 | V | COMMIT — happy path | `{ "type": "COMMIT", "content": "...", "commit_hash": "abc1234", "commit_message": "feat: ..." }` | 201 |
|
||||||
|
| TC-L-17 | C | IMPLEMENTATION_PLAN (lars) | Lars' token + story id + IMPLEMENTATION_PLAN body | 201 |
|
||||||
|
| TC-L-18 | C | TEST_RESULT PASSED (lars) | Lars' token + story id + TEST_RESULT body | 201 |
|
||||||
|
| TC-L-19 | C | COMMIT (lars) | Lars' token + story id + COMMIT body | 201 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.6 PATCH /api/tasks/:id
|
||||||
|
|
||||||
|
| ID | Layer | Scenario | Input | Expected |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| TC-T-01 | V | No token | No Authorization header | 401 |
|
||||||
|
| TC-T-02 | V | Invalid token | `Bearer invalid` | 401 |
|
||||||
|
| TC-T-03 | V | Demo user | Valid token, `isDemo: true` | 403 |
|
||||||
|
| TC-T-04 | V | Task not found | Valid token, unknown task id | 404 |
|
||||||
|
| TC-T-05 | V | Cross-user: task in other user's product | Token for user A, task in user B's product | 404 |
|
||||||
|
| TC-T-06 | V | Invalid status value | `{ "status": "UNKNOWN" }` | 400 |
|
||||||
|
| TC-T-07 | V | Empty body (no recognized fields) | `{}` | 400 |
|
||||||
|
| TC-T-08 | V | Update status only | `{ "status": "IN_PROGRESS" }` | 200 |
|
||||||
|
| TC-T-09 | V | Update implementation_plan only | `{ "implementation_plan": "Step 1: ..." }` | 200 |
|
||||||
|
| TC-T-10 | V | Update both fields | `{ "status": "DONE", "implementation_plan": "..." }` | 200 |
|
||||||
|
| TC-T-11 | V | Team member can update task | Token for team member (not owner), valid task | 200 |
|
||||||
|
| TC-T-12 | C | Update status to IN_PROGRESS (lars) | Lars' token + task id + `{ "status": "IN_PROGRESS" }` | 200 |
|
||||||
|
| TC-T-13 | C | Update status to DONE (lars) | Lars' token + task id + `{ "status": "DONE" }` | 200 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.7 POST /api/todos
|
||||||
|
|
||||||
|
| ID | Layer | Scenario | Input | Expected |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| TC-TD-01 | V | No token | No Authorization header | 401 |
|
||||||
|
| TC-TD-02 | V | Invalid token | `Bearer invalid` | 401 |
|
||||||
|
| TC-TD-03 | V | Demo user | Valid token, `isDemo: true` | 403 |
|
||||||
|
| TC-TD-04 | V | Missing title | `{ "product_id": "..." }` | 400 |
|
||||||
|
| TC-TD-05 | V | Empty title | `{ "title": "" }` | 400 |
|
||||||
|
| TC-TD-06 | V | Without product_id (global todo) | `{ "title": "My todo" }` | 201 |
|
||||||
|
| TC-TD-07 | V | With valid product_id | `{ "title": "My todo", "product_id": "..." }` | 201 |
|
||||||
|
| TC-TD-08 | V | With product_id not accessible to user | `{ "title": "...", "product_id": "<other user's product>" }` | 403 or 404 |
|
||||||
|
| TC-TD-09 | C | Happy path without product (lars) | Lars' token + `{ "title": "Test todo" }` | 201 |
|
||||||
|
| TC-TD-10 | C | Happy path with product (lars) | Lars' token + `{ "title": "...", "product_id": "..." }` | 201 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Test Files
|
||||||
|
|
||||||
|
| File | Endpoints covered | Layer |
|
||||||
|
|---|---|---|
|
||||||
|
| `__tests__/api/security.test.ts` | products (GET), tasks/:id (PATCH) — already exists, extend | V |
|
||||||
|
| `__tests__/api/products.test.ts` | GET /api/products — happy paths + edge cases | V |
|
||||||
|
| `__tests__/api/next-story.test.ts` | GET /api/products/:id/next-story | V |
|
||||||
|
| `__tests__/api/sprint-tasks.test.ts` | GET /api/sprints/:id/tasks | V |
|
||||||
|
| `__tests__/api/story-log.test.ts` | POST /api/stories/:id/log | V |
|
||||||
|
| `__tests__/api/reorder.test.ts` | PATCH /api/stories/:id/tasks/reorder | V |
|
||||||
|
| `__tests__/api/tasks.test.ts` | PATCH /api/tasks/:id | V |
|
||||||
|
| `__tests__/api/todos.test.ts` | POST /api/todos | V |
|
||||||
|
| `scripts/test-api.sh` | All 7 endpoints | C |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Exit Criteria
|
||||||
|
|
||||||
|
The test phase is complete when all of the following are met:
|
||||||
|
|
||||||
|
| Criterion | Target |
|
||||||
|
|---|---|
|
||||||
|
| All Vitest tests pass | `npm test` exits 0 |
|
||||||
|
| No test is skipped or pending | 0 skipped |
|
||||||
|
| All curl scripts return expected HTTP codes | 100% pass rate on `scripts/test-api.sh` |
|
||||||
|
| Demo user blocked on all 4 write endpoints | Verified via TC-RO-03, TC-L-03, TC-T-03, TC-TD-03 |
|
||||||
|
| Cross-user access impossible | Verified via TC-P-08, TC-NS-07, TC-ST-04, TC-RO-05, TC-L-05, TC-T-05, TC-TD-08 |
|
||||||
|
| Security review passed | No cross-user data leak found in any endpoint |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Mock divergence: mocked Prisma behavior differs from real DB | Medium | High | Keep mocked boundary narrow (only `prisma.*` calls, not business logic); validate happy paths with curl against real DB |
|
||||||
|
| Seed data insufficient for cross-user tests | Medium | Medium | Add a second test user `tester` to `prisma/seed.ts` |
|
||||||
|
| API token not available during curl tests | Low | High | Document token creation step clearly in `test-api.sh` header |
|
||||||
|
| Zod schema changes break test expectations | Low | Low | Vitest tests will catch this immediately on next `npm test` run |
|
||||||
|
| Reorder scope validation not fully covered | Medium | High | TC-RO-08 explicitly tests mixed-story IDs; add to security review checklist |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Dependencies
|
||||||
|
|
||||||
|
- `prisma/seed.ts` must create (or document creation of) a second user for cross-user tests
|
||||||
|
- `scripts/` directory must exist before running the curl script
|
||||||
|
- A valid API token must be obtained from a seeded `lars` user session before running curl tests
|
||||||
|
- `npm test` must be runnable without a live database (all Vitest tests mock Prisma)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Planning
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Phase | What | Duration | Start | End |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P0 | Setup & shared infrastructure | 0.5 day | 2026-04-28 | 2026-04-28 |
|
||||||
|
| P1 | Security & auth layer (Vitest) | 1 day | 2026-04-29 | 2026-04-29 |
|
||||||
|
| P2 | Per-endpoint unit tests (Vitest) | 2 days | 2026-04-30 | 2026-05-01 |
|
||||||
|
| P3 | Curl scripts | 0.5 day | 2026-05-02 | 2026-05-02 |
|
||||||
|
| P4 | Review, edge cases, DoD verification | 0.5 day | 2026-05-05 | 2026-05-05 |
|
||||||
|
|
||||||
|
Total: **4.5 days**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Setup & Infrastructure (2026-04-28)
|
||||||
|
|
||||||
|
**Goal:** Everything required to write and run tests is in place.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
| ID | Task | Deliverable |
|
||||||
|
|---|---|---|
|
||||||
|
| P0-1 | Add `tester` user to `prisma/seed.ts` with no shared products | Updated seed file |
|
||||||
|
| P0-2 | Create `scripts/` directory with `test-api.sh` skeleton (TOKEN var, helper functions, empty cases) | `scripts/test-api.sh` |
|
||||||
|
| P0-3 | Verify `npm test` runs cleanly on `security.test.ts` | Green CI |
|
||||||
|
| P0-4 | Create `__tests__/api/` test file skeletons (empty describe blocks) for each new file | 7 new `.test.ts` files |
|
||||||
|
|
||||||
|
**Done when:** `npm test` passes, all skeleton files exist, seed creates 3 users (demo, lars, tester).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Security & Auth Layer (2026-04-29)
|
||||||
|
|
||||||
|
**Goal:** All auth (401), demo-block (403), and cross-user isolation test cases pass for all endpoints.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
| ID | Task | Test cases | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P1-1 | Extend `security.test.ts` — add missing endpoints to auth/demo/cross-user coverage | TC-NS-01–03,07 / TC-ST-01–04 / TC-RO-01–05 / TC-L-01–05 / TC-TD-01–03,08 | `security.test.ts` |
|
||||||
|
| P1-2 | Verify all 401 cases return `{ error: 'Unauthorized' }` | TC-P-01–03 etc. | `security.test.ts` |
|
||||||
|
| P1-3 | Verify all 403 demo cases return `{ error: 'Niet beschikbaar in demo-modus' }` | TC-RO-03, TC-L-03, TC-T-03, TC-TD-03 | `security.test.ts` |
|
||||||
|
| P1-4 | Verify cross-user returns empty array or 404 (not 403) for read endpoints | TC-P-08, TC-NS-07, TC-ST-04 | `security.test.ts` |
|
||||||
|
|
||||||
|
**Done when:** All P1 test cases green, 0 skipped. Security test file covers all 7 endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Per-Endpoint Unit Tests (2026-04-30 – 2026-05-01)
|
||||||
|
|
||||||
|
**Goal:** Happy paths, input validation, and edge cases covered per endpoint.
|
||||||
|
|
||||||
|
### Day 1 (2026-04-30) — Read endpoints
|
||||||
|
|
||||||
|
| ID | Task | Test cases | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P2-1 | `products.test.ts` — happy paths, empty result, archived filter | TC-P-04–07,09 | `products.test.ts` |
|
||||||
|
| P2-2 | `next-story.test.ts` — happy path, no sprint, no stories | TC-NS-04–06,08 | `next-story.test.ts` |
|
||||||
|
| P2-3 | `sprint-tasks.test.ts` — happy path, limit param, empty sprint | TC-ST-05–09 | `sprint-tasks.test.ts` |
|
||||||
|
|
||||||
|
### Day 2 (2026-05-01) — Write endpoints
|
||||||
|
|
||||||
|
| ID | Task | Test cases | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P2-4 | `story-log.test.ts` — all 3 log types, each field validation | TC-L-06–16 | `story-log.test.ts` |
|
||||||
|
| P2-5 | `reorder.test.ts` — happy path, empty array, mixed-story IDs | TC-RO-06–09 | `reorder.test.ts` |
|
||||||
|
| P2-6 | `tasks.test.ts` — status update, plan update, both, invalid status, team member access | TC-T-06–11 | `tasks.test.ts` |
|
||||||
|
| P2-7 | `todos.test.ts` — with/without product_id, empty title | TC-TD-04–07 | `todos.test.ts` |
|
||||||
|
|
||||||
|
**Done when:** All Vitest files pass, `npm test` exits 0 with ≥60 test cases across all files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Curl Scripts (2026-05-02)
|
||||||
|
|
||||||
|
**Goal:** `scripts/test-api.sh` covers all 7 endpoints and all curl test cases pass against localhost.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
| ID | Task | Test cases | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P3-1 | Implement curl script for GET endpoints (products, next-story, sprint-tasks) | TC-P-09, TC-NS-08, TC-ST-09 | |
|
||||||
|
| P3-2 | Implement curl script for write endpoints (reorder, log ×3, tasks ×2, todos ×2) | TC-RO-10, TC-L-17–19, TC-T-12–13, TC-TD-09–10 | |
|
||||||
|
| P3-3 | Add negative cases to curl script: no token (401), demo token (403) | TC-P-01, TC-TD-03 | |
|
||||||
|
| P3-4 | Run full script against seeded local DB, fix any failures | All C cases | |
|
||||||
|
| P3-5 | Document token acquisition steps in `scripts/README.md` or script header | | |
|
||||||
|
|
||||||
|
**Done when:** `bash scripts/test-api.sh` prints all PASS, no failures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Review & DoD Verification (2026-05-05)
|
||||||
|
|
||||||
|
**Goal:** Test suite is complete, DoD exit criteria are all met.
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
| ID | Task |
|
||||||
|
|---|---|
|
||||||
|
| P4-1 | Run full `npm test` — verify 0 failures, 0 skipped |
|
||||||
|
| P4-2 | Run `scripts/test-api.sh` against staging/Neon DB — verify all PASS |
|
||||||
|
| P4-3 | Walk through security review checklist (cross-user access per endpoint) |
|
||||||
|
| P4-4 | Verify demo user is blocked on all 4 write endpoints via curl |
|
||||||
|
| P4-5 | Update `__tests__/lars-flow-checklist.md` to reference the new curl script |
|
||||||
|
| P4-6 | Add test instructions to README (`npm test`, `bash scripts/test-api.sh`) |
|
||||||
|
| P4-7 | Commit test files per commit strategy (separate commits per layer) |
|
||||||
|
|
||||||
|
**Done when:** All exit criteria from section 7 are met. Test plan status → `Approved`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Plan
|
||||||
|
|
||||||
|
Following the strict commit strategy, test work is committed in these layers:
|
||||||
|
|
||||||
|
```
|
||||||
|
chore(tests): add tester user to prisma seed
|
||||||
|
test(security): extend security.test.ts to cover all 7 endpoints
|
||||||
|
test(products): add unit tests for GET /api/products
|
||||||
|
test(next-story): add unit tests for GET /api/products/:id/next-story
|
||||||
|
test(sprint-tasks): add unit tests for GET /api/sprints/:id/tasks
|
||||||
|
test(story-log): add unit tests for POST /api/stories/:id/log
|
||||||
|
test(reorder): add unit tests for PATCH /api/stories/:id/tasks/reorder
|
||||||
|
test(tasks): add unit tests for PATCH /api/tasks/:id
|
||||||
|
test(todos): add unit tests for POST /api/todos
|
||||||
|
chore(scripts): add test-api.sh curl test script
|
||||||
|
docs(tests): update README with test instructions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Timeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Week of 2026-04-28
|
||||||
|
Mon 28 Apr │ P0 — Setup & infrastructure
|
||||||
|
Tue 29 Apr │ P1 — Security & auth layer (Vitest)
|
||||||
|
Wed 30 Apr │ P2 Day 1 — Read endpoint unit tests
|
||||||
|
Thu 01 May │ P2 Day 2 — Write endpoint unit tests
|
||||||
|
Fri 02 May │ P3 — Curl scripts
|
||||||
|
|
||||||
|
Week of 2026-05-05
|
||||||
|
Mon 05 May │ P4 — Review, DoD verification, commit
|
||||||
|
```
|
||||||
39
lib/chart-colors.ts
Normal file
39
lib/chart-colors.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// Mapping van MD3-tokens naar CSS-var-strings voor Recharts fill/stroke.
|
||||||
|
// Recharts accepteert gewone strings — 'var(--status-done)' werkt direct.
|
||||||
|
export const STATUS_COLORS = {
|
||||||
|
TO_DO: 'var(--status-todo)',
|
||||||
|
IN_PROGRESS: 'var(--status-in-progress)',
|
||||||
|
REVIEW: 'var(--status-in-progress)',
|
||||||
|
DONE: 'var(--status-done)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const PRIORITY_COLORS = {
|
||||||
|
1: 'var(--priority-critical)',
|
||||||
|
2: 'var(--priority-high)',
|
||||||
|
3: 'var(--priority-medium)',
|
||||||
|
4: 'var(--priority-low)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const VERIFY_COLORS = {
|
||||||
|
ALIGNED: 'var(--status-done)',
|
||||||
|
PARTIAL: 'var(--priority-medium)',
|
||||||
|
EMPTY: 'var(--priority-critical)',
|
||||||
|
DIVERGENT: 'var(--priority-high)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const JOB_STATUS_COLORS = {
|
||||||
|
queued: 'var(--muted-foreground)',
|
||||||
|
claimed: 'var(--status-in-progress)',
|
||||||
|
running: 'var(--status-in-progress)',
|
||||||
|
done: 'var(--status-done)',
|
||||||
|
failed: 'var(--priority-critical)',
|
||||||
|
cancelled: 'var(--muted-foreground)',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const SERIES_COLORS = [
|
||||||
|
'var(--chart-1)',
|
||||||
|
'var(--chart-2)',
|
||||||
|
'var(--chart-3)',
|
||||||
|
'var(--chart-4)',
|
||||||
|
'var(--chart-5)',
|
||||||
|
] as const
|
||||||
118
lib/insights/agent-throughput.ts
Normal file
118
lib/insights/agent-throughput.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export interface DayCount {
|
||||||
|
day: string
|
||||||
|
queued: number
|
||||||
|
claimed: number
|
||||||
|
running: number
|
||||||
|
done: number
|
||||||
|
failed: number
|
||||||
|
cancelled: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThroughputKpi {
|
||||||
|
todayCount: number
|
||||||
|
successRate7d: number
|
||||||
|
avgDurationSeconds7d: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobsPerDayResult {
|
||||||
|
perDay: DayCount[]
|
||||||
|
kpi: ThroughputKpi
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled'] as const
|
||||||
|
|
||||||
|
type RawDayRow = { day: Date; status: string; count: bigint }
|
||||||
|
type RawKpiRow = { today_count: bigint; done_7d: bigint; terminal_7d: bigint; avg_seconds: number | null }
|
||||||
|
|
||||||
|
function toDateStr(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getJobsPerDay(
|
||||||
|
userId: string,
|
||||||
|
days = 14,
|
||||||
|
productId?: string,
|
||||||
|
): Promise<JobsPerDayResult> {
|
||||||
|
const [dayRows, kpiRows] = await Promise.all([
|
||||||
|
productId
|
||||||
|
? prisma.$queryRaw<RawDayRow[]>`
|
||||||
|
SELECT DATE(created_at) AS day, LOWER(status::text) AS status, COUNT(*) AS count
|
||||||
|
FROM claude_jobs
|
||||||
|
WHERE user_id = ${userId}
|
||||||
|
AND product_id = ${productId}
|
||||||
|
AND created_at > NOW() - (${days} || ' days')::INTERVAL
|
||||||
|
GROUP BY DATE(created_at), status
|
||||||
|
ORDER BY day ASC
|
||||||
|
`
|
||||||
|
: prisma.$queryRaw<RawDayRow[]>`
|
||||||
|
SELECT DATE(created_at) AS day, LOWER(status::text) AS status, COUNT(*) AS count
|
||||||
|
FROM claude_jobs
|
||||||
|
WHERE user_id = ${userId}
|
||||||
|
AND created_at > NOW() - (${days} || ' days')::INTERVAL
|
||||||
|
GROUP BY DATE(created_at), status
|
||||||
|
ORDER BY day ASC
|
||||||
|
`,
|
||||||
|
productId
|
||||||
|
? prisma.$queryRaw<RawKpiRow[]>`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE DATE(created_at) = CURRENT_DATE) AS today_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'DONE' AND created_at > NOW() - INTERVAL '7 days') AS done_7d,
|
||||||
|
COUNT(*) FILTER (WHERE status IN ('DONE','FAILED','CANCELLED') AND created_at > NOW() - INTERVAL '7 days') AS terminal_7d,
|
||||||
|
AVG(EXTRACT(EPOCH FROM (finished_at - claimed_at))) FILTER (WHERE status = 'DONE' AND created_at > NOW() - INTERVAL '7 days') AS avg_seconds
|
||||||
|
FROM claude_jobs
|
||||||
|
WHERE user_id = ${userId}
|
||||||
|
AND product_id = ${productId}
|
||||||
|
`
|
||||||
|
: prisma.$queryRaw<RawKpiRow[]>`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE DATE(created_at) = CURRENT_DATE) AS today_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'DONE' AND created_at > NOW() - INTERVAL '7 days') AS done_7d,
|
||||||
|
COUNT(*) FILTER (WHERE status IN ('DONE','FAILED','CANCELLED') AND created_at > NOW() - INTERVAL '7 days') AS terminal_7d,
|
||||||
|
AVG(EXTRACT(EPOCH FROM (finished_at - claimed_at))) FILTER (WHERE status = 'DONE' AND created_at > NOW() - INTERVAL '7 days') AS avg_seconds
|
||||||
|
FROM claude_jobs
|
||||||
|
WHERE user_id = ${userId}
|
||||||
|
`,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Build lookup: dayStr → status → count
|
||||||
|
const lookup = new Map<string, Map<string, number>>()
|
||||||
|
for (const row of dayRows) {
|
||||||
|
const d = toDateStr(row.day)
|
||||||
|
if (!lookup.has(d)) lookup.set(d, new Map())
|
||||||
|
lookup.get(d)!.set(row.status, Number(row.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate full date range with zero-fills
|
||||||
|
const now = new Date()
|
||||||
|
const perDay: DayCount[] = []
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(now)
|
||||||
|
d.setUTCDate(d.getUTCDate() - i)
|
||||||
|
const key = toDateStr(d)
|
||||||
|
const statusMap = lookup.get(key) ?? new Map()
|
||||||
|
perDay.push({
|
||||||
|
day: key,
|
||||||
|
queued: statusMap.get('queued') ?? 0,
|
||||||
|
claimed: statusMap.get('claimed') ?? 0,
|
||||||
|
running: statusMap.get('running') ?? 0,
|
||||||
|
done: statusMap.get('done') ?? 0,
|
||||||
|
failed: statusMap.get('failed') ?? 0,
|
||||||
|
cancelled: statusMap.get('cancelled') ?? 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpiRow = kpiRows[0]
|
||||||
|
const done7d = Number(kpiRow?.done_7d ?? 0)
|
||||||
|
const terminal7d = Number(kpiRow?.terminal_7d ?? 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
perDay,
|
||||||
|
kpi: {
|
||||||
|
todayCount: Number(kpiRow?.today_count ?? 0),
|
||||||
|
successRate7d: terminal7d === 0 ? 0 : Math.round((done7d / terminal7d) * 100) / 100,
|
||||||
|
avgDurationSeconds7d: kpiRow?.avg_seconds != null ? Math.round(Number(kpiRow.avg_seconds)) : null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
69
lib/insights/backlog-health.ts
Normal file
69
lib/insights/backlog-health.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
|
|
||||||
|
export interface StuckTask {
|
||||||
|
taskId: string
|
||||||
|
title: string
|
||||||
|
productId: string
|
||||||
|
productName: string
|
||||||
|
sprintGoal: string | null
|
||||||
|
daysStuck: number
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacklogHealth {
|
||||||
|
storiesWithoutAc: number
|
||||||
|
tasksWithoutPlan: number
|
||||||
|
stuckTasks: StuckTask[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVEN_DAYS_MS = 7 * 86_400_000
|
||||||
|
|
||||||
|
export async function getBacklogHealth(userId: string): Promise<BacklogHealth> {
|
||||||
|
const now = new Date()
|
||||||
|
const stuckCutoff = new Date(now.getTime() - SEVEN_DAYS_MS)
|
||||||
|
|
||||||
|
const [storiesWithoutAc, tasksWithoutPlan, rawStuck] = await Promise.all([
|
||||||
|
prisma.story.count({
|
||||||
|
where: {
|
||||||
|
OR: [{ acceptance_criteria: null }, { acceptance_criteria: '' }],
|
||||||
|
product: productAccessFilter(userId),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.task.count({
|
||||||
|
where: {
|
||||||
|
implementation_plan: null,
|
||||||
|
story: { product: productAccessFilter(userId) },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'IN_PROGRESS',
|
||||||
|
updated_at: { lt: stuckCutoff },
|
||||||
|
story: { product: productAccessFilter(userId) },
|
||||||
|
},
|
||||||
|
orderBy: { updated_at: 'asc' },
|
||||||
|
take: 10,
|
||||||
|
include: {
|
||||||
|
story: {
|
||||||
|
include: {
|
||||||
|
product: { select: { id: true, name: true } },
|
||||||
|
sprint: { select: { sprint_goal: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const stuckTasks: StuckTask[] = rawStuck.map(t => ({
|
||||||
|
taskId: t.id,
|
||||||
|
title: t.title,
|
||||||
|
productId: t.story.product.id,
|
||||||
|
productName: t.story.product.name,
|
||||||
|
sprintGoal: t.story.sprint?.sprint_goal ?? null,
|
||||||
|
daysStuck: Math.floor((now.getTime() - t.updated_at.getTime()) / 86_400_000),
|
||||||
|
updatedAt: t.updated_at.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { storiesWithoutAc, tasksWithoutPlan, stuckTasks }
|
||||||
|
}
|
||||||
62
lib/insights/velocity.ts
Normal file
62
lib/insights/velocity.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
|
|
||||||
|
export interface VelocitySprint {
|
||||||
|
sprintId: string
|
||||||
|
sprintGoal: string
|
||||||
|
productId: string
|
||||||
|
productName: string
|
||||||
|
doneCount: number
|
||||||
|
completedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VelocityData {
|
||||||
|
sprints: VelocitySprint[]
|
||||||
|
productNames: { id: string; name: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVelocity(userId: string, sprintsBack = 5): Promise<VelocityData> {
|
||||||
|
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: { id: true, name: true } },
|
||||||
|
tasks: { select: { status: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reverse to chronological order (oldest first, for x-axis)
|
||||||
|
// Type-guard so the narrowed array carries `completed_at: Date` (not Date | null).
|
||||||
|
// A `.filter(s => s.completed_at != null)` alone does NOT narrow the element type.
|
||||||
|
type SprintWithCompletedAt = (typeof sprints)[number] & { completed_at: Date }
|
||||||
|
const chronological = [...sprints]
|
||||||
|
.filter((s): s is SprintWithCompletedAt => s.completed_at != null)
|
||||||
|
.reverse()
|
||||||
|
|
||||||
|
const result: VelocitySprint[] = chronological.map(sprint => ({
|
||||||
|
sprintId: sprint.id,
|
||||||
|
sprintGoal: sprint.sprint_goal,
|
||||||
|
productId: sprint.product.id,
|
||||||
|
productName: sprint.product.name,
|
||||||
|
doneCount: sprint.tasks.filter(t => t.status === 'DONE').length,
|
||||||
|
completedAt: sprint.completed_at.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const productNames: { id: string; name: string }[] = []
|
||||||
|
for (const s of result) {
|
||||||
|
if (!seen.has(s.productId)) {
|
||||||
|
seen.add(s.productId)
|
||||||
|
productNames.push({ id: s.productId, name: s.productName })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sprints: result, productNames }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "sprints" ADD COLUMN "end_date" DATE,
|
||||||
|
ADD COLUMN "start_date" DATE;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX IF NOT EXISTS "claude_jobs_status_finished_at_idx" ON "claude_jobs"("status", "finished_at");
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VerifyRequired" AS ENUM ('ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "tasks" ADD COLUMN "verify_required" "VerifyRequired" NOT NULL DEFAULT 'ALIGNED_OR_PARTIAL';
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- AlterTable: optional repo_url override on Task. Used when an MCP-server
|
||||||
|
-- task is tracked under a different product's PBI (cross-repo support).
|
||||||
|
-- Falls back to product.repo_url when NULL.
|
||||||
|
ALTER TABLE "tasks" ADD COLUMN "repo_url" TEXT;
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
-- Add notify_pbi_change() function and pbis_notify_change trigger so that
|
||||||
|
-- INSERT/UPDATE/DELETE on pbis emits a pg_notify on 'scrum4me_changes'.
|
||||||
|
-- Payload field names match BacklogPbi directly (no client-side rename needed).
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION notify_pbi_change() RETURNS trigger AS $$
|
||||||
|
DECLARE
|
||||||
|
rec record;
|
||||||
|
payload jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
rec := OLD;
|
||||||
|
ELSE
|
||||||
|
rec := NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
payload := jsonb_build_object(
|
||||||
|
'op', CASE TG_OP
|
||||||
|
WHEN 'INSERT' THEN 'I'
|
||||||
|
WHEN 'UPDATE' THEN 'U'
|
||||||
|
WHEN 'DELETE' THEN 'D'
|
||||||
|
END,
|
||||||
|
'entity', 'pbi',
|
||||||
|
'id', rec.id,
|
||||||
|
'product_id', rec.product_id,
|
||||||
|
'title', rec.title,
|
||||||
|
'code', rec.code,
|
||||||
|
'priority', rec.priority,
|
||||||
|
'status', rec.status,
|
||||||
|
'sort_order', rec.sort_order,
|
||||||
|
'created_at', rec.created_at
|
||||||
|
);
|
||||||
|
|
||||||
|
IF TG_OP = 'UPDATE' THEN
|
||||||
|
payload := payload || jsonb_build_object(
|
||||||
|
'changed_fields',
|
||||||
|
COALESCE((
|
||||||
|
SELECT jsonb_agg(n.key)
|
||||||
|
FROM jsonb_each(to_jsonb(NEW)) n
|
||||||
|
JOIN jsonb_each(to_jsonb(OLD)) o USING (key)
|
||||||
|
WHERE n.value IS DISTINCT FROM o.value
|
||||||
|
), '[]'::jsonb)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM pg_notify('scrum4me_changes', payload::text);
|
||||||
|
RETURN rec;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS pbis_notify_change ON pbis;
|
||||||
|
CREATE TRIGGER pbis_notify_change
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON pbis
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION notify_pbi_change();
|
||||||
|
|
@ -45,6 +45,12 @@ enum VerifyResult {
|
||||||
DIVERGENT
|
DIVERGENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum VerifyRequired {
|
||||||
|
ALIGNED
|
||||||
|
ALIGNED_OR_PARTIAL
|
||||||
|
ANY
|
||||||
|
}
|
||||||
|
|
||||||
enum TaskStatus {
|
enum TaskStatus {
|
||||||
TO_DO
|
TO_DO
|
||||||
IN_PROGRESS
|
IN_PROGRESS
|
||||||
|
|
@ -223,6 +229,8 @@ model Sprint {
|
||||||
product_id String
|
product_id String
|
||||||
sprint_goal String
|
sprint_goal String
|
||||||
status SprintStatus @default(ACTIVE)
|
status SprintStatus @default(ACTIVE)
|
||||||
|
start_date DateTime? @db.Date
|
||||||
|
end_date DateTime? @db.Date
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
completed_at DateTime?
|
completed_at DateTime?
|
||||||
stories Story[]
|
stories Story[]
|
||||||
|
|
@ -244,8 +252,14 @@ model Task {
|
||||||
priority Int
|
priority Int
|
||||||
sort_order Float
|
sort_order Float
|
||||||
status TaskStatus @default(TO_DO)
|
status TaskStatus @default(TO_DO)
|
||||||
verify_only Boolean @default(false)
|
verify_only Boolean @default(false)
|
||||||
created_at DateTime @default(now())
|
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
|
||||||
|
// Override product.repo_url for branch/worktree/push purposes. Set when
|
||||||
|
// a task targets a different repo than its parent product (e.g. an
|
||||||
|
// MCP-server task tracked under the main product's PBI). Falls back to
|
||||||
|
// product.repo_url when null.
|
||||||
|
repo_url String?
|
||||||
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
claude_questions ClaudeQuestion[]
|
claude_questions ClaudeQuestion[]
|
||||||
claude_jobs ClaudeJob[]
|
claude_jobs ClaudeJob[]
|
||||||
|
|
@ -283,6 +297,7 @@ model ClaudeJob {
|
||||||
@@index([user_id, status])
|
@@index([user_id, status])
|
||||||
@@index([task_id, status])
|
@@index([task_id, status])
|
||||||
@@index([status, claimed_at])
|
@@index([status, claimed_at])
|
||||||
|
@@index([status, finished_at])
|
||||||
@@map("claude_jobs")
|
@@map("claude_jobs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,8 @@ export const useBacklogStore = create<BacklogStore>((set) => ({
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// I
|
// I — idempotent: skip if already present (optimistic update may have arrived first)
|
||||||
|
if (state.pbis.some((p) => p.id === id)) return {}
|
||||||
return { pbis: [...state.pbis, data as unknown as BacklogPbi] }
|
return { pbis: [...state.pbis, data as unknown as BacklogPbi] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,8 +96,9 @@ export const useBacklogStore = create<BacklogStore>((set) => ({
|
||||||
}
|
}
|
||||||
return { storiesByPbi }
|
return { storiesByPbi }
|
||||||
}
|
}
|
||||||
// I
|
// I — idempotent: skip if already present
|
||||||
const pbiId = data.pbi_id as string
|
const pbiId = data.pbi_id as string
|
||||||
|
if ((state.storiesByPbi[pbiId] ?? []).some((s) => s.id === id)) return {}
|
||||||
return {
|
return {
|
||||||
storiesByPbi: {
|
storiesByPbi: {
|
||||||
...state.storiesByPbi,
|
...state.storiesByPbi,
|
||||||
|
|
@ -127,8 +129,9 @@ export const useBacklogStore = create<BacklogStore>((set) => ({
|
||||||
}
|
}
|
||||||
return { tasksByStory }
|
return { tasksByStory }
|
||||||
}
|
}
|
||||||
// I
|
// I — idempotent: skip if already present
|
||||||
const storyId = data.story_id as string
|
const storyId = data.story_id as string
|
||||||
|
if ((state.tasksByStory[storyId] ?? []).some((t) => t.id === id)) return {}
|
||||||
return {
|
return {
|
||||||
tasksByStory: {
|
tasksByStory: {
|
||||||
...state.tasksByStory,
|
...state.tasksByStory,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ interface SoloStore {
|
||||||
rollback: (taskId: string, prevStatus: TaskStatus) => void
|
rollback: (taskId: string, prevStatus: TaskStatus) => void
|
||||||
updatePlan: (taskId: string, plan: string | null) => void
|
updatePlan: (taskId: string, plan: string | null) => void
|
||||||
updateVerifyOnly: (taskId: string, value: boolean) => void
|
updateVerifyOnly: (taskId: string, value: boolean) => void
|
||||||
|
updateVerifyRequired: (taskId: string, value: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY') => void
|
||||||
|
|
||||||
markPending: (taskId: string) => void
|
markPending: (taskId: string) => void
|
||||||
clearPending: (taskId: string) => void
|
clearPending: (taskId: string) => void
|
||||||
|
|
@ -112,6 +113,9 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
|
||||||
updateVerifyOnly: (taskId, value) =>
|
updateVerifyOnly: (taskId, value) =>
|
||||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_only: value } } })),
|
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_only: value } } })),
|
||||||
|
|
||||||
|
updateVerifyRequired: (taskId, value) =>
|
||||||
|
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_required: value } } })),
|
||||||
|
|
||||||
markPending: (taskId) =>
|
markPending: (taskId) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
if (s.pendingOps.has(taskId)) return s
|
if (s.pendingOps.has(taskId)) return s
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue