Scrum4Me/components/sprint/sprint-board-client.tsx
Janpeter Visser ff22196714
Sprint: Stories en taken krijgen één voorspelbare volgorde gekoppeld aan hun code; drag-and-drop herordening voor stories/taken verdwijnt, priority wordt puur label. (#201)
* feat(code): add parseCodeNumber helper to lib/code.ts

Pure helper that extracts the trailing numeric sequence from a code string
(ST-007 → 7, T-42 → 42). Non-conforming codes fall back to Number.MAX_SAFE_INTEGER
so they sort to the end. Includes 5 unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(tasks): add code field to BacklogTask type and all task selects

Adds `code: string | null` to BacklogTask interface and includes it in
all Prisma task.findMany selects (backlog API, stories tasks API, page
hydration routes). Updates coerceTaskPayload and test fixtures to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sort-order): derive story/task sort_order from parseCodeNumber(code)

All create paths (createStoryAction, saveTask, createTaskAction,
materializeIdeaPlanAction) and code-edit paths (updateStoryAction, saveTask
update) now set sort_order = parseCodeNumber(code) instead of last+1.
Removes stale last-record queries from create paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sort-order): decouple sprint membership actions from sort_order

createSprintAction and addStoryToSprintAction no longer write sort_order
when adding stories to a sprint. sort_order is derived from code via
parseCodeNumber, so membership should only set sprint_id + status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(ordering): remove priority from all story/task orderBy

Story- en taak-ordering is nu puur sort_order asc (created_at als
tiebreaker). PBI-ordering (priority + sort_order) blijft ongewijzigd.

Gewijzigd: backlog/route, pbis/stories/route, claude-context/route,
next-story/route, workspace/route, tasks/route, sprint-runs (query +
in-memory sort), solo-workspace-server, page.tsx (app + mobile + sprint),
store compareStory, actions/sprints story-query, next-story test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(dnd): remove drag-and-drop reorder for stories and tasks

- Remove reorderStoriesAction, reorderTasksAction, reorderSprintStoriesAction
- Delete REST route app/api/stories/[id]/tasks/reorder/route.ts
- Remove DnD from backlog story-panel and task-panel (flat list)
- Remove reorder-within-sprint branch from sprint-board-client handleDragEnd
- Switch SortableSprintRow to plain SprintRow using useDraggable (membership drag kept)
- Remove all DnD from task-list (status toggle + edit kept)
- Remove story-order/task-order/sprint-story-order/sprint-task-order mutation types and store handlers
- Remove related tests for deleted reorder route; fix sprint store tests

* feat(backlog): toon code-badge op backlog-taakkaarten

Geeft code={task.code} door aan <BacklogCard> in TaskCard (task-panel.tsx).
BacklogCard rendert de CodeBadge al conditionally — alleen de prop ontbrak.

* feat(migration): backfill story/task sort_order from code numeric suffix

One-time Prisma migration that sets sort_order = trailing numeric part
of code for all existing stories and tasks, consistent with
parseCodeNumber (fallback = Number.MAX_SAFE_INTEGER for non-conforming
codes). PBIs are intentionally excluded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs+tests(sort-order): update for code-binding order on stories/tasks

- Rewrite docs/patterns/sort-order.md: float-insertion PBI only; story/task
  sort_order = parseCodeNumber(code), never drag/membership mutated
- Update plan-to-pbi-flow.md: sort_order auto, sprint_id param, priority=label
- Update make-plan.md: priority=label, array order = execution order
- Update rest-contract.md: fix sprint-tasks ordering, remove reorder endpoint
- Add ADR-0011: code is bindende volgordesleutel voor stories/taken
- Regenerate docs/INDEX.md via npm run docs
- Remove reorderStoriesAction/reorderTasksAction mocks from backlog tests
- Remove dnd-kit mocks from task-panel test (panel no longer uses dnd)
- Extend materializeIdeaPlanAction test: assert sort_order=parseCodeNumber(code)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:02:36 +02:00

242 lines
8.2 KiB
TypeScript

'use client'
import { useState, useTransition } from 'react'
import {
DndContext, DragEndEvent, DragStartEvent, DragOverlay,
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
} from '@dnd-kit/core'
import { sortableKeyboardCoordinates } 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 { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
import { selectStoriesForActiveSprint } from '@/stores/sprint-workspace/selectors'
import type { SprintWorkspaceStory } from '@/stores/sprint-workspace/types'
import {
addStoryToSprintAction,
removeStoryFromSprintAction,
} from '@/actions/sprints'
import { debugProps } from '@/lib/debug'
interface SprintBoardClientProps {
productId: string
sprintId: string
pbisWithStories: PbiWithStories[]
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,
pbisWithStories,
isDemo,
currentUserId,
members,
}: SprintBoardClientProps) {
const sprintStories = useSprintWorkspaceStore(
useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]),
)
const selectedStoryId = useSprintWorkspaceStore((s) => s.context.activeStoryId)
const sprintStoryIds = new Set(sprintStories.map(s => s.id))
const [activeDragStory, setActiveDragStory] = useState<SprintStory | null>(null)
const [, startTransition] = useTransition()
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragStart(event: DragStartEvent) {
const id = event.active.id.toString()
if (id.startsWith('pb:')) {
const story = pbisWithStories.flatMap(p => p.stories).find(s => s.id === id.slice(3))
setActiveDragStory(story ?? null)
}
}
function handleDragEnd(event: DragEndEvent) {
setActiveDragStory(null)
const { active, over } = event
if (!over) return
const activeId = active.id.toString()
const overId = over.id.toString()
// Drag from product backlog (left) → add to sprint (middle)
if (activeId.startsWith('pb:')) {
const storyId = activeId.slice(3)
const droppingOnSprint =
overId === 'sprint-zone' ||
(!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) handleAdd(storyId, storyData)
}
return
}
// Drag from sprint (middle) → product backlog (left) → remove
if (overId === 'backlog-zone') {
handleRemove(activeId)
return
}
}
function handleAdd(storyId: string, storyData: SprintStory) {
if (sprintStoryIds.has(storyId)) return
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) {
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 handleRemove(storyId: string) {
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) {
useSprintWorkspaceStore.getState().setActiveStory(null)
}
startTransition(async () => {
const result = await removeStoryFromSprintAction(storyId)
if (!result.success) {
useSprintWorkspaceStore.setState((s) => {
if (prevStory) s.entities.storiesById[storyId] = prevStory
s.relations.storyIdsBySprint[sprintId] = prevSprintStoryIds
})
toast.error('Verwijderen 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">
<DndContext
id="sprint-board"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SplitPane
cookieKey={`sprint-${productId}`}
defaultSplit={[28, 35, 37]}
tabLabels={['Backlog', 'Sprint', 'Taken']}
panes={[
<SprintBacklogRight
key="backlog"
pbisWithStories={pbisWithStories}
sprintStoryIds={sprintStoryIds}
isDemo={isDemo}
productId={productId}
onAdd={(storyId) => {
const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId)
if (storyData) handleAdd(storyId, storyData)
}}
/>,
<SprintBacklogLeft
key="sprint"
sprintId={sprintId}
isDemo={isDemo}
onRemove={handleRemove}
onSelect={(storyId) => useSprintWorkspaceStore.getState().setActiveStory(storyId)}
selectedStoryId={selectedStoryId}
currentUserId={currentUserId}
productId={productId}
members={members}
onAssigneeChange={handleAssigneeChange}
/>,
selectedStoryId ? (
<TaskList
key="tasks"
sprintId={sprintId}
productId={productId}
isDemo={isDemo}
/>
) : (
<div key="tasks-empty" className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">Selecteer een story om de taken te bekijken.</p>
</div>
),
]}
/>
<DragOverlay>
{activeDragStory && (
<div className="flex items-center gap-3 px-6 py-2 bg-popover border border-primary rounded shadow-lg text-sm opacity-95 w-72" data-debug-id="sprint-board-client__drag-overlay">
<span className="text-muted-foreground select-none"></span>
<span className="truncate flex-1">{activeDragStory.title}</span>
</div>
)}
</DragOverlay>
</DndContext>
</div>
)
}