feat(PBI-63): meerdere sprints per product + EXCLUDED + sprint-switcher (#161)

- Sprint lifecycle: ACTIVE→OPEN, COMPLETED→CLOSED, +ARCHIVED (FAILED behouden)
- TaskStatus: +EXCLUDED (overgeslagen door agent-loop via bestaande TO_DO filter)
- Cookie-gebaseerde actieve sprint per product (lib/active-sprint.ts)
- Route splitsen: /products/[id]/sprint/[sprintId] + /sprint redirect-page
- NavBar: gestapelde product/sprint dropdowns + BUILDING-badge derivatie
- Backlog selectie-modus + nieuwe-sprint-dialog (createSprintWithPbisAction)
- Migratie 20260507210000_sprint_lifecycle: ALTER TYPE RENAME (geen data-rewrite)
- Version bump 1.0.0 → 1.2.0

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-08 00:15:04 +02:00 committed by GitHub
parent d68aa1e5e6
commit 4a9db57e94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 966 additions and 290 deletions

View file

@ -77,7 +77,7 @@ const mockPrisma = prisma as unknown as Mocked
const SPRINT_OK = {
id: 'sprint-1',
status: 'ACTIVE',
status: 'OPEN',
product_id: 'prod-1',
product: { id: 'prod-1', pr_strategy: 'SPRINT' },
}
@ -303,7 +303,7 @@ describe('startSprintRunAction — SPRINT_BATCH', () => {
describe('startSprintRunAction — guards', () => {
it('weigert wanneer Sprint niet ACTIVE is', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'COMPLETED' })
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'CLOSED' })
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_ACTIVE' })
@ -346,7 +346,7 @@ describe('resumeSprintAction', () => {
expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-2' })
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
where: { id: 'sprint-1' },
data: { status: 'ACTIVE', completed_at: null },
data: { status: 'OPEN', completed_at: null },
})
expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({
where: { sprint_id: 'sprint-1', status: 'FAILED' },
@ -359,7 +359,7 @@ describe('resumeSprintAction', () => {
})
it('weigert als sprint niet FAILED is', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'ACTIVE' })
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'OPEN' })
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_FAILED' })

View file

@ -49,7 +49,7 @@ const mockPrisma = prisma as unknown as {
$transaction: ReturnType<typeof vi.fn>
}
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' }
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'OPEN' }
beforeEach(() => {
vi.clearAllMocks()

View file

@ -50,7 +50,7 @@ const mockRequireProductWriter = requireProductWriter as ReturnType<typeof vi.fn
const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn>
const STORY = { id: 'story-1', product_id: 'product-1', assignee_id: null }
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' }
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'OPEN' }
beforeEach(() => {
vi.clearAllMocks()

View file

@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as {
}
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' }
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'OPEN' }
const STORY = {
id: 'story-1',
title: 'Account aanmaken',

View file

@ -197,7 +197,7 @@ describe('GET /api/products/:id/next-story', () => {
expect.objectContaining({
where: expect.objectContaining({
product_id: 'prod-other',
status: 'ACTIVE',
status: 'OPEN',
product: expect.objectContaining({
OR: expect.arrayContaining([{ user_id: 'user-1' }]),
}),

View file

@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as {
}
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' }
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'OPEN' }
function makeTask(n: number) {
return {

View file

@ -144,7 +144,7 @@ describe('propagateStatusUpwards — story-niveau', () => {
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
return []
})
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' })
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }])
const result = await propagateStatusUpwards('task-1', 'DONE')
@ -171,7 +171,7 @@ describe('propagateStatusUpwards — story-niveau', () => {
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
return []
})
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'COMPLETED' })
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'CLOSED' })
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }])
const result = await propagateStatusUpwards('task-1', 'TO_DO')
@ -243,7 +243,7 @@ describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => {
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
return []
})
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' })
// findMany on pbi:
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'FAILED' }])
mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
@ -285,7 +285,7 @@ describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => {
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
return []
})
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' })
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'DONE' }])
mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' })
@ -295,7 +295,7 @@ describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => {
expect(result.sprintRunChanged).toBe(true)
expect(mockPrisma.sprint.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'sprint-1' },
data: expect.objectContaining({ status: 'COMPLETED' }),
data: expect.objectContaining({ status: 'CLOSED' }),
}))
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'run-1' },