- 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>
746 lines
24 KiB
Markdown
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.
|