diff --git a/__tests__/components/sprint/sprint-task-dialog-mount.test.tsx b/__tests__/components/sprint/sprint-task-dialog-mount.test.tsx
new file mode 100644
index 0000000..886dbfe
--- /dev/null
+++ b/__tests__/components/sprint/sprint-task-dialog-mount.test.tsx
@@ -0,0 +1,119 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
+vi.mock('@/actions/tasks', () => ({
+ saveTask: vi.fn(),
+ deleteTask: vi.fn(),
+}))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+
+import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount'
+import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
+import type { SprintWorkspaceTaskDetail } from '@/stores/sprint-workspace/types'
+
+const TASK_DETAIL: SprintWorkspaceTaskDetail = {
+ id: 't1',
+ code: 'T-1',
+ title: 'Mijn taak',
+ description: 'Beschrijving',
+ priority: 2,
+ sort_order: 1,
+ status: 'in_progress',
+ story_id: 'story-1',
+ sprint_id: 'sprint-1',
+ created_at: new Date('2026-01-15'),
+ _detail: true,
+ implementation_plan: 'Stap 1\nStap 2',
+}
+
+function resetStore() {
+ useSprintWorkspaceStore.setState((s) => {
+ s.context.activeProduct = null
+ s.context.activeSprintId = null
+ s.context.activeStoryId = null
+ s.context.activeTaskId = null
+ s.entities.sprintsById = {}
+ s.entities.storiesById = {}
+ s.entities.tasksById = {}
+ s.relations.sprintIdsByProduct = {}
+ s.relations.storyIdsBySprint = {}
+ s.relations.taskIdsByStory = {}
+ s.loading.loadedProductSprintsIds = {}
+ s.loading.loadingProductId = null
+ s.loading.loadedSprintIds = {}
+ s.loading.loadingSprintId = null
+ s.loading.loadedStoryIds = {}
+ s.loading.loadedTaskIds = {}
+ s.loading.activeRequestId = null
+ s.pendingMutations = {}
+ })
+}
+
+beforeEach(() => {
+ resetStore()
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('SprintTaskDialogMount', () => {
+ it('rendert niets wanneer er geen active task is', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container.textContent).toBe('')
+ })
+
+ it('rendert niets wanneer active task geen _detail heeft', () => {
+ useSprintWorkspaceStore.setState((s) => {
+ s.entities.tasksById['t1'] = {
+ id: 't1',
+ code: 'T-1',
+ title: 'Mijn taak',
+ description: null,
+ priority: 2,
+ sort_order: 1,
+ status: 'todo',
+ story_id: 'story-1',
+ sprint_id: 'sprint-1',
+ created_at: new Date(),
+ }
+ s.context.activeTaskId = 't1'
+ })
+
+ const { container } = render(
+ ,
+ )
+ expect(container.textContent).toBe('')
+ })
+
+ it('rendert TaskDialog met titel "Taak bewerken" wanneer detail aanwezig is', () => {
+ useSprintWorkspaceStore.setState((s) => {
+ s.entities.tasksById['t1'] = TASK_DETAIL
+ s.context.activeTaskId = 't1'
+ })
+
+ render()
+
+ expect(screen.getByText('Taak bewerken')).toBeTruthy()
+ expect((screen.getByLabelText(/Titel/) as HTMLInputElement).value).toBe('Mijn taak')
+ })
+
+ it('clear activeTaskId wanneer Annuleren wordt geklikt', async () => {
+ useSprintWorkspaceStore.setState((s) => {
+ s.entities.tasksById['t1'] = TASK_DETAIL
+ s.context.activeTaskId = 't1'
+ })
+
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Annuleren' }))
+
+ await waitFor(() => {
+ expect(useSprintWorkspaceStore.getState().context.activeTaskId).toBeNull()
+ })
+ })
+})
diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx
index 84ec08e..4306219 100644
--- a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx
+++ b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx
@@ -1,4 +1,3 @@
-import { Suspense } from 'react'
import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
@@ -9,6 +8,8 @@ import {
SprintHydrationWrapper,
type SprintHydrationData,
} from '@/components/sprint/sprint-hydration-wrapper'
+import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount'
+import { SprintUrlTaskSync } from '@/components/sprint/sprint-url-task-sync'
import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie'
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
@@ -18,8 +19,6 @@ import { parsePauseContext } from '@/lib/pause-context'
import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog'
import type { SprintWorkspaceTask } from '@/stores/sprint-workspace/types'
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
-import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
-import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
import Link from 'next/link'
interface Props {
@@ -27,13 +26,12 @@ interface Props {
searchParams: Promise<{
newTask?: string
storyId?: string
- editTask?: string
}>
}
export default async function SprintBoardPage({ params, searchParams }: Props) {
const { id, sprintId } = await params
- const { newTask, storyId: storyIdParam, editTask } = await searchParams
+ const { newTask, storyId: storyIdParam } = await searchParams
const session = await getSession()
if (!session.userId) redirect('/login')
@@ -229,6 +227,8 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
currentUserId={session.userId}
members={members}
/>
+
+
@@ -246,18 +246,6 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
isDemo={isDemo}
/>
)}
-
- {editTask && !newTask && (
- }>
-
-
- )}
)
}
diff --git a/app/_components/tasks/task-dialog.tsx b/app/_components/tasks/task-dialog.tsx
index 3431cda..638d834 100644
--- a/app/_components/tasks/task-dialog.tsx
+++ b/app/_components/tasks/task-dialog.tsx
@@ -60,7 +60,9 @@ interface TaskDialogProps {
task?: TaskDialogTask
storyId?: string
productId: string
- closePath: string
+ closePath?: string
+ onClose?: () => void
+ onSaved?: (taskId: string) => void
isDemo?: boolean
}
@@ -81,7 +83,7 @@ const textareaClass = cn(
'overflow-y-auto',
)
-export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) {
+export function TaskDialog({ task, storyId, productId, closePath, onClose, onSaved, isDemo = false }: TaskDialogProps) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [confirmDelete, setConfirmDelete] = useState(false)
@@ -100,11 +102,12 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
},
})
- function handleClose() {
- router.push(closePath)
+ function close() {
+ if (onClose) { onClose(); return }
+ if (closePath) router.push(closePath)
}
- const closeGuard = useDirtyCloseGuard(form.formState.isDirty, handleClose)
+ const closeGuard = useDirtyCloseGuard(form.formState.isDirty, close)
const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)())
function onSubmit(data: TaskInput) {
@@ -117,7 +120,8 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
if (result.ok) {
toast.success(isEdit ? 'Taak opgeslagen' : 'Taak aangemaakt')
- router.push(closePath)
+ onSaved?.(result.task.id)
+ close()
return
}
@@ -152,7 +156,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
const result = await deleteTask(task.id, { productId })
if (result.ok) {
toast.success('Taak verwijderd')
- router.push(closePath)
+ close()
return
}
if (result.code === 403) {
diff --git a/components/sprint/sprint-task-dialog-mount.tsx b/components/sprint/sprint-task-dialog-mount.tsx
new file mode 100644
index 0000000..1ca0f45
--- /dev/null
+++ b/components/sprint/sprint-task-dialog-mount.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
+import { selectActiveTask } from '@/stores/sprint-workspace/selectors'
+import { isDetail } from '@/stores/sprint-workspace/types'
+import { TaskDialog } from '@/app/_components/tasks/task-dialog'
+import { taskStatusFromApi } from '@/lib/task-status'
+import type { TaskStatus } from '@prisma/client'
+
+interface Props {
+ productId: string
+ isDemo: boolean
+}
+
+export function SprintTaskDialogMount({ productId, isDemo }: Props) {
+ const task = useSprintWorkspaceStore(selectActiveTask)
+ const setActiveTask = useSprintWorkspaceStore((s) => s.setActiveTask)
+
+ if (!task || !isDetail(task)) return null
+
+ const status = (taskStatusFromApi(String(task.status)) ?? 'TO_DO') as TaskStatus
+ const createdAt = task.created_at instanceof Date ? task.created_at : new Date(task.created_at)
+
+ return (
+ setActiveTask(null)}
+ isDemo={isDemo}
+ />
+ )
+}
diff --git a/components/sprint/sprint-url-task-sync.tsx b/components/sprint/sprint-url-task-sync.tsx
new file mode 100644
index 0000000..893b0af
--- /dev/null
+++ b/components/sprint/sprint-url-task-sync.tsx
@@ -0,0 +1,29 @@
+'use client'
+
+// PBI-75: URL-deeplink → store sync voor sprint task-edit.
+//
+// Patroon spiegelt components/backlog/url-task-sync.tsx: zodra de route
+// `?editTask=` draagt, schrijven we de taak-hint en roepen we
+// setActiveTask aan op de sprint-workspace-store. De dialog wordt
+// vervolgens client-side gemount door SprintTaskDialogMount.
+
+import { useEffect } from 'react'
+import { useSearchParams } from 'next/navigation'
+import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
+import { writeTaskHint } from '@/stores/sprint-workspace/restore'
+
+export function SprintUrlTaskSync() {
+ const searchParams = useSearchParams()
+ const editTask = searchParams.get('editTask')
+
+ useEffect(() => {
+ if (!editTask) return
+ const sprintId = useSprintWorkspaceStore.getState().context.activeSprintId
+ if (sprintId) {
+ writeTaskHint(sprintId, editTask)
+ }
+ useSprintWorkspaceStore.getState().setActiveTask(editTask)
+ }, [editTask])
+
+ return null
+}
diff --git a/components/sprint/task-list.tsx b/components/sprint/task-list.tsx
index d95ed60..c4ea4a3 100644
--- a/components/sprint/task-list.tsx
+++ b/components/sprint/task-list.tsx
@@ -223,7 +223,7 @@ export function TaskList({ sprintId: _sprintId, productId: _productId, isDemo }:
}
function openEditDialog(taskId: string) {
- router.push(`${pathname}?editTask=${taskId}`)
+ useSprintWorkspaceStore.getState().setActiveTask(taskId)
}
return (
diff --git a/docs/INDEX.md b/docs/INDEX.md
index 3848845..ef5e696 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -53,6 +53,7 @@ Auto-generated on 2026-05-10 from front-matter and headings.
| [M12 — Idea entity + Grill/Plan Claude jobs](./plans/M12-ideas.md) | planned | — |
| [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | active | 2026-05-03 |
| [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — |
+| [PBI-75 — Sprint task-edit client-side via workspace-store](./plans/PBI-75-sprint-task-edit-store.md) | — | — |
| [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — |
| [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 |
| [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 |
diff --git a/docs/plans/PBI-75-sprint-task-edit-store.md b/docs/plans/PBI-75-sprint-task-edit-store.md
new file mode 100644
index 0000000..b122553
--- /dev/null
+++ b/docs/plans/PBI-75-sprint-task-edit-store.md
@@ -0,0 +1,128 @@
+# PBI-75 — Sprint task-edit client-side via workspace-store
+
+## Context
+
+In het Sprint-scherm (`/products//sprint/`) duurt het bewerken van een taak onevenredig lang. Klik op een taakregel of het potlood-icoon roept `router.push(?editTask=)` aan vanuit [`components/sprint/task-list.tsx:226`](../../components/sprint/task-list.tsx). Dat triggert:
+
+- **Volledige server re-render** van [`app/(app)/products/[id]/sprint/[sprintId]/page.tsx`](../../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`](../../app/_components/tasks/edit-task-loader.tsx) voor task-detail (incl. `implementation_plan`)
+- **Na save**: `router.push(closePath)` + `revalidatePath` → opnieuw alle queries
+
+De [`sprint-workspace-store`](../../stores/sprint-workspace/store.ts) (sinds PBI-74 Story 9) bevat al alles voor een client-side edit-flow:
+
+- [`setActiveTask(taskId)`](../../stores/sprint-workspace/store.ts) (regel 337) — zet `context.activeTaskId` + roept `ensureTaskLoaded()` aan
+- [`ensureTaskLoaded(taskId)`](../../stores/sprint-workspace/store.ts) (regel 414) — `GET /api/tasks/{id}` → upsert met `_detail: true`
+- [`selectActiveTask`](../../stores/sprint-workspace/selectors.ts) (regel 91) — bestaat, nog geen consumer
+- [`applyTaskEvent`](../../stores/sprint-workspace/store.ts) (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`](../../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 `` 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=` 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`](../../lib/task-status.ts) (lowercase API → Prisma UPPER_SNAKE)
+- `implementation_plan: task.implementation_plan ?? null`
+- `created_at: new Date(task.created_at)`
+
+Rendert ` 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`](../../components/backlog/url-task-sync.tsx) maar tegen `useSprintWorkspaceStore` en `writeTaskHint` uit [`stores/sprint-workspace/restore`](../../stores/sprint-workspace/restore.ts).
+
+### Wijziging — `components/sprint/task-list.tsx` (regels 225-227)
+Vervang:
+```ts
+function openEditDialog(taskId: string) {
+ router.push(`${pathname}?editTask=${taskId}`)
+}
+```
+door:
+```ts
+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 `` (regels 250-260)
+- Verwijder ongebruikte imports (`EditTaskLoader`, `TaskDialogSkeleton`, evt. `Suspense`)
+- Mount binnen `SprintHydrationWrapper`:
+ ```tsx
+
+
+
+
+
+ ```
+- `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):
+```ts
+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:
+```ts
+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.ts` — `selectActiveTask` 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/` in Network
+2. **Save**: wijzig titel → Opslaan → dialog sluit → store toont nieuwe titel via SSE
+3. **Deeplink**: `?editTask=` → 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