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:
parent
d292e445d9
commit
0d126695db
6 changed files with 1990 additions and 0 deletions
371
docs/plans/Local github setup.md
Normal file
371
docs/plans/Local github setup.md
Normal 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 PR’s
|
||||
- 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
|
||||
- 8–16 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 PR’s
|
||||
- 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
|
||||
197
docs/plans/lees-de-readme-md-validated-book.md
Normal file
197
docs/plans/lees-de-readme-md-validated-book.md
Normal 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.
|
||||
746
docs/plans/zustand-store-rearchitecture.md
Normal file
746
docs/plans/zustand-store-rearchitecture.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue