diff --git a/CLAUDE.md b/CLAUDE.md index 4ed8cdc..40245da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,7 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo | Prisma singleton | `docs/patterns/prisma-client.md` | | Server Action (auth + Zod) | `docs/patterns/server-action.md` | | Route Handler (REST) | `docs/patterns/route-handler.md` | +| Workspace-store + realtime (PBI-74) | `docs/patterns/workspace-store.md` | | Zustand optimistic update | `docs/patterns/zustand-optimistic.md` | | Float sort_order / drag-and-drop | `docs/patterns/sort-order.md` | | Proxy / route protection | `docs/patterns/proxy.md` | diff --git a/docs/INDEX.md b/docs/INDEX.md index e3a4668..4ee9fa0 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -90,7 +90,8 @@ Auto-generated on 2026-05-09 from front-matter and headings. | [Float sort_order (drag-and-drop volgorde)](./patterns/sort-order.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 | -| [Zustand optimistische update + rollback](./patterns/zustand-optimistic.md) | active | 2026-05-03 | +| [Workspace-store + realtime — bounded-context patroon](./patterns/workspace-store.md) | active | 2026-05-10 | +| [Zustand optimistische update + rollback](./patterns/zustand-optimistic.md) | active | 2026-05-10 | ## Other Docs diff --git a/docs/patterns/workspace-store.md b/docs/patterns/workspace-store.md new file mode 100644 index 0000000..20691c9 --- /dev/null +++ b/docs/patterns/workspace-store.md @@ -0,0 +1,414 @@ +--- +title: "Workspace-store + realtime — bounded-context patroon" +status: active +audience: [ai-agent, contributor] +language: nl +last_updated: 2026-05-10 +when_to_read: "When adding a new bounded-context client store backed by SSE, or when modifying product/sprint workspace state." +--- + +# Patroon: workspace-store + realtime + +Sinds PBI-74 is `product-workspace-store` de blueprint voor client-state op een +**bounded context** (één coherente workflow). Andere bounded contexts mogen +hetzelfde patroon volgen — `sprint-workspace-store`, `solo-store`, +`notifications-store`. Dit document beschrijft wanneer je een workspace-store +opzet, hoe je 'm structureert, hoe SSE en de store samenwerken, en welke +gotchas in code-comments hoort. + +Bron-ontwerp: [zustand-store-rearchitecture.md](../plans/zustand-store-rearchitecture.md). +Referentie-implementatie: [stores/product-workspace/](../../stores/product-workspace/). + +--- + +## Wanneer een workspace-store + +Eén store **per bounded context**, niet per pagina en niet één megastore. + +| Workflow | Store | +|---|---| +| Product backlog (PBI/story/task selectie + DnD) | `product-workspace-store` | +| Sprint board | `sprint-workspace-store` (toekomstig, PBI > 74) | +| Solo execution | `solo-store` | +| Notifications/questions | `notifications-store` | +| Idea grill/plan-flow | `idea-store` | +| Lijst van producten | `products-store` (≠ active product) | + +Splits niet per panel; bundel niet over workflows. + +--- + +## State-shape + +Vlak en **genormaliseerd**. Vijf slices: + +```ts +{ + context: { active*Id } // huidige selectie + entities: { *ById } // entity-maps per kind + relations: { ids[], idsByParent } // gesorteerde id-lijsten + loading: { loaded*Ids, activeRequestId } // race-safe markers + sync: { realtimeStatus, lastResyncAt, resyncReason } + pendingMutations: { [id]: { mutation, createdAt } } +} +``` + +**Acties** zijn in dezelfde store: +`hydrateSnapshot`, `setActive*`, `ensure*Loaded`, `applyRealtimeEvent`, +`resyncActiveScopes`, `resyncLoadedScopes`, +`applyOptimisticMutation`/`rollbackMutation`/`settleMutation`. + +Gebruik `zustand/middleware/immer`. Mutation-style (G3 — return nooit een +nieuwe state uit een immer-recipe; muteer de draft). + +--- + +## Selectors + +Module-level **`EMPTY`**-refs (G1) en `useShallow` voor lijsten (G2). + +```ts +// stores/product-workspace/selectors.ts +const EMPTY_PBIS: BacklogPbi[] = [] + +export function selectVisiblePbis(s: Store): BacklogPbi[] { + if (s.relations.pbiIds.length === 0) return EMPTY_PBIS + return s.relations.pbiIds.map((id) => s.entities.pbisById[id]).filter(Boolean) +} +``` + +```tsx +// component +import { useShallow } from 'zustand/react/shallow' +import { selectVisiblePbis } from '@/stores/product-workspace/selectors' + +const pbis = useStore(useShallow(selectVisiblePbis)) +const activePbiId = useStore((s) => s.context.activePbiId) // primitive — geen useShallow +``` + +Single-value selectors (`selectActivePbi`) hebben geen `useShallow` nodig. + +--- + +## ensure*Loaded — race-safe loaders + +Elke setter genereert een nieuwe `requestId`, schrijft 'm in +`loading.activeRequestId`, en triggert de loader. De loader checkt **na de +fetch** of de guard nog matcht — anders bail-out. + +```ts +setActivePbi(pbiId) { + const requestId = newRequestId() + set((s) => { + s.context.activePbiId = pbiId + s.context.activeStoryId = null + s.context.activeTaskId = null + s.loading.activeRequestId = requestId + }) + if (pbiId) void get().ensurePbiLoaded(pbiId, requestId) +} + +async ensurePbiLoaded(pbiId, requestId) { + const stories = await fetchJson(`/api/pbis/${pbiId}/stories`) + if (requestId && get().loading.activeRequestId !== requestId) return + if (!Array.isArray(stories)) return + set((s) => { /* apply */ }) +} +``` + +**Belangrijke regels:** + +- Gebruik `get().method()` per call (G4) — nooit `state.method()` via een + gecaptured snapshot. Method-refs zijn niet stabiel over immer state-versies. +- `fetch(url, { cache: 'no-store' })` op alle client-fetches. +- Server read-routes: `export const dynamic = 'force-dynamic'`. + +--- + +## SSE-hook beheert transport, store beheert betekenis + +```txt +useXxxRealtime(activeId) +-> opent /api/realtime/xxx?... +-> parsed event +-> dispatcht naar store.applyRealtimeEvent(event) +-> beheert reconnect/backoff/status +-> op 'ready' na (re)connect: telt cycles; latere ready triggert resync('reconnect') +``` + +```ts +// applyRealtimeEvent regels +known pbi/story/task event + → upsert + sort, parent-move bij wijziging parent_id + → idempotent: bestaande id bij INSERT → return + → DELETE → ruim child entities op + clear actieve selectie als die viel + +unknown entity met matching product_id, geen 'type' veld + → resyncActiveScopes('unknown-event') + +job/worker/heartbeat (heeft 'type' veld) + → negeer +``` + +**Idempotent:** een event dat al via een optimistic mutation is toegepast, +mag geen dubbele insert of verkeerde rollback veroorzaken. INSERTs checken +`if (entity exists) return`. UPDATEs zijn altijd merge-into-existing. + +Payload-contract: zie [realtime-notify-payload.md](./realtime-notify-payload.md). + +--- + +## Hidden tab + reconnect resync + +EventSource blijft open als de tab `hidden` wordt — gemiste events worden +opgehaald via een expliciete resync-laag. + +```ts +// In de realtime-hook +const onVisibility = () => { + if (document.visibilityState === 'visible' && sourceRef.current === null) { + connect() // alleen als de stream weg is (b.v. server hard-close na 240s) + } +} +// Geen close() bij hidden. + +source.addEventListener('ready', () => { + readyCountRef.current += 1 + if (readyCountRef.current > 1) { + void store.resyncActiveScopes('reconnect') + } +}) +``` + +```ts +// useWorkspaceResync — visibility + online +useEffect(() => { + const onVisibility = () => { + if (document.visibilityState === 'visible') { + void store.resyncActiveScopes('visible') + } + } + const onOnline = () => void store.resyncActiveScopes('reconnect') + + document.addEventListener('visibilitychange', onVisibility) + window.addEventListener('online', onOnline) + return () => { /* remove */ } +}, []) +``` + +**Mount in dezelfde wrapper als de realtime-hook.** Doe nooit alleen het +sluiten-op-hidden weghalen zonder de resync-laag erbij — dan verlies je het +vangnet. + +`resyncActiveScopes` triggert alleen de loaders die gekoppeld zijn aan de +huidige selectie: + +```ts +async resyncActiveScopes(reason) { + const ctx = get().context + const tasks: Promise[] = [] + if (ctx.activeProduct?.id) tasks.push(get().ensureProductLoaded(ctx.activeProduct.id)) + if (ctx.activePbiId) tasks.push(get().ensurePbiLoaded(ctx.activePbiId)) + if (ctx.activeStoryId) tasks.push(get().ensureStoryLoaded(ctx.activeStoryId)) + if (ctx.activeTaskId) tasks.push(get().ensureTaskLoaded(ctx.activeTaskId)) + set((s) => { s.sync.lastResyncAt = Date.now(); s.sync.resyncReason = reason }) + await Promise.allSettled(tasks) +} +``` + +--- + +## LocalStorage = restore-hint, niet waarheid + +Selectie-id's worden gepersisteerd om bij cold reload de vorige selectie te +herstellen, **maar de hint wordt pas toegepast nadat ensure-load is gelukt +en de hint-id bevestigd is in `entities.byId`**. + +```ts +setActiveProduct(product) { + set((s) => { s.context.activeProduct = product; ... }) + writeProductHint(product?.id ?? null) + + if (product) { + void (async () => { + await get().ensureProductLoaded(product.id, requestId) + if (get().loading.activeRequestId !== requestId) return + const hint = readHints().perProduct[product.id]?.lastActivePbiId + if (hint && get().entities.pbisById[hint]) { + get().setActivePbi(hint) // cascade — die doet zelfde voor story + } + })() + } +} +``` + +**Geen `setTimeout(0)` of microtask-trick.** De fetch is dan nog niet klaar, +de validatie `entities.byId[hint]` faalt altijd. Chain altijd `await +ensureXxxLoaded` en valideer in dezelfde `requestId`-cycle. + +**URL wint van hint.** Maak een client-component (b.v. +[`UrlTaskSync`](../../components/backlog/url-task-sync.tsx)) die op mount +`useSearchParams().get('editTask')` leest, de hint overschrijft via +`writeTaskHint`, en `setActiveTask` aanroept. De restore-flow leest de +task-hint pas na drie ensure-awaits, dus de URL-write komt altijd eerder. + +--- + +## Optimistic mutations + +Voor DnD en status-toggles. De store registreert alleen het rollback-snapshot; +de component muteert state direct én roept de server aan. + +```tsx +function handleDragEnd(event) { + const store = useStore.getState() + const prevOrder = [...store.relations.pbiIds] + const newOrder = arrayMove(prevOrder, oldIndex, newIndex) + + // 1. Snapshot voor rollback + const mutationId = store.applyOptimisticMutation({ + kind: 'pbi-order', + prevPbiIds: prevOrder, + }) + + // 2. Optimistisch toepassen + useStore.setState((s) => { s.relations.pbiIds = newOrder }) + + // 3. Server bevestigt (of niet) + startTransition(async () => { + const result = await reorderPbisAction(productId, newOrder) + const st = useStore.getState() + if (result.success) { + st.settleMutation(mutationId) + } else { + st.rollbackMutation(mutationId) + toast.error('Volgorde opslaan mislukt') + } + }) +} +``` + +**Cross-priority drag** vereist twee mutaties: een `pbi-order` voor de lijst +plus een `entity-patch` voor de priority op de PBI zelf. Beide settle/rollback +samen. + +**SSE-echo van een net optimistisch toegepaste wijziging** moet idempotent +zijn — INSERT → bestaat al → return; UPDATE → merge into existing. + +--- + +## API endpoints + +Voor elke `ensure*Loaded` een GET-route met: + +- Auth via `authenticateApiRequest` (Bearer-token of iron-session cookie). +- Access-control via `productAccessFilter(userId)` voor product-context; + `getAccessibleProduct` voor explicit guards. +- `export const dynamic = 'force-dynamic'`. +- Status-vertaling via `taskStatusToApi` / `storyStatusToApi` / + `pbiStatusToApi` (DB UPPER_SNAKE → API lowercase). + +Referentie: +[GET /api/products/:id/backlog](../../app/api/products/[id]/backlog/route.ts), +[GET /api/pbis/:id/stories](../../app/api/pbis/[id]/stories/route.ts), +[GET /api/stories/:id/tasks](../../app/api/stories/[id]/tasks/route.ts), +[GET /api/tasks/:id](../../app/api/tasks/[id]/route.ts). + +`TaskDetail` shape extends `BacklogTask` met `_detail: true` plus extra +velden (`implementation_plan`, `acceptance_criteria`, `requires_opus`, +`verify_only`, `verify_required`). Gebruik de `isDetail()` typeguard om de +extra velden te tonen. + +--- + +## Tests + +Vitest + jsdom; setup in [`tests/setup.ts`](../../tests/setup.ts): + +- `MemoryStorage` shim voor localStorage (G6 — vitest 4 + jsdom 29 mist 'm + als configurable global). +- `globalThis.fetch` herconfigureerbaar gemaakt zodat `vi.spyOn` werkt + (anders krijg je `Cannot redefine property: fetch`). +- Default fetch-stub die `null` JSON returnt — voorkomt unhandled rejections + uit fire-and-forget `ensure*Loaded` calls die in tests niet expliciet + gemockt zijn. Tests overrulen met `vi.spyOn(globalThis, 'fetch')` per case. +- `mockImplementation` (G8) — niet `mockResolvedValue` — anders is de + Response-body na de eerste `.json()` weg. + +```ts +// G5: snapshot original actions module-level, restore in beforeEach +const originalActions = (() => { + const s = useStore.getState() + return { /* alle action-refs */ } +})() + +function resetStore() { + useStore.setState((s) => { + Object.assign(s, initialDataSlices) + Object.assign(s, originalActions) + }) +} + +beforeEach(resetStore) +``` + +**Acties mocken:** gebruik `setState((s) => { s.method = vi.fn() })`. Niet +`vi.spyOn(state, 'method')` — de immer-frozen state is niet redefinable. + +**Verplichte test-cases per workspace-store:** + +- `hydrateSnapshot` vult entities + relations met sortering. +- Selection cascade: `setActivePbi` reset story+task; `setActiveStory` reset + task. +- `setActiveProduct(null)` ruimt entities en relations op. +- `applyRealtimeEvent` pbi/story/task `I|U|D` met sortering en parent-move. +- Event voor ander `product_id` wordt genegeerd. +- Unknown entity met matching product → `resyncActiveScopes('unknown-event')` + trigger. +- Job/worker/heartbeat/question events met `type`-veld → geen resync. +- Delete-cleanup van actieve selectie. +- Race-safe `ensure*Loaded` met requestId-guard (oude in-flight mag niet + nieuwere selectie overschrijven). +- `ensureTaskLoaded` zet `_detail: true`. +- `resyncActiveScopes` triggert ensure-keten met juiste URLs en zet + `lastResyncAt` + `resyncReason`. +- localStorage restore-hints per setter. +- Hint die niet (meer) in entities zit wordt genegeerd. +- Optimistic mutation rollback/settle/SSE-echo idempotent. + +--- + +## Gotchas — comment-template voor in code + +Documenteer deze in code via comments boven de fix. + +| # | Symptoom | Fix | +|---|---|---| +| **G1** | "Maximum update depth exceeded" — `s.byId[x] ?? []` levert nieuwe array per render | Module-level `EMPTY` const als fallback | +| **G2** | Component re-rendert op iedere store-mutatie ondanks dat z'n data niet wijzigt | `useShallow(selectXxx)` voor lijsten | +| **G3** | Hele state lijkt gewist na een `setState((s) => ({ context: ... }))` | Gebruik mutation-style: `setState((s) => { s.context.x = y })` (immer recipe muteert draft) | +| **G4** | "method is not a function" in async context, of inconsistente state-mutaties | `get().method()` per call; nooit `const m = state.method` cachen | +| **G5** | Tests beïnvloeden elkaar via `setState({ resyncActiveScopes: vi.fn() })` | `originalActions` snapshot op module-load + restore in `beforeEach` | +| **G6** | `localStorage.clear is not a function` in vitest | `MemoryStorage` shim in `tests/setup.ts` | +| **G7** | "Failed to parse URL from /api/..." in test-fetch | Mock fetch via `vi.spyOn(globalThis, 'fetch')` of stub in setup | +| **G8** | "Body is unusable: Body has already been read" | `vi.fn().mockImplementation(() => Promise.resolve(new Response(...)))` — niet `mockResolvedValue` met een vooraf-gemaakte Response | + +--- + +## Migratiepad voor een nieuwe workspace-store + +Volg dezelfde 8 stappen als PBI-74 (zie +[zustand-workspace-store-implementation.md](../plans/zustand-workspace-store-implementation.md)): + +1. Skelet — types, store, selectors, restore + tests; geen UI-impact. +2. Hydratie overstappen (parallel naast bestaande store). +3. Componenten omzetten — `useShallow` voor lijsten, `setActiveX` setters. +4. Race-safe loaders + restore-hints + URL-prioriteit. +5. Hidden-tab + reconnect-resync (één PR — anders verlies je vangnet). +6. Unknown-event filter (`isUnknownEntityEvent`). +7. Cache-headers + LIST-endpoints (`force-dynamic`, `cache: 'no-store'`). +8. Oude store opruimen. + +Stap 9 ("sprint-workspace-store") is de toepassing van dit patroon op de +sprint-flow — kan starten zodra `product-workspace-store` enkele weken +stabiel in productie staat. diff --git a/docs/patterns/zustand-optimistic.md b/docs/patterns/zustand-optimistic.md index 0a75f95..792c02a 100644 --- a/docs/patterns/zustand-optimistic.md +++ b/docs/patterns/zustand-optimistic.md @@ -3,34 +3,99 @@ title: "Zustand optimistische update + rollback" status: active audience: [ai-agent, contributor] language: nl -last_updated: 2026-05-03 -when_to_read: "When adding client-side state mutations that need optimistic UI and rollback." +last_updated: 2026-05-10 +when_to_read: "When adding client-side state mutations that need optimistic UI and rollback (DnD, status toggles)." --- # Patroon: Zustand optimistische update + rollback -Gebruik dit patroon bij elke dnd-kit `onDragEnd` handler. +Sinds PBI-74 lopen optimistic mutations via `applyOptimisticMutation`/ +`rollbackMutation`/`settleMutation` op de **workspace-store**. Het bredere +patroon (store-design, SSE-integratie, restore-hints, tests) staat in +[workspace-store.md](./workspace-store.md). Dit document beschrijft het +DnD/status-mutation flow specifiek. -```ts -const { pbiOrder, reorderPbis, rollbackPbis } = usePlannerStore() +## Patroon -async function handleDragEnd(event: DragEndEvent) { +1. Snapshot rollback-info via `applyOptimisticMutation` — krijgt `mutationId`. +2. Pas state direct aan via `setState`. +3. Server-actie aanroepen. +4. Op success: `settleMutation(mutationId)` (ruimt pending-record op). +5. Op error: `rollbackMutation(mutationId)` (herstelt vorige state + toast). + +Cross-priority drag vereist twee mutaties (order + entity-patch) die samen +settlen of rollbacken. + +## Voorbeeld — PBI reorder + +```tsx +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' + +function handleDragEnd(event: DragEndEvent) { const { active, over } = event if (!over || active.id === over.id) return - const prevOrder = [...pbiOrder[productId]] - const newOrder = arrayMove(prevOrder, oldIndex, newIndex) + const store = useProductWorkspaceStore.getState() + const prevOrder = [...store.relations.pbiIds] + 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) - // 1. Optimistisch updaten (direct zichtbaar voor gebruiker) - reorderPbis(productId, newOrder) + // 1. Snapshot rollback-info + const mutationId = store.applyOptimisticMutation({ + kind: 'pbi-order', + prevPbiIds: prevOrder, + }) - // 2. Persisteren via Server Action - const result = await reorderPbisAction(productId, newOrder) + // 2. Optimistisch toepassen + useProductWorkspaceStore.setState((s) => { + s.relations.pbiIds = newOrder + }) - // 3. Rollback bij fout - if (!result.success) { - rollbackPbis(productId, prevOrder) - toast.error('Volgorde opslaan mislukt') - } + // 3-5. Server bevestigt of niet + startTransition(async () => { + const result = await reorderPbisAction(productId, newOrder) + const st = useProductWorkspaceStore.getState() + if (result.success) { + st.settleMutation(mutationId) + } else { + st.rollbackMutation(mutationId) + toast.error('Volgorde opslaan mislukt') + } + }) } ``` + +## Voorbeeld — entity-patch (priority-wijziging) + +```tsx +const prevPbi = store.entities.pbisById[id] +const patchMutationId = store.applyOptimisticMutation({ + kind: 'entity-patch', + entity: 'pbi', + id, + prev: prevPbi, +}) +useProductWorkspaceStore.setState((s) => { + const pbi = s.entities.pbisById[id] + if (pbi) pbi.priority = newPriority +}) +// settle/rollback identiek aan order-flow +``` + +## Mutation-soorten + +| `kind` | Rollback-data | Use-case | +|---|---|---| +| `pbi-order` | `prevPbiIds` | DnD reorder van PBI's | +| `story-order` | `pbiId` + `prevStoryIds` | DnD reorder van stories binnen een PBI | +| `task-order` | `storyId` + `prevTaskIds` | DnD reorder van tasks binnen een story | +| `entity-patch` | `entity` + `id` + `prev` (volledig vorig record of `undefined` voor delete-rollback) | Property-wijzigingen (priority, status), of optimistic delete/undelete | + +## SSE-echo idempotent verwerken + +Wanneer de server bevestigt en de NOTIFY-trigger het bijbehorende event +emitteert, mag `applyRealtimeEvent` **geen dubbele insert** veroorzaken en +**geen rollback triggeren**. INSERTs checken bestaan; UPDATEs mergen +into-existing. Zie `applyRealtimeEvent` in [`stores/product-workspace/store.ts`](../../stores/product-workspace/store.ts).