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
|
|
@ -520,7 +520,7 @@ body
|
||||||
.mockResolvedValueOnce({ id: 't-B1' })
|
.mockResolvedValueOnce({ id: 't-B1' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids', async () => {
|
it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids; sort_order = parseCodeNumber(code)', async () => {
|
||||||
const r = await materializeIdeaPlanAction('idea-1')
|
const r = await materializeIdeaPlanAction('idea-1')
|
||||||
expect(r).toMatchObject({
|
expect(r).toMatchObject({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -534,6 +534,15 @@ body
|
||||||
expect(m.pbi.create).toHaveBeenCalledTimes(1)
|
expect(m.pbi.create).toHaveBeenCalledTimes(1)
|
||||||
expect(m.story.create).toHaveBeenCalledTimes(2)
|
expect(m.story.create).toHaveBeenCalledTimes(2)
|
||||||
expect(m.task.create).toHaveBeenCalledTimes(3)
|
expect(m.task.create).toHaveBeenCalledTimes(3)
|
||||||
|
|
||||||
|
// story sort_order = parseCodeNumber(auto-code): ST-001→1, ST-002→2
|
||||||
|
expect(m.story.create.mock.calls[0][0].data.sort_order).toBe(1)
|
||||||
|
expect(m.story.create.mock.calls[1][0].data.sort_order).toBe(2)
|
||||||
|
|
||||||
|
// task sort_order = parseCodeNumber(auto-code): T-1→1, T-2→2, T-3→3
|
||||||
|
expect(m.task.create.mock.calls[0][0].data.sort_order).toBe(1)
|
||||||
|
expect(m.task.create.mock.calls[1][0].data.sort_order).toBe(2)
|
||||||
|
expect(m.task.create.mock.calls[2][0].data.sort_order).toBe(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('blocks when not PLAN_READY (e.g. GRILLED)', async () => {
|
it('blocks when not PLAN_READY (e.g. GRILLED)', async () => {
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ describe('GET /api/products/:id/next-story', () => {
|
||||||
expect(data.tasks[0]).toMatchObject({ id: 'task-1', status: 'todo' })
|
expect(data.tasks[0]).toMatchObject({ id: 'task-1', status: 'todo' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('queries story ordered by priority then sort_order', async () => {
|
it('queries story ordered by sort_order only', async () => {
|
||||||
mockPrisma.sprint.findFirst.mockResolvedValue(SPRINT)
|
mockPrisma.sprint.findFirst.mockResolvedValue(SPRINT)
|
||||||
mockPrisma.story.findFirst.mockResolvedValue(STORY)
|
mockPrisma.story.findFirst.mockResolvedValue(STORY)
|
||||||
|
|
||||||
|
|
@ -103,7 +103,7 @@ describe('GET /api/products/:id/next-story', () => {
|
||||||
|
|
||||||
expect(mockPrisma.story.findFirst).toHaveBeenCalledWith(
|
expect(mockPrisma.story.findFirst).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@/lib/prisma', () => ({
|
|
||||||
prisma: {
|
|
||||||
story: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
},
|
|
||||||
task: {
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
$transaction: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/lib/api-auth', () => ({
|
|
||||||
authenticateApiRequest: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
|
||||||
import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
story: { findFirst: ReturnType<typeof vi.fn> }
|
|
||||||
task: { update: ReturnType<typeof vi.fn> }
|
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
function makeStory(taskIds: string[]) {
|
|
||||||
return {
|
|
||||||
id: 'story-1',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
tasks: taskIds.map(id => ({ id })),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeRequest(body: unknown, storyId = 'story-1'): [Request, { params: Promise<{ id: string }> }] {
|
|
||||||
return [
|
|
||||||
new Request(`http://localhost/api/stories/${storyId}/tasks/reorder`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
{ params: Promise.resolve({ id: storyId }) },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('PATCH /api/stories/:id/tasks/reorder', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
|
||||||
mockPrisma.$transaction.mockResolvedValue([])
|
|
||||||
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', sort_order: 1 })
|
|
||||||
})
|
|
||||||
|
|
||||||
// TC-RO-06 — body validation fires before story lookup
|
|
||||||
it('returns 422 when task_ids is an empty array', async () => {
|
|
||||||
const res = await patchReorder(...makeRequest({ task_ids: [] }))
|
|
||||||
expect(res.status).toBe(422)
|
|
||||||
expect(mockPrisma.story.findFirst).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
// TC-RO-07
|
|
||||||
it('returns 422 when task_ids is not an array', async () => {
|
|
||||||
const res = await patchReorder(...makeRequest({ task_ids: 'task-1' }))
|
|
||||||
expect(res.status).toBe(422)
|
|
||||||
expect(mockPrisma.story.findFirst).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns 422 when task_ids is missing entirely', async () => {
|
|
||||||
const res = await patchReorder(...makeRequest({}))
|
|
||||||
expect(res.status).toBe(422)
|
|
||||||
})
|
|
||||||
|
|
||||||
// TC-RO-08
|
|
||||||
it('returns 422 when task_ids contains an ID not belonging to the story', async () => {
|
|
||||||
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2']))
|
|
||||||
|
|
||||||
const res = await patchReorder(...makeRequest({ task_ids: ['task-1', 'task-from-other-story'] }))
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
expect(res.status).toBe(422)
|
|
||||||
expect(data.error).toContain('task-from-other-story')
|
|
||||||
})
|
|
||||||
|
|
||||||
// TC-RO-09
|
|
||||||
it('reorders tasks and returns 200 with success: true', async () => {
|
|
||||||
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2', 'task-3']))
|
|
||||||
|
|
||||||
const res = await patchReorder(...makeRequest({ task_ids: ['task-3', 'task-1', 'task-2'] }))
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(data).toEqual({ success: true })
|
|
||||||
expect(mockPrisma.$transaction).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates each task with its new sort_order index', async () => {
|
|
||||||
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2']))
|
|
||||||
|
|
||||||
await patchReorder(...makeRequest({ task_ids: ['task-2', 'task-1'] }))
|
|
||||||
|
|
||||||
expect(mockPrisma.task.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ where: { id: 'task-2' }, data: { sort_order: 1 } })
|
|
||||||
)
|
|
||||||
expect(mockPrisma.task.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ where: { id: 'task-1' }, data: { sort_order: 2 } })
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -54,7 +54,6 @@ import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
import { GET as getProducts } from '@/app/api/products/route'
|
import { GET as getProducts } from '@/app/api/products/route'
|
||||||
import { GET as getNextStory } from '@/app/api/products/[id]/next-story/route'
|
import { GET as getNextStory } from '@/app/api/products/[id]/next-story/route'
|
||||||
import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route'
|
import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route'
|
||||||
import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route'
|
|
||||||
import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route'
|
import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route'
|
||||||
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
|
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
|
||||||
|
|
||||||
|
|
@ -276,56 +275,6 @@ describe('GET /api/sprints/:id/tasks', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── PATCH /api/stories/:id/tasks/reorder ────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PATCH /api/stories/:id/tasks/reorder', () => {
|
|
||||||
const VALID_BODY = { task_ids: ['task-x'] }
|
|
||||||
|
|
||||||
// TC-RO-01
|
|
||||||
it('returns 401 when no valid token provided', async () => {
|
|
||||||
mockAuth.mockResolvedValue(UNAUTHORIZED)
|
|
||||||
const res = await patchReorder(
|
|
||||||
makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY),
|
|
||||||
routeCtx('story-1')
|
|
||||||
)
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
// TC-RO-03
|
|
||||||
it('returns 403 for demo users', async () => {
|
|
||||||
mockAuth.mockResolvedValue(DEMO_AUTH)
|
|
||||||
const res = await patchReorder(
|
|
||||||
makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY),
|
|
||||||
routeCtx('story-1')
|
|
||||||
)
|
|
||||||
expect(res.status).toBe(403)
|
|
||||||
const data = await res.json()
|
|
||||||
expect(data.error).toBe('Niet beschikbaar in demo-modus')
|
|
||||||
})
|
|
||||||
|
|
||||||
// TC-RO-04 / TC-RO-05
|
|
||||||
it('returns 404 when story is not accessible to the authenticated user', async () => {
|
|
||||||
mockAuth.mockResolvedValue(USER_2_AUTH)
|
|
||||||
mockPrisma.story.findFirst.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const res = await patchReorder(
|
|
||||||
makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY),
|
|
||||||
routeCtx('story-1')
|
|
||||||
)
|
|
||||||
expect(res.status).toBe(404)
|
|
||||||
expect(mockPrisma.story.findFirst).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: expect.objectContaining({
|
|
||||||
id: 'story-1',
|
|
||||||
product: expect.objectContaining({
|
|
||||||
OR: expect.arrayContaining([{ user_id: 'user-2' }]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ─── POST /api/stories/:id/log ────────────────────────────────────────────────
|
// ─── POST /api/stories/:id/log ────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('POST /api/stories/:id/log', () => {
|
describe('POST /api/stories/:id/log', () => {
|
||||||
|
|
|
||||||
|
|
@ -25,18 +25,16 @@ Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, wri
|
||||||
|
|
||||||
// Mock server actions
|
// Mock server actions
|
||||||
vi.mock('@/actions/stories', () => ({
|
vi.mock('@/actions/stories', () => ({
|
||||||
reorderStoriesAction: vi.fn().mockResolvedValue({ success: true }),
|
|
||||||
reorderPbisAction: vi.fn().mockResolvedValue({ success: true }),
|
reorderPbisAction: vi.fn().mockResolvedValue({ success: true }),
|
||||||
updatePbiPriorityAction: vi.fn().mockResolvedValue({ success: true }),
|
updatePbiPriorityAction: vi.fn().mockResolvedValue({ success: true }),
|
||||||
}))
|
}))
|
||||||
vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) }))
|
vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) }))
|
||||||
vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) }))
|
|
||||||
vi.mock('@/actions/user-settings', () => ({
|
vi.mock('@/actions/user-settings', () => ({
|
||||||
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
|
updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }),
|
||||||
}))
|
}))
|
||||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||||
|
|
||||||
// Mock dnd-kit
|
// Mock dnd-kit (still needed for PBI panel which supports drag-and-drop)
|
||||||
vi.mock('@dnd-kit/core', () => ({
|
vi.mock('@dnd-kit/core', () => ({
|
||||||
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
PointerSensor: class {},
|
PointerSensor: class {},
|
||||||
|
|
@ -71,7 +69,7 @@ const STORIES: BacklogStory[] = [
|
||||||
{ id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, sort_order: 1, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() },
|
{ id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, sort_order: 1, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() },
|
||||||
]
|
]
|
||||||
const TASKS: BacklogTask[] = [
|
const TASKS: BacklogTask[] = [
|
||||||
{ id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
|
{ id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
|
||||||
]
|
]
|
||||||
|
|
||||||
function resetStores() {
|
function resetStores() {
|
||||||
|
|
|
||||||
|
|
@ -33,37 +33,8 @@ function setActiveStoryAndTasks(storyId: string | null, tasks: BacklogTask[] = [
|
||||||
const mockPush = vi.fn()
|
const mockPush = vi.fn()
|
||||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) }))
|
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) }))
|
||||||
|
|
||||||
// Mock reorderTasksAction
|
|
||||||
vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) }))
|
|
||||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||||
|
|
||||||
// Mock dnd-kit to avoid jsdom drag complexity
|
|
||||||
vi.mock('@dnd-kit/core', () => ({
|
|
||||||
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
PointerSensor: class {},
|
|
||||||
KeyboardSensor: class {},
|
|
||||||
useSensor: vi.fn(),
|
|
||||||
useSensors: vi.fn(() => []),
|
|
||||||
closestCenter: vi.fn(),
|
|
||||||
DragOverlay: () => null,
|
|
||||||
}))
|
|
||||||
vi.mock('@dnd-kit/sortable', () => ({
|
|
||||||
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
||||||
useSortable: () => ({
|
|
||||||
attributes: {}, listeners: {}, setNodeRef: vi.fn(),
|
|
||||||
transform: null, transition: undefined, isDragging: false,
|
|
||||||
}),
|
|
||||||
rectSortingStrategy: {},
|
|
||||||
sortableKeyboardCoordinates: {},
|
|
||||||
arrayMove: (arr: unknown[], from: number, to: number) => {
|
|
||||||
const next = [...arr]
|
|
||||||
next.splice(from, 1)
|
|
||||||
next.splice(to, 0, arr[from])
|
|
||||||
return next
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } }))
|
|
||||||
|
|
||||||
import { TaskPanel } from '@/components/backlog/task-panel'
|
import { TaskPanel } from '@/components/backlog/task-panel'
|
||||||
|
|
||||||
const PRODUCT_ID = 'prod-1'
|
const PRODUCT_ID = 'prod-1'
|
||||||
|
|
@ -71,8 +42,8 @@ const STORY_ID = 'story-1'
|
||||||
const CLOSE_PATH = `/products/${PRODUCT_ID}`
|
const CLOSE_PATH = `/products/${PRODUCT_ID}`
|
||||||
|
|
||||||
const TASKS = [
|
const TASKS = [
|
||||||
{ id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
|
{ id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
|
||||||
{ id: 'task-2', title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() },
|
{ id: 'task-2', code: null, title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() },
|
||||||
]
|
]
|
||||||
|
|
||||||
function renderPanel(isDemo = false) {
|
function renderPanel(isDemo = false) {
|
||||||
|
|
@ -141,12 +112,4 @@ describe('TaskPanel', () => {
|
||||||
expect((btn as HTMLButtonElement).disabled).toBe(true)
|
expect((btn as HTMLButtonElement).disabled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cards have no drag listeners in demo mode (whole-card drag disabled)', () => {
|
|
||||||
setActiveStoryAndTasks(STORY_ID, TASKS)
|
|
||||||
// In demo mode, listeners ({} from useSortable mock) are not spread onto the card.
|
|
||||||
// The mock always returns empty listeners, so we just verify the cards render without error.
|
|
||||||
renderPanel(true)
|
|
||||||
expect(screen.getByText('Eerste taak')).toBeTruthy()
|
|
||||||
expect(screen.getByText('Tweede taak')).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
25
__tests__/lib/code.test.ts
Normal file
25
__tests__/lib/code.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
import { parseCodeNumber } from '@/lib/code'
|
||||||
|
|
||||||
|
describe('parseCodeNumber', () => {
|
||||||
|
it('parses a standard story code', () => {
|
||||||
|
expect(parseCodeNumber('ST-001')).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses a task code', () => {
|
||||||
|
expect(parseCodeNumber('T-42')).toBe(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses a large number', () => {
|
||||||
|
expect(parseCodeNumber('ST-1000')).toBe(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns MAX_SAFE_INTEGER for a code with no trailing digits', () => {
|
||||||
|
expect(parseCodeNumber('FOO')).toBe(Number.MAX_SAFE_INTEGER)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns MAX_SAFE_INTEGER for an empty string', () => {
|
||||||
|
expect(parseCodeNumber('')).toBe(Number.MAX_SAFE_INTEGER)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -106,6 +106,7 @@ function makeStory(overrides: Partial<BacklogStory> & { id: string; pbi_id: stri
|
||||||
function makeTask(overrides: Partial<BacklogTask> & { id: string; story_id: string }): BacklogTask {
|
function makeTask(overrides: Partial<BacklogTask> & { id: string; story_id: string }): BacklogTask {
|
||||||
return {
|
return {
|
||||||
id: overrides.id,
|
id: overrides.id,
|
||||||
|
code: overrides.code ?? null,
|
||||||
title: overrides.title ?? `Task ${overrides.id}`,
|
title: overrides.title ?? `Task ${overrides.id}`,
|
||||||
description: overrides.description ?? null,
|
description: overrides.description ?? null,
|
||||||
priority: overrides.priority ?? 2,
|
priority: overrides.priority ?? 2,
|
||||||
|
|
|
||||||
|
|
@ -852,56 +852,6 @@ describe('restore-hint flow — chain triggert na ensure*Loaded', () => {
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('optimistic mutations', () => {
|
describe('optimistic mutations', () => {
|
||||||
it('rollback herstelt vorige sprint-story-order', () => {
|
|
||||||
useSprintWorkspaceStore.getState().hydrateSnapshot(
|
|
||||||
snapshotWith(
|
|
||||||
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
|
|
||||||
[
|
|
||||||
makeStory({ id: 'a', pbi_id: 'p', sprint_id: 'sp-1', sort_order: 1 }),
|
|
||||||
makeStory({ id: 'b', pbi_id: 'p', sprint_id: 'sp-1', sort_order: 2 }),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const prevOrder = [
|
|
||||||
...useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1'],
|
|
||||||
]
|
|
||||||
|
|
||||||
const id = useSprintWorkspaceStore.getState().applyOptimisticMutation({
|
|
||||||
kind: 'sprint-story-order',
|
|
||||||
sprintId: 'sp-1',
|
|
||||||
prevStoryIds: prevOrder,
|
|
||||||
})
|
|
||||||
useSprintWorkspaceStore.setState((s) => {
|
|
||||||
s.relations.storyIdsBySprint['sp-1'] = ['b', 'a']
|
|
||||||
})
|
|
||||||
|
|
||||||
useSprintWorkspaceStore.getState().rollbackMutation(id)
|
|
||||||
expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual(
|
|
||||||
prevOrder,
|
|
||||||
)
|
|
||||||
expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('settle ruimt pending op zonder state te wijzigen', () => {
|
|
||||||
useSprintWorkspaceStore.getState().hydrateSnapshot(
|
|
||||||
snapshotWith(makeSprint({ id: 'sp-1', product_id: 'prod-1' }), [
|
|
||||||
makeStory({ id: 'a', pbi_id: 'p', sprint_id: 'sp-1' }),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
const id = useSprintWorkspaceStore.getState().applyOptimisticMutation({
|
|
||||||
kind: 'sprint-story-order',
|
|
||||||
sprintId: 'sp-1',
|
|
||||||
prevStoryIds: ['a'],
|
|
||||||
})
|
|
||||||
expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeDefined()
|
|
||||||
|
|
||||||
useSprintWorkspaceStore.getState().settleMutation(id)
|
|
||||||
expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeUndefined()
|
|
||||||
expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual([
|
|
||||||
'a',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('SSE-echo van een al-bestaande sprint is idempotent', () => {
|
it('SSE-echo van een al-bestaande sprint is idempotent', () => {
|
||||||
useSprintWorkspaceStore.setState((s) => {
|
useSprintWorkspaceStore.setState((s) => {
|
||||||
s.context.activeProduct = { id: 'prod-1', name: 'P' }
|
s.context.activeProduct = { id: 'prod-1', name: 'P' }
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { canTransition, isGrillMdEditable, isIdeaEditable, isPlanMdEditable } fr
|
||||||
import { nextIdeaCode } from '@/lib/idea-code-server'
|
import { nextIdeaCode } from '@/lib/idea-code-server'
|
||||||
import { parsePlanMd } from '@/lib/idea-plan-parser'
|
import { parsePlanMd } from '@/lib/idea-plan-parser'
|
||||||
import { ACTIVE_JOB_STATUSES } from '@/lib/job-status'
|
import { ACTIVE_JOB_STATUSES } from '@/lib/job-status'
|
||||||
|
import { parseCodeNumber } from '@/lib/code'
|
||||||
|
|
||||||
import type { ClaudeJobKind, Idea, IdeaStatus } from '@prisma/client'
|
import type { ClaudeJobKind, Idea, IdeaStatus } from '@prisma/client'
|
||||||
|
|
||||||
|
|
@ -720,16 +721,17 @@ export async function materializeIdeaPlanAction(
|
||||||
|
|
||||||
for (let si = 0; si < plan.stories.length; si++) {
|
for (let si = 0; si < plan.stories.length; si++) {
|
||||||
const s = plan.stories[si]
|
const s = plan.stories[si]
|
||||||
|
const storyCode = `ST-${String(nextStoryN++).padStart(3, '0')}`
|
||||||
const story = await tx.story.create({
|
const story = await tx.story.create({
|
||||||
data: {
|
data: {
|
||||||
pbi_id: pbi.id,
|
pbi_id: pbi.id,
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
code: `ST-${String(nextStoryN++).padStart(3, '0')}`,
|
code: storyCode,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
description: s.description ?? null,
|
description: s.description ?? null,
|
||||||
acceptance_criteria: s.acceptance_criteria ?? null,
|
acceptance_criteria: s.acceptance_criteria ?? null,
|
||||||
priority: s.priority,
|
priority: s.priority,
|
||||||
sort_order: si + 1, // sequential within PBI
|
sort_order: parseCodeNumber(storyCode),
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
|
|
@ -738,11 +740,12 @@ export async function materializeIdeaPlanAction(
|
||||||
|
|
||||||
for (let ti = 0; ti < s.tasks.length; ti++) {
|
for (let ti = 0; ti < s.tasks.length; ti++) {
|
||||||
const t = s.tasks[ti]
|
const t = s.tasks[ti]
|
||||||
|
const taskCode = `T-${nextTaskN++}`
|
||||||
const task = await tx.task.create({
|
const task = await tx.task.create({
|
||||||
data: {
|
data: {
|
||||||
story_id: story.id,
|
story_id: story.id,
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
code: `T-${nextTaskN++}`,
|
code: taskCode,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
description: t.description ?? null,
|
description: t.description ?? null,
|
||||||
implementation_plan: t.implementation_plan ?? null,
|
implementation_plan: t.implementation_plan ?? null,
|
||||||
|
|
@ -751,7 +754,7 @@ export async function materializeIdeaPlanAction(
|
||||||
// gemixte task-priorities binnen één story zouden anders de
|
// gemixte task-priorities binnen één story zouden anders de
|
||||||
// YAML-volgorde verstoren (zie plan-fix task-volgorde-na-upload).
|
// YAML-volgorde verstoren (zie plan-fix task-volgorde-na-upload).
|
||||||
priority: s.priority,
|
priority: s.priority,
|
||||||
sort_order: ti + 1,
|
sort_order: parseCodeNumber(taskCode),
|
||||||
status: 'TO_DO',
|
status: 'TO_DO',
|
||||||
verify_required: t.verify_required ?? 'ALIGNED_OR_PARTIAL',
|
verify_required: t.verify_required ?? 'ALIGNED_OR_PARTIAL',
|
||||||
verify_only: t.verify_only ?? false,
|
verify_only: t.verify_only ?? false,
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,10 @@ async function startSprintRunCore(
|
||||||
// TO_DO, dus EXCLUDED/IN_PROGRESS/REVIEW/DONE/FAILED tasks komen niet
|
// TO_DO, dus EXCLUDED/IN_PROGRESS/REVIEW/DONE/FAILED tasks komen niet
|
||||||
// terecht in pre-flight blockers, jobs of SprintTaskExecution-rijen.
|
// terecht in pre-flight blockers, jobs of SprintTaskExecution-rijen.
|
||||||
where: { status: 'TO_DO' },
|
where: { status: 'TO_DO' },
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }],
|
||||||
})
|
})
|
||||||
|
|
||||||
const blockers: PreFlightBlocker[] = []
|
const blockers: PreFlightBlocker[] = []
|
||||||
|
|
@ -167,7 +167,6 @@ async function startSprintRunCore(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
a.pbi.priority - b.pbi.priority ||
|
a.pbi.priority - b.pbi.priority ||
|
||||||
a.pbi.sort_order - b.pbi.sort_order ||
|
a.pbi.sort_order - b.pbi.sort_order ||
|
||||||
a.priority - b.priority ||
|
|
||||||
a.sort_order - b.sort_order,
|
a.sort_order - b.sort_order,
|
||||||
)
|
)
|
||||||
.flatMap((s) => s.tasks)
|
.flatMap((s) => s.tasks)
|
||||||
|
|
|
||||||
|
|
@ -431,7 +431,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
||||||
if (pbi) {
|
if (pbi) {
|
||||||
const stories = await prisma.story.findMany({
|
const stories = await prisma.story.findMany({
|
||||||
where: { pbi_id: pbi.id, sprint_id: null },
|
where: { pbi_id: pbi.id, sprint_id: null },
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }],
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
if (stories.length > 0) {
|
if (stories.length > 0) {
|
||||||
|
|
@ -440,7 +440,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
||||||
...stories.map((s, i) =>
|
...stories.map((s, i) =>
|
||||||
prisma.story.update({
|
prisma.story.update({
|
||||||
where: { id: s.id },
|
where: { id: s.id },
|
||||||
data: { sprint_id: sprint.id, status: 'IN_SPRINT', sort_order: i + 1 },
|
data: { sprint_id: sprint.id, status: 'IN_SPRINT' },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
prisma.task.updateMany({
|
prisma.task.updateMany({
|
||||||
|
|
@ -531,14 +531,9 @@ export async function addStoryToSprintAction(sprintId: string, storyId: string)
|
||||||
if (!story) return { error: 'Story niet gevonden' }
|
if (!story) return { error: 'Story niet gevonden' }
|
||||||
if (story.product_id !== sprint.product_id) return { error: 'Story hoort niet bij deze Sprint' }
|
if (story.product_id !== sprint.product_id) return { error: 'Story hoort niet bij deze Sprint' }
|
||||||
|
|
||||||
const last = await prisma.story.findFirst({
|
|
||||||
where: { sprint_id: sprintId },
|
|
||||||
orderBy: { sort_order: 'desc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.story.update({
|
await prisma.story.update({
|
||||||
where: { id: storyId },
|
where: { id: storyId },
|
||||||
data: { sprint_id: sprintId, status: 'IN_SPRINT', sort_order: (last?.sort_order ?? 0) + 1.0 },
|
data: { sprint_id: sprintId, status: 'IN_SPRINT' },
|
||||||
})
|
})
|
||||||
|
|
||||||
revalidatePath(`/products/${sprint.product_id}/sprint`)
|
revalidatePath(`/products/${sprint.product_id}/sprint`)
|
||||||
|
|
@ -567,32 +562,6 @@ export async function removeStoryFromSprintAction(storyId: string) {
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderSprintStoriesAction(sprintId: string, orderedIds: string[]) {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
|
||||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
|
||||||
if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige Sprint Backlog-volgorde' }
|
|
||||||
|
|
||||||
const sprint = await prisma.sprint.findFirst({
|
|
||||||
where: { id: sprintId, product: productAccessFilter(session.userId) },
|
|
||||||
})
|
|
||||||
if (!sprint) return { error: 'Sprint niet gevonden' }
|
|
||||||
|
|
||||||
const stories = await prisma.story.findMany({
|
|
||||||
where: { id: { in: orderedIds }, sprint_id: sprintId, product_id: sprint.product_id },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
if (stories.length !== orderedIds.length) return { error: 'Ongeldige Sprint Backlog-volgorde' }
|
|
||||||
|
|
||||||
await prisma.$transaction(
|
|
||||||
orderedIds.map((id, i) =>
|
|
||||||
prisma.story.update({ where: { id }, data: { sort_order: i + 1.0 } })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
revalidatePath(`/products/${sprint.product_id}/sprint`)
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function completeSprintAction(
|
export async function completeSprintAction(
|
||||||
sprintId: string,
|
sprintId: string,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { prisma } from '@/lib/prisma'
|
||||||
import { SessionData, sessionOptions } from '@/lib/session'
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access'
|
import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access'
|
||||||
import { requireProductWriter } from '@/lib/auth'
|
import { requireProductWriter } from '@/lib/auth'
|
||||||
import { isValidCode, normalizeCode } from '@/lib/code'
|
import { isValidCode, normalizeCode, parseCodeNumber } from '@/lib/code'
|
||||||
import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server'
|
import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server'
|
||||||
import { createStorySchema, updateStorySchema } from '@/lib/schemas/story'
|
import { createStorySchema, updateStorySchema } from '@/lib/schemas/story'
|
||||||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
@ -78,12 +78,6 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const last = await prisma.story.findFirst({
|
|
||||||
where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority },
|
|
||||||
orderBy: { sort_order: 'desc' },
|
|
||||||
})
|
|
||||||
const sort_order = (last?.sort_order ?? 0) + 1.0
|
|
||||||
|
|
||||||
const insert = (code: string) =>
|
const insert = (code: string) =>
|
||||||
prisma.story.create({
|
prisma.story.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -94,7 +88,7 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
|
||||||
description: parsed.data.description ?? null,
|
description: parsed.data.description ?? null,
|
||||||
acceptance_criteria: parsed.data.acceptance_criteria ?? null,
|
acceptance_criteria: parsed.data.acceptance_criteria ?? null,
|
||||||
priority: parsed.data.priority,
|
priority: parsed.data.priority,
|
||||||
sort_order,
|
sort_order: parseCodeNumber(code),
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -167,7 +161,7 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData)
|
||||||
await prisma.story.update({
|
await prisma.story.update({
|
||||||
where: { id: parsed.data.id },
|
where: { id: parsed.data.id },
|
||||||
data: {
|
data: {
|
||||||
...(code ? { code } : {}),
|
...(code ? { code, sort_order: parseCodeNumber(code) } : {}),
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
description: parsed.data.description ?? null,
|
description: parsed.data.description ?? null,
|
||||||
acceptance_criteria: parsed.data.acceptance_criteria ?? null,
|
acceptance_criteria: parsed.data.acceptance_criteria ?? null,
|
||||||
|
|
@ -363,43 +357,3 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string)
|
||||||
return { success: true, count: result.count }
|
return { success: true, count: result.count }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderStoriesAction(
|
|
||||||
pbiId: string,
|
|
||||||
productId: string,
|
|
||||||
orderedIds: string[],
|
|
||||||
newPriority?: number
|
|
||||||
) {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
|
||||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
|
||||||
if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige story-volgorde' }
|
|
||||||
if (newPriority !== undefined && (!Number.isInteger(newPriority) || newPriority < 1 || newPriority > 4)) {
|
|
||||||
return { error: 'Ongeldige prioriteit' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const pbi = await prisma.pbi.findFirst({
|
|
||||||
where: { id: pbiId, product: productAccessFilter(session.userId) },
|
|
||||||
})
|
|
||||||
if (!pbi) return { error: 'PBI niet gevonden' }
|
|
||||||
|
|
||||||
const stories = await prisma.story.findMany({
|
|
||||||
where: { id: { in: orderedIds }, pbi_id: pbiId, product_id: pbi.product_id },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
if (stories.length !== orderedIds.length) return { error: 'Ongeldige story-volgorde' }
|
|
||||||
|
|
||||||
await prisma.$transaction(
|
|
||||||
orderedIds.map((id, i) =>
|
|
||||||
prisma.story.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
sort_order: i + 1.0,
|
|
||||||
...(newPriority !== undefined ? { priority: newPriority } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
revalidatePath(`/products/${pbi.product_id}`)
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { productAccessFilter } from '@/lib/product-access'
|
||||||
import { requireProductWriter } from '@/lib/auth'
|
import { requireProductWriter } from '@/lib/auth'
|
||||||
import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task'
|
import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task'
|
||||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||||
import { normalizeCode } from '@/lib/code'
|
import { normalizeCode, parseCodeNumber } from '@/lib/code'
|
||||||
import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server'
|
import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server'
|
||||||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
|
|
@ -80,6 +80,7 @@ export async function saveTask(
|
||||||
description: description ?? null,
|
description: description ?? null,
|
||||||
implementation_plan: implementation_plan ?? null,
|
implementation_plan: implementation_plan ?? null,
|
||||||
priority,
|
priority,
|
||||||
|
...(inputCode ? { code: inputCode, sort_order: parseCodeNumber(inputCode) } : {}),
|
||||||
},
|
},
|
||||||
select: { id: true, title: true, status: true },
|
select: { id: true, title: true, status: true },
|
||||||
})
|
})
|
||||||
|
|
@ -106,15 +107,8 @@ export async function saveTask(
|
||||||
})
|
})
|
||||||
if (!story) return { ok: false, code: 403, error: 'forbidden' }
|
if (!story) return { ok: false, code: 403, error: 'forbidden' }
|
||||||
|
|
||||||
const last = await prisma.task.findFirst({
|
|
||||||
where: { story_id: context.storyId },
|
|
||||||
orderBy: { sort_order: 'desc' },
|
|
||||||
select: { sort_order: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const productId = story.product_id
|
const productId = story.product_id
|
||||||
const sprintId = story.sprint_id ?? null
|
const sprintId = story.sprint_id ?? null
|
||||||
const sortOrder = (last?.sort_order ?? 0) + 1.0
|
|
||||||
const storyId = context.storyId
|
const storyId = context.storyId
|
||||||
|
|
||||||
const task = await createWithCodeRetry(
|
const task = await createWithCodeRetry(
|
||||||
|
|
@ -130,7 +124,7 @@ export async function saveTask(
|
||||||
description: description ?? null,
|
description: description ?? null,
|
||||||
implementation_plan: implementation_plan ?? null,
|
implementation_plan: implementation_plan ?? null,
|
||||||
priority,
|
priority,
|
||||||
sort_order: sortOrder,
|
sort_order: parseCodeNumber(code),
|
||||||
status: 'TO_DO',
|
status: 'TO_DO',
|
||||||
},
|
},
|
||||||
select: { id: true, title: true, status: true },
|
select: { id: true, title: true, status: true },
|
||||||
|
|
@ -207,11 +201,6 @@ export async function createTaskAction(_prevState: unknown, formData: FormData)
|
||||||
})
|
})
|
||||||
if (!story) return { error: 'Story niet gevonden' }
|
if (!story) return { error: 'Story niet gevonden' }
|
||||||
|
|
||||||
const last = await prisma.task.findFirst({
|
|
||||||
where: { story_id: storyId },
|
|
||||||
orderBy: { sort_order: 'desc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const productId = story.product_id
|
const productId = story.product_id
|
||||||
const task = await createWithCodeRetry(
|
const task = await createWithCodeRetry(
|
||||||
() => generateNextTaskCode(productId),
|
() => generateNextTaskCode(productId),
|
||||||
|
|
@ -225,7 +214,7 @@ export async function createTaskAction(_prevState: unknown, formData: FormData)
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
description: parsed.data.description ?? null,
|
description: parsed.data.description ?? null,
|
||||||
priority: parsed.data.priority,
|
priority: parsed.data.priority,
|
||||||
sort_order: (last?.sort_order ?? 0) + 1.0,
|
sort_order: parseCodeNumber(code),
|
||||||
status: 'TO_DO',
|
status: 'TO_DO',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -333,22 +322,3 @@ export async function updateTaskPlanAction(taskId: string, productId: string, im
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderTasksAction(storyId: string, orderedIds: string[]) {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
|
||||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
|
||||||
|
|
||||||
const story = await prisma.story.findFirst({
|
|
||||||
where: { id: storyId, product: productAccessFilter(session.userId) },
|
|
||||||
})
|
|
||||||
if (!story) return { error: 'Story niet gevonden' }
|
|
||||||
|
|
||||||
await prisma.$transaction(
|
|
||||||
orderedIds.map((id, i) =>
|
|
||||||
prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
revalidatePath(`/products/${story.product_id}/sprint/planning`)
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
||||||
const [stories, tasks] = await Promise.all([
|
const [stories, tasks] = await Promise.all([
|
||||||
prisma.story.findMany({
|
prisma.story.findMany({
|
||||||
where: { product_id: id },
|
where: { product_id: id },
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
code: true,
|
code: true,
|
||||||
|
|
@ -75,6 +75,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
||||||
where: { story: { pbi: { product_id: id } } },
|
where: { story: { pbi: { product_id: id } } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
code: true,
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
priority: true,
|
priority: true,
|
||||||
|
|
@ -83,7 +84,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
||||||
story_id: true,
|
story_id: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
},
|
},
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
||||||
where: { sprint_id: sprint.id },
|
where: { sprint_id: sprint.id },
|
||||||
orderBy: { sort_order: 'asc' },
|
orderBy: { sort_order: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] },
|
tasks: { orderBy: [{ sort_order: 'asc' }] },
|
||||||
assignee: { select: { id: true, username: true } },
|
assignee: { select: { id: true, username: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
@ -128,7 +128,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||||
include: {
|
include: {
|
||||||
stories: {
|
stories: {
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }:
|
||||||
const [stories, tasks] = await Promise.all([
|
const [stories, tasks] = await Promise.all([
|
||||||
prisma.story.findMany({
|
prisma.story.findMany({
|
||||||
where: { product_id: id },
|
where: { product_id: id },
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
code: true,
|
code: true,
|
||||||
|
|
@ -61,6 +61,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }:
|
||||||
where: { story: { pbi: { product_id: id } } },
|
where: { story: { pbi: { product_id: id } } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
code: true,
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
priority: true,
|
priority: true,
|
||||||
|
|
@ -69,7 +70,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }:
|
||||||
story_id: true,
|
story_id: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
},
|
},
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export async function GET(
|
||||||
|
|
||||||
const stories = await prisma.story.findMany({
|
const stories = await prisma.story.findMany({
|
||||||
where: { pbi_id: id },
|
where: { pbi_id: id },
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
code: true,
|
code: true,
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export async function GET(
|
||||||
}),
|
}),
|
||||||
prisma.story.findMany({
|
prisma.story.findMany({
|
||||||
where: { product_id: id },
|
where: { product_id: id },
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
code: true,
|
code: true,
|
||||||
|
|
@ -66,6 +66,7 @@ export async function GET(
|
||||||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
code: true,
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
priority: true,
|
priority: true,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export async function GET(
|
||||||
if (activeSprint) {
|
if (activeSprint) {
|
||||||
const story = await prisma.story.findFirst({
|
const story = await prisma.story.findFirst({
|
||||||
where: { sprint_id: activeSprint.id, status: 'IN_SPRINT' },
|
where: { sprint_id: activeSprint.id, status: 'IN_SPRINT' },
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }],
|
||||||
include: {
|
include: {
|
||||||
tasks: {
|
tasks: {
|
||||||
orderBy: { sort_order: 'asc' },
|
orderBy: { sort_order: 'asc' },
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export async function GET(
|
||||||
|
|
||||||
const story = await prisma.story.findFirst({
|
const story = await prisma.story.findFirst({
|
||||||
where: { sprint_id: sprint.id, status: 'IN_SPRINT' },
|
where: { sprint_id: sprint.id, status: 'IN_SPRINT' },
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }],
|
||||||
include: {
|
include: {
|
||||||
tasks: {
|
tasks: {
|
||||||
orderBy: { sort_order: 'asc' },
|
orderBy: { sort_order: 'asc' },
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ export async function GET(
|
||||||
where: { sprint_id: id },
|
where: { sprint_id: id },
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ story: { sort_order: 'asc' } },
|
{ story: { sort_order: 'asc' } },
|
||||||
{ priority: 'asc' },
|
|
||||||
{ sort_order: 'asc' },
|
{ sort_order: 'asc' },
|
||||||
],
|
],
|
||||||
take: limit,
|
take: limit,
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export async function GET(
|
||||||
const [stories, tasks] = await Promise.all([
|
const [stories, tasks] = await Promise.all([
|
||||||
prisma.story.findMany({
|
prisma.story.findMany({
|
||||||
where: { sprint_id: id },
|
where: { sprint_id: id },
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||||
include: {
|
include: {
|
||||||
tasks: { select: { id: true, status: true } },
|
tasks: { select: { id: true, status: true } },
|
||||||
assignee: { select: { id: true, username: true } },
|
assignee: { select: { id: true, username: true } },
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { productAccessFilter } from '@/lib/product-access'
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const bodySchema = z.object({
|
|
||||||
task_ids: z.array(z.string()).min(1),
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function PATCH(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
const auth = await authenticateApiRequest(request)
|
|
||||||
if ('error' in auth) {
|
|
||||||
return Response.json({ error: auth.error }, { status: auth.status })
|
|
||||||
}
|
|
||||||
if (auth.isDemo) {
|
|
||||||
return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id: storyId } = await params
|
|
||||||
|
|
||||||
let body: unknown
|
|
||||||
try {
|
|
||||||
body = await request.json()
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: 'Malformed JSON' }, { status: 400 })
|
|
||||||
}
|
|
||||||
const parsed = bodySchema.safeParse(body)
|
|
||||||
if (!parsed.success) {
|
|
||||||
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const story = await prisma.story.findFirst({
|
|
||||||
where: { id: storyId, product: productAccessFilter(auth.userId) },
|
|
||||||
include: { tasks: { select: { id: true } } },
|
|
||||||
})
|
|
||||||
if (!story) {
|
|
||||||
return Response.json({ error: 'Story niet gevonden' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const storyTaskIds = new Set(story.tasks.map(t => t.id))
|
|
||||||
const invalidId = parsed.data.task_ids.find(id => !storyTaskIds.has(id))
|
|
||||||
if (invalidId) {
|
|
||||||
return Response.json({ error: `Ongeldig task_id: ${invalidId}` }, { status: 422 })
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.$transaction(
|
|
||||||
parsed.data.task_ids.map((id, i) =>
|
|
||||||
prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response.json({ success: true })
|
|
||||||
}
|
|
||||||
|
|
@ -33,6 +33,7 @@ export async function GET(
|
||||||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
code: true,
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
priority: true,
|
priority: true,
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useTransition } from 'react'
|
import { useState } 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 { CheckSquare, Square } from 'lucide-react'
|
import { CheckSquare, Square } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
|
@ -40,7 +20,6 @@ import {
|
||||||
selectStoryIsBlocked,
|
selectStoryIsBlocked,
|
||||||
} from '@/stores/product-workspace/selectors'
|
} from '@/stores/product-workspace/selectors'
|
||||||
import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types'
|
import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types'
|
||||||
import { reorderStoriesAction } from '@/actions/stories'
|
|
||||||
import { StoryDialog, type StoryDialogState } from './story-dialog'
|
import { StoryDialog, type StoryDialogState } from './story-dialog'
|
||||||
import { debugProps } from '@/lib/debug'
|
import { debugProps } from '@/lib/debug'
|
||||||
import { BacklogCard } from './backlog-card'
|
import { BacklogCard } from './backlog-card'
|
||||||
|
|
@ -80,8 +59,7 @@ interface StoryPanelProps {
|
||||||
activeSprintId?: string | null
|
activeSprintId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sortable story block ---
|
function StoryBlock({
|
||||||
function SortableStoryBlock({
|
|
||||||
story,
|
story,
|
||||||
isSelected,
|
isSelected,
|
||||||
cherrypick,
|
cherrypick,
|
||||||
|
|
@ -98,26 +76,11 @@ function SortableStoryBlock({
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
onEdit: () => 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 (
|
return (
|
||||||
<BacklogCard
|
<BacklogCard
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
title={story.title}
|
title={story.title}
|
||||||
code={story.code}
|
code={story.code}
|
||||||
priority={story.priority}
|
priority={story.priority}
|
||||||
isDragging={isDragging}
|
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
badge={
|
badge={
|
||||||
|
|
@ -196,8 +159,6 @@ function StoryCherrypickButton({
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Main component ---
|
// --- 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) {
|
export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPanelProps) {
|
||||||
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
|
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
|
||||||
const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId)
|
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 setPref = useUserSettingsStore((s) => s.setPref)
|
||||||
const setSortMode = (v: SortMode) => void setPref(['views', 'storyPanel', 'sort'], v)
|
const setSortMode = (v: SortMode) => void setPref(['views', 'storyPanel', 'sort'], v)
|
||||||
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
|
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 base = rawStories
|
||||||
const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s]))
|
|
||||||
const orderedStories = rawStories
|
|
||||||
|
|
||||||
const base = orderedStories
|
|
||||||
.filter(s => !filterStatus || s.status === filterStatus)
|
.filter(s => !filterStatus || s.status === filterStatus)
|
||||||
.filter(s => !filterPriority || s.priority === filterPriority)
|
.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
|
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
|
const hasActiveFilters = filterStatus !== null || filterPriority !== null
|
||||||
|
|
||||||
return (
|
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 }}
|
action={{ label: 'Maak je eerste story aan', onClick: () => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<div className="grid grid-cols-3 gap-2">
|
||||||
id="story-panel"
|
{filtered.map(story => (
|
||||||
sensors={sensors}
|
<StoryBlockWithCherrypick
|
||||||
collisionDetection={closestCenter}
|
key={story.id}
|
||||||
onDragStart={handleDragStart}
|
story={story}
|
||||||
onDragEnd={handleDragEnd}
|
productId={productId}
|
||||||
>
|
activeSprintId={activeSprintId}
|
||||||
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}>
|
isSelected={selectedStoryId === story.id}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
|
||||||
{filtered.map(story => (
|
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
|
||||||
<StoryBlockWithCherrypick
|
/>
|
||||||
key={story.id}
|
))}
|
||||||
story={story}
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -406,9 +273,7 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PBI-79 / ST-1337: wrapper rond SortableStoryBlock met cherrypick-handling.
|
// PBI-79 / ST-1337: wrapper rond StoryBlock met cherrypick-handling.
|
||||||
// Subscribed per story zodat enkel de relevante rij re-rendert bij draft- of
|
|
||||||
// crossSprintBlocks-mutaties.
|
|
||||||
function StoryBlockWithCherrypick({
|
function StoryBlockWithCherrypick({
|
||||||
story,
|
story,
|
||||||
productId,
|
productId,
|
||||||
|
|
@ -443,7 +308,6 @@ function StoryBlockWithCherrypick({
|
||||||
} | null = null
|
} | null = null
|
||||||
|
|
||||||
if (draft) {
|
if (draft) {
|
||||||
// State A′: muteer draft via per-PBI overrides.
|
|
||||||
const intent = draft.pbiIntent[story.pbi_id] ?? 'none'
|
const intent = draft.pbiIntent[story.pbi_id] ?? 'none'
|
||||||
const override = draft.storyOverrides[story.pbi_id] ?? {
|
const override = draft.storyOverrides[story.pbi_id] ?? {
|
||||||
add: [],
|
add: [],
|
||||||
|
|
@ -474,7 +338,6 @@ function StoryBlockWithCherrypick({
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if (activeSprintId) {
|
} else if (activeSprintId) {
|
||||||
// State B: muteer pending buffer via toggleStorySprintMembership.
|
|
||||||
const inSprintDb = story.sprint_id === activeSprintId
|
const inSprintDb = story.sprint_id === activeSprintId
|
||||||
const inAdds = pending.adds.includes(story.id)
|
const inAdds = pending.adds.includes(story.id)
|
||||||
const inRemoves = pending.removes.includes(story.id)
|
const inRemoves = pending.removes.includes(story.id)
|
||||||
|
|
@ -489,7 +352,7 @@ function StoryBlockWithCherrypick({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableStoryBlock
|
<StoryBlock
|
||||||
story={story}
|
story={story}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
cherrypick={cherrypick}
|
cherrypick={cherrypick}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useTransition } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
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 { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||||||
|
|
@ -33,7 +12,6 @@ import type {
|
||||||
BacklogTask,
|
BacklogTask,
|
||||||
TaskDetail,
|
TaskDetail,
|
||||||
} from '@/stores/product-workspace/types'
|
} from '@/stores/product-workspace/types'
|
||||||
import { reorderTasksAction } from '@/actions/tasks'
|
|
||||||
import { BacklogCard } from './backlog-card'
|
import { BacklogCard } from './backlog-card'
|
||||||
import { debugProps } from '@/lib/debug'
|
import { debugProps } from '@/lib/debug'
|
||||||
import { EmptyPanel } from './empty-panel'
|
import { EmptyPanel } from './empty-panel'
|
||||||
|
|
@ -52,32 +30,18 @@ const STATUS_LABELS: Record<string, string> = {
|
||||||
DONE: 'Klaar',
|
DONE: 'Klaar',
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableTaskCard({
|
function TaskCard({
|
||||||
task,
|
task,
|
||||||
isDemo,
|
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
task: BacklogTask | TaskDetail
|
task: BacklogTask | TaskDetail
|
||||||
isDemo: boolean
|
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
|
||||||
useSortable({ id: task.id })
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BacklogCard
|
<BacklogCard
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
{...attributes}
|
|
||||||
{...(isDemo ? {} : listeners)}
|
|
||||||
title={task.title}
|
title={task.title}
|
||||||
priority={task.priority}
|
priority={task.priority}
|
||||||
isDragging={isDragging}
|
code={task.code}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
badge={
|
badge={
|
||||||
<Badge
|
<Badge
|
||||||
|
|
@ -99,64 +63,17 @@ interface TaskPanelProps {
|
||||||
closePath: string
|
closePath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PBI-74 / T-851: leest tasks voor active story via selectTasksForActiveStory
|
// PBI-74 / T-851: leest tasks voor active story via selectTasksForActiveStory.
|
||||||
// (useShallow). DnD via applyOptimisticMutation('task-order'). Detail-view
|
|
||||||
// (ensureTaskLoaded + isDetail()) zit in de task-dialog, niet in deze lijst.
|
|
||||||
export function TaskPanel({ isDemo, closePath }: TaskPanelProps) {
|
export function TaskPanel({ isDemo, closePath }: TaskPanelProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [, startTransition] = useTransition()
|
|
||||||
const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId)
|
const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId)
|
||||||
const rawTasks = useProductWorkspaceStore(useShallow(selectTasksForActiveStory)) as
|
const rawTasks = useProductWorkspaceStore(useShallow(selectTasksForActiveStory)) as
|
||||||
| (BacklogTask | TaskDetail)[]
|
| (BacklogTask | TaskDetail)[]
|
||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const tasks: (BacklogTask | TaskDetail)[] | null = selectedStoryId
|
const tasks: (BacklogTask | TaskDetail)[] | null = selectedStoryId
|
||||||
? rawTasks
|
? rawTasks
|
||||||
: null
|
: null
|
||||||
|
|
||||||
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)
|
|
||||||
if (!selectedStoryId || !tasks) return
|
|
||||||
const { active, over } = event
|
|
||||||
if (!over || active.id === over.id) return
|
|
||||||
|
|
||||||
const store = useProductWorkspaceStore.getState()
|
|
||||||
const prevOrder = [...(store.relations.taskIdsByStory[selectedStoryId] ?? [])]
|
|
||||||
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: 'task-order',
|
|
||||||
storyId: selectedStoryId,
|
|
||||||
prevTaskIds: prevOrder,
|
|
||||||
})
|
|
||||||
useProductWorkspaceStore.setState((s) => {
|
|
||||||
s.relations.taskIdsByStory[selectedStoryId] = newOrder
|
|
||||||
})
|
|
||||||
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await reorderTasksAction(selectedStoryId, newOrder)
|
|
||||||
const st = useProductWorkspaceStore.getState()
|
|
||||||
if (result?.error) {
|
|
||||||
st.rollbackMutation(orderMutationId)
|
|
||||||
toast.error(result.error)
|
|
||||||
} else {
|
|
||||||
st.settleMutation(orderMutationId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const navActions = (
|
const navActions = (
|
||||||
<DemoTooltip show={isDemo}>
|
<DemoTooltip show={isDemo}>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -201,42 +118,19 @@ export function TaskPanel({ isDemo, closePath }: TaskPanelProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTask = activeDragId ? tasks.find((t) => t.id === activeDragId) : null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" {...dp}>
|
<div className="flex flex-col h-full" {...dp}>
|
||||||
<PanelNavBar title="Taken" actions={navActions} />
|
<PanelNavBar title="Taken" actions={navActions} />
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
<DndContext
|
<div className="grid grid-cols-2 gap-2">
|
||||||
id="task-panel"
|
{tasks.map((task) => (
|
||||||
sensors={sensors}
|
<TaskCard
|
||||||
collisionDetection={closestCenter}
|
key={task.id}
|
||||||
onDragStart={handleDragStart}
|
task={task}
|
||||||
onDragEnd={handleDragEnd}
|
onClick={() => router.push(`${closePath}?editTask=${task.id}`)}
|
||||||
>
|
/>
|
||||||
<SortableContext items={tasks.map((t) => t.id)} strategy={rectSortingStrategy}>
|
))}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
</div>
|
||||||
{tasks.map((task) => (
|
|
||||||
<SortableTaskCard
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
isDemo={isDemo}
|
|
||||||
onClick={() => router.push(`${closePath}?editTask=${task.id}`)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
|
|
||||||
<DragOverlay>
|
|
||||||
{activeTask && (
|
|
||||||
<BacklogCard
|
|
||||||
title={activeTask.title}
|
|
||||||
priority={activeTask.priority}
|
|
||||||
className="border-primary shadow-xl opacity-90"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
import { useMemo, useState, useTransition } from 'react'
|
import { useMemo, useState, useTransition } from 'react'
|
||||||
import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, Pencil } from 'lucide-react'
|
import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, Pencil } from 'lucide-react'
|
||||||
import { useDroppable, useDraggable } from '@dnd-kit/core'
|
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 { toast } from 'sonner'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
@ -76,7 +74,7 @@ export interface PbiWithStories {
|
||||||
|
|
||||||
// --- Left panel: Sprint Backlog ---
|
// --- Left panel: Sprint Backlog ---
|
||||||
|
|
||||||
function SortableSprintRow({
|
function SprintRow({
|
||||||
story, isDemo, onRemove, onSelect, onEdit, isSelected,
|
story, isDemo, onRemove, onSelect, onEdit, isSelected,
|
||||||
currentUserId, productId, members, onAssigneeChange,
|
currentUserId, productId, members, onAssigneeChange,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -91,8 +89,8 @@ function SortableSprintRow({
|
||||||
members: ProductMember[]
|
members: ProductMember[]
|
||||||
onAssigneeChange: (storyId: string, id: string | null, username: string | null) => void
|
onAssigneeChange: (storyId: string, id: string | null, username: string | null) => void
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: story.id })
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: story.id })
|
||||||
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }
|
const style = { opacity: isDragging ? 0.4 : 1 }
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
|
|
||||||
function handleClaim(e: React.MouseEvent) {
|
function handleClaim(e: React.MouseEvent) {
|
||||||
|
|
@ -312,9 +310,9 @@ export function SprintBacklogLeft({
|
||||||
{isOver ? 'Loslaten om toe te voegen aan Sprint' : 'Geen stories in de Sprint. Sleep stories vanuit het linkerpaneel.'}
|
{isOver ? 'Loslaten om toe te voegen aan Sprint' : 'Geen stories in de Sprint. Sleep stories vanuit het linkerpaneel.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<SortableContext items={orderedStories.map(s => s.id)} strategy={verticalListSortingStrategy}>
|
<>
|
||||||
{orderedStories.map(story => (
|
{orderedStories.map(story => (
|
||||||
<SortableSprintRow
|
<SprintRow
|
||||||
key={story.id}
|
key={story.id}
|
||||||
story={story}
|
story={story}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
|
|
@ -328,7 +326,7 @@ export function SprintBacklogLeft({
|
||||||
onAssigneeChange={onAssigneeChange}
|
onAssigneeChange={onAssigneeChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<StoryDialog
|
<StoryDialog
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
DndContext, DragEndEvent, DragStartEvent, DragOverlay,
|
DndContext, DragEndEvent, DragStartEvent, DragOverlay,
|
||||||
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
|
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
|
||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core'
|
||||||
import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable'
|
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { SplitPane } from '@/components/split-pane/split-pane'
|
import { SplitPane } from '@/components/split-pane/split-pane'
|
||||||
|
|
@ -18,7 +18,6 @@ import type { SprintWorkspaceStory } from '@/stores/sprint-workspace/types'
|
||||||
import {
|
import {
|
||||||
addStoryToSprintAction,
|
addStoryToSprintAction,
|
||||||
removeStoryFromSprintAction,
|
removeStoryFromSprintAction,
|
||||||
reorderSprintStoriesAction,
|
|
||||||
} from '@/actions/sprints'
|
} from '@/actions/sprints'
|
||||||
import { debugProps } from '@/lib/debug'
|
import { debugProps } from '@/lib/debug'
|
||||||
|
|
||||||
|
|
@ -106,11 +105,6 @@ export function SprintBoardClient({
|
||||||
handleRemove(activeId)
|
handleRemove(activeId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reorder within sprint
|
|
||||||
if (activeId !== overId && !activeId.startsWith('pb:')) {
|
|
||||||
handleReorder(activeId, overId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAdd(storyId: string, storyData: SprintStory) {
|
function handleAdd(storyId: string, storyData: SprintStory) {
|
||||||
|
|
@ -173,35 +167,6 @@ export function SprintBoardClient({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) {
|
||||||
useSprintWorkspaceStore.setState((s) => {
|
useSprintWorkspaceStore.setState((s) => {
|
||||||
const story = s.entities.storiesById[storyId]
|
const story = s.entities.storiesById[storyId]
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,8 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useTransition } from 'react'
|
import { useTransition } from 'react'
|
||||||
import { useRouter, usePathname } from 'next/navigation'
|
import { useRouter, usePathname } from 'next/navigation'
|
||||||
import {
|
|
||||||
DndContext, DragEndEvent, DragOverlay,
|
|
||||||
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
|
|
||||||
} from '@dnd-kit/core'
|
|
||||||
import {
|
|
||||||
SortableContext, useSortable, verticalListSortingStrategy, arrayMove,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
} from '@dnd-kit/sortable'
|
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
|
||||||
import { Pencil } from 'lucide-react'
|
import { Pencil } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
@ -25,7 +15,7 @@ import type {
|
||||||
SprintWorkspaceTask,
|
SprintWorkspaceTask,
|
||||||
SprintWorkspaceTaskDetail,
|
SprintWorkspaceTaskDetail,
|
||||||
} from '@/stores/sprint-workspace/types'
|
} from '@/stores/sprint-workspace/types'
|
||||||
import { updateTaskStatusAction, reorderTasksAction } from '@/actions/tasks'
|
import { updateTaskStatusAction } from '@/actions/tasks'
|
||||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { debugProps } from '@/lib/debug'
|
import { debugProps } from '@/lib/debug'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
@ -53,7 +43,6 @@ const STATUS_LABELS: Record<string, string> = {
|
||||||
EXCLUDED: 'Uitgesloten',
|
EXCLUDED: 'Uitgesloten',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Behouden voor type-compat met SprintBoardClient props (verdwijnt zodra
|
// Behouden voor type-compat met SprintBoardClient props (verdwijnt zodra
|
||||||
// SprintBoardClient ook geen tasks-prop meer doorgeeft — T-883).
|
// SprintBoardClient ook geen tasks-prop meer doorgeeft — T-883).
|
||||||
export interface Task {
|
export interface Task {
|
||||||
|
|
@ -75,7 +64,7 @@ interface TaskListProps {
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableTaskRow({
|
function TaskRow({
|
||||||
task, code, isDemo, onStatusToggle, onEdit,
|
task, code, isDemo, onStatusToggle, onEdit,
|
||||||
}: {
|
}: {
|
||||||
task: WorkspaceTask
|
task: WorkspaceTask
|
||||||
|
|
@ -84,11 +73,8 @@ function SortableTaskRow({
|
||||||
onStatusToggle: () => void
|
onStatusToggle: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id })
|
|
||||||
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} className="group px-2 py-1">
|
<div className="group px-2 py-1">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container hover:bg-surface-container-high cursor-pointer',
|
'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container hover:bg-surface-container-high cursor-pointer',
|
||||||
|
|
@ -105,17 +91,6 @@ function SortableTaskRow({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!isDemo && (
|
|
||||||
<span
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
⠿
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
|
|
@ -162,55 +137,12 @@ export function TaskList({ sprintId: _sprintId, productId: _productId, isDemo }:
|
||||||
const orderedTasks = useSprintWorkspaceStore(
|
const orderedTasks = useSprintWorkspaceStore(
|
||||||
useShallow(selectTasksForActiveStory),
|
useShallow(selectTasksForActiveStory),
|
||||||
)
|
)
|
||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const taskMap: Record<string, WorkspaceTask> = {}
|
|
||||||
for (const t of orderedTasks) taskMap[t.id] = t
|
|
||||||
|
|
||||||
const doneCount = orderedTasks.filter(t => t.status === 'DONE').length
|
const doneCount = orderedTasks.filter(t => t.status === 'DONE').length
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
||||||
)
|
|
||||||
|
|
||||||
function handleDragEnd(event: DragEndEvent) {
|
|
||||||
const { active, over } = event
|
|
||||||
if (!storyId) return
|
|
||||||
if (!over || active.id === over.id) return
|
|
||||||
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)
|
|
||||||
const st = useSprintWorkspaceStore.getState()
|
|
||||||
if (result.success) {
|
|
||||||
st.settleMutation(mutationId)
|
|
||||||
} else {
|
|
||||||
st.rollbackMutation(mutationId)
|
|
||||||
toast.error('Volgorde opslaan mislukt')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStatusToggle(task: WorkspaceTask) {
|
function handleStatusToggle(task: WorkspaceTask) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await updateTaskStatusAction(task.id, STATUS_CYCLE[task.status] ?? 'TO_DO')
|
await updateTaskStatusAction(task.id, STATUS_CYCLE[task.status] ?? 'TO_DO')
|
||||||
|
|
@ -263,36 +195,18 @@ export function TaskList({ sprintId: _sprintId, productId: _productId, isDemo }:
|
||||||
</DemoTooltip>
|
</DemoTooltip>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<>
|
||||||
id="task-list"
|
{orderedTasks.map((task) => (
|
||||||
sensors={sensors}
|
<TaskRow
|
||||||
collisionDetection={closestCenter}
|
key={task.id}
|
||||||
onDragStart={e => setActiveDragId(e.active.id as string)}
|
task={task}
|
||||||
onDragEnd={handleDragEnd}
|
code={task.code}
|
||||||
>
|
isDemo={isDemo}
|
||||||
<SortableContext items={orderedTasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
|
onStatusToggle={() => handleStatusToggle(task)}
|
||||||
{orderedTasks.map((task) => (
|
onEdit={() => openEditDialog(task.id)}
|
||||||
<SortableTaskRow
|
/>
|
||||||
key={task.id}
|
))}
|
||||||
task={task}
|
</>
|
||||||
code={task.code}
|
|
||||||
isDemo={isDemo}
|
|
||||||
onStatusToggle={() => handleStatusToggle(task)}
|
|
||||||
onEdit={() => openEditDialog(task.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
<DragOverlay>
|
|
||||||
{activeDragId && taskMap[activeDragId] && (
|
|
||||||
<div className={cn(
|
|
||||||
'rounded border border-primary px-3 py-2 bg-surface-container shadow-lg opacity-90 text-sm',
|
|
||||||
PRIORITY_BORDER[taskMap[activeDragId].priority],
|
|
||||||
)}>
|
|
||||||
{taskMap[activeDragId].title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ Auto-generated on 2026-05-14 from front-matter and headings.
|
||||||
| 0007 | [ADR-0007: Agent ↔ user question channel via persistent table + LISTEN/NOTIFY](./adr/0007-claude-question-channel-design.md) | accepted |
|
| 0007 | [ADR-0007: Agent ↔ user question channel via persistent table + LISTEN/NOTIFY](./adr/0007-claude-question-channel-design.md) | accepted |
|
||||||
| 0008 | [ADR-0008: Agent instructions in CLAUDE.md + topical runbooks](./adr/0008-agent-instructions-in-claude-md.md) | accepted |
|
| 0008 | [ADR-0008: Agent instructions in CLAUDE.md + topical runbooks](./adr/0008-agent-instructions-in-claude-md.md) | accepted |
|
||||||
| 0010 | [ADR-0010: Eén product = één repo; cross-product planning vereist (later) een Initiative-laag](./adr/0010-product-per-repo-cross-product-planning.md) | accepted |
|
| 0010 | [ADR-0010: Eén product = één repo; cross-product planning vereist (later) een Initiative-laag](./adr/0010-product-per-repo-cross-product-planning.md) | accepted |
|
||||||
|
| 0011 | [ADR-0011: code is de bindende volgordesleutel voor stories en taken; priority is label](./adr/0011-code-volgordesleutel-stories-taken.md) | accepted |
|
||||||
|
|
||||||
## Specifications
|
## Specifications
|
||||||
|
|
||||||
|
|
@ -74,7 +75,7 @@ Auto-generated on 2026-05-14 from front-matter and headings.
|
||||||
| [Realtime NOTIFY payload — veldnaam-contract](./patterns/realtime-notify-payload.md) | active | 2026-05-03 |
|
| [Realtime NOTIFY payload — veldnaam-contract](./patterns/realtime-notify-payload.md) | active | 2026-05-03 |
|
||||||
| [Route Handler (REST API)](./patterns/route-handler.md) | active | 2026-05-08 |
|
| [Route Handler (REST API)](./patterns/route-handler.md) | active | 2026-05-08 |
|
||||||
| [Server Action](./patterns/server-action.md) | active | 2026-05-08 |
|
| [Server Action](./patterns/server-action.md) | active | 2026-05-08 |
|
||||||
| [Float sort_order (drag-and-drop volgorde)](./patterns/sort-order.md) | active | 2026-05-03 |
|
| [sort_order — PBI drag-and-drop vs. code-bindende volgorde voor stories/taken](./patterns/sort-order.md) | active | 2026-05-14 |
|
||||||
| [Story met UI-component](./patterns/story-with-ui-component.md) | active | 2026-05-03 |
|
| [Story met UI-component](./patterns/story-with-ui-component.md) | active | 2026-05-03 |
|
||||||
| [Web Push](./patterns/web-push.md) | active | 2026-05-07 |
|
| [Web Push](./patterns/web-push.md) | active | 2026-05-07 |
|
||||||
| [Workspace-store + realtime — bounded-context patroon](./patterns/workspace-store.md) | active | 2026-05-10 |
|
| [Workspace-store + realtime — bounded-context patroon](./patterns/workspace-store.md) | active | 2026-05-10 |
|
||||||
|
|
|
||||||
58
docs/adr/0011-code-volgordesleutel-stories-taken.md
Normal file
58
docs/adr/0011-code-volgordesleutel-stories-taken.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# ADR-0011: code is de bindende volgordesleutel voor stories en taken; priority is label
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Vóór dit besluit werden stories en taken geordend op `[priority ASC, sort_order ASC]`.
|
||||||
|
Gebruikers konden de volgorde via drag-and-drop aanpassen (float-insertion in `sort_order`).
|
||||||
|
Dit leidde tot meerdere problemen:
|
||||||
|
|
||||||
|
1. **Onvoorspelbare uitvoervolgorde voor de AI-agent**: een agent die taken aanmaakt via
|
||||||
|
`create_task` (in call-volgorde) zag die volgorde ongedaan gemaakt zodra een andere
|
||||||
|
agent of gebruiker de `priority` aanpaste.
|
||||||
|
2. **Divergentie tussen `code` en `sort_order`**: `code` reflecteerde de aanmaak-volgorde
|
||||||
|
(`T-1`, `T-2`, …), maar `sort_order` kon na herordening compleet anders zijn.
|
||||||
|
3. **DnD-reorder voor stories/taken was overbodig**: de enige betekenisvolle volgorde voor
|
||||||
|
een AI-gedreven sprint is de creatie-volgorde van taken — handmatige herordening voegde
|
||||||
|
verwarring toe zonder toegevoegde waarde.
|
||||||
|
|
||||||
|
## Beslissing
|
||||||
|
|
||||||
|
- **`code` is de bindende volgordesleutel** voor stories en taken.
|
||||||
|
- **`sort_order` is een numerieke spiegel van `code`**, berekend via `parseCodeNumber(code)`
|
||||||
|
uit `lib/code.ts`. Hierbij extraheert `parseCodeNumber` het trailertal van de code-string
|
||||||
|
(bijv. `"ST-042"` → `42`, `"T-7"` → `7`).
|
||||||
|
- **`sort_order` wordt automatisch gezet** bij `create` (server berekent de waarde) en bij
|
||||||
|
code-edit (PATCH met nieuw `code`). Sprint-membership-acties laten `sort_order` ongemoeid.
|
||||||
|
- **Drag-and-drop herordening van stories en taken is verwijderd.** Enkel PBI-ordering
|
||||||
|
gebruikt nog float-insertion (zie ADR-0002).
|
||||||
|
- **`priority` is een puur label** (urgentie-aanduiding voor de gebruiker). Geen enkele
|
||||||
|
`orderBy` op stories of taken gebruikt nog `priority`.
|
||||||
|
|
||||||
|
## Gevolgen
|
||||||
|
|
||||||
|
### Positief
|
||||||
|
|
||||||
|
- De uitvoervolgorde van taken is deterministisch en gelijk aan de aanroep-volgorde van
|
||||||
|
`create_task` — agents kunnen dit betrouwbaar afleiden zonder extra queries.
|
||||||
|
- `code` en `sort_order` zijn altijd in sync — geen divergentie meer na herordening.
|
||||||
|
- Minder complexiteit: geen reorder-route, geen reorder-server-actions, geen DnD-context
|
||||||
|
in backlog-story-panel en task-panel.
|
||||||
|
|
||||||
|
### Negatief
|
||||||
|
|
||||||
|
- Gebruikers kunnen de volgorde van stories en taken niet handmatig herordenen via slepen.
|
||||||
|
Volgorde aanpassen vereist nu het wijzigen van de `code` (of het aanmaken in de gewenste
|
||||||
|
volgorde). Dit is een bewuste trade-off ten gunste van voorspelbaarheid voor de agent.
|
||||||
|
- `parseCodeNumber` retourneert `0` voor codes zonder numeriek suffix (bijv. `"CUSTOM-FOO"`).
|
||||||
|
Zulke codes clusteren bij positie 0 — vermijdbaar door codes met een numeriek suffix te
|
||||||
|
gebruiken.
|
||||||
|
|
||||||
|
## Zie ook
|
||||||
|
|
||||||
|
- [ADR-0002: float sort_order voor drag-and-drop (PBI-ordering)](./0002-float-sort-order.md)
|
||||||
|
- [docs/patterns/sort-order.md](../patterns/sort-order.md) — implementatiepatroon
|
||||||
|
- `lib/code.ts` — `parseCodeNumber`-helper
|
||||||
|
|
@ -169,7 +169,7 @@ Hoogst geprioriteerde open story in de actieve sprint.
|
||||||
|
|
||||||
### `GET /api/sprints/:id/tasks`
|
### `GET /api/sprints/:id/tasks`
|
||||||
|
|
||||||
Lijst taken van de sprint, geordend op `(story.sort_order, task.priority, task.sort_order)`.
|
Lijst taken van de sprint, geordend op `(story.sort_order, task.sort_order)` — code-volgorde, geen priority.
|
||||||
|
|
||||||
**Query params:** `?limit=N` (default 10, max 50)
|
**Query params:** `?limit=N` (default 10, max 50)
|
||||||
|
|
||||||
|
|
@ -193,19 +193,6 @@ Lijst taken van de sprint, geordend op `(story.sort_order, task.priority, task.s
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `PATCH /api/stories/:id/tasks/reorder`
|
|
||||||
|
|
||||||
Volgorde van taken binnen een story aanpassen.
|
|
||||||
|
|
||||||
**Body:**
|
|
||||||
```json
|
|
||||||
{ "task_ids": ["task-id-a", "task-id-b", "task-id-c"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Alle IDs moeten bij de story horen. **Foutcodes:** `422` bij Zod-fouten of als een task_id niet tot de story behoort.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `PATCH /api/tasks/:id`
|
### `PATCH /api/tasks/:id`
|
||||||
|
|
||||||
Status of implementation_plan bijwerken. Minstens één van beide is verplicht.
|
Status of implementation_plan bijwerken. Minstens één van beide is verplicht.
|
||||||
|
|
@ -537,7 +524,7 @@ worden. Tot dan retourneert de stub-default in vitest een lege response.
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `ensureProductLoaded(productId)` | `GET /api/products/:id/backlog` | **ontbreekt** | T-870 (Story 7) |
|
| `ensureProductLoaded(productId)` | `GET /api/products/:id/backlog` | **ontbreekt** | T-870 (Story 7) |
|
||||||
| `ensurePbiLoaded(pbiId)` | `GET /api/pbis/:id/stories` | **ontbreekt** (en `/api/pbis` route-folder bestaat nog niet) | T-870 (Story 7) |
|
| `ensurePbiLoaded(pbiId)` | `GET /api/pbis/:id/stories` | **ontbreekt** (en `/api/pbis` route-folder bestaat nog niet) | T-870 (Story 7) |
|
||||||
| `ensureStoryLoaded(storyId)` | `GET /api/stories/:id/tasks` | **ontbreekt** (alleen `tasks/reorder` bestaat) | T-870 (Story 7) |
|
| `ensureStoryLoaded(storyId)` | `GET /api/stories/:id/tasks` | **ontbreekt** | T-870 (Story 7) |
|
||||||
| `ensureTaskLoaded(taskId)` | `GET /api/tasks/:id` | **ontbreekt** (alleen `PATCH` bestaat) | T-870 (Story 7) |
|
| `ensureTaskLoaded(taskId)` | `GET /api/tasks/:id` | **ontbreekt** (alleen `PATCH` bestaat) | T-870 (Story 7) |
|
||||||
|
|
||||||
Vereisten voor de toe te voegen routes:
|
Vereisten voor de toe te voegen routes:
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,22 @@
|
||||||
---
|
---
|
||||||
title: "Float sort_order (drag-and-drop volgorde)"
|
title: "sort_order — PBI drag-and-drop vs. code-bindende volgorde voor stories/taken"
|
||||||
status: active
|
status: active
|
||||||
audience: [ai-agent, contributor]
|
audience: [ai-agent, contributor]
|
||||||
language: nl
|
language: nl
|
||||||
last_updated: 2026-05-03
|
last_updated: 2026-05-14
|
||||||
when_to_read: "When implementing drag-and-drop reordering or inserting between items."
|
when_to_read: "When implementing ordering for PBIs (drag-and-drop) or stories/tasks (code-binding)."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Patroon: Float sort_order (drag-and-drop volgorde)
|
# Patroon: sort_order — PBI vs. Story/Taak
|
||||||
|
|
||||||
## Berekening bij tussenvoeging
|
`sort_order` heeft voor PBI's een andere betekenis dan voor stories en taken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PBI — float-insertion (drag-and-drop)
|
||||||
|
|
||||||
|
PBI's ondersteunen drag-and-drop herordening. `sort_order` is een `Float` die via de
|
||||||
|
midpoint-formule wordt berekend bij tussenvoeging:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
function getSortOrder(before: number | null, after: number | null): number {
|
function getSortOrder(before: number | null, after: number | null): number {
|
||||||
|
|
@ -20,9 +27,9 @@ function getSortOrder(before: number | null, after: number | null): number {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Herindexeer als precisie opraakt
|
### Herindexeer als precisie opraakt
|
||||||
|
|
||||||
Trigger wanneer het kleinste verschil tussen twee opeenvolgende items < 0.001 is.
|
Trigger wanneer het kleinste verschil tussen twee opeenvolgende PBI's < 0.001 is:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
async function reindexIfNeeded(items: { id: string; sort_order: number }[]) {
|
async function reindexIfNeeded(items: { id: string; sort_order: number }[]) {
|
||||||
|
|
@ -37,12 +44,62 @@ async function reindexIfNeeded(items: { id: string; sort_order: number }[]) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Reorder Server Actions
|
### Reorder Server Action (PBI-only)
|
||||||
|
|
||||||
Een drag-and-drop reorder stuurt altijd client-controlled ID-lijsten naar de server. Behandel die lijst als onbetrouwbaar.
|
Een drag-and-drop reorder stuurt client-controlled ID-lijsten naar de server.
|
||||||
|
Behandel die lijst als onbetrouwbaar:
|
||||||
|
|
||||||
- Weiger dubbele IDs.
|
- Weiger dubbele IDs.
|
||||||
- Haal alle IDs op met de parent-scope, bijvoorbeeld `product_id`, `pbi_id`, `sprint_id` of `story_id`.
|
- Haal alle IDs op met de parent-scope (`product_id`).
|
||||||
- Weiger de operatie als het aantal gevonden records niet exact gelijk is aan het aantal aangeleverde IDs.
|
- Weiger de operatie als het aantal gevonden records niet exact gelijk is aan het aantal aangeleverde IDs.
|
||||||
- Update pas daarna `sort_order` in een transactie.
|
- Update pas daarna `sort_order` in een transactie.
|
||||||
- Gebruik bij priority changes dezelfde parent uit de database, niet een los meegegeven `productId`.
|
- Gebruik bij priority changes dezelfde parent uit de database, niet een los meegegeven `productId`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Story / Taak — code-bindende volgorde (geen drag-and-drop)
|
||||||
|
|
||||||
|
Voor stories en taken is `sort_order` een **numerieke spiegel van `code`**, berekend via
|
||||||
|
`parseCodeNumber(code)` uit `lib/code.ts`. Drag-and-drop herordening bestaat niet voor
|
||||||
|
stories en taken.
|
||||||
|
|
||||||
|
### Wanneer `sort_order` wordt gezet
|
||||||
|
|
||||||
|
| Moment | Wat er gebeurt |
|
||||||
|
|---|---|
|
||||||
|
| `story.create` / `task.create` | `sort_order = parseCodeNumber(code)` |
|
||||||
|
| Idea-materialisatie (`materializeIdeaPlanAction`) | idem — stories en taken krijgen `sort_order = parseCodeNumber(storyCode / taskCode)` |
|
||||||
|
| Code-edit (PATCH met nieuw `code`) | `sort_order = parseCodeNumber(newCode)` wordt bijgewerkt |
|
||||||
|
| Sprint-membership-acties | `sort_order` wordt **niet** aangeraakt |
|
||||||
|
|
||||||
|
### `parseCodeNumber`
|
||||||
|
|
||||||
|
Extraheert het trailertal uit een code-string:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// lib/code.ts
|
||||||
|
export function parseCodeNumber(code: string): number {
|
||||||
|
const match = code.match(/(\d+)$/)
|
||||||
|
return match ? parseInt(match[1], 10) : 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Voorbeelden: `"ST-042"` → `42`, `"T-7"` → `7`, `"CUSTOM-FOO"` → `0`.
|
||||||
|
|
||||||
|
### Ordering queries
|
||||||
|
|
||||||
|
Stories en taken worden **uitsluitend** op `sort_order` geordend — nooit op `priority`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// stories binnen een sprint
|
||||||
|
orderBy: [{ sort_order: 'asc' }]
|
||||||
|
|
||||||
|
// taken binnen een story
|
||||||
|
orderBy: { sort_order: 'asc' }
|
||||||
|
|
||||||
|
// taken binnen een sprint (story-volgorde eerst)
|
||||||
|
orderBy: [{ story: { sort_order: 'asc' } }, { sort_order: 'asc' }]
|
||||||
|
```
|
||||||
|
|
||||||
|
`priority` is een **label** (urgentie-aanduiding voor de gebruiker), geen
|
||||||
|
sorteerkriteria voor stories of taken.
|
||||||
|
|
|
||||||
|
|
@ -80,14 +80,15 @@ plan goedgekeurd
|
||||||
### 2. `create_story`
|
### 2. `create_story`
|
||||||
|
|
||||||
```
|
```
|
||||||
{ pbi_id, title, description?, acceptance_criteria?, priority, sort_order? }
|
{ pbi_id, sprint_id, title, description?, acceptance_criteria?, priority }
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`title`** — concreet, in user-story stijl als dat past ("Als developer wil ik …")
|
- **`title`** — concreet, in user-story stijl als dat past ("Als developer wil ik …")
|
||||||
- **`description`** — technische context, scope-grenzen, niet-doelen
|
- **`description`** — technische context, scope-grenzen, niet-doelen
|
||||||
- **`acceptance_criteria`** — markdown checklist (`- [ ] …`); bepaalt wanneer de Story `DONE` is
|
- **`acceptance_criteria`** — markdown checklist (`- [ ] …`); bepaalt wanneer de Story `DONE` is
|
||||||
- `product_id` wordt afgeleid uit de PBI — niet meegeven
|
- `product_id` wordt afgeleid uit de PBI — niet meegeven
|
||||||
- **Sprint-koppeling:** de story wordt aan de actieve OPEN-sprint gehangen (de zojuist aangemaakte). Als er meerdere OPEN-sprints bestaan: bevestig eerst met de gebruiker welke sprint geldt, of breidt `create_story` uit met een expliciete `sprint_id` parameter (apart PBI).
|
- **`sprint_id`** — geef de zojuist aangemaakte sprint mee. Als er meerdere OPEN-sprints bestaan: bevestig eerst met de gebruiker welke sprint geldt.
|
||||||
|
- **`sort_order`** — NIET meegeven. De server berekent `sort_order = parseCodeNumber(code)` automatisch. De volgorde van stories = de volgorde van hun codes (= aanroep-volgorde); niet priority.
|
||||||
- Status start op `OPEN`
|
- Status start op `OPEN`
|
||||||
|
|
||||||
> **Eén story per PBI** is de gebruikelijke verhouding. Splits alleen op in meerdere stories als het plan logisch in onafhankelijk-shipbare delen valt — let op dat dit dan ook meerdere sprints betekent.
|
> **Eén story per PBI** is de gebruikelijke verhouding. Splits alleen op in meerdere stories als het plan logisch in onafhankelijk-shipbare delen valt — let op dat dit dan ook meerdere sprints betekent.
|
||||||
|
|
@ -95,17 +96,17 @@ plan goedgekeurd
|
||||||
### 3. `create_task` (één call per taak)
|
### 3. `create_task` (één call per taak)
|
||||||
|
|
||||||
```
|
```
|
||||||
{ story_id, title, description?, implementation_plan?, priority, sort_order? }
|
{ story_id, title, description?, implementation_plan?, priority }
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`title`** — werkwoord-vorm: "Implementeer …", "Verplaats …", "Voeg test toe voor …"
|
- **`title`** — werkwoord-vorm: "Implementeer …", "Verplaats …", "Voeg test toe voor …"
|
||||||
- **`description`** — wat de taak afdekt, in 1-3 zinnen
|
- **`description`** — wat de taak afdekt, in 1-3 zinnen
|
||||||
- **`implementation_plan`** — **belangrijk**: markdown met de daadwerkelijke stappen + file-paths + reuse-pointers; dit is wat de Implementation-agent later inleest
|
- **`implementation_plan`** — **belangrijk**: markdown met de daadwerkelijke stappen + file-paths + reuse-pointers; dit is wat de Implementation-agent later inleest
|
||||||
- **`sort_order`** — leeg laten voor de eerste call; daarna server-side `last + 1`, of expliciet meegeven om volgorde af te dwingen
|
- **`sort_order`** — NIET meegeven. De server berekent `sort_order = parseCodeNumber(code)` automatisch. De uitvoervolgorde = de aanroep-volgorde (= code-volgorde); niet priority.
|
||||||
- `sprint_id` wordt geërfd van de Story — niet meegeven
|
- `sprint_id` wordt geërfd van de Story — niet meegeven
|
||||||
- Status start op `TO_DO`
|
- Status start op `TO_DO`
|
||||||
|
|
||||||
> De gebruiker werkt taken af in **sort_order**. Zet voorbereidende taken (data-model, types) vóór UI-taken; tests komen ná de feature-implementatie tenzij TDD expliciet is afgesproken.
|
> De uitvoervolgorde van taken is gelijk aan de **aanroep-volgorde** van `create_task` (= code-volgorde). `priority` is een label (urgentie), géén sorteerkriteria. Zet voorbereidende taken (data-model, types) vóór UI-taken; tests komen ná de feature-implementatie tenzij TDD expliciet is afgesproken.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
10
lib/code.ts
10
lib/code.ts
|
|
@ -14,3 +14,13 @@ export function normalizeCode(input: string | null | undefined): string | null {
|
||||||
const trimmed = input.trim()
|
const trimmed = input.trim()
|
||||||
return trimmed === '' ? null : trimmed
|
return trimmed === '' ? null : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the trailing numeric sequence from a code (e.g. "ST-007" → 7, "T-42" → 42).
|
||||||
|
* Non-conforming codes (no trailing digits, empty string) return Number.MAX_SAFE_INTEGER
|
||||||
|
* so they sort to the end.
|
||||||
|
*/
|
||||||
|
export function parseCodeNumber(code: string): number {
|
||||||
|
const m = code.match(/(\d+)$/)
|
||||||
|
return m ? Number.parseInt(m[1], 10) : Number.MAX_SAFE_INTEGER
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ stories:
|
||||||
2. Test toevoegen Y
|
2. Test toevoegen Y
|
||||||
3. Verifieer Z
|
3. Verifieer Z
|
||||||
# task.priority is optioneel en wordt door materialize GENEGEERD.
|
# task.priority is optioneel en wordt door materialize GENEGEERD.
|
||||||
# Tasks erven story.priority; sort_order = positie in deze tasks-array.
|
# Tasks erven story.priority; sort_order wordt afgeleid van de auto-code.
|
||||||
verify_required: ALIGNED_OR_PARTIAL # ALIGNED | ALIGNED_OR_PARTIAL | ANY
|
verify_required: ALIGNED_OR_PARTIAL # ALIGNED | ALIGNED_OR_PARTIAL | ANY
|
||||||
verify_only: false # true voor pure verify-passes
|
verify_only: false # true voor pure verify-passes
|
||||||
- title: "Taak B"
|
- title: "Taak B"
|
||||||
|
|
@ -146,9 +146,9 @@ Beschrijf:
|
||||||
- `pbi.priority`, `story.priority`: integer 1–4, **verplicht**.
|
- `pbi.priority`, `story.priority`: integer 1–4, **verplicht**.
|
||||||
- `task.priority`: integer 1–4, **optioneel**. **Wordt door materialize genegeerd**
|
- `task.priority`: integer 1–4, **optioneel**. **Wordt door materialize genegeerd**
|
||||||
ten faveure van story-priority — alle tasks binnen een story erven dezelfde
|
ten faveure van story-priority — alle tasks binnen een story erven dezelfde
|
||||||
priority. Reden: worker sorteert op `priority ASC, sort_order ASC`; gemixte
|
priority. `priority` is een **label** (urgentie), géén sorteerkriteria voor stories
|
||||||
task-priorities zouden de YAML-volgorde verstoren. De YAML-volgorde *is* de
|
of taken. De **YAML-array-volgorde** is de execution-volgorde: de server berekent
|
||||||
execution-volgorde — daar is `sort_order` (positie in de array) voor.
|
`sort_order = parseCodeNumber(auto-code)` op basis van aanroep-volgorde.
|
||||||
- Minimaal 1 story; per story minimaal 1 taak.
|
- Minimaal 1 story; per story minimaal 1 taak.
|
||||||
- `implementation_plan`: max 8000 chars.
|
- `implementation_plan`: max 8000 chars.
|
||||||
- `verify_required`: enum exact `ALIGNED` | `ALIGNED_OR_PARTIAL` | `ANY`.
|
- `verify_required`: enum exact `ALIGNED` | `ALIGNED_OR_PARTIAL` | `ANY`.
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ export async function getSoloWorkspaceSnapshot(
|
||||||
{ story: { pbi: { priority: 'asc' } } },
|
{ story: { pbi: { priority: 'asc' } } },
|
||||||
{ story: { pbi: { sort_order: 'asc' } } },
|
{ story: { pbi: { sort_order: 'asc' } } },
|
||||||
{ story: { sort_order: 'asc' } },
|
{ story: { sort_order: 'asc' } },
|
||||||
{ priority: 'asc' },
|
|
||||||
{ sort_order: 'asc' },
|
{ sort_order: 'asc' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|
@ -58,7 +57,7 @@ export async function getSoloWorkspaceSnapshot(
|
||||||
title: true,
|
title: true,
|
||||||
tasks: {
|
tasks: {
|
||||||
select: { id: true, title: true, description: true, priority: true, status: true },
|
select: { id: true, title: true, description: true, priority: true, status: true },
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { sort_order: 'asc' },
|
orderBy: { sort_order: 'asc' },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- Backfill story/task sort_order from trailing numeric part of code.
|
||||||
|
-- Consistent with parseCodeNumber: no trailing digits → Number.MAX_SAFE_INTEGER (9007199254740991).
|
||||||
|
-- PBIs are intentionally excluded (they keep drag-and-drop + priority ordering).
|
||||||
|
|
||||||
|
UPDATE "stories"
|
||||||
|
SET sort_order = COALESCE(
|
||||||
|
CAST(SUBSTRING(code FROM '[0-9]+$') AS DOUBLE PRECISION),
|
||||||
|
9007199254740991
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE "tasks"
|
||||||
|
SET sort_order = COALESCE(
|
||||||
|
CAST(SUBSTRING(code FROM '[0-9]+$') AS DOUBLE PRECISION),
|
||||||
|
9007199254740991
|
||||||
|
);
|
||||||
|
|
@ -557,10 +557,6 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
|
||||||
case 'pbi-order':
|
case 'pbi-order':
|
||||||
// store-call passes new order via separate set, snapshot is prevPbiIds
|
// store-call passes new order via separate set, snapshot is prevPbiIds
|
||||||
break
|
break
|
||||||
case 'story-order':
|
|
||||||
break
|
|
||||||
case 'task-order':
|
|
||||||
break
|
|
||||||
case 'entity-patch':
|
case 'entity-patch':
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -577,12 +573,6 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
|
||||||
case 'pbi-order':
|
case 'pbi-order':
|
||||||
s.relations.pbiIds = [...mutation.prevPbiIds]
|
s.relations.pbiIds = [...mutation.prevPbiIds]
|
||||||
break
|
break
|
||||||
case 'story-order':
|
|
||||||
s.relations.storyIdsByPbi[mutation.pbiId] = [...mutation.prevStoryIds]
|
|
||||||
break
|
|
||||||
case 'task-order':
|
|
||||||
s.relations.taskIdsByStory[mutation.storyId] = [...mutation.prevTaskIds]
|
|
||||||
break
|
|
||||||
case 'entity-patch': {
|
case 'entity-patch': {
|
||||||
const { entity, id, prev } = mutation
|
const { entity, id, prev } = mutation
|
||||||
if (prev) {
|
if (prev) {
|
||||||
|
|
@ -1039,6 +1029,7 @@ function coerceTaskPayload(id: string, p: Record<string, unknown>): BacklogTask
|
||||||
const title = p.title ?? p.task_title ?? ''
|
const title = p.title ?? p.task_title ?? ''
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
code: (p.code as string | null | undefined) ?? null,
|
||||||
title: String(title),
|
title: String(title),
|
||||||
description: (p.description as string | null | undefined) ?? null,
|
description: (p.description as string | null | undefined) ?? null,
|
||||||
priority: Number(p.priority ?? 4),
|
priority: Number(p.priority ?? 4),
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export interface BacklogStory {
|
||||||
|
|
||||||
export interface BacklogTask {
|
export interface BacklogTask {
|
||||||
id: string
|
id: string
|
||||||
|
code: string | null
|
||||||
title: string
|
title: string
|
||||||
description: string | null
|
description: string | null
|
||||||
priority: number
|
priority: number
|
||||||
|
|
@ -108,18 +109,6 @@ export interface OptimisticPbiOrderMutation {
|
||||||
prevPbiIds: string[]
|
prevPbiIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OptimisticStoryOrderMutation {
|
|
||||||
kind: 'story-order'
|
|
||||||
pbiId: string
|
|
||||||
prevStoryIds: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OptimisticTaskOrderMutation {
|
|
||||||
kind: 'task-order'
|
|
||||||
storyId: string
|
|
||||||
prevTaskIds: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OptimisticEntityPatchMutation {
|
export interface OptimisticEntityPatchMutation {
|
||||||
kind: 'entity-patch'
|
kind: 'entity-patch'
|
||||||
entity: 'pbi' | 'story' | 'task'
|
entity: 'pbi' | 'story' | 'task'
|
||||||
|
|
@ -129,8 +118,6 @@ export interface OptimisticEntityPatchMutation {
|
||||||
|
|
||||||
export type OptimisticMutation =
|
export type OptimisticMutation =
|
||||||
| OptimisticPbiOrderMutation
|
| OptimisticPbiOrderMutation
|
||||||
| OptimisticStoryOrderMutation
|
|
||||||
| OptimisticTaskOrderMutation
|
|
||||||
| OptimisticEntityPatchMutation
|
| OptimisticEntityPatchMutation
|
||||||
|
|
||||||
export interface PendingOptimisticMutation {
|
export interface PendingOptimisticMutation {
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,6 @@ function compareSprint(a: SprintWorkspaceSprint, b: SprintWorkspaceSprint): numb
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareStory(a: SprintWorkspaceStory, b: SprintWorkspaceStory): number {
|
function compareStory(a: SprintWorkspaceStory, b: SprintWorkspaceStory): number {
|
||||||
if (a.priority !== b.priority) return a.priority - b.priority
|
|
||||||
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
|
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
|
||||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||||
}
|
}
|
||||||
|
|
@ -520,12 +519,6 @@ export const useSprintWorkspaceStore = create<SprintWorkspaceStore>()(
|
||||||
const { mutation } = pending
|
const { mutation } = pending
|
||||||
set((s) => {
|
set((s) => {
|
||||||
switch (mutation.kind) {
|
switch (mutation.kind) {
|
||||||
case 'sprint-story-order':
|
|
||||||
s.relations.storyIdsBySprint[mutation.sprintId] = [...mutation.prevStoryIds]
|
|
||||||
break
|
|
||||||
case 'sprint-task-order':
|
|
||||||
s.relations.taskIdsByStory[mutation.storyId] = [...mutation.prevTaskIds]
|
|
||||||
break
|
|
||||||
case 'entity-patch': {
|
case 'entity-patch': {
|
||||||
const { entity, id, prev } = mutation
|
const { entity, id, prev } = mutation
|
||||||
if (prev) {
|
if (prev) {
|
||||||
|
|
|
||||||
|
|
@ -122,18 +122,6 @@ export type ResyncReason =
|
||||||
|
|
||||||
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
|
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
|
||||||
|
|
||||||
export interface OptimisticSprintStoryOrderMutation {
|
|
||||||
kind: 'sprint-story-order'
|
|
||||||
sprintId: string
|
|
||||||
prevStoryIds: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OptimisticSprintTaskOrderMutation {
|
|
||||||
kind: 'sprint-task-order'
|
|
||||||
storyId: string
|
|
||||||
prevTaskIds: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OptimisticEntityPatchMutation {
|
export interface OptimisticEntityPatchMutation {
|
||||||
kind: 'entity-patch'
|
kind: 'entity-patch'
|
||||||
entity: 'sprint' | 'story' | 'task'
|
entity: 'sprint' | 'story' | 'task'
|
||||||
|
|
@ -147,8 +135,6 @@ export interface OptimisticEntityPatchMutation {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OptimisticMutation =
|
export type OptimisticMutation =
|
||||||
| OptimisticSprintStoryOrderMutation
|
|
||||||
| OptimisticSprintTaskOrderMutation
|
|
||||||
| OptimisticEntityPatchMutation
|
| OptimisticEntityPatchMutation
|
||||||
|
|
||||||
export interface PendingOptimisticMutation {
|
export interface PendingOptimisticMutation {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue