docs: plan load render workspace alignment

This commit is contained in:
Janpeter Visser 2026-05-10 07:11:34 +02:00
parent 98ee05d458
commit 81b5a8477c
3 changed files with 315 additions and 0 deletions

View file

@ -46,6 +46,7 @@ Auto-generated on 2026-05-10 from front-matter and headings.
| [Landing v2 — lokaal & veilig + architectuurdiagram](./plans/landing-local-first.md) | active | 2026-05-03 |
| [Landing v3 — van idee tot pull request](./plans/landing-v3-idea-flow.md) | active | 2026-05-04 |
| [Scrum4Me-Research — Zustand rearchitecture (reset + execute)](./plans/lees-de-readme-md-validated-book.md) | — | — |
| [Verbeterplan load/render Product Backlog, Sprint en Solo](./plans/load-render-improvement-plan-2026-05-10.md) | draft | 2026-05-10 |
| [Advies — Zelf een Git-platform hosten naast of in plaats van GitHub](./plans/Local github setup.md) | — | — |
| [M10 — Password-loze inlog via QR-pairing](./plans/M10-qr-pairing-login.md) | active | 2026-05-03 |
| [M11 — Claude vraagt, gebruiker antwoordt](./plans/M11-claude-questions.md) | active | 2026-05-03 |
@ -132,6 +133,7 @@ Auto-generated on 2026-05-10 from front-matter and headings.
| [Realtime smoke-checklist — PBI / Story / Task](./realtime-smoke.md) | `realtime-smoke.md` | active | 2026-05-03 |
| [Caveman plan — Beelink naar Ubuntu Scrum4Me server](./recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md) | `recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md` | draft | 2026-05-09 |
| [Aanbeveling — Claude VM jobflow en gitstrategie](./recommendations/claude-vm-job-flow-git-strategy.md) | `recommendations/claude-vm-job-flow-git-strategy.md` | draft | 2026-05-09 |
| [Load/render implementatie review](./recommendations/load-render-implementation-review-2026-05-10.md) | `recommendations/load-render-implementation-review-2026-05-10.md` | review | 2026-05-10 |
| [Agent-flow: open issues & decision log](./runbooks/agent-flow-pitfalls.md) | `runbooks/agent-flow-pitfalls.md` | active | 2026-05-03 |
| [Auto-PR flow: van story-DONE naar gemergde PR](./runbooks/auto-pr-flow.md) | `runbooks/auto-pr-flow.md` | active | 2026-05-06 |
| [Branch, PR & Commit Strategy](./runbooks/branch-and-commit.md) | `runbooks/branch-and-commit.md` | active | 2026-05-03 |

View file

@ -0,0 +1,201 @@
---
title: "Verbeterplan load/render Product Backlog, Sprint en Solo"
date: 2026-05-10
status: draft
scope: ["Product Backlog", "Sprint", "Solo", "workspace stores", "realtime resync"]
source_review: "../recommendations/load-render-implementation-review-2026-05-10.md"
chosen_solo_option: "5B - Solo workspace-store migratie"
---
# Verbeterplan load/render Product Backlog, Sprint en Solo
## Doel
Maak de load/render-flow van Product Backlog, Sprint en Solo voorspelbaar, gelijkvormig en goedkoper:
- geen dubbele initial loads;
- een expliciet status-contract tussen server, API, stores en UI;
- consistente hydration/resync na reconnect, tab-visible en refresh;
- minder stale render state in Solo;
- duidelijke store-eigenaarschap per scherm.
## Uitgangspunten
- De route/API-boundary mag lowercase API-statussen blijven gebruiken.
- De interne Product/Sprint workspace UI verwacht nu story/task statussen als DB `UPPER_SNAKE`.
- Product Backlog en Sprint zijn de referentie voor het gewenste patroon: server snapshot, client hydration wrapper, workspace-store, SSE, directe store-resync.
- Solo hoeft niet in dezelfde grote store te worden gemigreerd in de eerste stap, maar zijn refresh-hydration moet wel correct worden.
## Fase 1 - Status-contract vastleggen en afdwingen
### Stap 1.1 - Leg het interne contract vast
Besluit en documenteer:
- PBI-status in Product Backlog blijft API-lowercase zolang `PBI_STATUS_LABELS` en `PBI_STATUS_COLORS` daarop gebouwd zijn.
- Story-status en task-status in Product/Sprint workspace-stores zijn intern `UPPER_SNAKE`.
- API routes blijven lowercase teruggeven aan externe/REST clients.
### Stap 1.2 - Voeg adapters toe aan de workspace-store boundary
Maak kleine adapterfuncties voor API-responses voordat data in stores wordt gehydrateerd:
- Product workspace:
- full backlog snapshot;
- PBI stories;
- story tasks;
- task detail.
- Sprint workspace:
- sprint workspace snapshot;
- story tasks;
- task detail.
Gebruik bestaande mappers uit `lib/task-status.ts`, bijvoorbeeld `storyStatusFromApi` en `taskStatusFromApi`.
### Stap 1.3 - Voeg regressietests toe
Test minimaal:
- API lowercase `todo` wordt in task UI-store `TO_DO`;
- API lowercase `in_sprint` wordt in story UI-store `IN_SPRINT`;
- bestaande PBI lowercase status blijft lowercase;
- Sprint `STATUS_CYCLE` krijgt nooit lowercase input vanuit de store.
## Fase 2 - Dubbele Product Backlog load verwijderen
### Stap 2.1 - Maak hydration eigenaar van de initial backlog snapshot
Pas Product Backlog aan naar hetzelfde eigenaarschap als Sprint:
- `BacklogHydrationWrapper` hydrateert snapshot;
- wrapper zet ook `context.activeProduct`;
- wrapper markeert `loadedProductId`;
- `SetCurrentProduct` start op routes met eigen hydration geen full `ensureProductLoaded`.
### Stap 2.2 - Guard `setActiveProduct`
Voeg een guard toe zodat `setActiveProduct(product)` geen `ensureProductLoaded` start als:
- hetzelfde product al actief is;
- `loading.loadedProductId === product.id`;
- er al een volledige snapshot gehydrateerd is.
### Stap 2.3 - Meet en verifieer
Controleer in devtools/server logs:
- openen van Product Backlog doet geen extra `/api/products/:id/backlog` na de server-render;
- navigeren tussen product routes laadt nog steeds correct;
- restore hints voor laatste PBI/story/task blijven werken.
## Fase 3 - Sprint selectie gelijkvormig maken
### Stap 3.1 - Verplaats geselecteerde story naar de sprint workspace-store
Vervang lokale `selectedStoryId` in `SprintBoardClient` door:
- `useSprintWorkspaceStore((s) => s.context.activeStoryId)`;
- `useSprintWorkspaceStore.getState().setActiveStory(storyId)`;
- reset via `setActiveStory(null)` bij verwijderen uit sprint.
### Stap 3.2 - Laat `TaskList` active-context gebruiken
Maak `TaskList` gelijkvormig met Product Backlog:
- lees taken via `selectTasksForActiveStory`;
- behoud `storyId` alleen als fallback of verwijder de prop;
- zorg dat `resyncActiveScopes` nu de actieve story/task werkelijk kan meenemen.
### Stap 3.3 - Restore-hints testen
Verifieer:
- story-selectie blijft behouden na refresh/reconnect;
- task-paneel toont dezelfde story na tab-visible resync;
- verwijderen van de actieve story reset taakpaneel netjes.
## Fase 4 - Solo refresh-hydration correct maken
### Stap 4.1 - Vervang task-id-only dependency
Vervang `taskKey = initialTasks.map(t => t.id).join(',')` door een render-relevante fingerprint, bijvoorbeeld:
- `id`;
- `status`;
- `sort_order`;
- `title`;
- `implementation_plan`;
- `story_id`;
- `story_title`;
- `story_code`;
- `task_code`;
- relevante verify/queue velden.
Of hydrateer op iedere nieuwe `initialTasks` prop als performance acceptabel is.
### Stap 4.2 - Sync unassigned stories uit props
Voeg een effect toe die `unassignedStories` bijwerkt wanneer `initialUnassigned` inhoudelijk wijzigt.
### Stap 4.3 - Sorteer solo kolommen expliciet
Render `columnTasks` gesorteerd op `sort_order` en daarna stabiel op code/titel/id. Vertrouw niet op object insertion order.
### Stap 4.4 - Test gemiste event scenario's
Test:
- tab hidden, task status wijzigt extern, tab visible: kaart staat in juiste kolom;
- reconnect met dezelfde task ids maar gewijzigde titel/status: UI update;
- nieuwe unassigned story verschijnt na refresh;
- gewijzigde `sort_order` past de render-volgorde aan.
## Fase 5 - Solo naar een gelijkvormig workspace-store patroon
Gekozen route: **Optie B**. Solo wordt naar een workspace-store patroon gemigreerd dat aansluit op Product Backlog en Sprint.
### Optie B - Grote stap
Migreer Solo naar een workspace-store patroon vergelijkbaar met Product/Sprint:
- normalized entities;
- active sprint/product context;
- loaded scopes;
- resync methods;
- realtime event adapters.
Concrete taken:
- Introduceer `stores/solo-workspace/{types,selectors,store}.ts`.
- Introduceer een `SoloHydrationWrapper` die server snapshot en actieve context hydrateert.
- Laat `SoloBoard` renderen vanuit selectors in de solo workspace-store.
- Verplaats realtime event handling en job/worker status naar de solo workspace-store.
- Vervang `router.refresh()` als primaire resync door `resyncActiveScopes`.
- Houd route refresh alleen over als expliciete fallback voor onbekende events of navigatiecases.
## Fase 6 - Observability en performance check
Voeg tijdelijk of permanent meetpunten toe:
- log of dev-only counter voor hydration calls per scherm;
- log of dev-only counter voor API `ensure*Loaded` calls;
- React Profiler rond Product Backlog/Sprint/Solo pane containers;
- netwerkcheck op dubbele fetches.
Acceptatiecriteria:
- Product Backlog doet bij eerste openen maximaal een server snapshot plus SSE connect, geen extra full-backlog client fetch.
- Product en Sprint stores bevatten geen lowercase story/task statussen.
- Solo refresh verwerkt bestaande tasks met gewijzigde velden.
- Product Backlog, Sprint en Solo hebben per scherm precies een duidelijke eigenaar voor initial hydration.
## Voorgestelde implementatievolgorde
1. Status adapters en tests toevoegen.
2. Product Backlog dubbele load verwijderen.
3. Sprint active story selectie naar store verplaatsen.
4. Solo workspace-store introduceren en hydrateren.
5. Solo realtime/resync naar workspace-store verplaatsen.
6. Performance/netwerk verifiëren.
Deze volgorde beperkt risico: eerst het data-contract, daarna de extra load, daarna gelijkvormigheid en Solo-resync.

View file

@ -0,0 +1,112 @@
---
title: "Load/render implementatie review"
date: 2026-05-10
status: review
scope: ["Product Backlog", "Sprint", "Solo"]
---
# Load/render implementatie review
## Samenvatting
De drie schermen zijn niet gelijkvormig opgebouwd.
- Product Backlog en Sprint gebruiken allebei een server-fetched snapshot, een hydration wrapper, een genormaliseerde workspace-store, SSE en directe scope-resync.
- Solo gebruikt server props, een eigen `useSoloStore`, een globale SSE-bridge en `router.refresh()` als resync-mechanisme.
- Product Backlog wijkt af doordat het naast server hydration ook nog via de product layout een client-side full backlog fetch start. Dat kan de lange rendering verklaren.
- Product Backlog en Sprint hebben daarnaast een status-contract mismatch: server pages hydrateren story/task statussen als DB `UPPER_SNAKE`, maar API-resync routes geven lowercase API-statussen terug terwijl de UI maps uppercase verwachten.
## Bevindingen
### P1 - Statussen wisselen tussen uppercase en lowercase na client load/resync
`lib/task-status.ts` zegt expliciet dat de DB `UPPER_SNAKE` houdt en de API lowercase exposeert (`lib/task-status.ts:1-2`). De API mapt bijvoorbeeld `TO_DO -> todo` en `OPEN -> open` (`lib/task-status.ts:12-35`).
De server-render paden hydrateren story/task statussen echter direct uit Prisma:
- Product Backlog stories/tasks blijven uppercase in `app/(app)/products/[id]/page.tsx:86-98`.
- Sprint stories/tasks blijven uppercase in `app/(app)/products/[id]/sprint/[sprintId]/page.tsx:94-125`.
De client load/resync paden mappen dezelfde data naar lowercase:
- Product Backlog full snapshot: `app/api/products/[id]/backlog/route.ts:80-99`.
- PBI stories: `app/api/pbis/[id]/stories/route.ts:49-50`.
- Story tasks: `app/api/stories/[id]/tasks/route.ts:46-48`.
- Sprint workspace snapshot: `app/api/sprints/[id]/workspace/route.ts:71-108`.
De UI verwacht voor stories/tasks juist uppercase:
- Backlog stories: `components/backlog/story-panel.tsx:41-50`.
- Backlog tasks: `components/backlog/task-panel.tsx:42-53`.
- Sprint stories: `components/sprint/sprint-backlog.tsx:33-38`.
- Sprint tasks: `components/sprint/task-list.tsx:33-54`.
Impact: na een client fetch of resync kunnen labels, kleuren, filters en status-cycles anders of leeg renderen. In Sprint is dit extra riskant omdat `STATUS_CYCLE[task.status]` bij lowercase statussen terugvalt naar `TO_DO`.
Aanpak: kies een intern store-contract. Het meest consistent met de bestaande UI is: DB-uppercase in de workspace-stores houden, en API lowercase alleen aan de route/API-boundary gebruiken. Converteer API-responses dus terug naar DB-statussen voordat ze in Product/Sprint workspace stores landen, of pas alle UI maps en acties consequent aan op API-statussen.
### P1 - Product Backlog doet een dubbele full backlog load
Product Backlog haalt op de server al alle PBI's, stories en tasks op (`app/(app)/products/[id]/page.tsx:47-84`) en hydrateert die in de client via `BacklogHydrationWrapper` (`components/backlog/backlog-hydration-wrapper.tsx:60-67`).
Tegelijkertijd mount de product layout altijd `SetCurrentProduct` (`app/(app)/products/[id]/layout.tsx:19-22`). Die roept `setActiveProduct` aan (`components/shared/set-current-product.tsx:10-14`). `setActiveProduct` start altijd `ensureProductLoaded`, en die fetcht opnieuw de volledige backlog via `/api/products/:id/backlog` (`stores/product-workspace/store.ts:217-257`, `stores/product-workspace/store.ts:329-345`).
Impact: op Product Backlog komt na de server render nog een client full-backlog API-call en store hydration. Dat veroorzaakt extra werk, extra renders, en door de status mismatch hierboven kan de tweede load de net gehydrateerde uppercase data overschrijven met lowercase data.
Aanpak: maak server hydration en client ensure geen dubbele eigenaren van dezelfde initial load. Bijvoorbeeld:
- `SetCurrentProduct` alleen context laten zetten zonder `ensureProductLoaded` wanneer de route zelf een snapshot hydrateert.
- Of `BacklogHydrationWrapper` ook `activeProduct` zetten en `loadedProductId` markeren, waarna `setActiveProduct`/`ensureProductLoaded` guarded wordt.
- Of Product Backlog hetzelfde patroon geven als Sprint: wrapper hydrateert snapshot en zet de actieve context direct.
### P1 - Solo resync werkt niet voor bestaande taken met dezelfde ids
`useSoloRealtime` gebruikt `router.refresh()` om gemiste events na reconnect/visible/online op te halen (`lib/realtime/use-solo-realtime.ts:96-104`, `lib/realtime/use-solo-realtime.ts:190-205`). De comment zegt dat server props opnieuw binnenkomen en `initTasks` de store reset.
Maar `SoloBoard` roept `initTasks(initialTasks)` alleen opnieuw aan als de lijst task-ids verandert:
- `const taskKey = initialTasks.map(t => t.id).join(',')` (`components/solo/solo-board.tsx:79`)
- effect dependency is alleen `[taskKey]` (`components/solo/solo-board.tsx:80-83`)
- `initTasks` vervangt de store (`stores/solo-store.ts:105-106`)
Impact: als een gemist event alleen status, titel, sort_order, plan of andere velden wijzigt, en de task-id set gelijk blijft, dan doet de refresh niets in de solo-store. Het scherm blijft stale ondanks de resync.
Aanpak: gebruik een volledige fingerprint van de render-relevante velden, of hydrateer de store op iedere nieuwe `initialTasks` prop. Als renderperformance een zorg is, maak de fingerprint expliciet met `id`, `status`, `sort_order`, `title`, story metadata en planvelden.
### P2 - Solo sync't openstaande stories niet na refresh
`SoloBoard` initialiseert `unassignedStories` eenmalig uit props (`components/solo/solo-board.tsx:66`). De knop en sheet renderen daarna vanuit lokale state (`components/solo/solo-board.tsx:220-225`, `components/solo/solo-board.tsx:278-284`).
Impact: als `router.refresh()` nieuwe unassigned stories ophaalt, wordt de lokale state niet bijgewerkt. Het aantal en de sheet kunnen stale blijven.
Aanpak: sync `initialUnassigned` via een effect/fingerprint, of maak unassigned stories onderdeel van dezelfde hydrateerbare solo-store.
### P2 - Sprint gebruikt niet hetzelfde active-context patroon als Product Backlog
Product Backlog selecteert PBI/story/task via de workspace-store context. `TaskPanel` leest bijvoorbeeld `context.activeStoryId` en `selectTasksForActiveStory` (`components/backlog/task-panel.tsx:108-115`).
Sprint hydrateert wel een sprint workspace-store, maar de geselecteerde story staat lokaal in `SprintBoardClient`:
- `useState<string | null>(null)` voor `selectedStoryId` (`components/sprint/sprint-board-client.tsx:66`)
- selectie wordt als prop doorgegeven (`components/sprint/sprint-board-client.tsx:238-257`)
- `TaskList` leest tasks via `selectTasksForStory(s, storyId)`, niet via de actieve store-context (`components/sprint/task-list.tsx:161-164`)
De sprint-store heeft wel `setActiveStory`, `selectTasksForActiveStory` en resync van `activeStoryId`, maar het scherm gebruikt dat pad niet (`stores/sprint-workspace/store.ts:305-327`, `stores/sprint-workspace/store.ts:458-466`).
Impact: Sprint werkt deels, maar is niet gelijkvormig met Product Backlog. De restore-hints en active-scope resync voor story/task zijn in dit scherm praktisch omzeild.
Aanpak: zet story-selectie in de sprint workspace-store en laat `TaskList` dezelfde active-context selector gebruiken als Product Backlog, of verwijder de ongebruikte active story/task mechanismen uit de sprint-store.
## Vergelijking per scherm
| Scherm | Initial load | Client hydration | Realtime/resync | Selectie/render patroon |
| --- | --- | --- | --- | --- |
| Product Backlog | Server haalt full backlog op | `BacklogHydrationWrapper` hydrateert product workspace-store | SSE + `resyncActiveScopes` | Store-context voor actieve PBI/story/task |
| Sprint | Server haalt sprint snapshot op | `SprintHydrationWrapper` hydrateert sprint workspace-store en zet context | SSE + `resyncActiveScopes` | Sprint/story lijst uit store, maar story selectie lokaal |
| Solo | Server haalt solo props op | `SoloBoard` init `useSoloStore` via effect op task-ids | Globale SSE + `router.refresh()` | Eigen store voor tasks, lokale state voor unassigned stories |
## Conclusie
De lange rendering is waarschijnlijk niet door `debug_id` op zichzelf veroorzaakt. De meest concrete render/load oorzaak zit in Product Backlog: server snapshot plus een tweede client-side full backlog load via `SetCurrentProduct`. Daarnaast zorgt de status-contract mismatch ervoor dat die tweede load en latere resyncs een andere datastructuur in dezelfde UI stoppen.
De schermen zijn functioneel verwant, maar niet gelijkvormig geimplementeerd. Product Backlog en Sprint moeten eerst hetzelfde status- en hydration-contract krijgen. Daarna kan Solo naar hetzelfde patroon groeien, of minimaal zijn `router.refresh()`-hydratie correct laten doorwerken op bestaande tasks en unassigned stories.