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 = { const SPRINT_OK = {
id: 'sprint-1', id: 'sprint-1',
status: 'ACTIVE', status: 'OPEN',
product_id: 'prod-1', product_id: 'prod-1',
product: { id: 'prod-1', pr_strategy: 'SPRINT' }, product: { id: 'prod-1', pr_strategy: 'SPRINT' },
} }
@ -303,7 +303,7 @@ describe('startSprintRunAction — SPRINT_BATCH', () => {
describe('startSprintRunAction — guards', () => { describe('startSprintRunAction — guards', () => {
it('weigert wanneer Sprint niet ACTIVE is', async () => { 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' }) const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_ACTIVE' }) 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(result).toMatchObject({ ok: true, sprint_run_id: 'run-2' })
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
where: { id: 'sprint-1' }, where: { id: 'sprint-1' },
data: { status: 'ACTIVE', completed_at: null }, data: { status: 'OPEN', completed_at: null },
}) })
expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({ expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({
where: { sprint_id: 'sprint-1', status: 'FAILED' }, where: { sprint_id: 'sprint-1', status: 'FAILED' },
@ -359,7 +359,7 @@ describe('resumeSprintAction', () => {
}) })
it('weigert als sprint niet FAILED is', async () => { 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' }) const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_FAILED' }) 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> $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(() => { beforeEach(() => {
vi.clearAllMocks() 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 mockGetIronSession = getIronSession as ReturnType<typeof vi.fn>
const STORY = { id: 'story-1', product_id: 'product-1', assignee_id: null } 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(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()

View file

@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as {
} }
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn> 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 = { const STORY = {
id: 'story-1', id: 'story-1',
title: 'Account aanmaken', title: 'Account aanmaken',

View file

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

View file

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

42
actions/active-sprint.ts Normal file
View file

@ -0,0 +1,42 @@
'use server'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { productAccessFilter } from '@/lib/product-access'
import { setActiveSprintCookie } from '@/lib/active-sprint'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
const setSchema = z.object({
productId: z.string().min(1),
sprintId: z.string().min(1),
})
export async function setActiveSprintAction(productId: string, sprintId: string) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = setSchema.safeParse({ productId, sprintId })
if (!parsed.success) return { error: 'Ongeldig product- of sprint-id' }
const sprint = await prisma.sprint.findFirst({
where: {
id: parsed.data.sprintId,
product_id: parsed.data.productId,
product: productAccessFilter(session.userId),
},
select: { id: true },
})
if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' }
await setActiveSprintCookie(parsed.data.productId, parsed.data.sprintId)
revalidatePath('/', 'layout')
return { success: true, sprintId: parsed.data.sprintId }
}

View file

@ -63,7 +63,7 @@ async function startSprintRunCore(
include: { product: true }, include: { product: true },
}) })
if (!sprint) return { ok: false, error: 'SPRINT_NOT_FOUND', code: 404 } if (!sprint) return { ok: false, error: 'SPRINT_NOT_FOUND', code: 404 }
if (sprint.status !== 'ACTIVE') if (sprint.status !== 'OPEN')
return { ok: false, error: 'SPRINT_NOT_ACTIVE', code: 400 } return { ok: false, error: 'SPRINT_NOT_ACTIVE', code: 400 }
const activeRun = await tx.sprintRun.findFirst({ const activeRun = await tx.sprintRun.findFirst({
@ -80,6 +80,9 @@ async function startSprintRunCore(
include: { include: {
pbi: true, pbi: true,
tasks: { tasks: {
// EXCLUDED-taken worden hier impliciet uitgesloten: de filter is strikt
// TO_DO, dus EXCLUDED/IN_PROGRESS/REVIEW/DONE/FAILED tasks komen niet
// terecht in pre-flight blockers, jobs of SprintTaskExecution-rijen.
where: { status: 'TO_DO' }, where: { status: 'TO_DO' },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
}, },
@ -246,7 +249,7 @@ export async function resumeSprintAction(input: unknown): Promise<StartResult> {
// Sprint terug naar ACTIVE // Sprint terug naar ACTIVE
await tx.sprint.update({ await tx.sprint.update({
where: { id: sprint_id }, where: { id: sprint_id },
data: { status: 'ACTIVE', completed_at: null }, data: { status: 'OPEN', completed_at: null },
}) })
// FAILED stories binnen sprint terug naar IN_SPRINT (DONE blijft) // FAILED stories binnen sprint terug naar IN_SPRINT (DONE blijft)

View file

@ -14,6 +14,8 @@ import {
import { enforceUserRateLimit } from '@/lib/rate-limit' import { enforceUserRateLimit } from '@/lib/rate-limit'
import { propagateStatusUpwards } from '@/lib/tasks-status-update' import { propagateStatusUpwards } from '@/lib/tasks-status-update'
import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server' import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server'
import { setActiveSprintCookie } from '@/lib/active-sprint'
import { z } from 'zod'
async function getSession() { async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions) return getIronSession<SessionData>(await cookies(), sessionOptions)
@ -51,7 +53,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
if (!product) return { error: 'Product niet gevonden', code: 403 } if (!product) return { error: 'Product niet gevonden', code: 403 }
const existing = await prisma.sprint.findFirst({ const existing = await prisma.sprint.findFirst({
where: { product_id: parsed.data.productId, status: 'ACTIVE' }, where: { product_id: parsed.data.productId, status: 'OPEN' },
}) })
if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 } if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 }
@ -63,7 +65,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
product_id: parsed.data.productId, product_id: parsed.data.productId,
code, code,
sprint_goal: parsed.data.sprint_goal, sprint_goal: parsed.data.sprint_goal,
status: 'ACTIVE', status: 'OPEN',
start_date: parsed.data.start_date, start_date: parsed.data.start_date,
end_date: parsed.data.end_date, end_date: parsed.data.end_date,
}, },
@ -271,7 +273,7 @@ export async function completeSprintAction(
), ),
prisma.sprint.update({ prisma.sprint.update({
where: { id: sprintId }, where: { id: sprintId },
data: { status: 'COMPLETED', completed_at: new Date() }, data: { status: 'CLOSED', completed_at: new Date() },
}), }),
]) ])
@ -307,3 +309,76 @@ export async function setAllSprintTasksDoneAction(
revalidatePath(`/products/${sprint.product_id}/sprint`) revalidatePath(`/products/${sprint.product_id}/sprint`)
return { ok: true } return { ok: true }
} }
const createSprintWithPbisSchema = z.object({
productId: z.string().min(1),
sprint_goal: z.string().min(1).max(2000),
start_date: z.string().nullable().optional(),
end_date: z.string().nullable().optional(),
pbi_ids: z.array(z.string().min(1)).min(1),
})
function parseDate(value: string | null | undefined): Date | null {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
export async function createSprintWithPbisAction(input: {
productId: string
sprint_goal: string
start_date?: string | null
end_date?: string | null
pbi_ids: string[]
}): Promise<{ success: true; sprintId: string } | { error: string; code: number }> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const limited = enforceUserRateLimit('create-sprint', session.userId)
if (limited) return { error: limited.error, code: limited.code }
const parsed = createSprintWithPbisSchema.safeParse(input)
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
if (!product) return { error: 'Product niet gevonden', code: 403 }
const pbis = await prisma.pbi.findMany({
where: { id: { in: parsed.data.pbi_ids }, product_id: parsed.data.productId },
select: { id: true },
})
if (pbis.length !== parsed.data.pbi_ids.length) {
return { error: "Een of meer PBI's behoren niet tot dit product", code: 422 }
}
const sprint = await createWithCodeRetry(
() => generateNextSprintCode(parsed.data.productId),
(code) =>
prisma.$transaction(async (tx) => {
const created = await tx.sprint.create({
data: {
product_id: parsed.data.productId,
code,
sprint_goal: parsed.data.sprint_goal,
status: 'OPEN',
start_date: parseDate(parsed.data.start_date),
end_date: parseDate(parsed.data.end_date),
},
})
await tx.story.updateMany({
where: { pbi_id: { in: parsed.data.pbi_ids } },
data: { sprint_id: created.id, status: 'IN_SPRINT' },
})
await tx.task.updateMany({
where: { story: { pbi_id: { in: parsed.data.pbi_ids } } },
data: { sprint_id: created.id },
})
return created
}),
)
await setActiveSprintCookie(parsed.data.productId, sprint.id)
revalidatePath(`/products/${parsed.data.productId}`, 'layout')
return { success: true, sprintId: sprint.id }
}

View file

@ -349,7 +349,7 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string)
const userId = session.userId const userId = session.userId
const sprint = await prisma.sprint.findFirst({ const sprint = await prisma.sprint.findFirst({
where: { product_id: productId, status: 'ACTIVE' }, where: { product_id: productId, status: 'OPEN' },
}) })
if (!sprint) return { error: 'Geen actieve sprint gevonden' } if (!sprint) return { error: 'Geen actieve sprint gevonden' }

View file

@ -57,7 +57,7 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
getBurndownData(userId), getBurndownData(userId),
getSprintStatusBreakdown(userId), getSprintStatusBreakdown(userId),
prisma.sprint.findMany({ prisma.sprint.findMany({
where: { status: 'ACTIVE', product: productAccessFilter(userId) }, where: { status: 'OPEN', product: productAccessFilter(userId) },
select: { select: {
id: true, id: true,
code: true, code: true,

View file

@ -2,6 +2,8 @@ import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/auth-guard' import { requireSession } from '@/lib/auth-guard'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access' import { productAccessFilter } from '@/lib/product-access'
import { resolveActiveSprint } from '@/lib/active-sprint'
import { sprintStatusToApi, type SprintStatusApi } from '@/lib/task-status'
import { NavBar } from '@/components/shared/nav-bar' import { NavBar } from '@/components/shared/nav-bar'
import { MinWidthBanner } from '@/components/shared/min-width-banner' import { MinWidthBanner } from '@/components/shared/min-width-banner'
import { StatusBar } from '@/components/shared/status-bar' import { StatusBar } from '@/components/shared/status-bar'
@ -36,7 +38,9 @@ export default async function AppLayout({ children }: { children: React.ReactNod
// Resolve active product — clear stale reference if archived or inaccessible // Resolve active product — clear stale reference if archived or inaccessible
let activeProduct: { id: string; name: string } | null = null let activeProduct: { id: string; name: string } | null = null
let hasActiveSprint = false let sprints: { id: string; code: string; status: SprintStatusApi }[] = []
let activeSprint: { id: string; code: string; status: SprintStatusApi } | null = null
let buildingSprintIds: string[] = []
if (user.active_product_id) { if (user.active_product_id) {
const product = await prisma.product.findFirst({ const product = await prisma.product.findFirst({
where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) }, where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) },
@ -44,11 +48,30 @@ export default async function AppLayout({ children }: { children: React.ReactNod
}) })
if (product) { if (product) {
activeProduct = product activeProduct = product
const sprint = await prisma.sprint.findFirst({ const allSprints = await prisma.sprint.findMany({
where: { product_id: product.id, status: 'ACTIVE' }, where: { product_id: product.id },
select: { id: true }, orderBy: { created_at: 'desc' },
select: { id: true, code: true, status: true },
}) })
hasActiveSprint = !!sprint sprints = allSprints.map(s => ({
id: s.id,
code: s.code,
status: sprintStatusToApi(s.status),
}))
const resolved = await resolveActiveSprint(product.id)
activeSprint = resolved
? { id: resolved.id, code: resolved.code, status: sprintStatusToApi(resolved.status) }
: null
if (allSprints.length > 0) {
const runs = await prisma.sprintRun.findMany({
where: {
sprint_id: { in: allSprints.map(s => s.id) },
status: { in: ['QUEUED', 'RUNNING'] },
},
select: { sprint_id: true },
})
buildingSprintIds = Array.from(new Set(runs.map(r => r.sprint_id)))
}
} else { } else {
await prisma.user.update({ await prisma.user.update({
where: { id: session.userId }, where: { id: session.userId },
@ -71,7 +94,9 @@ export default async function AppLayout({ children }: { children: React.ReactNod
email={user.email} email={user.email}
activeProduct={activeProduct} activeProduct={activeProduct}
products={accessibleProducts} products={accessibleProducts}
hasActiveSprint={hasActiveSprint} sprints={sprints}
activeSprint={activeSprint}
buildingSprintIds={buildingSprintIds}
minQuotaPct={user.min_quota_pct} minQuotaPct={user.min_quota_pct}
/> />
<MinWidthBanner /> <MinWidthBanner />

View file

@ -34,7 +34,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
if (!product) notFound() if (!product) notFound()
const [activeSprint, user] = await Promise.all([ const [activeSprint, user] = await Promise.all([
prisma.sprint.findFirst({ where: { product_id: id, status: 'ACTIVE' } }), prisma.sprint.findFirst({ where: { product_id: id, status: 'OPEN' } }),
prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }), prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }),
]) ])

View file

@ -20,7 +20,7 @@ export default async function SoloProductPage({ params }: Props) {
if (!product) notFound() if (!product) notFound()
const sprint = await prisma.sprint.findFirst({ const sprint = await prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE' }, where: { product_id: id, status: 'OPEN' },
}) })
if (!sprint) { if (!sprint) {

View file

@ -0,0 +1,223 @@
import { Suspense } from 'react'
import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { pbiStatusToApi } from '@/lib/task-status'
import { setActiveSprintCookie } from '@/lib/active-sprint'
import { SprintBoardClient } from '@/components/sprint/sprint-board-client'
import { SprintHeader } from '@/components/sprint/sprint-header'
import { SprintRunControls } from '@/components/sprint/sprint-run-controls'
import { parsePauseContext } from '@/lib/pause-context'
import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog'
import type { Task } from '@/components/sprint/task-list'
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
import Link from 'next/link'
interface Props {
params: Promise<{ id: string; sprintId: string }>
searchParams: Promise<{
newTask?: string
storyId?: string
editTask?: string
}>
}
export default async function SprintBoardPage({ params, searchParams }: Props) {
const { id, sprintId } = await params
const { newTask, storyId: storyIdParam, editTask } = await searchParams
const session = await getSession()
if (!session.userId) redirect('/login')
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const sprint = await prisma.sprint.findFirst({
where: { id: sprintId, product_id: id },
select: {
id: true,
code: true,
sprint_goal: true,
status: true,
start_date: true,
end_date: true,
},
})
if (!sprint) notFound()
await setActiveSprintCookie(id, sprint.id)
const activeSprintRun = await prisma.sprintRun.findFirst({
where: {
sprint_id: sprint.id,
status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] },
},
select: { id: true, status: true, pause_context: true },
orderBy: { created_at: 'desc' },
})
const pauseContext =
activeSprintRun?.status === 'PAUSED'
? parsePauseContext(activeSprintRun.pause_context)
: null
// Sprint stories with full task data and assignee
const [sprintStories, productMembers] = await Promise.all([
prisma.story.findMany({
where: { sprint_id: sprint.id },
orderBy: { sort_order: 'asc' },
include: {
tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] },
assignee: { select: { id: true, username: true } },
},
}),
prisma.productMember.findMany({
where: { product_id: id },
include: { user: { select: { id: true, username: true } } },
}),
])
// All members who can be assigned: owner + product members
const members: ProductMember[] = [
{ userId: product.user_id, username: (await prisma.user.findUnique({ where: { id: product.user_id }, select: { username: true } }))?.username ?? 'Eigenaar' },
...productMembers.map(m => ({ userId: m.user_id, username: m.user.username })),
]
const sprintStoryItems: SprintStory[] = sprintStories.map(s => ({
id: s.id,
code: s.code,
title: s.title,
description: s.description,
acceptance_criteria: s.acceptance_criteria,
pbi_id: s.pbi_id,
created_at: s.created_at,
priority: s.priority,
status: s.status,
taskCount: s.tasks.length,
doneCount: s.tasks.filter(t => t.status === 'DONE').length,
assignee_id: s.assignee_id,
assignee_username: s.assignee?.username ?? null,
}))
const tasksByStory: Record<string, Task[]> = {}
for (const story of sprintStories) {
tasksByStory[story.id] = story.tasks.map(t => ({
id: t.id,
code: t.code,
title: t.title,
description: t.description,
priority: t.priority,
status: t.status,
story_id: t.story_id,
sprint_id: t.sprint_id,
}))
}
// All PBIs with their stories for the left (product backlog) panel
const pbis = await prisma.pbi.findMany({
where: { product_id: id },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
include: {
stories: {
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
},
},
})
const pbisWithStories: PbiWithStories[] = pbis
.filter(pbi => pbi.stories.length > 0)
.map(pbi => ({
id: pbi.id,
code: pbi.code,
title: pbi.title,
priority: pbi.priority,
status: pbiStatusToApi(pbi.status),
description: pbi.description,
stories: pbi.stories.map(s => ({
id: s.id,
code: s.code,
title: s.title,
description: s.description,
acceptance_criteria: s.acceptance_criteria,
pbi_id: s.pbi_id,
created_at: s.created_at,
priority: s.priority,
status: s.status,
taskCount: 0,
doneCount: 0,
assignee_id: null,
assignee_username: null,
})),
}))
const sprintStoryIdList = sprintStories.map(s => s.id)
const isDemo = session.isDemo ?? false
const closePath = `/products/${id}/sprint/${sprint.id}`
return (
<div className="flex flex-col h-full">
<SprintHeader
productId={id}
productName={product.name}
sprint={sprint}
isDemo={isDemo}
sprintStories={sprintStoryItems}
/>
<div className="border-b border-border bg-surface-container-low px-4 py-2 shrink-0">
<SprintRunControls
sprintId={sprint.id}
productId={id}
sprintStatus={sprint.status}
activeSprintRunId={activeSprintRun?.id ?? null}
activeSprintRunStatus={activeSprintRun?.status ?? null}
pauseContext={pauseContext}
isDemo={isDemo}
/>
</div>
<div className="flex-1 overflow-hidden">
<SprintBoardClient
productId={id}
sprintId={sprint.id}
stories={sprintStoryItems}
pbisWithStories={pbisWithStories}
sprintStoryIdList={sprintStoryIdList}
tasksByStory={tasksByStory}
isDemo={isDemo}
currentUserId={session.userId}
members={members}
/>
</div>
<div className="border-t border-border px-4 py-2 bg-surface-container-low shrink-0">
<Link href={`/products/${id}`} className="text-sm text-muted-foreground hover:text-foreground">
Product Backlog
</Link>
</div>
{newTask && (
<TaskDialog
storyId={storyIdParam}
productId={id}
closePath={closePath}
isDemo={isDemo}
/>
)}
{editTask && !newTask && (
<Suspense fallback={<TaskDialogSkeleton />}>
<EditTaskLoader
taskId={editTask}
userId={session.userId}
productId={id}
closePath={closePath}
isDemo={isDemo}
/>
</Suspense>
)}
</div>
)
}

View file

@ -1,10 +1,10 @@
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
interface Props { interface Props {
params: Promise<{ id: string }> params: Promise<{ id: string; sprintId: string }>
} }
export default async function SprintPlanningRedirect({ params }: Props) { export default async function SprintPlanningRedirect({ params }: Props) {
const { id } = await params const { id, sprintId } = await params
redirect(`/products/${id}/sprint`) redirect(`/products/${id}/sprint/${sprintId}`)
} }

View file

@ -1,220 +1,15 @@
import { Suspense } from 'react' import { redirect } from 'next/navigation'
import { notFound, redirect } from 'next/navigation' import { resolveActiveSprint } from '@/lib/active-sprint'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { pbiStatusToApi } from '@/lib/task-status'
import { SprintBoardClient } from '@/components/sprint/sprint-board-client'
import { SprintHeader } from '@/components/sprint/sprint-header'
import { SprintRunControls } from '@/components/sprint/sprint-run-controls'
import { parsePauseContext } from '@/lib/pause-context'
import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog'
import type { Task } from '@/components/sprint/task-list'
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
import Link from 'next/link'
interface Props { interface Props {
params: Promise<{ id: string }> params: Promise<{ id: string }>
searchParams: Promise<{
newTask?: string
storyId?: string
editTask?: string
}>
} }
export default async function SprintBoardPage({ params, searchParams }: Props) { export default async function SprintRedirectPage({ params }: Props) {
const { id } = await params const { id } = await params
const { newTask, storyId: storyIdParam, editTask } = await searchParams const active = await resolveActiveSprint(id)
if (!active) {
const session = await getSession() redirect(`/products/${id}?alert=no_sprint`)
if (!session.userId) redirect('/login')
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const sprint = await prisma.sprint.findFirst({
where: { product_id: id, status: { in: ['ACTIVE', 'FAILED'] } },
select: {
id: true,
code: true,
sprint_goal: true,
status: true,
start_date: true,
end_date: true,
},
})
if (!sprint) redirect(`/products/${id}`)
const activeSprintRun = await prisma.sprintRun.findFirst({
where: {
sprint_id: sprint.id,
status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] },
},
select: { id: true, status: true, pause_context: true },
orderBy: { created_at: 'desc' },
})
const pauseContext =
activeSprintRun?.status === 'PAUSED'
? parsePauseContext(activeSprintRun.pause_context)
: null
// Sprint stories with full task data and assignee
const [sprintStories, productMembers] = await Promise.all([
prisma.story.findMany({
where: { sprint_id: sprint.id },
orderBy: { sort_order: 'asc' },
include: {
tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] },
assignee: { select: { id: true, username: true } },
},
}),
prisma.productMember.findMany({
where: { product_id: id },
include: { user: { select: { id: true, username: true } } },
}),
])
// All members who can be assigned: owner + product members
const members: ProductMember[] = [
{ userId: product.user_id, username: (await prisma.user.findUnique({ where: { id: product.user_id }, select: { username: true } }))?.username ?? 'Eigenaar' },
...productMembers.map(m => ({ userId: m.user_id, username: m.user.username })),
]
const sprintStoryItems: SprintStory[] = sprintStories.map(s => ({
id: s.id,
code: s.code,
title: s.title,
description: s.description,
acceptance_criteria: s.acceptance_criteria,
pbi_id: s.pbi_id,
created_at: s.created_at,
priority: s.priority,
status: s.status,
taskCount: s.tasks.length,
doneCount: s.tasks.filter(t => t.status === 'DONE').length,
assignee_id: s.assignee_id,
assignee_username: s.assignee?.username ?? null,
}))
const tasksByStory: Record<string, Task[]> = {}
for (const story of sprintStories) {
tasksByStory[story.id] = story.tasks.map(t => ({
id: t.id,
code: t.code,
title: t.title,
description: t.description,
priority: t.priority,
status: t.status,
story_id: t.story_id,
sprint_id: t.sprint_id,
}))
} }
redirect(`/products/${id}/sprint/${active.id}`)
// All PBIs with their stories for the left (product backlog) panel
const pbis = await prisma.pbi.findMany({
where: { product_id: id },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
include: {
stories: {
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
},
},
})
const pbisWithStories: PbiWithStories[] = pbis
.filter(pbi => pbi.stories.length > 0)
.map(pbi => ({
id: pbi.id,
code: pbi.code,
title: pbi.title,
priority: pbi.priority,
status: pbiStatusToApi(pbi.status),
description: pbi.description,
stories: pbi.stories.map(s => ({
id: s.id,
code: s.code,
title: s.title,
description: s.description,
acceptance_criteria: s.acceptance_criteria,
pbi_id: s.pbi_id,
created_at: s.created_at,
priority: s.priority,
status: s.status,
taskCount: 0,
doneCount: 0,
assignee_id: null,
assignee_username: null,
})),
}))
const sprintStoryIdList = sprintStories.map(s => s.id)
const isDemo = session.isDemo ?? false
const closePath = `/products/${id}/sprint`
return (
<div className="flex flex-col h-full">
<SprintHeader
productId={id}
productName={product.name}
sprint={sprint}
isDemo={isDemo}
sprintStories={sprintStoryItems}
/>
<div className="border-b border-border bg-surface-container-low px-4 py-2 shrink-0">
<SprintRunControls
sprintId={sprint.id}
productId={id}
sprintStatus={sprint.status}
activeSprintRunId={activeSprintRun?.id ?? null}
activeSprintRunStatus={activeSprintRun?.status ?? null}
pauseContext={pauseContext}
isDemo={isDemo}
/>
</div>
<div className="flex-1 overflow-hidden">
<SprintBoardClient
productId={id}
sprintId={sprint.id}
stories={sprintStoryItems}
pbisWithStories={pbisWithStories}
sprintStoryIdList={sprintStoryIdList}
tasksByStory={tasksByStory}
isDemo={isDemo}
currentUserId={session.userId}
members={members}
/>
</div>
<div className="border-t border-border px-4 py-2 bg-surface-container-low shrink-0">
<Link href={`/products/${id}`} className="text-sm text-muted-foreground hover:text-foreground">
Product Backlog
</Link>
</div>
{newTask && (
<TaskDialog
storyId={storyIdParam}
productId={id}
closePath={closePath}
isDemo={isDemo}
/>
)}
{editTask && !newTask && (
<Suspense fallback={<TaskDialogSkeleton />}>
<EditTaskLoader
taskId={editTask}
userId={session.userId}
productId={id}
closePath={closePath}
isDemo={isDemo}
/>
</Suspense>
)}
</div>
)
} }

View file

@ -25,7 +25,7 @@ export default async function MobileSoloProductPage({ params }: Props) {
if (!product) notFound() if (!product) notFound()
const sprint = await prisma.sprint.findFirst({ const sprint = await prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE' }, where: { product_id: id, status: 'OPEN' },
}) })
if (!sprint) { if (!sprint) {

View file

@ -15,10 +15,11 @@ const STATUS_CONFIG: Record<TaskStatus, { label: string; dot: string }> = {
REVIEW: { label: 'Review', dot: 'bg-status-review' }, REVIEW: { label: 'Review', dot: 'bg-status-review' },
DONE: { label: 'Klaar', dot: 'bg-status-done' }, DONE: { label: 'Klaar', dot: 'bg-status-done' },
FAILED: { label: 'Gefaald', dot: 'bg-status-failed' }, FAILED: { label: 'Gefaald', dot: 'bg-status-failed' },
EXCLUDED: { label: 'Uitgesloten', dot: 'bg-muted-foreground/40' },
} }
// FAILED ontbreekt bewust: alleen via sprint-cascade gezet, niet handmatig kiesbaar. // FAILED ontbreekt bewust: alleen via sprint-cascade gezet, niet handmatig kiesbaar.
const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE'] const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE', 'EXCLUDED']
function StatusIndicator({ status }: { status: TaskStatus }) { function StatusIndicator({ status }: { status: TaskStatus }) {
return ( return (

View file

@ -31,7 +31,7 @@ export async function GET(
const [activeSprint, openIdeas] = await Promise.all([ const [activeSprint, openIdeas] = await Promise.all([
prisma.sprint.findFirst({ prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE' }, where: { product_id: id, status: 'OPEN' },
select: { id: true, sprint_goal: true, status: true }, select: { id: true, sprint_goal: true, status: true },
}), }),
prisma.idea.findMany({ prisma.idea.findMany({

View file

@ -15,7 +15,7 @@ export async function GET(
const { id } = await params const { id } = await params
const sprint = await prisma.sprint.findFirst({ const sprint = await prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE', product: productAccessFilter(auth.userId) }, where: { product_id: id, status: 'OPEN', product: productAccessFilter(auth.userId) },
}) })
if (!sprint) { if (!sprint) {
return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 }) return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 })

View file

@ -261,7 +261,7 @@ export async function GET(request: NextRequest) {
async function prisma_sprint_findActive(productId: string): Promise<{ id: string } | null> { async function prisma_sprint_findActive(productId: string): Promise<{ id: string } | null> {
const { prisma } = await import('@/lib/prisma') const { prisma } = await import('@/lib/prisma')
return prisma.sprint.findFirst({ return prisma.sprint.findFirst({
where: { product_id: productId, status: 'ACTIVE' }, where: { product_id: productId, status: 'OPEN' },
select: { id: true }, select: { id: true },
orderBy: { created_at: 'desc' }, orderBy: { created_at: 'desc' },
}) })

View file

@ -21,6 +21,7 @@ import {
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner' import { toast } from 'sonner'
import { CheckSquare, Square } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@ -33,6 +34,7 @@ import { cn } from '@/lib/utils'
import { PbiDialog, type PbiDialogState } from './pbi-dialog' import { PbiDialog, type PbiDialogState } from './pbi-dialog'
import { BacklogCard } from './backlog-card' import { BacklogCard } from './backlog-card'
import { EmptyPanel } from './empty-panel' import { EmptyPanel } from './empty-panel'
import { NewSprintDialog } from '@/components/sprint/new-sprint-dialog'
import { DemoTooltip } from '@/components/shared/demo-tooltip' import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { PRIORITY_COLORS } from '@/components/shared/priority-select' import { PRIORITY_COLORS } from '@/components/shared/priority-select'
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select' import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
@ -124,19 +126,26 @@ function SortablePbiRow({
pbi, pbi,
isSelected, isSelected,
isDemo, isDemo,
selectionMode,
isChecked,
onSelect, onSelect,
onToggleCheck,
onEdit, onEdit,
onDelete, onDelete,
}: { }: {
pbi: Pbi pbi: Pbi
isSelected: boolean isSelected: boolean
isDemo: boolean isDemo: boolean
selectionMode: boolean
isChecked: boolean
onSelect: () => void onSelect: () => void
onToggleCheck: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: pbi.id, id: pbi.id,
disabled: selectionMode,
}) })
const style = { const style = {
@ -144,6 +153,37 @@ function SortablePbiRow({
transition, transition,
} }
if (selectionMode) {
return (
<BacklogCard
ref={setNodeRef}
style={style}
title={pbi.title}
code={pbi.code}
priority={pbi.priority}
isSelected={isChecked}
role="button"
tabIndex={0}
aria-pressed={isChecked}
onClick={onToggleCheck}
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleCheck() } }}
badge={
<Badge className={cn('text-xs font-normal', PBI_STATUS_COLORS[pbi.status])}>
{PBI_STATUS_LABELS[pbi.status]}
</Badge>
}
actions={
<div
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground"
aria-hidden="true"
>
{isChecked ? <CheckSquare size={18} className="text-primary" /> : <Square size={18} />}
</div>
}
/>
)
}
return ( return (
<BacklogCard <BacklogCard
ref={setNodeRef} ref={setNodeRef}
@ -207,8 +247,25 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
const [prefsLoaded, setPrefsLoaded] = useState(false) const [prefsLoaded, setPrefsLoaded] = useState(false)
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null) const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null) const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [selectionMode, setSelectionMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [newSprintOpen, setNewSprintOpen] = useState(false)
const [, startTransition] = useTransition() const [, startTransition] = useTransition()
function exitSelection() {
setSelectionMode(false)
setSelectedIds(new Set())
}
function toggleCheck(id: string) {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
// Load persisted preferences once after mount (client-only). // Load persisted preferences once after mount (client-only).
// setState calls here are intentional: hydrating from localStorage on first paint. // setState calls here are intentional: hydrating from localStorage on first paint.
useEffect(() => { useEffect(() => {
@ -452,8 +509,23 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
<DemoTooltip show={isDemo}> <DemoTooltip show={isDemo}>
<Button <Button
size="sm" size="sm"
variant={selectionMode ? 'default' : 'outline'}
className="h-7 text-xs" className="h-7 text-xs"
disabled={isDemo} disabled={isDemo}
onClick={() => {
if (isDemo) return
if (selectionMode) exitSelection()
else setSelectionMode(true)
}}
>
{selectionMode ? 'Selecteren stoppen' : "Selecteer PBI's"}
</Button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<Button
size="sm"
className="h-7 text-xs"
disabled={isDemo || selectionMode}
onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })} onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
> >
+ PBI + PBI
@ -486,7 +558,10 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
pbi={pbi} pbi={pbi}
isSelected={selectedPbiId === pbi.id} isSelected={selectedPbiId === pbi.id}
isDemo={isDemo} isDemo={isDemo}
selectionMode={selectionMode}
isChecked={selectedIds.has(pbi.id)}
onSelect={() => selectPbi(pbi.id)} onSelect={() => selectPbi(pbi.id)}
onToggleCheck={() => toggleCheck(pbi.id)}
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })} onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
onDelete={() => handleDelete(pbi.id)} onDelete={() => handleDelete(pbi.id)}
/> />
@ -507,11 +582,53 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
)} )}
</div> </div>
{selectionMode && (
<div className="border-t border-border bg-surface-container px-4 py-2 flex items-center justify-between gap-2 shrink-0">
<span className="text-sm text-foreground">
{selectedIds.size} geselecteerd
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={exitSelection}
>
Annuleer
</Button>
<Button
size="sm"
className="h-7 text-xs"
disabled={selectedIds.size === 0}
onClick={() => setNewSprintOpen(true)}
>
Nieuwe sprint
</Button>
</div>
</div>
)}
<PbiDialog <PbiDialog
state={dialogState} state={dialogState}
onClose={() => setDialogState(null)} onClose={() => setDialogState(null)}
isDemo={isDemo} isDemo={isDemo}
/> />
<NewSprintDialog
open={newSprintOpen}
productId={productId}
pbiIds={Array.from(selectedIds)}
onOpenChange={(open) => {
setNewSprintOpen(open)
if (!open) {
// Sluit selectie bij geslaagde aanmaak; bij annuleren laat de selectie staan
}
}}
onCreated={() => {
setNewSprintOpen(false)
exitSelection()
}}
/>
</div> </div>
) )
} }

View file

@ -20,6 +20,10 @@ import { NotificationsBell } from '@/components/shared/notifications-bell'
import { SoloNavStatusIndicators } from '@/components/solo/nav-status-indicators' import { SoloNavStatusIndicators } from '@/components/solo/nav-status-indicators'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { setActiveProductAction } from '@/actions/active-product' import { setActiveProductAction } from '@/actions/active-product'
import { setActiveSprintAction } from '@/actions/active-sprint'
import type { SprintStatusApi } from '@/lib/task-status'
type SprintItem = { id: string; code: string; status: SprintStatusApi }
interface NavBarProps { interface NavBarProps {
isDemo: boolean isDemo: boolean
@ -29,10 +33,26 @@ interface NavBarProps {
email: string | null email: string | null
activeProduct: { id: string; name: string } | null activeProduct: { id: string; name: string } | null
products: { id: string; name: string }[] products: { id: string; name: string }[]
hasActiveSprint: boolean sprints: SprintItem[]
activeSprint: SprintItem | null
buildingSprintIds: string[]
minQuotaPct: number minQuotaPct: number
} }
const SPRINT_STATUS_LABEL: Record<SprintStatusApi, string> = {
open: 'Open',
closed: 'Gesloten',
archived: 'Gearchiveerd',
failed: 'Mislukt',
}
const SPRINT_STATUS_BADGE: Record<SprintStatusApi, string> = {
open: 'bg-status-in-progress text-foreground',
closed: 'bg-status-done text-foreground',
archived: 'bg-surface-container text-muted-foreground',
failed: 'bg-status-failed text-foreground',
}
export function NavBar({ export function NavBar({
isDemo, isDemo,
roles, roles,
@ -41,12 +61,16 @@ export function NavBar({
email, email,
activeProduct, activeProduct,
products, products,
hasActiveSprint, sprints,
activeSprint,
buildingSprintIds,
minQuotaPct, minQuotaPct,
}: NavBarProps) { }: NavBarProps) {
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const buildingSet = new Set(buildingSprintIds)
const hasActiveSprint = !!activeSprint
function handleSwitchProduct(productId: string) { function handleSwitchProduct(productId: string) {
startTransition(async () => { startTransition(async () => {
@ -61,6 +85,23 @@ export function NavBar({
}) })
} }
function handleSwitchSprint(sprintId: string) {
if (!activeProduct) return
if (sprintId === activeSprint?.id) return
startTransition(async () => {
const result = await setActiveSprintAction(activeProduct.id, sprintId)
if (result?.error) {
toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt')
return
}
if (pathname.includes('/sprint')) {
router.push(`/products/${activeProduct.id}/sprint/${sprintId}`)
} else {
router.refresh()
}
})
}
const activeId = activeProduct?.id ?? null const activeId = activeProduct?.id ?? null
// Nav link helpers // Nav link helpers
@ -90,7 +131,6 @@ export function NavBar({
const sprintNode = () => { const sprintNode = () => {
if (!activeId) return disabledSpan('Sprint') if (!activeId) return disabledSpan('Sprint')
const href = `/products/${activeId}/sprint`
const isActive = pathname.includes('/sprint') const isActive = pathname.includes('/sprint')
if (!hasActiveSprint) { if (!hasActiveSprint) {
return ( return (
@ -107,6 +147,7 @@ export function NavBar({
</TooltipProvider> </TooltipProvider>
) )
} }
const href = `/products/${activeId}/sprint/${activeSprint!.id}`
return navLink(href, 'Sprint', isActive) return navLink(href, 'Sprint', isActive)
} }
@ -149,8 +190,8 @@ export function NavBar({
</nav> </nav>
</div> </div>
{/* Midden: actief product dropdown */} {/* Midden: actief product + sprint, gestapeld */}
<div className="flex items-center justify-center"> <div className="flex flex-col items-center justify-center gap-0.5">
{activeProduct ? ( {activeProduct ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
@ -187,6 +228,70 @@ export function NavBar({
) : ( ) : (
<span className="text-sm text-muted-foreground/50 select-none">Geen actief product</span> <span className="text-sm text-muted-foreground/50 select-none">Geen actief product</span>
)} )}
{activeProduct && (
sprints.length === 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
className="text-xs text-muted-foreground/50 px-2 cursor-not-allowed select-none"
aria-disabled="true"
>
Geen sprints
</TooltipTrigger>
<TooltipContent>Maak een sprint aan vanuit de Product Backlog</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<DropdownMenu>
<DropdownMenuTrigger
disabled={isPending}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 rounded-md hover:bg-surface-container focus:outline-none"
>
<span className="truncate max-w-[160px]">
{activeSprint ? activeSprint.code : 'Selecteer sprint'}
</span>
{activeSprint && (
<Badge
className={cn(
'text-[10px] px-1.5 py-0',
buildingSet.has(activeSprint.id)
? 'bg-warning text-warning-foreground'
: SPRINT_STATUS_BADGE[activeSprint.status],
)}
>
{buildingSet.has(activeSprint.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[activeSprint.status]}
</Badge>
)}
<ChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-64">
{sprints.map(s => (
<DropdownMenuItem
key={s.id}
onClick={() => handleSwitchSprint(s.id)}
className={cn(
'flex items-center justify-between gap-2',
s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium',
)}
>
<span className="truncate">{s.code}</span>
<Badge
className={cn(
'text-[10px] px-1.5 py-0 shrink-0',
buildingSet.has(s.id)
? 'bg-warning text-warning-foreground'
: SPRINT_STATUS_BADGE[s.status],
)}
>
{buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]}
</Badge>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
)}
</div> </div>
{/* Rechts: solo-status + notifications + account-menu */} {/* Rechts: solo-status + notifications + account-menu */}

View file

@ -0,0 +1,187 @@
'use client'
import { useState, useTransition, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog'
import {
useDirtyCloseGuard,
DirtyCloseGuardDialog,
} from '@/components/shared/use-dirty-close-guard'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
import {
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { createSprintWithPbisAction } from '@/actions/sprints'
interface NewSprintDialogProps {
open: boolean
productId: string
pbiIds: string[]
onOpenChange: (open: boolean) => void
onCreated?: (sprintId: string) => void
}
function todayLocalDate() {
return new Date().toLocaleDateString('en-CA')
}
export function NewSprintDialog({
open,
productId,
pbiIds,
onOpenChange,
onCreated,
}: NewSprintDialogProps) {
const [sprintGoal, setSprintGoal] = useState('')
const [startDate, setStartDate] = useState(todayLocalDate())
const [endDate, setEndDate] = useState(todayLocalDate())
const [error, setError] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [isPending, startTransition] = useTransition()
const formRef = useRef<HTMLFormElement>(null)
const router = useRouter()
function reset() {
setSprintGoal('')
setStartDate(todayLocalDate())
setEndDate(todayLocalDate())
setError(null)
setDirty(false)
}
const closeGuard = useDirtyCloseGuard(dirty, () => {
onOpenChange(false)
reset()
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!sprintGoal.trim() || pbiIds.length === 0) return
setError(null)
startTransition(async () => {
const result = await createSprintWithPbisAction({
productId,
sprint_goal: sprintGoal.trim(),
start_date: startDate || null,
end_date: endDate || null,
pbi_ids: pbiIds,
})
if ('error' in result) {
setError(result.error)
toast.error(result.error)
return
}
toast.success('Nieuwe sprint aangemaakt')
reset()
onCreated?.(result.sprintId)
router.push(`/products/${productId}/sprint/${result.sprintId}`)
})
}
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
return (
<>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) closeGuard.attemptClose()
else onOpenChange(o)
}}
>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">Nieuwe sprint</DialogTitle>
<p className="text-xs text-muted-foreground mt-1">
{pbiIds.length} PBI{pbiIds.length === 1 ? '' : "'s"} worden in deze sprint geplaatst
</p>
</div>
<form
ref={formRef}
id="new-sprint-form"
onSubmit={handleSubmit}
onChange={() => setDirty(true)}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Sprint Goal <span className="text-error">*</span>
</label>
<Textarea
value={sprintGoal}
onChange={(e) => setSprintGoal(e.target.value)}
required
rows={3}
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
autoFocus
/>
</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"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
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"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Einddatum</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
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"
/>
</div>
</div>
{error && (
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
{error}
</div>
)}
</form>
<div className={entityDialogFooterClasses}>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={closeGuard.attemptClose}
disabled={isPending}
>
Annuleren
</Button>
<Button
type="submit"
form="new-sprint-form"
disabled={isPending || !sprintGoal.trim() || pbiIds.length === 0}
>
{isPending ? 'Aanmaken…' : 'Sprint aanmaken'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
)
}

View file

@ -20,7 +20,7 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { type PauseContext, pauseReasonLabel } from '@/lib/pause-context' import { type PauseContext, pauseReasonLabel } from '@/lib/pause-context'
type SprintStatusValue = 'ACTIVE' | 'COMPLETED' | 'FAILED' type SprintStatusValue = 'OPEN' | 'CLOSED' | 'ARCHIVED' | 'FAILED'
type SprintRunStatusValue = type SprintRunStatusValue =
| 'QUEUED' | 'QUEUED'
| 'RUNNING' | 'RUNNING'
@ -78,7 +78,7 @@ export function SprintRunControls({
activeSprintRunStatus === 'RUNNING' || activeSprintRunStatus === 'RUNNING' ||
activeSprintRunStatus === 'PAUSED') activeSprintRunStatus === 'PAUSED')
const canStart = sprintStatus === 'ACTIVE' && !hasActiveRun const canStart = sprintStatus === 'OPEN' && !hasActiveRun
const canResume = sprintStatus === 'FAILED' const canResume = sprintStatus === 'FAILED'
const canResumePaused = const canResumePaused =
activeSprintRunStatus === 'PAUSED' && pauseContext !== null activeSprintRunStatus === 'PAUSED' && pauseContext !== null

View file

@ -27,13 +27,24 @@ const STATUS_CYCLE: Record<string, 'TO_DO' | 'IN_PROGRESS' | 'DONE'> = {
TO_DO: 'IN_PROGRESS', TO_DO: 'IN_PROGRESS',
IN_PROGRESS: 'DONE', IN_PROGRESS: 'DONE',
DONE: 'TO_DO', DONE: 'TO_DO',
EXCLUDED: 'TO_DO',
} }
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30', TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30',
IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30', IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
DONE: 'bg-status-done/15 text-status-done border-status-done/30', DONE: 'bg-status-done/15 text-status-done border-status-done/30',
EXCLUDED: 'bg-surface-container-low text-muted-foreground border-border',
FAILED: 'bg-status-failed/15 text-status-failed border-status-failed/30',
REVIEW: 'bg-status-review/15 text-status-review border-status-review/30',
}
const STATUS_LABELS: Record<string, string> = {
TO_DO: 'To Do',
IN_PROGRESS: 'Bezig',
REVIEW: 'Review',
DONE: 'Klaar',
FAILED: 'Mislukt',
EXCLUDED: 'Uitgesloten',
} }
const STATUS_LABELS: Record<string, string> = { TO_DO: 'To Do', IN_PROGRESS: 'Bezig', DONE: 'Klaar' }
export interface Task { export interface Task {
@ -101,6 +112,7 @@ function SortableTaskRow({
<p className={cn( <p className={cn(
'text-sm leading-snug line-clamp-2 flex-1', 'text-sm leading-snug line-clamp-2 flex-1',
task.status === 'DONE' && 'line-through text-muted-foreground', task.status === 'DONE' && 'line-through text-muted-foreground',
task.status === 'EXCLUDED' && 'text-muted-foreground/70 italic',
)}> )}>
{task.title} {task.title}
</p> </p>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 937 KiB

Before After
Before After

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({ const sprints = await prisma.sprint.findMany({
where: { where: {
status: 'ACTIVE', status: 'OPEN',
product: productAccessFilter(userId), product: productAccessFilter(userId),
}, },
select: { select: {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "scrum4me", "name": "scrum4me",
"version": "1.0.0", "version": "1.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"predev": "npx --yes kill-port 3000 || exit 0", "predev": "npx --yes kill-port 3000 || exit 0",

View file

@ -0,0 +1,19 @@
-- PBI-63: Sprint-lifecycle migratie
--
-- 1. Hernoem SprintStatus ACTIVE → OPEN, COMPLETED → CLOSED (bestaande data behouden, geen rewrite)
-- 2. Voeg ARCHIVED toe (FAILED blijft)
-- 3. Pas Sprint.status default aan naar OPEN
-- 4. Voeg EXCLUDED toe aan TaskStatus
--
-- ALTER TYPE ... RENAME VALUE werkt vanaf PostgreSQL 10 zonder data-rewrite.
-- ALTER TYPE ... ADD VALUE moet buiten een transaction-block uitgevoerd worden;
-- Prisma migrate runt elk SQL-bestand zonder impliciete BEGIN/COMMIT, dus
-- losse statements zijn voldoende.
ALTER TYPE "SprintStatus" RENAME VALUE 'ACTIVE' TO 'OPEN';
ALTER TYPE "SprintStatus" RENAME VALUE 'COMPLETED' TO 'CLOSED';
ALTER TYPE "SprintStatus" ADD VALUE 'ARCHIVED';
ALTER TABLE "sprints" ALTER COLUMN "status" SET DEFAULT 'OPEN';
ALTER TYPE "TaskStatus" ADD VALUE 'EXCLUDED';

View file

@ -61,6 +61,7 @@ enum TaskStatus {
REVIEW REVIEW
DONE DONE
FAILED FAILED
EXCLUDED
} }
enum LogType { enum LogType {
@ -75,8 +76,9 @@ enum TestStatus {
} }
enum SprintStatus { enum SprintStatus {
ACTIVE OPEN
COMPLETED CLOSED
ARCHIVED
FAILED FAILED
} }
@ -302,7 +304,7 @@ model Sprint {
product_id String product_id String
code String @db.VarChar(30) code String @db.VarChar(30)
sprint_goal String sprint_goal String
status SprintStatus @default(ACTIVE) status SprintStatus @default(OPEN)
start_date DateTime? @db.Date start_date DateTime? @db.Date
end_date DateTime? @db.Date end_date DateTime? @db.Date
created_at DateTime @default(now()) created_at DateTime @default(now())

View file

@ -21,7 +21,7 @@ export type ParsedMilestone = {
title: string title: string
goal: string goal: string
priority: 1 | 2 | 3 | 4 priority: 1 | 2 | 3 | 4
sprint_status: 'ACTIVE' | 'COMPLETED' sprint_status: 'OPEN' | 'CLOSED'
sort_order: number sort_order: number
stories: ParsedStory[] stories: ParsedStory[]
} }
@ -66,19 +66,19 @@ const MILESTONE_GOAL: Record<string, string> = {
} }
const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']> = { const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']> = {
M0: 'COMPLETED', M0: 'CLOSED',
M1: 'COMPLETED', M1: 'CLOSED',
M2: 'COMPLETED', M2: 'CLOSED',
M3: 'COMPLETED', M3: 'CLOSED',
'M3.5': 'COMPLETED', 'M3.5': 'CLOSED',
M4: 'COMPLETED', M4: 'CLOSED',
M5: 'COMPLETED', M5: 'CLOSED',
M6: 'COMPLETED', M6: 'CLOSED',
M7: 'COMPLETED', M7: 'CLOSED',
M8: 'COMPLETED', M8: 'CLOSED',
M9: 'COMPLETED', M9: 'CLOSED',
M10: 'COMPLETED', M10: 'CLOSED',
M11: 'COMPLETED', M11: 'CLOSED',
} }
const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/ const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/
@ -154,7 +154,7 @@ export async function loadBacklog(
title, title,
goal: MILESTONE_GOAL[key] ?? title, goal: MILESTONE_GOAL[key] ?? title,
priority: MILESTONE_PRIORITY[key] ?? 4, priority: MILESTONE_PRIORITY[key] ?? 4,
sprint_status: MILESTONE_SPRINT_STATUS[key] ?? 'COMPLETED', sprint_status: MILESTONE_SPRINT_STATUS[key] ?? 'CLOSED',
sort_order: milestones.length + 1, sort_order: milestones.length + 1,
stories: [], stories: [],
} }

View file

@ -155,7 +155,7 @@ async function main() {
const forceOpen = ms.key === 'M3.5' const forceOpen = ms.key === 'M3.5'
for (const s of ms.stories) { for (const s of ms.stories) {
const isActive = ms.sprint_status === 'ACTIVE' const isActive = ms.sprint_status === 'OPEN'
const effectivelyDone = !forceOpen && s.status === 'DONE' const effectivelyDone = !forceOpen && s.status === 'DONE'
const inSprint = isActive || effectivelyDone const inSprint = isActive || effectivelyDone
const storyStatus = effectivelyDone ? 'DONE' : isActive ? 'IN_SPRINT' : 'OPEN' const storyStatus = effectivelyDone ? 'DONE' : isActive ? 'IN_SPRINT' : 'OPEN'