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

@ -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}`)
}