Scrum4Me/docs/plans/zustand-store-rearchitecture.md
Madhura68 0d126695db docs: add plans and recommendations
- docs/plans/Local github setup.md
- docs/plans/lees-de-readme-md-validated-book.md
- docs/plans/zustand-store-rearchitecture.md
- docs/recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md
- docs/recommendations/claude-vm-job-flow-git-strategy.md
- docs/INDEX.md updated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:54:23 +02:00

746 lines
24 KiB
Markdown

---
title: "Zustand store rearchitecture - active context, realtime en resync"
status: ready-to-execute
audience: [maintainer, contributor, ai-agent]
language: nl
last_updated: 2026-05-09
revision: 3
---
# Zustand store rearchitecture
Doel: de client-state van Scrum4Me voorspelbaar houden terwijl de app groeit.
De database blijft de bron van waarheid. Zustand wordt de live client-projectie
voor de actieve workflow: snel genoeg voor optimistic UI, robuust genoeg tegen
gemiste SSE-events, hidden tabs en onbekende notify-vormen.
## Kernkeuze
Geen app-brede megastore en ook geen pure splits per pagina. Stores per bounded
context:
| Store | Scope | Verantwoordelijkheid |
|---|---|---|
| `product-workspace-store` | actief product | active context, product backlog data, selectie, DnD-order, SSE, resync |
| `sprint-workspace-store` | actieve sprint | sprint stories, sprint tasks, assignment, sprint-DnD, SSE, resync |
| `solo-store` | actieve user + product | uitvoerbare taken, worker/job status, solo-kanban realtime |
| `notifications-store` | user | vragen, alerts, notification badge |
| `idea-store` | idee/product | grill/make-plan jobstate, open vragen, idea-status |
| `jobs-store` | jobs pagina | actieve/afgeronde jobs en jobs-page selectie |
| kleine UI stores | app/client | debug mode, lichte UI-voorkeuren |
De huidige `backlog-store`, `planner-store`, `selection-store` en
`product-store` worden samengevoegd tot `product-workspace-store`. Ze
beschrijven dezelfde workflow en verdelen nu PBI/story/task waarheid,
order-state en selectie over meerdere stores.
## Source of truth
| Data | Waarheid | Zustand rol | Persistentie |
|---|---|---|---|
| `activeProductId` | `users.active_product_id` in DB | client mirror voor navigatie en actieve stream | DB |
| `activePbiId` | runtime selectie of URL | active context | optioneel localStorage restore hint |
| `activeStoryId` | runtime selectie of URL | active context | optioneel localStorage restore hint |
| `activeTaskId` | runtime selectie of URL dialog param | active context + task detail | optioneel localStorage restore hint |
| PBI/story/task data | DB | genormaliseerde live client-projectie | geen localStorage |
| DnD-order | DB `sort_order`, tijdelijk optimistic in store | relatie-arrays met rollback | DB na server action |
| filters/sort UI | client preference | component/store UI state | localStorage mag |
LocalStorage is dus geen waarheid voor actieve entiteiten. Het mag alleen helpen
om een vorige selectie te herstellen nadat de server de actieve product-context
heeft bepaald — én alleen als de hint-id na laden nog bestaat in de store.
## Product workspace store
Vorm:
```ts
type ProductWorkspaceStore = {
context: {
activeProduct: { id: string; name: string } | null
activePbiId: string | null
activeStoryId: string | null
activeTaskId: string | null
}
entities: {
pbisById: Record<string, BacklogPbi>
storiesById: Record<string, BacklogStory>
tasksById: Record<string, BacklogTask | TaskDetail>
}
relations: {
pbiIds: string[]
storyIdsByPbi: Record<string, string[]>
taskIdsByStory: Record<string, string[]>
}
loading: {
loadedProductId: string | null
loadingProductId: string | null
loadedPbiIds: Record<string, true>
loadedStoryIds: Record<string, true>
loadedTaskIds: Record<string, true>
activeRequestId: string | null
}
sync: {
realtimeStatus: 'connecting' | 'open' | 'disconnected'
lastEventAt: number | null
lastResyncAt: number | null
resyncReason: ResyncReason | null
}
hydrateSnapshot(snapshot: ProductBacklogSnapshot): void
setActiveProduct(product: { id: string; name: string } | null): void
setActivePbi(pbiId: string | null): void
setActiveStory(storyId: string | null): void
setActiveTask(taskId: string | null): void
ensureProductLoaded(productId: string, requestId?: string): Promise<void>
ensurePbiLoaded(pbiId: string, requestId?: string): Promise<void>
ensureStoryLoaded(storyId: string, requestId?: string): Promise<void>
ensureTaskLoaded(taskId: string, requestId?: string): Promise<void>
applyRealtimeEvent(event: ProductRealtimeEvent): void
resyncActiveScopes(reason: ResyncReason): Promise<void>
resyncLoadedScopes(reason: ResyncReason): Promise<void>
applyOptimisticMutation(mutation: OptimisticMutation): string
rollbackMutation(mutationId: string): void
settleMutation(mutationId: string): void
}
```
State blijft vlak en genormaliseerd. Componenten lezen via selectors:
```ts
selectVisiblePbis(state)
selectStoriesForActivePbi(state)
selectTasksForActiveStory(state)
selectActivePbi(state)
selectActiveStory(state)
selectActiveTask(state)
```
Een task zit in `tasksById` als `BacklogTask` (lite) zolang alleen de lijst
geladen is, en wordt verrijkt naar `TaskDetail` zodra `ensureTaskLoaded` is
gedraaid. Componenten gebruiken een `isDetail()`-typeguard voor de extra
velden.
## Active context flow
### Product wisselen
```txt
setActiveProduct(product)
-> nieuw requestId, zet activeRequestId
-> zet activeProduct, reset activePbiId/activeStoryId/activeTaskId
-> reset entities + relations als product wisselt
-> SSE-stream wisselt mee met product.id
-> ;(async) await ensureProductLoaded(product.id, requestId)
-> nadat ensure resolved + activeRequestId nog == requestId:
probeer restore hint (activePbiId) — alleen als hint-id in entities zit
```
`activeProduct` komt server-side uit de layout via `users.active_product_id`.
De client-store spiegelt dit zodat client componenten niet overal props hoeven
te ontvangen.
### PBI selecteren
```txt
setActivePbi(pbiId)
-> nieuw requestId
-> zet activePbiId, reset activeStoryId en activeTaskId
-> schrijf lastActivePbiIdByProduct[productId] als restore hint
-> ;(async) await ensurePbiLoaded(pbiId, requestId)
-> nadat ensure resolved + activeRequestId nog == requestId:
probeer restore hint (activeStoryId)
```
### Story selecteren
```txt
setActiveStory(storyId)
-> nieuw requestId
-> zet activeStoryId, reset activeTaskId
-> ensureStoryLoaded(storyId, requestId)
-> schrijf lastActiveStoryIdByProduct[productId] als restore hint
```
### Task selecteren
```txt
setActiveTask(taskId)
-> zet activeTaskId
-> ensureTaskLoaded(taskId)
-> schrijf lastActiveTaskIdByProduct[productId] als restore hint
```
### Race-safe loaders
Setters mogen loaders starten, maar loaders moeten race-safe zijn.
```ts
setActivePbi(pbiId) {
const requestId = crypto.randomUUID()
set((s) => {
s.context.activePbiId = pbiId
s.context.activeStoryId = null
s.context.activeTaskId = null
s.loading.activeRequestId = requestId
})
void get().ensurePbiLoaded(pbiId, requestId)
}
```
Bij terugkomst:
```ts
if (get().loading.activeRequestId !== requestId) return
```
Een trage fetch van een oude selectie mag nooit de nieuwste selectie of data
overschrijven.
> **Niet `state.ensureXxx(...)` aanroepen vanuit een gecaptured snapshot.**
> Method-references zijn niet noodzakelijk identiek over verschillende
> immer-state-versies. Roep acties intern aan via `get().ensureXxx(...)`
> direct vóór gebruik. Zie §Implementation-gotchas G4.
## Hydration-strategie
Er zijn twee patronen. Kies bewust per pagina.
### Patroon A — Server snapshot (productpagina, sprintboard)
Voor pagina's die een specifieke product-route hebben en SSR/RSC kunnen
benutten:
```txt
1. Server layout leest `users.active_product_id`.
2. Server-page fetcht initial backlog snapshot voor dat product.
3. Client krijgt snapshot via prop / `BacklogHydrationWrapper` → `hydrateSnapshot()`.
4. Vervolgens:
- leest store optionele restore hints (activePbiId/Story/Task).
- Herstelt alleen als id nog bestaat in entities en toegankelijk is.
- Anders selectie leeg laten.
5. SSE-hook mount op activeProductId.
```
### Patroon B — Cascading client-load (productpicker zonder server-context)
Voor pagina's zonder server-determined product (b.v. een dashboard met
product-pulldown). De `hydrateSnapshot` blijft beschikbaar als API maar wordt
niet gebruikt; loaders worden door de UI getriggerd via `setActiveProduct`,
`setActivePbi`, etc.
```txt
1. UI biedt productpicker; geen server-side activeProduct.
2. Op mount: lees `lastActiveProductId` uit localStorage (als hint).
3. setActiveProduct(restoredProduct) → trigger ensureProductLoaded.
4. Na elke ensure*Loaded: pas vervolg-restore-hint toe (zie restore-flow).
5. SSE-hook mount op activeProductId.
```
### Restore-hint flow
```txt
setActiveProduct(p):
;(async () => {
await ensureProductLoaded(p.id, requestId)
if (loading.activeRequestId !== requestId) return
const hint = hints.perProduct[p.id]?.lastActivePbiId
if (hint && entities.pbisById[hint]) setActivePbi(hint)
})()
setActivePbi(pbiId):
;(async () => {
await ensurePbiLoaded(pbiId, requestId)
if (loading.activeRequestId !== requestId) return
const hint = hints.perProduct[productId]?.lastActiveStoryId
if (hint && entities.storiesById[hint]) setActiveStory(hint)
})()
```
> **Geen setTimeout(0) of microtask-trick.** De fetch is dan nog niet klaar,
> dus de validatie `entities.byId[hint]` faalt altijd. Chain dus altijd
> `await ensureXxxLoaded` en valideer in dezelfde requestId-cycle.
Als een route een expliciete task in de URL heeft, wint de URL boven de
restore hint. Voorbeeld: `?editTask=<id>` of een toekomstige deep link.
## SSE integratie
De SSE-hook beheert alleen transport:
```txt
useProductWorkspaceRealtime(activeProductId)
-> opent /api/realtime/backlog?product_id=...
-> parsed events
-> dispatcht naar store.applyRealtimeEvent(event)
-> beheert reconnect/backoff/status (via store.setRealtimeStatus)
-> op 'ready' na (re)connect: void store.resyncActiveScopes('reconnect')
```
De store beheert de betekenis:
```txt
PBI insert/update
-> upsert pbisById
-> voeg id toe aan pbiIds indien nodig
-> sorteer pbiIds op priority/sort_order
PBI delete
-> verwijder pbi
-> verwijder child stories en tasks
-> clear actieve selectie als die onder deze PBI viel
Story insert/update
-> upsert storiesById
-> verplaats id tussen storyIdsByPbi indien pbi_id wijzigt
-> sorteer alleen de betrokken parent-lijsten
Story delete
-> verwijder story
-> verwijder child tasks
-> clear activeStoryId/activeTaskId indien nodig
Task insert/update
-> upsert tasksById
-> verplaats id tussen taskIdsByStory indien story_id wijzigt
-> sorteer alleen de betrokken task-lijst
Task delete
-> verwijder task
-> clear activeTaskId indien nodig
```
SSE-events zijn idempotent. Een event dat al optimistisch is toegepast, mag
geen dubbele insert of verkeerde rollback veroorzaken.
## Reconciliation en resync
SSE is snel, maar niet voldoende als enige correctheidsmechanisme:
- browsers kunnen hidden tabs throttlen of freezen;
- Postgres NOTIFY heeft geen replay;
- de tab kan offline zijn;
- een event-router kan een relevant semantisch event niet herkennen;
- sommige wijzigingen vereisen refetch in plaats van een kleine patch.
Daarom krijgt de store een expliciete resync-laag.
```ts
type ResyncReason =
| 'visible'
| 'reconnect'
| 'manual'
| 'unknown-event'
| 'stale-scope'
| 'mutation-settled'
```
### Hidden tab beleid
Sluit de SSE-stream niet actief zodra de tab hidden wordt. Laat `EventSource`
open zolang browser en netwerk dit toelaten.
Bij overgang hidden -> visible:
```txt
resyncActiveScopes('visible')
```
Bij reconnect of nieuw `ready` event na disconnect:
```txt
resyncActiveScopes('reconnect')
```
> Het bestaande `use-backlog-realtime.ts` sluit de EventSource op `hidden`.
> Vervang dat gedrag in dezelfde PR als waarin `resyncActiveScopes('visible')`
> wordt toegevoegd; los gezien zou je oude gedrag kwijtraken zonder vangnet.
### Active scopes
Minimale resync:
```txt
activeProductId -> refetch product/PBI snapshot
activePbiId -> refetch stories voor PBI
activeStoryId -> refetch tasks voor story
activeTaskId -> refetch task detail
```
Implementeer `resyncActiveScopes` zonder gecaptured snapshot:
```ts
async resyncActiveScopes(reason) {
const ctx = get().context
const tasks: Promise<void>[] = []
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)
}
```
Als de UX merkt dat eerder bezochte panels stale blijven, breid dit uit naar
`resyncLoadedScopes`, dat alle scopes in `loadedPbiIds`, `loadedStoryIds` en
`loadedTaskIds` parallel herlaadt.
### Unknown relevant events
Een SSE-route mag onbekende product-events niet stil negeren — maar ook niet
elk geluid blind als refetch-trigger interpreteren.
```txt
known pbi/story/task event
-> applyRealtimeEvent(event)
known semantic event, zoals story_log of claude_job_status
-> patch specifieke slice of markeer scope stale
unknown event met product_id == activeProductId EN entity-shape
-> resyncActiveScopes('unknown-event')
worker_*, claude_job_*, heartbeat, question-events
-> negeer voor de product-workspace, behoort op andere bounded contexts
(solo-store, notifications-store, jobs-store)
```
Concrete filter:
```ts
function isUnknownEntityEvent(p: Record<string, unknown>): boolean {
if (typeof p.entity !== 'string') return false
if (['pbi', 'story', 'task'].includes(p.entity)) return false
if ('type' in p) return false // job/worker hebben `type`
return true
}
```
## Fetch en cache regels
Read-routes die store-data voeden:
```ts
export const dynamic = 'force-dynamic'
```
Client fetches vanuit `ensure...Loaded` en `resync...`:
```ts
fetch(url, { cache: 'no-store' })
```
SSE-routes blijven ook `force-dynamic`. SSE-routes zelf veranderen niet door
deze rearchitecture: auth (`getSession()`) en `getAccessibleProduct()` blijven
leidend. De rearchitecture raakt alleen wat de client met de events doet.
Waar nuttig stuurt de SSE-route na `LISTEN` een initial state event. Dat
voorkomt de race waarbij de status wijzigt tussen de eerste DB-read en het
moment dat LISTEN actief is.
## Optimistic updates
Voor DnD en status toggles:
```txt
1. Maak mutationId.
2. Bewaar rollback snapshot van alleen de betrokken relatie/entity.
3. Patch store direct.
4. Start server action.
5. Success: settle mutation.
6. Error: rollback mutation.
7. SSE echo: idempotent toepassen of markeren als bevestigd.
```
Voor create/update/delete dialogs is optimisme optioneel. Standaard mag:
```txt
server action -> resultaat in store verwerken -> SSE echo idempotent negeren
```
## Sprint workspace
Na stabilisatie van product-workspace volgt dezelfde vorm voor sprint:
```txt
sprint-workspace-store
activeSprintId
selectedStoryId
selectedTaskId
storiesById
tasksById
pbisById
storyIdsByPbi
sprintStoryIds
taskIdsByStory
loaded scopes
applyRealtimeEvent()
resyncActiveScopes()
```
Dit vervangt op termijn de combinatie van lokale state in de sprint board en
de huidige `sprint-store` order maps. Pak het pas op nadat product-workspace
in productie staat en de eerste paar weken stabiel draait.
## Implementation-gotchas
Deze pitfalls zijn subtiel genoeg om opnieuw te maken. Documenteer ze in code
via comments boven de fix.
### G1. `s.byId[x] ?? []` triggert React-loop
```ts
// FOUT: nieuwe array per render → "Maximum update depth exceeded"
const stories = useStore((s) => s.storiesByPbi[pbiId] ?? [])
// GOED: stable empty const op module-level
const EMPTY: BacklogStory[] = []
const stories = useStore((s) => s.storiesByPbi[pbiId] ?? EMPTY)
```
### G2. List-selectors materialiseren → `useShallow` verplicht
`selectVisiblePbis(state)` en vrienden bouwen `ids.map(id => byId[id])` per
call. Zonder `useShallow` re-rendert het component op elke onafhankelijke
store-mutatie.
```ts
import { useShallow } from 'zustand/react/shallow'
const list = useStore(useShallow(selectVisiblePbis))
```
Single-value selectors (b.v. `selectActivePbi`) hebben dit niet nodig — die
retourneren een stable entity-reference.
### G3. immer + `setState((s) => ({...}))` REPLACES de state
Met `zustand/middleware/immer` interpreteert `produce` een return-waarde als
de nieuwe state. Dat lijkt op de pre-immer Zustand-API maar wist alle andere
slices én alle action-properties.
```ts
// FOUT (in immer-middleware): vervangt hele state met { context: {...} }
useStore.setState((s) => ({ context: { ...s.context, activePbiId: 'x' } }))
// GOED: mutation-style (immer recipe muteert draft)
useStore.setState((s) => { s.context.activePbiId = 'x' })
```
### G4. Method-refs zijn niet stabiel over state-versies
```ts
// FOUT: state-snapshot kan andere method-ref hebben dan de huidige
async resyncActiveScopes(reason) {
const state = get()
// ...
state.ensureProductLoaded(...) // niet betrouwbaar
}
// GOED: per call fresh ophalen via get()
async resyncActiveScopes(reason) {
const ctx = get().context
// ...
get().ensureProductLoaded(...)
}
```
### G5. Tests die acties mocken via setState lekken naar volgende tests
`useStore.setState({ resyncActiveScopes: vi.fn() })` blijft staan na de test.
`beforeEach` reset alleen data-velden. Snapshot originele acties op
module-load + restore in `beforeEach`:
```ts
const originalActions = (() => {
const s = useStore.getState()
return { resyncActiveScopes: s.resyncActiveScopes, /* ... */ }
})()
function resetStore() {
useStore.setState({ ...initialData, ...originalActions })
}
```
### G6. localStorage in vitest 4 + jsdom 29
In deze combinatie is `localStorage.clear/getItem/setItem` niet aanwezig op
het globale localStorage-object. Bind in `tests/setup.ts` een eigen
MemoryStorage:
```ts
class MemoryStorage implements Storage { /* ... */ }
const memory = new MemoryStorage()
Object.defineProperty(globalThis, 'localStorage', { value: memory, configurable: true })
Object.defineProperty(window, 'localStorage', { value: memory, configurable: true })
```
### G7. `fetch` in node-test omgeving accepteert geen relative URLs
`/api/products/...` faalt met `Invalid URL`. Mock fetch in elke test die
indirect een `ensure*Loaded` aanroept, of stub de implementatie.
### G8. `Response`-body wordt één keer geconsumeerd
`vi.spyOn(fetch).mockResolvedValue(response)` levert dezelfde Response aan
elke fetch — eerste `.json()` werkt, daarna error. Gebruik
`mockImplementation()` voor een fresh body per call:
```ts
vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify([]), { status: 200 })),
)
```
## Testing setup (Vitest)
Minimale config:
```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import path from 'node:path'
export default defineConfig({
test: {
environment: 'jsdom',
include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'],
setupFiles: ['tests/setup.ts'],
},
resolve: { alias: { '@': path.resolve(__dirname, '.') } },
})
```
`tests/setup.ts` bevat MemoryStorage-binding (zie G6) en `vi.restoreAllMocks()`
in `beforeEach`.
Verplichte test-cases per workspace-store:
- `hydrateSnapshot` vult entities + relations.
- 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.
- `applyRealtimeEvent`: event voor ander product wordt genegeerd.
- `applyRealtimeEvent`: unknown entity met matching product → resync trigger.
- Delete-cleanup van actieve selectie.
- `ensureProductLoaded` fetch + sortering.
- Race-safe `ensure*Loaded` met requestId-guard (oude in-flight mag niet
nieuwere selectie overschrijven).
- `ensureTaskLoaded` zet detail-flag.
- `resyncActiveScopes` triggert ensure-keten met juiste URLs en zet
`lastResyncAt` + `resyncReason`.
- localStorage restore-hints: `setActiveProduct` en `setActivePbi` schrijven
de juiste keys.
- Optimistic mutation: rollback herstelt vorige state; settle ruimt pending
op; SSE-echo wordt idempotent verwerkt.
## Implementatiepad
Migratie van het bestaande systeem (`backlog-store` + `planner-store` +
`selection-store` + `product-store`) naar `product-workspace-store`. Doe dit
in opeenvolgende PRs zodat elke stap in productie te verifiëren is.
### Stap 1 — Skelet opzetten (nieuwe store, nog niet gebruikt)
1. Maak `stores/product-workspace/` met:
- `types.ts` (entity types, snapshot, realtime event union, ResyncReason).
- `store.ts` (factory met immer-middleware, alle slices, alle acties).
- `selectors.ts` (pure functies, useShallow-vriendelijk).
- `restore.ts` (localStorage hints met validatie).
2. Voeg `applyRealtimeEvent`, `resyncActiveScopes`, `resyncLoadedScopes`,
`ensure*Loaded` toe — eerst zonder integraties; getest via Vitest.
3. Acceptatie: alle test-cases uit §Testing setup groen. Geen UI-impact nog.
### Stap 2 — Hydration overstappen
4. `BacklogHydrationWrapper` (of equivalent) roept `hydrateSnapshot` aan op de
nieuwe store i.p.v. `useBacklogStore.setInitialData`.
5. `lib/realtime/use-backlog-realtime.ts` dispatcht naar
`useProductWorkspaceStore.applyRealtimeEvent` i.p.v. `applyChange` op de
oude store.
6. Componenten lezen voorlopig nog van de oude stores; nieuwe store loopt
parallel mee als read-only schaduwkopie. Vergelijk in dev-tools dat de
inhoud klopt.
### Stap 3 — Componenten omzetten
7. Per panel/component: vervang `useBacklogStore`/`useSelectionStore`/
`useProductStore` reads door selectors uit de workspace-store + `useShallow`
waar nodig.
8. Setters (`selectPbi`, `selectStory`, `setCurrentProduct`) vervangen door de
workspace-acties (`setActivePbi`, `setActiveStory`, `setActiveProduct`).
9. Handle G1 en G2 expliciet: stable empty refs, useShallow voor lijsten.
### Stap 4 — Race-safe en restore-hints
10. `ensure*Loaded` met `activeRequestId`-guard implementeren conform
§Race-safe loaders.
11. localStorage hints introduceren met `await ensure*Loaded`-chain (zie
§Restore-hint flow).
12. Verifieer in een staging-omgeving: cold reload met persisted hint
herstelt selectie zonder fouten.
### Stap 5 — Hidden-tab + reconnect-resync
13. `use-backlog-realtime` aanpassen: niet meer sluiten op `hidden`, blijven
luisteren. Op `ready` na reconnect: `resyncActiveScopes('reconnect')`.
14. Aparte `useWorkspaceResync()`-hook: trigger `resyncActiveScopes('visible')`
bij `visibilitychange` van hidden→visible, en bij `online`-event.
15. Doe deze twee in één PR — los gezien zou je oude gedrag kwijtraken zonder
vangnet.
### Stap 6 — Unknown-event fallback
16. `applyRealtimeEvent` handelt onbekende entity-events af volgens §Unknown
relevant events. Filter op `isUnknownEntityEvent(payload)`.
17. Verifieer dat job-/worker-/heartbeat-events GEEN refetch triggeren.
### Stap 7 — Cache-headers
18. Zet `cache: 'no-store'` op alle client fetches uit `ensure*Loaded` en
`resync...`.
19. Bevestig `force-dynamic` op alle read-routes die store-data leveren.
### Stap 8 — Oude stores opruimen
20. Verwijder `stores/backlog-store.ts`, `stores/planner-store.ts`,
`stores/selection-store.ts`, `stores/product-store.ts` zodra geen
component er nog op leest. Grep over de hele codebase om verrassingen
te voorkomen.
21. `stores/products-store.ts` blijft (lijst van producten ≠ active product).
### Stap 9 — Sprint workspace
22. Herhaal het patroon voor `sprint-workspace-store`. Eerste een paar weken
de product-workspace stabiel laten draaien.
## Acceptatiecriteria
- Een PBI/story/task bestaat als waarheid maar op één plek in de
client-store.
- Product backlog panels lezen via selectors uit dezelfde workspace-store.
- PBI/story/task SSE-events patchen de store zonder full page refresh.
- Hidden -> visible herstelt gemiste wijzigingen binnen één resync-cyclus.
- Reconnect herstelt gemiste wijzigingen zonder afhankelijkheid van NOTIFY
replay.
- Directe entity-edits zonder herkenbare delta worden via resync zichtbaar
(unknown-event filter staat aan, job/worker noise niet meegerekend).
- LocalStorage kan een vorige selectie herstellen, maar nooit ontoegankelijke
of verwijderde entiteiten forceren; valideer altijd na ensure-load.
- Optimistic DnD heeft rollback en wordt niet dubbel toegepast door SSE
echoes.
- Read-routes en client fetches leveren geen stale browser/Next cache data.
- Test-suite dekt §Testing setup checklist en draait groen in CI.
- Geen "Maximum update depth exceeded" of "result of getServerSnapshot
should be cached" warnings in de console (zie §G1 en §G2).
- Auth en `getAccessibleProduct()` blijven ongewijzigd op SSE/read-routes;
deze rearchitecture raakt alleen client-state, geen serverlaag-security.