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:
parent
d68aa1e5e6
commit
4a9db57e94
43 changed files with 966 additions and 290 deletions
66
lib/active-sprint.ts
Normal file
66
lib/active-sprint.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export async function getSprintStatusBreakdown(userId: string): Promise<StatusCo
|
|||
where: {
|
||||
story: {
|
||||
sprint: {
|
||||
status: 'ACTIVE',
|
||||
status: 'OPEN',
|
||||
product: productAccessFilter(userId),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue