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>
This commit is contained in:
parent
b6249a41c0
commit
ff22196714
43 changed files with 296 additions and 951 deletions
|
|
@ -1,26 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
rectSortingStrategy,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
import { CheckSquare, Square } from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -40,7 +20,6 @@ import {
|
|||
selectStoryIsBlocked,
|
||||
} from '@/stores/product-workspace/selectors'
|
||||
import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types'
|
||||
import { reorderStoriesAction } from '@/actions/stories'
|
||||
import { StoryDialog, type StoryDialogState } from './story-dialog'
|
||||
import { debugProps } from '@/lib/debug'
|
||||
import { BacklogCard } from './backlog-card'
|
||||
|
|
@ -80,8 +59,7 @@ interface StoryPanelProps {
|
|||
activeSprintId?: string | null
|
||||
}
|
||||
|
||||
// --- Sortable story block ---
|
||||
function SortableStoryBlock({
|
||||
function StoryBlock({
|
||||
story,
|
||||
isSelected,
|
||||
cherrypick,
|
||||
|
|
@ -98,26 +76,11 @@ function SortableStoryBlock({
|
|||
onSelect: () => void
|
||||
onEdit: () => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: story.id,
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<BacklogCard
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title={story.title}
|
||||
code={story.code}
|
||||
priority={story.priority}
|
||||
isDragging={isDragging}
|
||||
isSelected={isSelected}
|
||||
onClick={onSelect}
|
||||
badge={
|
||||
|
|
@ -196,8 +159,6 @@ function StoryCherrypickButton({
|
|||
}
|
||||
|
||||
// --- Main component ---
|
||||
// PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi
|
||||
// (useShallow). DnD via applyOptimisticMutation('story-order').
|
||||
export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPanelProps) {
|
||||
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
|
||||
const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId)
|
||||
|
|
@ -210,14 +171,8 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa
|
|||
const setPref = useUserSettingsStore((s) => s.setPref)
|
||||
const setSortMode = (v: SortMode) => void setPref(['views', 'storyPanel', 'sort'], v)
|
||||
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [, startTransition] = useTransition()
|
||||
|
||||
// rawStories komt al gesorteerd binnen via selectStoriesForActivePbi.
|
||||
const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s]))
|
||||
const orderedStories = rawStories
|
||||
|
||||
const base = orderedStories
|
||||
const base = rawStories
|
||||
.filter(s => !filterStatus || s.status === filterStatus)
|
||||
.filter(s => !filterPriority || s.priority === filterPriority)
|
||||
|
||||
|
|
@ -231,74 +186,6 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa
|
|||
return a.priority !== b.priority ? a.priority - b.priority : 0
|
||||
})
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveDragId(event.active.id as string)
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveDragId(null)
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id || !selectedPbiId) return
|
||||
|
||||
const activeStory = storyMap[active.id as string]
|
||||
const overStory = storyMap[over.id as string]
|
||||
if (!activeStory || !overStory) return
|
||||
|
||||
const store = useProductWorkspaceStore.getState()
|
||||
const prevOrder = [...(store.relations.storyIdsByPbi[selectedPbiId] ?? [])]
|
||||
const oldIndex = prevOrder.indexOf(active.id as string)
|
||||
const newIndex = prevOrder.indexOf(over.id as string)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
const newOrder = arrayMove([...prevOrder], oldIndex, newIndex)
|
||||
|
||||
const orderMutationId = store.applyOptimisticMutation({
|
||||
kind: 'story-order',
|
||||
pbiId: selectedPbiId,
|
||||
prevStoryIds: prevOrder,
|
||||
})
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.relations.storyIdsByPbi[selectedPbiId] = newOrder
|
||||
})
|
||||
|
||||
const priorityChanged = activeStory.priority !== overStory.priority
|
||||
let priorityMutationId: string | null = null
|
||||
if (priorityChanged) {
|
||||
priorityMutationId = store.applyOptimisticMutation({
|
||||
kind: 'entity-patch',
|
||||
entity: 'story',
|
||||
id: active.id as string,
|
||||
prev: store.entities.storiesById[active.id as string],
|
||||
})
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
const story = s.entities.storiesById[active.id as string]
|
||||
if (story) story.priority = overStory.priority
|
||||
})
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await reorderStoriesAction(
|
||||
selectedPbiId,
|
||||
productId,
|
||||
newOrder,
|
||||
priorityChanged ? overStory.priority : undefined
|
||||
)
|
||||
const st = useProductWorkspaceStore.getState()
|
||||
if (result.success) {
|
||||
if (priorityMutationId) st.settleMutation(priorityMutationId)
|
||||
st.settleMutation(orderMutationId)
|
||||
} else {
|
||||
if (priorityMutationId) st.rollbackMutation(priorityMutationId)
|
||||
st.rollbackMutation(orderMutationId)
|
||||
toast.error('Volgorde opslaan mislukt')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const hasActiveFilters = filterStatus !== null || filterPriority !== null
|
||||
|
||||
return (
|
||||
|
|
@ -361,39 +248,19 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa
|
|||
action={{ label: 'Maak je eerste story aan', onClick: () => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }}
|
||||
/>
|
||||
) : (
|
||||
<DndContext
|
||||
id="story-panel"
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{filtered.map(story => (
|
||||
<StoryBlockWithCherrypick
|
||||
key={story.id}
|
||||
story={story}
|
||||
productId={productId}
|
||||
activeSprintId={activeSprintId}
|
||||
isSelected={selectedStoryId === story.id}
|
||||
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
|
||||
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay>
|
||||
{activeDragId && storyMap[activeDragId] && (
|
||||
<BacklogCard
|
||||
title={storyMap[activeDragId].title}
|
||||
priority={storyMap[activeDragId].priority}
|
||||
className="border-primary shadow-xl opacity-90"
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{filtered.map(story => (
|
||||
<StoryBlockWithCherrypick
|
||||
key={story.id}
|
||||
story={story}
|
||||
productId={productId}
|
||||
activeSprintId={activeSprintId}
|
||||
isSelected={selectedStoryId === story.id}
|
||||
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
|
||||
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -406,9 +273,7 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa
|
|||
)
|
||||
}
|
||||
|
||||
// PBI-79 / ST-1337: wrapper rond SortableStoryBlock met cherrypick-handling.
|
||||
// Subscribed per story zodat enkel de relevante rij re-rendert bij draft- of
|
||||
// crossSprintBlocks-mutaties.
|
||||
// PBI-79 / ST-1337: wrapper rond StoryBlock met cherrypick-handling.
|
||||
function StoryBlockWithCherrypick({
|
||||
story,
|
||||
productId,
|
||||
|
|
@ -443,7 +308,6 @@ function StoryBlockWithCherrypick({
|
|||
} | null = null
|
||||
|
||||
if (draft) {
|
||||
// State A′: muteer draft via per-PBI overrides.
|
||||
const intent = draft.pbiIntent[story.pbi_id] ?? 'none'
|
||||
const override = draft.storyOverrides[story.pbi_id] ?? {
|
||||
add: [],
|
||||
|
|
@ -474,7 +338,6 @@ function StoryBlockWithCherrypick({
|
|||
},
|
||||
}
|
||||
} else if (activeSprintId) {
|
||||
// State B: muteer pending buffer via toggleStorySprintMembership.
|
||||
const inSprintDb = story.sprint_id === activeSprintId
|
||||
const inAdds = pending.adds.includes(story.id)
|
||||
const inRemoves = pending.removes.includes(story.id)
|
||||
|
|
@ -489,7 +352,7 @@ function StoryBlockWithCherrypick({
|
|||
}
|
||||
|
||||
return (
|
||||
<SortableStoryBlock
|
||||
<StoryBlock
|
||||
story={story}
|
||||
isSelected={isSelected}
|
||||
cherrypick={cherrypick}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue