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

66
lib/active-sprint.ts Normal file
View file

@ -0,0 +1,66 @@
import { cookies } from 'next/headers'
import type { SprintStatus } from '@prisma/client'
import { prisma } from '@/lib/prisma'
export type ActiveSprint = {
id: string
code: string
status: SprintStatus
}
function cookieName(productId: string): string {
return `active_sprint_${productId}`
}
export async function getActiveSprintIdFromCookie(
productId: string,
): Promise<string | null> {
const store = await cookies()
return store.get(cookieName(productId))?.value ?? null
}
export async function setActiveSprintCookie(
productId: string,
sprintId: string,
): Promise<void> {
const store = await cookies()
store.set(cookieName(productId), sprintId, {
path: '/',
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365,
})
}
export async function clearActiveSprintCookie(productId: string): Promise<void> {
const store = await cookies()
store.delete(cookieName(productId))
}
export async function resolveActiveSprint(
productId: string,
): Promise<ActiveSprint | null> {
const cookieId = await getActiveSprintIdFromCookie(productId)
if (cookieId) {
const sprint = await prisma.sprint.findFirst({
where: { id: cookieId, product_id: productId },
select: { id: true, code: true, status: true },
})
if (sprint) return sprint
}
const open = await prisma.sprint.findFirst({
where: { product_id: productId, status: 'OPEN' },
orderBy: { created_at: 'desc' },
select: { id: true, code: true, status: true },
})
if (open) return open
const closed = await prisma.sprint.findFirst({
where: { product_id: productId, status: 'CLOSED' },
orderBy: { created_at: 'desc' },
select: { id: true, code: true, status: true },
})
return closed ?? null
}

View file

@ -58,7 +58,7 @@ export async function getBurndownData(userId: string): Promise<BurndownSprint[]>
const sprints = await prisma.sprint.findMany({
where: {
status: 'ACTIVE',
status: 'OPEN',
product: productAccessFilter(userId),
},
select: {

View file

@ -20,7 +20,7 @@ export async function getSprintStatusBreakdown(userId: string): Promise<StatusCo
where: {
story: {
sprint: {
status: 'ACTIVE',
status: 'OPEN',
product: productAccessFilter(userId),
},
},

View file

@ -19,7 +19,7 @@ export interface VelocityData {
export async function getVelocity(userId: string, sprintsBack = 5): Promise<VelocityData> {
const sprints = await prisma.sprint.findMany({
where: {
status: 'COMPLETED',
status: 'CLOSED',
product: productAccessFilter(userId),
},
orderBy: { completed_at: 'desc' },

View file

@ -111,7 +111,7 @@ export async function getAlignmentTrend(
): Promise<TrendPoint[]> {
const sprints = await prisma.sprint.findMany({
where: {
status: 'COMPLETED',
status: 'CLOSED',
product: productAccessFilter(userId),
},
orderBy: { completed_at: 'desc' },

View file

@ -15,6 +15,7 @@ const TASK_DB_TO_API = {
REVIEW: 'review',
DONE: 'done',
FAILED: 'failed',
EXCLUDED: 'excluded',
} as const satisfies Record<TaskStatus, string>
const TASK_API_TO_DB: Record<string, TaskStatus> = {
@ -23,6 +24,7 @@ const TASK_API_TO_DB: Record<string, TaskStatus> = {
review: 'REVIEW',
done: 'DONE',
failed: 'FAILED',
excluded: 'EXCLUDED',
}
const STORY_DB_TO_API = {
@ -54,14 +56,16 @@ const PBI_API_TO_DB: Record<string, PbiStatus> = {
}
const SPRINT_DB_TO_API = {
ACTIVE: 'active',
COMPLETED: 'completed',
OPEN: 'open',
CLOSED: 'closed',
ARCHIVED: 'archived',
FAILED: 'failed',
} as const satisfies Record<SprintStatus, string>
const SPRINT_API_TO_DB: Record<string, SprintStatus> = {
active: 'ACTIVE',
completed: 'COMPLETED',
open: 'OPEN',
closed: 'CLOSED',
archived: 'ARCHIVED',
failed: 'FAILED',
}

View file

@ -131,15 +131,15 @@ export async function propagateStatusUpwards(
let nextStatus: SprintStatus
if (anyPbiFailed) nextStatus = 'FAILED'
else if (allPbisDone) nextStatus = 'COMPLETED'
else nextStatus = 'ACTIVE'
else if (allPbisDone) nextStatus = 'CLOSED'
else nextStatus = 'OPEN'
if (nextStatus !== sprint.status) {
await tx.sprint.update({
where: { id: sprint.id },
data: {
status: nextStatus,
...(nextStatus === 'COMPLETED' ? { completed_at: new Date() } : {}),
...(nextStatus === 'CLOSED' ? { completed_at: new Date() } : {}),
},
})
sprintChanged = true
@ -149,7 +149,7 @@ export async function propagateStatusUpwards(
// SprintRun herevalueren — via ClaudeJob.sprint_run_id van deze task
let sprintRunChanged = false
if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'COMPLETED') {
if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'CLOSED') {
const job = await tx.claudeJob.findFirst({
where: { task_id: taskId, sprint_run_id: { not: null } },
orderBy: { created_at: 'desc' },