Scrum4Me/docs/plans/PBI-75-sprint-task-edit-store.md
Janpeter Visser a9b53dedf0
feat(PBI-75): sprint task-edit client-side via workspace-store (#183)
Klik op een taak in het sprint-scherm opent de edit-dialog nu
client-side via setActiveTask op de sprint-workspace-store.
Geen URL-navigatie, geen volledige server re-render — alleen
GET /api/tasks/{id} voor het detail. SSE propageert server-saves
automatisch terug.

- TaskDialog: optionele onClose/onSaved callbacks (closePath
  optional gemaakt — backwards compatible)
- SprintTaskDialogMount: nieuwe client-component die
  selectActiveTask consumeert en TaskDialog rendert
- SprintUrlTaskSync: deeplink (?editTask=<id>) → store
- Sprint page: mounts toegevoegd, editTask searchParam +
  EditTaskLoader-Suspense verwijderd
- TaskList.openEditDialog roept setActiveTask aan ipv router.push
- Vitest integratie-test voor SprintTaskDialogMount

Out-of-scope (follow-up PBIs): newTask-flow, mobile, product-backlog.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:21:42 +02:00

7.2 KiB

PBI-75 — Sprint task-edit client-side via workspace-store

Context

In het Sprint-scherm (/products/<id>/sprint/<sprintId>) duurt het bewerken van een taak onevenredig lang. Klik op een taakregel of het potlood-icoon roept router.push(?editTask=<id>) aan vanuit components/sprint/task-list.tsx:226. Dat triggert:

  • Volledige server re-render van app/(app)/products/[id]/sprint/[sprintId]/page.tsx met zware queries (sprint, alle sprintStories+tasks, productMembers, alle PBIs+stories voor het backlog-paneel)
  • Tweede Prisma-query in EditTaskLoader voor task-detail (incl. implementation_plan)
  • Na save: router.push(closePath) + revalidatePath → opnieuw alle queries

De sprint-workspace-store (sinds PBI-74 Story 9) bevat al alles voor een client-side edit-flow:

  • setActiveTask(taskId) (regel 337) — zet context.activeTaskId + roept ensureTaskLoaded() aan
  • ensureTaskLoaded(taskId) (regel 414) — GET /api/tasks/{id} → upsert met _detail: true
  • selectActiveTask (regel 91) — bestaat, nog geen consumer
  • applyTaskEvent (regel 748) — SSE-events propageren idempotent na server-save
  • Optimistic-mutation primitives (applyOptimisticMutation / settleMutation / rollbackMutation)

Het patroon "URL-param → store" bestaat al voor product-workspace in components/backlog/url-task-sync.tsx — we volgen dat als precedent.

Doel: klik op een taak opent de edit-dialoog client-side via store-state binnen ~100ms. Geen URL-navigatie, geen server re-render, alleen GET /api/tasks/{id} voor het detail.

Aanpak

Architectuur: store-mounted dialog + URL-sync component voor deeplinks.

  1. Klik-flow: TaskList.openEditDialog roept setActiveTask(taskId) aan op de store. Geen router.push.
  2. Render-flow: nieuwe client-component SprintTaskDialogMount zit binnen SprintHydrationWrapper, subscribet selectActiveTask, en rendert <TaskDialog> zodra de active task _detail === true is.
  3. Save-flow: TaskDialog krijgt optionele onClose/onSaved callbacks (backwards compatible met bestaande closePath). Mount geeft onClose = () => setActiveTask(null). Server action saveTask blijft revalidatePath doen voor server-cache; SSE-event update store via applyTaskEvent.
  4. Deeplink-flow: nieuwe SprintUrlTaskSync leest ?editTask=<id> en roept setActiveTask(id) aan (analoog aan url-task-sync.tsx).

Bestanden + wijzigingen

Nieuw — components/sprint/sprint-task-dialog-mount.tsx

Client component. Subscribet selectActiveTask (single-value, geen useShallow). Wanneer task aanwezig is en isDetail(task) true, mappt naar TaskDialogTask-shape:

  • status: via taskStatusFromApi uit lib/task-status.ts (lowercase API → Prisma UPPER_SNAKE)
  • implementation_plan: task.implementation_plan ?? null
  • created_at: new Date(task.created_at)

Rendert <TaskDialog task={mapped} productId={productId} onClose={() => setActiveTask(null)} isDemo={isDemo} />. Geen render tussen setActiveTask en _detail: true (detail-fetch <100ms).

Nieuw — components/sprint/sprint-url-task-sync.tsx

Kopie van components/backlog/url-task-sync.tsx maar tegen useSprintWorkspaceStore en writeTaskHint uit stores/sprint-workspace/restore.

Wijziging — components/sprint/task-list.tsx (regels 225-227)

Vervang:

function openEditDialog(taskId: string) {
  router.push(`${pathname}?editTask=${taskId}`)
}

door:

function openEditDialog(taskId: string) {
  useSprintWorkspaceStore.getState().setActiveTask(taskId)
}

openCreateDialog (regel 222) blijft URL-gebaseerd — out-of-scope.

Wijziging — app/(app)/products/[id]/sprint/[sprintId]/page.tsx

  • Verwijder editTask uit searchParams-destructuring (regel 36)
  • Verwijder editTask &&-block met <Suspense><EditTaskLoader> (regels 250-260)
  • Verwijder ongebruikte imports (EditTaskLoader, TaskDialogSkeleton, evt. Suspense)
  • Mount binnen SprintHydrationWrapper:
    <SprintHydrationWrapper ...>
      <SprintBoardClient ... />
      <SprintTaskDialogMount productId={id} isDemo={isDemo} />
      <SprintUrlTaskSync />
    </SprintHydrationWrapper>
    
  • newTask-block (regels 241-248) blijft ongemoeid — out-of-scope.

Wijziging — app/_components/tasks/task-dialog.tsx

Maak closePath optioneel + voeg onClose/onSaved toe (backwards compatible):

interface TaskDialogProps {
  task?: TaskDialogTask
  storyId?: string
  productId: string
  closePath?: string
  onClose?: () => void
  onSaved?: (taskId: string) => void
  isDemo?: boolean
}

Refactor de drie router.push(closePath)-calls (regels 104, 120, 155) naar één helper:

function close() {
  if (onClose) { onClose(); return }
  if (closePath) router.push(closePath)
}

Bestaande callers (EditTaskLoader, mobile, product-page, sprint newTask-block) blijven werken via closePath. Nieuwe SprintTaskDialogMount gebruikt onClose.

Geen wijziging

  • stores/sprint-workspace/selectors.tsselectActiveTask bestaat al
  • app/_components/tasks/edit-task-loader.tsx — nog gebruikt door product-page en mobile

Edge cases

  • Status-enum mapping: store API-lowercase → Prisma UPPER_SNAKE via taskStatusFromApi, fallback 'TO_DO'
  • _detail: true race: mount rendert pas wanneer isDetail(task) true is — geen flash met undefined velden
  • Demo-mode: prop blijft via server doorlopen, dialog respecteert al isDemo
  • Dirty-close-guard: ingebouwd in dialog (regels 107, 172) — werkt via onClose
  • SSE na save: applyTaskEvent updatet store automatisch
  • Deeplink + task niet bestaat: GET /api/tasks/{id} 404 → store doet niets, dialog opent niet (huidige redirect() verdwijnt — acceptabel)

Verificatie

  1. Browser (npm run dev): klik op task in takenlijst → dialog opent <100ms, geen URL-verandering, alleen GET /api/tasks/<id> in Network
  2. Save: wijzig titel → Opslaan → dialog sluit → store toont nieuwe titel via SSE
  3. Deeplink: ?editTask=<id> → dialog opent via SprintUrlTaskSync
  4. Bestaande flows ongebroken: product-page edit, mobile edit, sprint ?newTask=1
  5. npm run verify && npm run build
  6. Vitest: __tests__/components/sprint/sprint-task-dialog-mount.test.tsx — hydreer store, mock fetch, setActiveTask(id), assert UPPER_SNAKE status + onClose clear

Risico's

  • Andere mounts (mobile, product-backlog, sprint newTask) blijven URL-gebaseerd — closePath? optional houdt ze werkend
  • Geen redirect() bij not-found-deeplink (klein UX-verschil)
  • SSE-latency 100-500ms na save — eventueel later mitigeren via applyOptimisticMutation in onSaved-callback

Out-of-scope (follow-up PBIs)

  • ?newTask=1-flow naar store
  • Mobile + product-backlog mounts
  • EditTaskLoader verwijderen wanneer alle callers over zijn