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>
This commit is contained in:
Janpeter Visser 2026-05-09 22:54:14 +02:00
parent d292e445d9
commit 0d126695db
6 changed files with 1990 additions and 0 deletions

View file

@ -0,0 +1,371 @@
# Advies — Zelf een Git-platform hosten naast of in plaats van GitHub
## Situatie
Je wilt onderzoeken of je lokaal of op een eigen server een Git repository-platform kunt draaien vergelijkbaar met GitHub.
In jouw situatie spelen mee:
- Next.js/Vercel apps
- AI-workers / automation
- batch processing
- deploy pipelines
- private code
- mogelijk draaien op NAS of VPS
- integratie met Claude Code / Codex / agents
Het antwoord is: ja, dit kan uitstekend.
---
# Architectuur-opties
## Optie 1 — Alleen een centrale Git remote
De lichtste oplossing.
Je draait alleen een zogenaamde "bare repo" op een Linux server.
### Voordelen
- extreem simpel
- weinig resources
- volledige controle
- SSH push/pull
### Nadelen
- geen webinterface
- geen PRs
- geen issues
- geen gebruikersbeheer
- geen CI/CD UI
### Setup
Server:
```bash
mkdir -p /srv/git/myapp.git
cd /srv/git/myapp.git
git init --bare
```
Client:
```bash
git remote add origin ssh://user@server:/srv/git/myapp.git
git push -u origin main
```
---
# Optie 2 — Self-hosted GitHub alternatief
Dit is meestal de beste keuze.
Software opties:
| Software | Omschrijving |
|---|---|
| Gitea | Lichtgewicht GitHub alternatief |
| Forgejo | Community fork van Gitea |
| GitLab | Zeer compleet maar zwaar |
| OneDev | Moderne alles-in-één oplossing |
---
# Aanbevolen keuze: Gitea
## Waarom
Voor jouw situatie is Gitea waarschijnlijk de beste balans tussen:
- eenvoud
- performance
- features
- beheerlast
Je krijgt:
- Git hosting
- web UI
- pull requests
- issues
- SSH support
- webhooks
- CI integratie
- Docker support
- private repos
- multi-user support
---
# Aanbevolen architectuur voor jouw setup
## Huidige richting
```text
MacBook
GitHub
Vercel deploy
```
## Uitgebreide AI workflow
```text
MacBook
Gitea / GitHub
↓ webhook
AI Worker Server
Repo clone
Code generatie
Commit + push
PR creation
Merge
Vercel deploy
```
---
# Beste strategie voor jouw situatie
## Advies: hybride model
Gebruik:
| Component | Platform |
|---|---|
| publieke repos | GitHub |
| deploys | Vercel |
| AI worker orchestration | eigen server |
| interne experimenten | Gitea |
| automation | self-hosted |
Waarom:
- GitHub ecosystem blijft beschikbaar
- recruiters herkennen GitHub
- Copilot integratie blijft optimaal
- minder beheer
- sneller stabiel
---
# Wanneer volledig self-hosted interessant wordt
Volledig self-hosted wordt interessant als:
- privacy belangrijk is
- AI agents autonoom moeten kunnen werken
- je volledige controle wilt
- je GitHub limieten wilt vermijden
- je meerdere workers wilt draaien
Dan bouw je:
```text
Gitea
+ Postgres
+ Docker Registry
+ CI Runners
+ Reverse Proxy
+ Backups
+ Monitoring
```
---
# Aanbevolen infrastructuur
## Lichtgewicht setup
### Hardware
- Synology NAS of mini-PC
- 816 GB RAM
- SSD opslag
### Software stack
| Component | Advies |
|---|---|
| OS | Ubuntu Server |
| Containers | Docker Compose |
| Git platform | Gitea |
| Reverse proxy | Traefik |
| Database | Postgres |
| SSL | Let's Encrypt |
| Deploys | Vercel |
---
# Docker Compose voorbeeld
```yaml
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
ports:
- "3000:3000"
- "222:22"
volumes:
- ./gitea:/data
restart: always
```
Starten:
```bash
docker compose up -d
```
Daarna bereikbaar via:
```text
http://server-ip:3000
```
---
# Belangrijke aandachtspunten
## Backups
Bij self-hosting moet je zelf regelen:
- database backups
- repo backups
- disaster recovery
---
## Security
Je bent zelf verantwoordelijk voor:
- updates
- SSH security
- firewall
- SSL certificaten
- gebruikersbeheer
---
## CI/CD
GitHub Actions vervang je mogelijk door:
- Gitea Actions
- Drone CI
- Woodpecker CI
- self-hosted runners
---
# Integratie met jouw AI-worker ideeën
Dit sluit zeer goed aan op jouw eerdere ideeën:
- Neon database events
- worker servers
- auto-generated PRs
- selective deploys
- batch execution
Je kunt bijvoorbeeld:
1. story wordt aangemaakt
2. worker krijgt event via SSE/webhook
3. repo wordt gecloned
4. AI implementeert wijziging
5. commit + push
6. PR automatisch aangemaakt
7. review pipeline start
8. merge → deploy
Dit wordt veel eenvoudiger wanneer je volledige controle hebt over de Git infrastructuur.
---
# Concrete roadmap
## Fase 1 — huidige setup stabiliseren
Hou:
- GitHub
- Vercel
- Neon
Voeg toe:
- AI worker server
- webhooks
- automation pipeline
---
## Fase 2 — interne Git infrastructuur
Installeer:
- Gitea
- Docker
- Postgres
Gebruik dit voor:
- experimenten
- AI-generated branches
- interne repos
- automation testing
---
## Fase 3 — geavanceerde automation
Later toevoegen:
- self-hosted runners
- preview environments
- deploy approvals
- selective deployments
- agent orchestration
---
# Eindadvies
Voor jouw situatie:
## Niet meteen GitHub vervangen
Dat levert nu vooral extra beheerlast op.
## Wel nu al beginnen met:
- eigen AI worker server
- webhook automation
- lokale Git orchestration
- Gitea testomgeving
Dat sluit perfect aan op:
- Scrum4Me
- AI-assisted development
- batch story execution
- autonome pipelines

View file

@ -0,0 +1,197 @@
# Scrum4Me-Research — Zustand rearchitecture (reset + execute)
## Context
Het bestaande [docs/plans/zustand-store-rearchitecture.md](docs/plans/zustand-store-rearchitecture.md) beschrijft een doel-architectuur (`product-workspace-store` met genormaliseerde entities, race-safe loaders, resync-laag, optimistic mutations). De research-repo is dé plek om dat eerst te testen voordat het in `Scrum4Me/` belandt.
Probleem nu: de research-repo wijkt af van het hoofdproject. Mijn custom `data-store.ts` lijkt qua vorm op de doel-architectuur, maar springt over de baseline heen. We willen aantonen dat de migratie *vanaf* de huidige Scrum4Me-patronen werkt, niet vanaf een verzonnen tussenvorm.
Dus: eerst de research-repo terugbrengen naar dezelfde stores/hooks/routes als Scrum4Me nu heeft, dan de rearchitecture daarop uitvoeren.
## Bron-documenten
- **Doel-architectuur**: [docs/plans/zustand-store-rearchitecture.md](docs/plans/zustand-store-rearchitecture.md) (in research-repo). Dit plan voert dat document uit; herhaalt het niet.
- **Conventies**: [CLAUDE.md](../Scrum4Me/CLAUDE.md) hoofdproject. Taal NL, MD3 tokens, `@base-ui/react` render-prop, `*-server.ts`, enum UPPER_SNAKE↔lowercase via `lib/task-status.ts`.
## Drie-faseplan
### Fase A — Reset naar Scrum4Me-patronen
Doel: onze research-pagina werkt op exact dezelfde store/hook/route-vorm als het hoofdproject, met identiek gedrag.
**Verwijderen** (research-repo):
- [stores/data-store.ts](stores/data-store.ts) — mijn megastore
- [hooks/use-event-stream.ts](hooks/use-event-stream.ts) — vervangen door `use-backlog-realtime.ts`
- [hooks/use-browser-presence.ts](hooks/use-browser-presence.ts) — niet in main, drop voor reset
- [app/api/realtime/events/route.ts](app/api/realtime/events/route.ts) — vervangen door `app/api/realtime/backlog/route.ts`
- Mijn custom `loadX/resyncAll`-paden in selectie-componenten
**Kopiëren uit `/Users/janpetervisser/Development/Scrum4Me/`** (1-op-1 of stripped van auth):
| Bron | Doel |
|---|---|
| `stores/backlog-store.ts` | `stores/backlog-store.ts` (`pbis`, `storiesByPbi`, `tasksByStory`; `setInitialData`, `applyChange`) |
| `stores/planner-store.ts` | `stores/planner-store.ts` (DnD-order; voor research nog niet gebruikt maar we zetten 'm klaar) |
| `stores/selection-store.ts` | overschrijf bestaand (state: `selectedPbiId`, `selectedStoryId`, geen taskId/productId in main; add `selectedTaskId` + `productId` als research-uitbreiding) |
| `stores/product-store.ts` | `stores/product-store.ts` (`currentProduct`) |
| `stores/products-store.ts` | `stores/products-store.ts` (lijst, voor pulldown) |
| `lib/realtime/use-backlog-realtime.ts` | `lib/realtime/use-backlog-realtime.ts` (SSE-client → `applyChange` op backlog-store) |
| `lib/task-status.ts` | `lib/task-status.ts` (enum-converters) |
| `app/api/realtime/backlog/route.ts` | `app/api/realtime/backlog/route.ts` (SSE+LISTEN, **research-only: strip auth/session/getAccessibleProduct** — vraagt enkel `product_id` querystring) |
> ⚠️ **Auth-strip is research-only.** Het hoofdproject MOET sessie + `getAccessibleProduct()`-check op SSE en read-routes behouden. Bij backport vanaf de research-repo nooit de geknipte route 1-op-1 overnemen. Dit geldt voor zowel `app/api/realtime/backlog/route.ts` als alle read-routes onder `app/api/products/...`, `/pbis/...`, `/stories/...`, `/tasks/...`.
**API-routes (read)** — bestaande paden behouden, alleen `force-dynamic` blijft:
- `GET /api/products` (list voor pulldown)
- `GET /api/products/[id]/pbis` (open: READY+BLOCKED)
- `GET /api/pbis/[id]/stories`
- `GET /api/stories/[id]/tasks`
- `GET /api/tasks/[id]`
**Componenten herschrijven**:
- [components/product-select.tsx](components/product-select.tsx) → leest `useProductsStore`, schrijft naar `useProductStore.setCurrentProduct`
- [components/pbi-select.tsx](components/pbi-select.tsx) → leest `useBacklogStore` (filter op currentProduct), `useSelectionStore.selectPbi`. Triggert fetch op product-mount via een `useBacklogLoader`-helper die initial data binnenhaalt.
- [components/story-select.tsx](components/story-select.tsx) → idem voor stories
- [components/tasks-table.tsx](components/tasks-table.tsx) → leest `tasksByStory[selectedStoryId]`. **Max 10 rijen, scrollbaar** (al ingebouwd, behouden)
- [components/task-detail-card.tsx](components/task-detail-card.tsx) → fetcht task detail apart (geen full-fat backlog veld; matcht main's `tasks/[id]` route)
- [components/event-stream-panel.tsx](components/event-stream-panel.tsx) → blijft bestaan voor research-doel (event-tap), maar luistert nu mee op dezelfde EventSource via `use-backlog-realtime` (of een tweede readonly listener); selecteerbare events met JSON-detail rechts blijven. Twee checkboxes (Postgres / Browser). Truncate met ellipsis in de lijst.
**Werkwijzen (verifiëren tijdens reset)**:
- Comments en UI-tekst NL
- Geen `bg-blue-500` etc; enkel MD3 tokens (`bg-primary`, `bg-card`, `bg-status-done`, ...)
- shadcn-componenten al `base-nova` style
- Server-only files krijgen `*-server.ts` suffix waar van toepassing (in deze fase niet nodig — alle DB-toegang loopt via `lib/prisma.ts` in route handlers)
- TaskStatus-mapping via `lib/task-status.ts` als de UI lowercase wil
**Acceptatie Fase A**:
- `npx tsc --noEmit` schoon
- Pagina rendert, cascading werkt, tabel toont taken, detail-card vult, events stromen door (preview-verificatie)
- Stores matchen het hoofdproject qua vorm (vergelijking via `diff` uitvoerbaar voor backlog-store etc.)
### Fase B — Rearchitecture uitvoeren
Volgt de 15 stappen uit [docs/plans/zustand-store-rearchitecture.md](docs/plans/zustand-store-rearchitecture.md) §Implementatiepad. Concreet voor de research-repo:
1. **Map** `stores/product-workspace/` aanmaken (factory + provider + selectors).
2. **`activeProduct`** wordt nu nog gespiegeld vanuit `useProductStore`; voor de research-pagina geen layout/server-side bepaling — we lezen het uit de pulldown-state.
3. **Selection migreren**`selection-store``context.{activePbiId, activeStoryId, activeTaskId}` + `productId`. Setters cascaden de reset naar children (zoals doc beschrijft).
4. **Backlog naar entities + relations**`pbisById`, `storiesById`, `tasksById`, `pbiIds`, `storyIdsByPbi`, `taskIdsByStory`. Selectors:
- `selectVisiblePbis(productId)`
- `selectStoriesForActivePbi(state)`
- `selectTasksForActiveStory(state)`
- `selectActivePbi/Story/Task(state)`
5. **Planner-state** in dezelfde workspace-store landen (`relations` slice); voor research: niet actief gebruikt, wel structureel meekoppen.
6. **Race-safe loaders**`ensureProductLoaded`, `ensurePbiLoaded`, `ensureStoryLoaded`, `ensureTaskLoaded` met `requestId`-guard. Implementatie:
```ts
setActivePbi(pbiId) {
const requestId = crypto.randomUUID()
set({ context: { ..., activePbiId: pbiId, ... }, loading: { ..., activeRequestId: requestId } })
void get().ensurePbiLoaded(pbiId, requestId)
}
// in ensure: if (get().loading.activeRequestId !== requestId) return
```
7. **localStorage = restore hints**`lastActivePbiIdByProduct`, `lastActiveStoryIdByProduct`, `lastActiveTaskIdByProduct`. Niet de waarheid, alleen hint die getoetst wordt aan toegankelijkheid.
8. **`use-backlog-realtime` dispatcht naar `applyRealtimeEvent`** — store interpreteert pbi/story/task I|U|D events, doet upsert + parent-id move + sort.
9. **Hidden tab beleid**`EventSource` openhouden bij `hidden`. Op `visible``resyncActiveScopes('visible')`.
10. **Reconnect resync** — bij `ready` na disconnect of na exponential backoff: `resyncActiveScopes('reconnect')`.
11. **Unknown-event fallback** — onbekend event met `payload.product_id === activeProductId``resyncActiveScopes('unknown-event')`. Dit is wat het "veel events maar geen update" issue oplost.
12. **`force-dynamic` + `cache: 'no-store'`** — al gedaan in mijn fixes; behouden bij reset en versterken.
13. **Componenten naar selectors** — backlog-componenten lezen via `selectStoriesForActivePbi` etc., niet via raw store-velden.
14. **Tests** (Vitest, conform main):
- hydrate snapshot
- active selectie cascade
- race-safe ensure (laat trage promise van oude selectie geen nieuwe data overschrijven)
- SSE I|U|D voor pbi/story/task
- parent-move (story verandert van pbi)
- hidden→visible resync
- reconnect resync
- unknown-event resync
- delete-cleanup van actieve selectie
- localStorage restore-hint validatie tegen toegankelijkheid
15. **Sprint-workspace** — buiten scope; flag voor latere herhaling.
**Optimistic mutations** (§Optimistic in doc): voor research geen DnD, dus alleen het patroon dropunten en niet bouwen. Wel: `applyOptimisticMutation`-action wel klaarzetten in de store-API zodat het patroon zichtbaar is.
### Fase C — Werkwijzen verweven en doortrekken
**Tijdens Fase A én B respecteren**:
1. **Plan mode workflow** — eerst Plan, ExitPlanMode, dan code. Bij grote wendingen opnieuw plannen.
2. **TodoWrite** voor multi-step werk; markeer immediate completion.
3. **Verify via preview** voor elke observable verandering (de hook reminder doet dit al).
4. **`tsc --noEmit`** voor afronden van een stap.
5. **Comments/Dutch** consistent. WHY-comments over de invariant; geen WHAT-comments.
6. **MD3 tokens** alleen.
7. **Geen secrets in chat**`.env.local` blijft lokaal.
8. **Niet schrijven naar shared DB** zonder expliciete user-toestemming (geen `pg_notify` op shared channel).
9. **Source of truth = DB**. Zustand is projectie. localStorage = hint.
10. **Vóór elke fase**: kort statusrapport in de chat met wat er aankomt en waarom.
**Doortrekken naar hoofdproject** (out-of-scope deze run, maar geflagd):
- Na bewezen werking in research-repo: backport `product-workspace-store` + selectors + realtime-apply + resync-laag naar `Scrum4Me/stores/product-workspace/`.
- **Niet backporten**: de auth-stripped routes uit research. Main behoudt iron-session, `getAccessibleProduct()`, en alle product-access/sprint/personal filters in z'n SSE- en read-routes.
- **Wel backporten**: store-shape, selectors, race-safe `ensure*Loaded`, hidden-tab beleid, `resyncActiveScopes`, unknown-event fallback, restore-hint patroon, `force-dynamic` + `cache: 'no-store'`.
- Migratie main-project zal langer duren (DnD, sprint, jobs, tests). Apart plan.
## Bestandsmutaties (overzicht)
### Verwijderen na Fase A
- [stores/data-store.ts](stores/data-store.ts)
- [hooks/use-event-stream.ts](hooks/use-event-stream.ts)
- [hooks/use-browser-presence.ts](hooks/use-browser-presence.ts) — komt deels terug in Fase B als helper voor visibility/online resync trigger
- [app/api/realtime/events/route.ts](app/api/realtime/events/route.ts)
### Toevoegen Fase A (uit Scrum4Me)
- `stores/backlog-store.ts`
- `stores/planner-store.ts`
- `stores/selection-store.ts` (overschrijf)
- `stores/product-store.ts`
- `stores/products-store.ts`
- `lib/realtime/use-backlog-realtime.ts`
- `lib/task-status.ts`
- `app/api/realtime/backlog/route.ts` (zonder auth)
### Toevoegen Fase B (nieuw, conform doc)
- `stores/product-workspace/store.ts` (zustand factory)
- `stores/product-workspace/selectors.ts`
- `stores/product-workspace/types.ts`
- `stores/product-workspace/restore.ts` (localStorage hints)
- `stores/product-workspace/realtime-apply.ts` (SSE event → store)
- `stores/product-workspace/resync.ts` (`resyncActiveScopes`, `resyncLoadedScopes`)
- `tests/product-workspace/*.test.ts` (Vitest, install vitest als devDep)
### Te aanpassen in Fase B
- Alle `components/*.tsx` (nu shadcn select/table/card panels) → consumeren via selectors uit workspace-store
- `lib/realtime/use-backlog-realtime.ts` → dispatcht `applyRealtimeEvent` naar workspace-store i.p.v. `applyChange` naar backlog-store
- `event-stream-panel.tsx` → blijft bestaan (research-tap), maar leest events ook uit workspace-store of via een dunne `event-log-store` ernaast (in bounded-context-stijl: aparte log-store voor pure observatie hoort er niet thuis in de workspace-store)
## Verificatie
### Na Fase A (baseline)
1. `npm run dev` op port 3001
2. Pagina laadt, cascading werkt: product → PBI → story → tasks
3. Detail-card vult bij klik op task
4. Event-paneel toont realtime events (truncate + JSON-detail)
5. `npx tsc --noEmit` schoon
6. Vergelijk: `diff Scrum4Me/stores/backlog-store.ts Scrum4Me-Research/stores/backlog-store.ts` → identiek (modulo lokale interface-uitbreidingen waar gedocumenteerd)
### Na Fase B (target)
Alle acceptatiecriteria uit [docs/plans/zustand-store-rearchitecture.md §Acceptatiecriteria](docs/plans/zustand-store-rearchitecture.md):
- Eén waarheid per entity in de store ✓
- Selectors als enige UI-leesweg ✓
- SSE patcht zonder full-page refresh ✓
- Hidden→visible herstelt missers binnen één resync-cyclus ✓
- Reconnect resync werkt zonder NOTIFY-replay ✓
- Directe task-edits zonder `entity:'task'` NOTIFY worden via unknown-event fallback zichtbaar ✓
- LocalStorage = hint, geen forced state ✓
- `force-dynamic` + `cache: 'no-store'` overal ✓
### Manuele preview-verificatie (na elke fase)
- TODO via TodoWrite tijdens uitvoer; preview-screenshot na grote stappen
- Tab-switch test: open page, switch tab, doe een wijziging via een ander mechanisme (psql na user-akkoord, of UI in main-project), keer terug → verwacht: zonder warnings + data gerefresht
## Open vragen / risico's
1. **Reset-import** uit hoofdproject: voor de research-repo strippen we auth/session-deps uit de gekopieerde routes (research-repo heeft geen auth-laag, draait lokaal). Belangrijk: **dit is een research-repo-keuze; main behoudt de volledige auth-filters**. Zie de waarschuwing onder "API-routes (read)" hierboven.
2. **`use-backlog-realtime` heeft mogelijk auth-headers/session-checks**: bevestigen tijdens copy. Indien zo: research-versie gebruikt geen auth, route is publiek-bereikbaar binnen lokale dev. Geldt alleen lokaal — geen wijziging aan main.
3. **Tests-deps** (vitest, @testing-library/react) toevoegen tijdens Fase B. Of pas in Fase B step 14 vanwege scope.
4. **Event-paneel toekomst**: blijft het in research-repo of stoten we het af zodra de workspace-store af is? Voorstel: behouden als observatie-tool, maar er aparte `event-log-store` (kleine UI store) voor maken zodat het niet meelift in de workspace-store.
5. **README.md** update na Fase B (optioneel) — kort beschrijven dat dit nu het canonical migratie-pad demonstreert.

View file

@ -0,0 +1,746 @@
---
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.