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

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

View file

@ -2,6 +2,8 @@ import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/auth-guard'
import { prisma } from '@/lib/prisma'
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 { MinWidthBanner } from '@/components/shared/min-width-banner'
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
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) {
const product = await prisma.product.findFirst({
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) {
activeProduct = product
const sprint = await prisma.sprint.findFirst({
where: { product_id: product.id, status: 'ACTIVE' },
select: { id: true },
const allSprints = await prisma.sprint.findMany({
where: { product_id: product.id },
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 {
await prisma.user.update({
where: { id: session.userId },
@ -71,7 +94,9 @@ export default async function AppLayout({ children }: { children: React.ReactNod
email={user.email}
activeProduct={activeProduct}
products={accessibleProducts}
hasActiveSprint={hasActiveSprint}
sprints={sprints}
activeSprint={activeSprint}
buildingSprintIds={buildingSprintIds}
minQuotaPct={user.min_quota_pct}
/>
<MinWidthBanner />

View file

@ -34,7 +34,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
if (!product) notFound()
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 } }),
])

View file

@ -20,7 +20,7 @@ export default async function SoloProductPage({ params }: Props) {
if (!product) notFound()
const sprint = await prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE' },
where: { product_id: id, status: 'OPEN' },
})
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'
interface Props {
params: Promise<{ id: string }>
params: Promise<{ id: string; sprintId: string }>
}
export default async function SprintPlanningRedirect({ params }: Props) {
const { id } = await params
redirect(`/products/${id}/sprint`)
const { id, sprintId } = await params
redirect(`/products/${id}/sprint/${sprintId}`)
}

View file

@ -1,220 +1,15 @@
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 { 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'
import { redirect } from 'next/navigation'
import { resolveActiveSprint } from '@/lib/active-sprint'
interface Props {
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 { 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: { 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,
}))
const active = await resolveActiveSprint(id)
if (!active) {
redirect(`/products/${id}?alert=no_sprint`)
}
// 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>
)
redirect(`/products/${id}/sprint/${active.id}`)
}

View file

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

View file

@ -15,10 +15,11 @@ const STATUS_CONFIG: Record<TaskStatus, { label: string; dot: string }> = {
REVIEW: { label: 'Review', dot: 'bg-status-review' },
DONE: { label: 'Klaar', dot: 'bg-status-done' },
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.
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 }) {
return (

View file

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

View file

@ -15,7 +15,7 @@ export async function GET(
const { id } = await params
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) {
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> {
const { prisma } = await import('@/lib/prisma')
return prisma.sprint.findFirst({
where: { product_id: productId, status: 'ACTIVE' },
where: { product_id: productId, status: 'OPEN' },
select: { id: true },
orderBy: { created_at: 'desc' },
})