feat(ST-313): merge sprint board into single three-panel view

- TriplePane component with two resizable dividers, localStorage persistence, mobile tabs
- SprintBoardClient replaces SprintBacklogClient + PlanningRightClient
- Left panel: Product Backlog (PBIs with stories to add to sprint)
- Middle panel: Sprint Backlog (stories in sprint, click to select, sortable)
- Right panel: TaskList for selected story
- /sprint/planning redirects to /sprint
- Remove PlanningLeft, PlanningRightClient, SprintBacklogClient

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-25 22:53:39 +02:00
parent 4df83dcdbb
commit 0a27be4886
8 changed files with 320 additions and 273 deletions

View file

@ -3,16 +3,17 @@ import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'
import { prisma } from '@/lib/prisma'
import { SprintBacklogClient } from '@/components/sprint/sprint-backlog-client'
import { SprintBoardClient } from '@/components/sprint/sprint-board-client'
import { SprintHeader } from '@/components/sprint/sprint-header'
import type { SprintStory, PbiWithStories } from '@/components/sprint/sprint-backlog'
import type { Task } from '@/components/sprint/task-list'
import Link from 'next/link'
interface Props {
params: Promise<{ id: string }>
}
export default async function SprintBacklogPage({ params }: Props) {
export default async function SprintBoardPage({ params }: Props) {
const { id } = await params
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
@ -26,23 +27,40 @@ export default async function SprintBacklogPage({ params }: Props) {
})
if (!sprint) redirect(`/products/${id}`)
// Stories in this sprint
// Sprint stories with full task data
const sprintStories = await prisma.story.findMany({
where: { sprint_id: sprint.id },
orderBy: { sort_order: 'asc' },
include: { tasks: { select: { id: true, status: true } } },
include: {
tasks: {
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
},
},
})
const sprintStoryItems: SprintStory[] = sprintStories.map((s: (typeof sprintStories)[number]) => ({
const sprintStoryItems: SprintStory[] = sprintStories.map(s => ({
id: s.id,
title: s.title,
priority: s.priority,
status: s.status,
taskCount: s.tasks.length,
doneCount: s.tasks.filter((t: (typeof s.tasks)[number]) => t.status === 'DONE').length,
doneCount: s.tasks.filter(t => t.status === 'DONE').length,
}))
// All PBIs with their non-sprint stories for the right panel
const tasksByStory: Record<string, Task[]> = {}
for (const story of sprintStories) {
tasksByStory[story.id] = story.tasks.map(t => ({
id: t.id,
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' }],
@ -54,11 +72,11 @@ export default async function SprintBacklogPage({ params }: Props) {
})
const pbisWithStories: PbiWithStories[] = pbis
.filter((pbi: (typeof pbis)[number]) => pbi.stories.length > 0)
.map((pbi: (typeof pbis)[number]) => ({
.filter(pbi => pbi.stories.length > 0)
.map(pbi => ({
id: pbi.id,
title: pbi.title,
stories: pbi.stories.map((s: (typeof pbi.stories)[number]) => ({
stories: pbi.stories.map(s => ({
id: s.id,
title: s.title,
priority: s.priority,
@ -68,7 +86,7 @@ export default async function SprintBacklogPage({ params }: Props) {
})),
}))
const sprintStoryIdList = sprintStories.map((s: (typeof sprintStories)[number]) => s.id)
const sprintStoryIdList = sprintStories.map(s => s.id)
const isDemo = session.isDemo ?? false
return (
@ -82,20 +100,18 @@ export default async function SprintBacklogPage({ params }: Props) {
/>
<div className="flex-1 overflow-hidden">
<SprintBacklogClient
<SprintBoardClient
productId={id}
sprintId={sprint.id}
stories={sprintStoryItems}
pbisWithStories={pbisWithStories}
sprintStoryIdList={sprintStoryIdList}
tasksByStory={tasksByStory}
isDemo={isDemo}
/>
</div>
<div className="border-t border-border px-4 py-2 bg-surface-container-low shrink-0 flex items-center gap-4">
<Link href={`/products/${id}/sprint/planning`} className="text-sm text-primary hover:underline">
Sprint Planning
</Link>
<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>

View file

@ -1,134 +1,10 @@
import { notFound, redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'
import { prisma } from '@/lib/prisma'
import { SplitPane } from '@/components/split-pane/split-pane'
import { PlanningLeft } from '@/components/sprint/planning-left'
import type { Task } from '@/components/sprint/task-list'
import { SprintHeader } from '@/components/sprint/sprint-header'
import type { SprintStory } from '@/components/sprint/sprint-backlog'
import Link from 'next/link'
import { redirect } from 'next/navigation'
interface Props {
params: Promise<{ id: string }>
}
export default async function SprintPlanningPage({ params }: Props) {
export default async function SprintPlanningRedirect({ params }: Props) {
const { id } = await params
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
const product = await prisma.product.findFirst({
where: { id, user_id: session.userId },
})
if (!product) notFound()
const sprint = await prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE' },
})
if (!sprint) redirect(`/products/${id}`)
const sprintStories = await prisma.story.findMany({
where: { sprint_id: sprint.id },
orderBy: { sort_order: 'asc' },
include: {
tasks: {
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
},
},
})
const sprintStoryItems: SprintStory[] = sprintStories.map((s: (typeof sprintStories)[number]) => ({
id: s.id,
title: s.title,
priority: s.priority,
status: s.status,
taskCount: s.tasks.length,
doneCount: s.tasks.filter((t: (typeof s.tasks)[number]) => t.status === 'DONE').length,
}))
// Tasks by story
const tasksByStory: Record<string, Task[]> = {}
for (const story of sprintStories) {
tasksByStory[story.id] = story.tasks.map((t: (typeof story.tasks)[number]) => ({
id: t.id,
title: t.title,
description: t.description,
priority: t.priority,
status: t.status,
story_id: t.story_id,
sprint_id: t.sprint_id,
}))
}
const isDemo = session.isDemo ?? false
return (
<div className="flex flex-col h-full">
<SprintHeader
productId={id}
productName={product.name}
sprint={sprint}
isDemo={isDemo}
sprintStories={sprintStoryItems}
/>
<div className="flex-1 overflow-hidden">
<SplitPane
storageKey={`planning-${id}`}
left={
<PlanningLeft
stories={sprintStoryItems}
/>
}
right={
<PlanningRight
sprintId={sprint.id}
productId={id}
stories={sprintStoryItems}
tasksByStory={tasksByStory}
isDemo={isDemo}
/>
}
/>
</div>
<div className="border-t border-border px-4 py-2 bg-surface-container-low shrink-0">
<Link href={`/products/${id}/sprint`} className="text-sm text-muted-foreground hover:text-foreground">
Sprint Backlog
</Link>
</div>
</div>
)
redirect(`/products/${id}/sprint`)
}
// Right panel — shows tasks of selected story
function PlanningRight({
sprintId,
productId,
stories,
tasksByStory,
isDemo,
}: {
sprintId: string
productId: string
stories: SprintStory[]
tasksByStory: Record<string, Task[]>
isDemo: boolean
}) {
// This is a Server Component wrapper — PlanningLeft manages selection via URL/store
// We render TaskList for the first story if only one, or show instruction
// The actual selection is client-side via PlanningLeft
return (
<PlanningRightClient
sprintId={sprintId}
productId={productId}
stories={stories}
tasksByStory={tasksByStory}
isDemo={isDemo}
/>
)
}
// We need a client component for the right side that reads selection store
import { PlanningRightClient } from '@/components/sprint/planning-right-client'