feat(PBI-74): migreer sprint-board componenten naar workspace-store (Story 9 / T-881)

- TaskList: leest tasks via selectTasksForStory met useShallow; DnD via
  applyOptimisticMutation('sprint-task-order') + settle/rollback
- SprintBacklogLeft: leest stories via selectStoriesForActiveSprint met
  useShallow; props 'stories' verwijderd
- SprintBoardClient: leest sprintStories uit selector i.p.v. lokale state;
  add/remove via direct setState met manuele snapshot-rollback;
  reorder via applyOptimisticMutation('sprint-story-order'); assignee-
  change via store entity-mutation; tasksByStory en sprintStoryIdList
  props weg
- app/(app)/.../sprint/[sprintId]/page.tsx: bouwt SprintHydrationData voor
  wrapper; geeft alleen non-store props door aan SprintBoardClient

useSprintStore wordt nergens meer geïmporteerd — alleen comment-referentie
in SprintHydrationWrapper. Cleanup van het bestand zelf in T-883.

Verify groen (671 tests, typecheck, lint clean).
This commit is contained in:
Janpeter Visser 2026-05-10 06:44:35 +02:00
parent 307b998871
commit c16d1ecbac
4 changed files with 170 additions and 145 deletions

View file

@ -16,7 +16,6 @@ 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 type { SprintWorkspaceTask } from '@/stores/sprint-workspace/types'
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
@ -110,19 +109,8 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
assignee_username: s.assignee?.username ?? null,
}))
const tasksByStory: Record<string, Task[]> = {}
const tasksByStoryWorkspace: Record<string, SprintWorkspaceTask[]> = {}
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,
}))
tasksByStoryWorkspace[story.id] = story.tasks.map(t => ({
id: t.id,
code: t.code,
@ -176,7 +164,6 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
})),
}))
const sprintStoryIdList = sprintStories.map(s => s.id)
const isDemo = session.isDemo ?? false
const closePath = `/products/${id}/sprint/${sprint.id}`
@ -237,10 +224,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
key={sprint.id}
productId={id}
sprintId={sprint.id}
stories={sprintStoryItems}
pbisWithStories={pbisWithStories}
sprintStoryIdList={sprintStoryIdList}
tasksByStory={tasksByStory}
isDemo={isDemo}
currentUserId={session.userId}
members={members}

View file

@ -6,6 +6,7 @@ import { useDroppable, useDraggable } from '@dnd-kit/core'
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@ -20,7 +21,8 @@ import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
import { useSprintStore } from '@/stores/sprint-store'
import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
import { selectStoriesForActiveSprint } from '@/stores/sprint-workspace/selectors'
import { claimStoryAction, unclaimStoryAction, reassignStoryAction, claimAllUnassignedInActiveSprintAction } from '@/actions/stories'
import { PbiDialog, type PbiDialogState } from '@/components/backlog/pbi-dialog'
import { StoryDialog, type StoryDialogState } from '@/components/backlog/story-dialog'
@ -236,7 +238,6 @@ function SortableSprintRow({
interface SprintBacklogLeftProps {
sprintId: string
stories: SprintStory[]
isDemo: boolean
onRemove: (storyId: string) => void
onSelect: (storyId: string) => void
@ -248,19 +249,21 @@ interface SprintBacklogLeftProps {
}
export function SprintBacklogLeft({
sprintId, stories, isDemo, onRemove, onSelect, selectedStoryId,
sprintId: _sprintId, isDemo, onRemove, onSelect, selectedStoryId,
currentUserId, productId, members, onAssigneeChange,
}: SprintBacklogLeftProps) {
const { sprintStoryOrder } = useSprintStore()
const orderedStories = useSprintWorkspaceStore(
useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]),
)
const { setNodeRef, isOver } = useDroppable({ id: 'sprint-zone' })
const [isPending, startTransition] = useTransition()
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
const unassignedCount = stories.filter(s => s.assignee_id === null).length
const unassignedCount = orderedStories.filter(s => (s.assignee_id ?? null) === null).length
const currentUserUsername = members.find(m => m.userId === currentUserId)?.username ?? null
function handleClaimAll() {
const unassigned = stories.filter(s => s.assignee_id === null)
const unassigned = orderedStories.filter(s => (s.assignee_id ?? null) === null)
unassigned.forEach(s => onAssigneeChange(s.id, currentUserId, currentUserUsername))
startTransition(async () => {
const result = await claimAllUnassignedInActiveSprintAction(productId)
@ -273,10 +276,6 @@ export function SprintBacklogLeft({
})
}
const storyMap = Object.fromEntries(stories.map(s => [s.id, s]))
const order = sprintStoryOrder[sprintId] ?? stories.map(s => s.id)
const orderedStories = order.map(id => storyMap[id]).filter(Boolean)
return (
<div className="flex flex-col h-full" {...debugProps('sprint-backlog-left', 'SprintBacklogLeft', 'components/sprint/sprint-backlog.tsx')}>
<PanelNavBar

View file

@ -1,18 +1,20 @@
'use client'
import { useState, useEffect, useTransition } from 'react'
import { useState, useTransition } from 'react'
import {
DndContext, DragEndEvent, DragStartEvent, DragOverlay,
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
} from '@dnd-kit/core'
import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import { SplitPane } from '@/components/split-pane/split-pane'
import { SprintBacklogLeft, SprintBacklogRight } from './sprint-backlog'
import type { SprintStory, PbiWithStories, ProductMember } from './sprint-backlog'
import { TaskList } from './task-list'
import type { Task } from './task-list'
import { useSprintStore } from '@/stores/sprint-store'
import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
import { selectStoriesForActiveSprint } from '@/stores/sprint-workspace/selectors'
import type { SprintWorkspaceStory } from '@/stores/sprint-workspace/types'
import {
addStoryToSprintAction,
removeStoryFromSprintAction,
@ -23,45 +25,48 @@ import { debugProps } from '@/lib/debug'
interface SprintBoardClientProps {
productId: string
sprintId: string
stories: SprintStory[]
pbisWithStories: PbiWithStories[]
sprintStoryIdList: string[]
tasksByStory: Record<string, Task[]>
isDemo: boolean
currentUserId: string
members: ProductMember[]
}
function toWorkspaceStory(story: SprintStory, sprintId: string): SprintWorkspaceStory {
return {
id: story.id,
code: story.code,
title: story.title,
description: story.description,
acceptance_criteria: story.acceptance_criteria,
priority: story.priority,
sort_order: story.sort_order,
status: story.status,
pbi_id: story.pbi_id,
sprint_id: sprintId,
created_at: story.created_at,
taskCount: story.taskCount,
doneCount: story.doneCount,
assignee_id: story.assignee_id,
assignee_username: story.assignee_username,
}
}
export function SprintBoardClient({
productId,
sprintId,
stories,
pbisWithStories,
sprintStoryIdList,
tasksByStory,
isDemo,
currentUserId,
members,
}: SprintBoardClientProps) {
const [sprintStories, setSprintStories] = useState<SprintStory[]>(stories)
const [sprintStoryIds, setSprintStoryIds] = useState<Set<string>>(() => new Set(sprintStoryIdList))
const sprintStories = useSprintWorkspaceStore(
useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]),
)
const sprintStoryIds = new Set(sprintStories.map(s => s.id))
const [selectedStoryId, setSelectedStoryId] = useState<string | null>(null)
const {
sprintStoryOrder,
initSprint,
addStoryToSprint,
removeStoryFromSprint,
reorderSprintStories,
rollbackSprint,
} = useSprintStore()
const [activeDragStory, setActiveDragStory] = useState<SprintStory | null>(null)
const [, startTransition] = useTransition()
useEffect(() => {
initSprint(sprintId, stories.map(s => s.id))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sprintId])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
@ -82,9 +87,8 @@ export function SprintBoardClient({
const activeId = active.id.toString()
const overId = over.id.toString()
const order = sprintStoryOrder[sprintId] ?? sprintStories.map(s => s.id)
// Drag from left (product backlog) → add to sprint (middle)
// Drag from product backlog (left) → add to sprint (middle)
if (activeId.startsWith('pb:')) {
const storyId = activeId.slice(3)
const droppingOnSprint =
@ -92,106 +96,119 @@ export function SprintBoardClient({
(!overId.startsWith('pb:') && overId !== 'backlog-zone')
if (droppingOnSprint && !sprintStoryIds.has(storyId)) {
const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId)
if (!storyData) return
setSprintStoryIds(prev => new Set([...prev, storyId]))
setSprintStories(prev => [...prev, storyData])
addStoryToSprint(sprintId, storyId)
startTransition(async () => {
const result = await addStoryToSprintAction(sprintId, storyId)
if (!result.success) {
setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n })
setSprintStories(prev => prev.filter(s => s.id !== storyId))
removeStoryFromSprint(sprintId, storyId)
toast.error(result.error ?? 'Toevoegen mislukt')
}
})
if (storyData) handleAdd(storyId, storyData)
}
return
}
// Drag from middle (sprint backlog) → left (product backlog) → remove
// Drag from sprint (middle) → product backlog (left) → remove
if (overId === 'backlog-zone') {
const storyData = sprintStories.find(s => s.id === activeId)
setSprintStoryIds(prev => { const n = new Set(prev); n.delete(activeId); return n })
setSprintStories(prev => prev.filter(s => s.id !== activeId))
removeStoryFromSprint(sprintId, activeId)
if (selectedStoryId === activeId) setSelectedStoryId(null)
startTransition(async () => {
const result = await removeStoryFromSprintAction(activeId)
if (!result.success) {
if (storyData) {
setSprintStoryIds(prev => new Set([...prev, activeId]))
setSprintStories(prev => [...prev, storyData])
}
addStoryToSprint(sprintId, activeId)
toast.error('Verwijderen mislukt')
}
})
handleRemove(activeId)
return
}
// Reorder within sprint (middle panel)
// Reorder within sprint
if (activeId !== overId && !activeId.startsWith('pb:')) {
const prevOrder = [...order]
const newOrder = order.includes(overId)
? arrayMove([...order], order.indexOf(activeId), order.indexOf(overId))
: [...order.filter(id => id !== activeId), activeId]
reorderSprintStories(sprintId, newOrder)
startTransition(async () => {
const result = await reorderSprintStoriesAction(sprintId, newOrder)
if (!result.success) {
rollbackSprint(sprintId, prevOrder)
toast.error('Volgorde opslaan mislukt')
}
})
handleReorder(activeId, overId)
}
}
function handleAdd(storyId: string) {
function handleAdd(storyId: string, storyData: SprintStory) {
if (sprintStoryIds.has(storyId)) return
const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId)
if (!storyData) return
setSprintStoryIds(prev => new Set([...prev, storyId]))
setSprintStories(prev => [...prev, storyData])
addStoryToSprint(sprintId, storyId)
const store = useSprintWorkspaceStore.getState()
const prevStory = store.entities.storiesById[storyId]
const prevSprintStoryIds = [...(store.relations.storyIdsBySprint[sprintId] ?? [])]
useSprintWorkspaceStore.setState((s) => {
s.entities.storiesById[storyId] = toWorkspaceStory(storyData, sprintId)
const list = s.relations.storyIdsBySprint[sprintId] ?? []
if (!list.includes(storyId)) list.push(storyId)
s.relations.storyIdsBySprint[sprintId] = list
})
startTransition(async () => {
const result = await addStoryToSprintAction(sprintId, storyId)
if (!result.success) {
setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n })
setSprintStories(prev => prev.filter(s => s.id !== storyId))
removeStoryFromSprint(sprintId, storyId)
useSprintWorkspaceStore.setState((s) => {
if (prevStory === undefined) {
delete s.entities.storiesById[storyId]
} else {
s.entities.storiesById[storyId] = prevStory
}
s.relations.storyIdsBySprint[sprintId] = prevSprintStoryIds
})
toast.error(result.error ?? 'Toevoegen mislukt')
}
})
}
function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) {
setSprintStories(prev =>
prev.map(s => s.id === storyId ? { ...s, assignee_id: assigneeId, assignee_username: assigneeUsername } : s)
)
}
function handleRemove(storyId: string) {
const storyData = sprintStories.find(s => s.id === storyId)
setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n })
setSprintStories(prev => prev.filter(s => s.id !== storyId))
removeStoryFromSprint(sprintId, storyId)
const store = useSprintWorkspaceStore.getState()
const prevStory = store.entities.storiesById[storyId]
const prevSprintStoryIds = [...(store.relations.storyIdsBySprint[sprintId] ?? [])]
useSprintWorkspaceStore.setState((s) => {
const list = s.relations.storyIdsBySprint[sprintId]
if (list) {
s.relations.storyIdsBySprint[sprintId] = list.filter((id) => id !== storyId)
}
const story = s.entities.storiesById[storyId]
if (story) story.sprint_id = null
})
if (selectedStoryId === storyId) setSelectedStoryId(null)
startTransition(async () => {
const result = await removeStoryFromSprintAction(storyId)
if (!result.success) {
if (storyData) {
setSprintStoryIds(prev => new Set([...prev, storyId]))
setSprintStories(prev => [...prev, storyData])
}
addStoryToSprint(sprintId, storyId)
useSprintWorkspaceStore.setState((s) => {
if (prevStory) s.entities.storiesById[storyId] = prevStory
s.relations.storyIdsBySprint[sprintId] = prevSprintStoryIds
})
toast.error('Verwijderen mislukt')
}
})
}
const selectedTasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : []
function handleReorder(activeId: string, overId: string) {
const store = useSprintWorkspaceStore.getState()
const order = store.relations.storyIdsBySprint[sprintId] ?? []
const prevOrder = [...order]
const newOrder = order.includes(overId)
? arrayMove([...order], order.indexOf(activeId), order.indexOf(overId))
: [...order.filter(id => id !== activeId), activeId]
const mutationId = store.applyOptimisticMutation({
kind: 'sprint-story-order',
sprintId,
prevStoryIds: prevOrder,
})
useSprintWorkspaceStore.setState((s) => {
s.relations.storyIdsBySprint[sprintId] = newOrder
})
startTransition(async () => {
const result = await reorderSprintStoriesAction(sprintId, newOrder)
const st = useSprintWorkspaceStore.getState()
if (result.success) {
st.settleMutation(mutationId)
} else {
st.rollbackMutation(mutationId)
toast.error('Volgorde opslaan mislukt')
}
})
}
function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) {
useSprintWorkspaceStore.setState((s) => {
const story = s.entities.storiesById[storyId]
if (story) {
story.assignee_id = assigneeId
story.assignee_username = assigneeUsername
}
})
}
return (
<div {...debugProps('sprint-board-client')} className="contents">
@ -213,12 +230,14 @@ export function SprintBoardClient({
sprintStoryIds={sprintStoryIds}
isDemo={isDemo}
productId={productId}
onAdd={handleAdd}
onAdd={(storyId) => {
const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId)
if (storyData) handleAdd(storyId, storyData)
}}
/>,
<SprintBacklogLeft
key="sprint"
sprintId={sprintId}
stories={sprintStories}
isDemo={isDemo}
onRemove={handleRemove}
onSelect={setSelectedStoryId}
@ -234,7 +253,6 @@ export function SprintBoardClient({
storyId={selectedStoryId}
sprintId={sprintId}
productId={productId}
tasks={selectedTasks}
isDemo={isDemo}
/>
) : (

View file

@ -1,6 +1,6 @@
'use client'
import { useState, useTransition, useEffect } from 'react'
import { useState, useTransition } from 'react'
import { useRouter, usePathname } from 'next/navigation'
import {
DndContext, DragEndEvent, DragOverlay,
@ -13,12 +13,18 @@ import {
import { CSS } from '@dnd-kit/utilities'
import { Pencil } from 'lucide-react'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
import { useSprintStore } from '@/stores/sprint-store'
import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
import { selectTasksForStory } from '@/stores/sprint-workspace/selectors'
import type {
SprintWorkspaceTask,
SprintWorkspaceTaskDetail,
} from '@/stores/sprint-workspace/types'
import { updateTaskStatusAction, reorderTasksAction } from '@/actions/tasks'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { debugProps } from '@/lib/debug'
@ -48,9 +54,11 @@ const STATUS_LABELS: Record<string, string> = {
}
// Behouden voor type-compat met SprintBoardClient props (verdwijnt zodra
// SprintBoardClient ook geen tasks-prop meer doorgeeft — T-883).
export interface Task {
id: string
code: string
code: string | null
title: string
description: string | null
priority: number
@ -59,18 +67,19 @@ export interface Task {
sprint_id: string | null
}
type WorkspaceTask = SprintWorkspaceTask | SprintWorkspaceTaskDetail
interface TaskListProps {
storyId: string
sprintId: string
productId: string
tasks: Task[]
isDemo: boolean
}
function SortableTaskRow({
task, code, isDemo, onStatusToggle, onEdit,
}: {
task: Task
task: WorkspaceTask
code: string | null
isDemo: boolean
onStatusToggle: () => void
@ -149,22 +158,17 @@ function SortableTaskRow({
)
}
export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, tasks, isDemo }: TaskListProps) {
const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore()
export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, isDemo }: TaskListProps) {
const orderedTasks = useSprintWorkspaceStore(
useShallow((s) => selectTasksForStory(s, storyId)),
)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition()
const router = useRouter()
const pathname = usePathname()
const idKey = tasks.map(t => t.id).join(',')
useEffect(() => {
initTasks(storyId, idKey ? idKey.split(',') : [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storyId, idKey])
const taskMap = Object.fromEntries(tasks.map(t => [t.id, t]))
const order = taskOrder[storyId] ?? tasks.map(t => t.id)
const orderedTasks = order.map(id => taskMap[id]).filter(Boolean)
const taskMap: Record<string, WorkspaceTask> = {}
for (const t of orderedTasks) taskMap[t.id] = t
const doneCount = orderedTasks.filter(t => t.status === 'DONE').length
@ -176,17 +180,37 @@ export function TaskList({ storyId, sprintId: _sprintId, productId: _productId,
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return
const prevOrder = [...order]
const newOrder = arrayMove([...order], order.indexOf(active.id as string), order.indexOf(over.id as string))
reorderTasks(storyId, newOrder)
const store = useSprintWorkspaceStore.getState()
const prevOrder = [...(store.relations.taskIdsByStory[storyId] ?? [])]
const newOrder = arrayMove(
[...prevOrder],
prevOrder.indexOf(active.id as string),
prevOrder.indexOf(over.id as string),
)
const mutationId = store.applyOptimisticMutation({
kind: 'sprint-task-order',
storyId,
prevTaskIds: prevOrder,
})
useSprintWorkspaceStore.setState((s) => {
s.relations.taskIdsByStory[storyId] = newOrder
})
setActiveDragId(null)
startTransition(async () => {
const result = await reorderTasksAction(storyId, newOrder)
if (!result.success) { rollbackTasks(storyId, prevOrder); toast.error('Volgorde opslaan mislukt') }
const st = useSprintWorkspaceStore.getState()
if (result.success) {
st.settleMutation(mutationId)
} else {
st.rollbackMutation(mutationId)
toast.error('Volgorde opslaan mislukt')
}
})
}
function handleStatusToggle(task: Task) {
function handleStatusToggle(task: WorkspaceTask) {
startTransition(async () => {
await updateTaskStatusAction(task.id, STATUS_CYCLE[task.status] ?? 'TO_DO')
})