docs(cleanup): archief verouderde plannen, backlog en root-duplicaten

- 6 plans naar docs/old/plans/ (PBI-11/75/78, user-settings-store, Local github setup, lees-de-readme — laatste was verkeerde repo)
- docs/backlog/ naar docs/old/backlog/ (pre-MCP statische registry; live werk loopt via Scrum4Me-MCP)
- 6 root-level duplicaten naar docs/old/ (functional, {pbi,story,task}-dialog, product-backlog, backlog)
- 2 landing plans (niet uitgevoerd) krijgen archived: true frontmatter — blijven op plek maar uit INDEX
- scripts/generate-docs-index.mjs: skip docs/old/** + skip archived: true
- CLAUDE.md: rijen docs/backlog/, docs/plans/<key>-*.md, docs/manual/ weg; Track B-sectie verwijderd
- README.md / CHANGELOG.md / docs/plans/v1-readiness.md: link-fixes naar nieuwe locaties

Verify groen (lint + typecheck + 718 tests). docs/INDEX.md geregenereerd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-11 19:15:16 +02:00
parent bf7162a5fc
commit 0a265b96eb
23 changed files with 11 additions and 29 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,198 @@
# PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)
> **Status:** READY · priority 3 · sort_order 8
> **Stories:** ST-1133 (TaskDialog full-screen) · ST-1134 (foundation) · ST-1135 (UA-redirect) · ST-1136 (settings) · ST-1137 (backlog) · ST-1138 (solo) · ST-1139 (docs + E2E)
## Doel
Scrum4Me bruikbaar maken op een mobiele telefoon, beperkt tot drie schermen — Settings (account + product-selector + QR-pairing-instructie + logout), Product Backlog (PBI/Story/Task aanmaken), Solo Paneel (voortgang vastleggen). Landscape-orientatie afgedwongen via PWA-manifest + CSS-overlay. App-naam en -icoon onderdrukken op `/m/*`. Desktop-app blijft ongewijzigd.
## Drie architectuur-beslissingen
### Beslissing A — gedeelde dialog-classes (raakt ST-1133 + ST-1138)
Alle entity-dialogen (PbiDialog, StoryDialog, TaskDialog, TaskDetailDialog) delen dezelfde class-string in [components/shared/entity-dialog-layout.ts](../../components/shared/entity-dialog-layout.ts):
```ts
export const entityDialogContentClasses = cn(
'flex flex-col p-0 gap-0',
'max-h-[90vh] w-full max-w-[calc(100%-2rem)]',
'sm:max-w-[90vw] sm:max-h-[85vh]',
'lg:max-w-[50vw] lg:min-w-[480px]',
)
```
→ Mobile-fullscreen wordt via één edit op deze constant geregeld:
```ts
'max-sm:w-screen max-sm:h-screen max-sm:max-w-none max-sm:rounded-none'
```
**Gevolg voor stories:**
- ST-1133 T-317 muteert `entity-dialog-layout.ts`, niet `task-dialog.tsx` rechtstreeks
- ST-1138 T-332 vervalt als file-edit — wordt verify-only (controleer dat TaskDetailDialog mee-erft)
- PBI/Story-dialogen krijgen mobile-fullscreen "voor niets" (handig voor ST-1137)
### Beslissing B — eigen route group `app/(mobile)/`
Parent layout `app/(app)/layout.tsx` rendert NavBar, MinWidthBanner, StatusBar, SoloRealtimeBridge, NotificationsBridge. Een nested layout in `(app)/m/` kan deze parent-output **niet** verwijderen (Next.js layouts erven naar binnen, niet vervangen).
**Keuze:** verplaats `/m/*` naar een eigen route group `app/(mobile)/m/{settings,pair,products}/...` met eigen `app/(mobile)/layout.tsx`.
**Auth-guard duplicatie voorkomen** door `getSession()`-check te extraheren naar `lib/auth-guard.ts`:
```ts
// lib/auth-guard.ts
export async function requireSession() {
const session = await getSession()
if (!session.userId) redirect('/login')
return session
}
```
Beide layouts (`(app)/layout.tsx` en `(mobile)/layout.tsx`) roepen deze helper aan. Bestaande `/m/pair/page.tsx` (M10 QR-pairing) verhuist mee naar `app/(mobile)/m/pair/page.tsx` — geen URL-wijziging, alleen filesystem-move.
**Gevolg voor stories:**
- ST-1134 T-321 schrijft `app/(mobile)/layout.tsx`, niet `app/(app)/m/layout.tsx`
- ST-1136 page wordt `app/(mobile)/m/settings/page.tsx`
- ST-1137 page wordt `app/(mobile)/m/products/[id]/page.tsx`
- ST-1138 page wordt `app/(mobile)/m/products/[id]/solo/page.tsx`
- M10's `/m/pair` verhuist naar `app/(mobile)/m/pair/` — URL ongewijzigd, geen redirect-migratie nodig
### Beslissing C — gescheiden SplitPane cookie-key
ST-1137 hergebruikt `BacklogSplitPane` (drie panelen). Op mobile rendert die in tab-mode (auto-switch + back-button uit ST-1116). De SplitPane bewaart split-percentages in een cookie.
**Keuze:** gescheiden cookie-key voor mobile — `split-pane:backlog-3-mobile:<id>` — zodat mobile-gebruikers (die in tab-mode geen split-percentages bewerken maar wel terug kunnen schakelen) de desktop-split niet beïnvloeden.
**Gevolg voor stories:**
- ST-1137 T-328 geeft expliciete `cookieKey`-prop aan `BacklogSplitPane` op de mobile-route
## Hergebruik (al aanwezig)
| Wat | Bron |
|---|---|
| Mobile tab-mode in `SplitPane` (incl. `tabLabels`, `mobileBreakpoint`, `activeTab`) | ST-1116 — [components/split-pane/split-pane.tsx](../../components/split-pane/split-pane.tsx) |
| Click-cascade auto-switch in `BacklogSplitPane` | ST-1116 commit `3e86a8d` |
| QR-pairing route `/m/pair` | M10 commit `625221f` |
| `/m/pair` confirmation page | bestaand |
| Functional-spec mobile-tabs sectie | `docs/specs/functional.md:234-235` |
## Stories
### ST-1133 — TaskDialog full-screen op mobile (verifieer en fix)
**Doel:** entity-dialogen renderen 100vw × 100vh op viewport `<640px`.
**Acceptance:**
- `entityDialogContentClasses` in `components/shared/entity-dialog-layout.ts` bevat `max-sm:w-screen max-sm:h-screen max-sm:max-w-none max-sm:rounded-none`
- Sticky header en footer blijven bereikbaar; body scrollt
- Werkt voor TaskDialog, TaskDetailDialog, PbiDialog, StoryDialog (alle gebruiken de constant)
- Tests dekken mobile-render via `window.innerWidth`-mock voor minstens TaskDialog en TaskDetailDialog
- Geen regressie op desktop (`sm:max-w-[90vw]` blijft op `>=640px`)
**Tasks:**
- T-316 inventariseer huidige render
- T-317 fix de gedeelde constant
- T-318 tests
### ST-1134 — Mobile shell foundation (route group + landscape-guard + tab-bar + manifest)
**Doel:** route group `(mobile)`, landscape-overlay, bottom tab-bar, PWA-manifest.
**Acceptance:**
- `app/(mobile)/layout.tsx` rendert zonder NavBar / AppIcon / MinWidthBanner / StatusBar
- Auth-guard via gedeelde `lib/auth-guard.ts` helper; `(app)/layout.tsx` gebruikt dezelfde helper
- `<LandscapeGuard>` toont rotate-overlay in portrait (window.matchMedia)
- `<MobileTabBar>` bottom-fixed met 3 lucide-iconen (ListTree, Activity, Settings); tap-targets ≥44×44 px
- `public/manifest.json` bevat `"orientation": "landscape"`
- M10 `/m/pair` verhuist filesystem-only naar `app/(mobile)/m/pair/` — URL onveranderd
- Tests: LandscapeGuard render-states, TabBar route-active, auth-guard helper
**Tasks:**
- T-319 LandscapeGuard
- T-320 MobileTabBar
- T-321 `(mobile)/layout.tsx` + manifest + auth-guard extractie + filesystem-move van `/m/pair`
### ST-1135 — Mobile UA-redirect bij login
**Acceptance:**
- `lib/user-agent.ts` exporteert `isPhoneUA(ua: string | null): boolean` op basis van `Mobi`-substring
- `actions/auth.ts` `loginAction` redirect bij phone-UA naar `/m/products/[active]/solo`; zonder actief product naar `/m/settings`
- Tablet-UA en desktop-UA blijven op `/dashboard`
- Demo-user volgt zelfde routing
- Tests dekken alle paden (phone met/zonder product, tablet, desktop, null UA, demo)
**Tasks:** T-322 helper · T-323 loginAction integratie · T-324 tests
### ST-1136 — Mobile Settings-pagina
**Acceptance:**
- `app/(mobile)/m/settings/page.tsx`
- Toont username, isDemo-badge, actief-product-naam
- Product-selector — klik → `setActiveProductAction` + redirect `/m/products/[id]/solo`
- QR-pairing-instructie — link "Open scrum4me.app/login op je desktop om in te loggen via QR"
- Logout-knop met AlertDialog "Uitloggen?" → `logoutAction`
- Geen avatar-upload, geen bio-edit
- Tests render-states + logout-flow
**Tasks:** T-325 layout · T-326 logout-flow · T-327 tests
### ST-1137 — Mobile Product Backlog-pagina
**Acceptance:**
- `app/(mobile)/m/products/[id]/page.tsx` hergebruikt PbiList/StoryPanel/TaskPanel + backlog-store
- `BacklogSplitPane` rendert in tab-mode op `<1024px`; auto-switch op selectie blijft werken
- TaskDialog-searchParams wiring (`?newTask=`, `?editTask=`, `?storyId=`) werkt
- Cookie-key gescheiden: `split-pane:backlog-3-mobile:<id>`
- + knoppen voor PBI/Story/Task werken; demo blijft read-only
- Tests: page-rendering met initial state, tab-mode, click-cascade-flow
**Tasks:** T-328 page wrapper + cookie-key · T-329 TaskDialog wiring · T-330 tests
### ST-1138 — Mobile Solo Paneel
**Acceptance:**
- `app/(mobile)/m/products/[id]/solo/page.tsx` hergebruikt SoloBoard
- 3 kanban-kolommen blijven; horizontal scroll
- TaskDetailDialog rendert 100vw × 100vh op `<640px`**gedekt door beslissing A** (entityDialogContentClasses)
- "Voer uit"-knop bereikbaar
- SSE-stream blijft werken
- Tests: solo-page rendert, TaskDetailDialog erft mobile-classes (zonder eigen file-edit)
**Tasks:**
- T-331 page wrapper
- T-332 verify-only (geen file-edit; controleer dat shared constant uit ST-1133 doorwerkt)
- T-333 tests
### ST-1139 — Docs sync + end-to-end verificatie
**Acceptance:**
- `docs/specs/functional.md` heeft "Mobile shell"-sectie; desktop-first-clausule herzien
- `docs/architecture.md` beschrijft route group `(mobile)`, manifest landscape, UA-redirect, gedeelde auth-guard
- `npm run lint && npm test && npm run build` slagen
- E2E checklist (11 punten — zie hieronder)
- Bekende limiet: iOS Safari PWA-orientation-lock werkt niet 100% — CSS-overlay als fallback
**Tasks:** T-334 functional-spec · T-335 architecture-doc · T-336 E2E-verificatie
## Verificatie (E2E checklist uit T-336)
1. `npm run lint && npm test && npm run build` slagen
2. DevTools mobile-emulatie iPhone 12 landscape: `/m/products/[id]` rendert tab-mode, geen NavBar, tab-bar onderaan
3. Portrait → rotate-overlay zichtbaar; landscape → overlay verdwijnt
4. Tab-bar 3 iconen werken (Backlog/Solo/Settings)
5. Login phone-UA → redirect `/m/products/[id]/solo`; desktop-UA → `/dashboard`
6. Backlog-flow: + PBI, + Story, + Task in TaskDialog
7. Solo-flow: tap task → TaskDetailDialog full-screen, "Voer uit"-knop bereikbaar
8. TaskDialog full-screen op `<640px` (via shared constant)
9. PWA-installatie test op echte mobile (Android of iOS)
10. `/m/pair` QR-flow intact na route-group-verhuizing
11. Demo op mobile read-only; logout via `/m/settings` werkt; geen Scrum4Me-tekst of AppIcon op `/m/*`
## Out of scope
- Tablets (geen Mobi-UA) blijven desktop-flow gebruiken
- iOS PWA full-orientation-lock (CSS-overlay is fallback)
- Avatar/bio editor op mobile-settings
- 1-koloms-kanban (3-koloms blijft, swipe horizontaal)

View file

@ -0,0 +1,128 @@
# PBI-75 — Sprint task-edit client-side via workspace-store
## Context
In het Sprint-scherm (`/products/<id>/sprint/<sprintId>`) duurt het bewerken van een taak onevenredig lang. Klik op een taakregel of het potlood-icoon roept `router.push(?editTask=<id>)` aan vanuit [`components/sprint/task-list.tsx:226`](../../components/sprint/task-list.tsx). Dat triggert:
- **Volledige server re-render** van [`app/(app)/products/[id]/sprint/[sprintId]/page.tsx`](../../app/(app)/products/[id]/sprint/[sprintId]/page.tsx) met zware queries (sprint, alle sprintStories+tasks, productMembers, alle PBIs+stories voor het backlog-paneel)
- **Tweede Prisma-query** in [`EditTaskLoader`](../../app/_components/tasks/edit-task-loader.tsx) voor task-detail (incl. `implementation_plan`)
- **Na save**: `router.push(closePath)` + `revalidatePath` → opnieuw alle queries
De [`sprint-workspace-store`](../../stores/sprint-workspace/store.ts) (sinds PBI-74 Story 9) bevat al alles voor een client-side edit-flow:
- [`setActiveTask(taskId)`](../../stores/sprint-workspace/store.ts) (regel 337) — zet `context.activeTaskId` + roept `ensureTaskLoaded()` aan
- [`ensureTaskLoaded(taskId)`](../../stores/sprint-workspace/store.ts) (regel 414) — `GET /api/tasks/{id}` → upsert met `_detail: true`
- [`selectActiveTask`](../../stores/sprint-workspace/selectors.ts) (regel 91) — bestaat, nog geen consumer
- [`applyTaskEvent`](../../stores/sprint-workspace/store.ts) (regel 748) — SSE-events propageren idempotent na server-save
- Optimistic-mutation primitives (`applyOptimisticMutation` / `settleMutation` / `rollbackMutation`)
Het patroon "URL-param → store" bestaat al voor product-workspace in [`components/backlog/url-task-sync.tsx`](../../components/backlog/url-task-sync.tsx) — we volgen dat als precedent.
**Doel**: klik op een taak opent de edit-dialoog client-side via store-state binnen ~100ms. Geen URL-navigatie, geen server re-render, alleen `GET /api/tasks/{id}` voor het detail.
## Aanpak
**Architectuur**: store-mounted dialog + URL-sync component voor deeplinks.
1. **Klik-flow**: `TaskList.openEditDialog` roept `setActiveTask(taskId)` aan op de store. Geen `router.push`.
2. **Render-flow**: nieuwe client-component `SprintTaskDialogMount` zit binnen `SprintHydrationWrapper`, subscribet `selectActiveTask`, en rendert `<TaskDialog>` zodra de active task `_detail === true` is.
3. **Save-flow**: `TaskDialog` krijgt optionele `onClose`/`onSaved` callbacks (backwards compatible met bestaande `closePath`). Mount geeft `onClose = () => setActiveTask(null)`. Server action `saveTask` blijft `revalidatePath` doen voor server-cache; SSE-event update store via `applyTaskEvent`.
4. **Deeplink-flow**: nieuwe `SprintUrlTaskSync` leest `?editTask=<id>` en roept `setActiveTask(id)` aan (analoog aan `url-task-sync.tsx`).
## Bestanden + wijzigingen
### Nieuw — `components/sprint/sprint-task-dialog-mount.tsx`
Client component. Subscribet `selectActiveTask` (single-value, geen `useShallow`). Wanneer task aanwezig is en `isDetail(task)` true, mappt naar `TaskDialogTask`-shape:
- `status`: via `taskStatusFromApi` uit [`lib/task-status.ts`](../../lib/task-status.ts) (lowercase API → Prisma UPPER_SNAKE)
- `implementation_plan: task.implementation_plan ?? null`
- `created_at: new Date(task.created_at)`
Rendert `<TaskDialog task={mapped} productId={productId} onClose={() => setActiveTask(null)} isDemo={isDemo} />`. Geen render tussen `setActiveTask` en `_detail: true` (detail-fetch <100ms).
### Nieuw — `components/sprint/sprint-url-task-sync.tsx`
Kopie van [`components/backlog/url-task-sync.tsx`](../../components/backlog/url-task-sync.tsx) maar tegen `useSprintWorkspaceStore` en `writeTaskHint` uit [`stores/sprint-workspace/restore`](../../stores/sprint-workspace/restore.ts).
### Wijziging — `components/sprint/task-list.tsx` (regels 225-227)
Vervang:
```ts
function openEditDialog(taskId: string) {
router.push(`${pathname}?editTask=${taskId}`)
}
```
door:
```ts
function openEditDialog(taskId: string) {
useSprintWorkspaceStore.getState().setActiveTask(taskId)
}
```
`openCreateDialog` (regel 222) blijft URL-gebaseerd — out-of-scope.
### Wijziging — `app/(app)/products/[id]/sprint/[sprintId]/page.tsx`
- Verwijder `editTask` uit searchParams-destructuring (regel 36)
- Verwijder `editTask &&`-block met `<Suspense><EditTaskLoader>` (regels 250-260)
- Verwijder ongebruikte imports (`EditTaskLoader`, `TaskDialogSkeleton`, evt. `Suspense`)
- Mount binnen `SprintHydrationWrapper`:
```tsx
<SprintHydrationWrapper ...>
<SprintBoardClient ... />
<SprintTaskDialogMount productId={id} isDemo={isDemo} />
<SprintUrlTaskSync />
</SprintHydrationWrapper>
```
- `newTask`-block (regels 241-248) blijft ongemoeid — out-of-scope.
### Wijziging — `app/_components/tasks/task-dialog.tsx`
Maak `closePath` optioneel + voeg `onClose`/`onSaved` toe (backwards compatible):
```ts
interface TaskDialogProps {
task?: TaskDialogTask
storyId?: string
productId: string
closePath?: string
onClose?: () => void
onSaved?: (taskId: string) => void
isDemo?: boolean
}
```
Refactor de drie `router.push(closePath)`-calls (regels 104, 120, 155) naar één helper:
```ts
function close() {
if (onClose) { onClose(); return }
if (closePath) router.push(closePath)
}
```
Bestaande callers (`EditTaskLoader`, mobile, product-page, sprint `newTask`-block) blijven werken via `closePath`. Nieuwe `SprintTaskDialogMount` gebruikt `onClose`.
### Geen wijziging
- `stores/sprint-workspace/selectors.ts``selectActiveTask` bestaat al
- `app/_components/tasks/edit-task-loader.tsx` — nog gebruikt door product-page en mobile
## Edge cases
- **Status-enum mapping**: store API-lowercase → Prisma UPPER_SNAKE via `taskStatusFromApi`, fallback `'TO_DO'`
- **`_detail: true` race**: mount rendert pas wanneer `isDetail(task)` true is — geen flash met undefined velden
- **Demo-mode**: prop blijft via server doorlopen, dialog respecteert al `isDemo`
- **Dirty-close-guard**: ingebouwd in dialog (regels 107, 172) — werkt via `onClose`
- **SSE na save**: `applyTaskEvent` updatet store automatisch
- **Deeplink + task niet bestaat**: `GET /api/tasks/{id}` 404 → store doet niets, dialog opent niet (huidige `redirect()` verdwijnt — acceptabel)
## Verificatie
1. **Browser** (`npm run dev`): klik op task in takenlijst → dialog opent <100ms, geen URL-verandering, alleen `GET /api/tasks/<id>` in Network
2. **Save**: wijzig titel → Opslaan → dialog sluit → store toont nieuwe titel via SSE
3. **Deeplink**: `?editTask=<id>` → dialog opent via `SprintUrlTaskSync`
4. **Bestaande flows ongebroken**: product-page edit, mobile edit, sprint `?newTask=1`
5. **`npm run verify && npm run build`**
6. **Vitest**: `__tests__/components/sprint/sprint-task-dialog-mount.test.tsx` — hydreer store, mock fetch, `setActiveTask(id)`, assert UPPER_SNAKE status + `onClose` clear
## Risico's
- Andere mounts (mobile, product-backlog, sprint `newTask`) blijven URL-gebaseerd — `closePath?` optional houdt ze werkend
- Geen `redirect()` bij not-found-deeplink (klein UX-verschil)
- SSE-latency 100-500ms na save — eventueel later mitigeren via `applyOptimisticMutation` in `onSaved`-callback
## Out-of-scope (follow-up PBIs)
- `?newTask=1`-flow naar store
- Mobile + product-backlog mounts
- `EditTaskLoader` verwijderen wanneer alle callers over zijn

View file

@ -0,0 +1,186 @@
# PBI-78 — Cost-analyse widget op Insights-pagina
## Context
De insights-pagina heeft al een `TokenUsageCard` die KPI's + per-job tabel toont, maar **alleen voor de actieve sprint van een gefilterd product**. Daardoor mis je het globale plaatje: hoeveel geef je deze maand uit, welk model is de grootste kostenpost, hoe goed werkt prompt-caching, en welke job-kinds (IDEA_MAKE_PLAN met Opus vs TASK_IMPLEMENTATION met Sonnet) trekken het budget.
We voegen een nieuwe sectie **"Cost analyse"** toe (tussen Sprint Health en Plan-quality). Eén shared periode-selector (7d/30d/90d/MTD) stuurt vier visualisaties aan op basis van best practices uit de Anthropic Console + LLM-observability tools (Datadog, Portkey):
1. Trend-chart over tijd
2. Breakdown per model
3. Breakdown per job-kind
4. Cache efficiency
De bestaande `TokenUsageCard` blijft staan als sprint-detail (top duurste jobs voor de actieve sprint).
## Bestaande infrastructuur (hergebruik)
**Reeds aanwezig in DB:**
- [prisma/schema.prisma](../../prisma/schema.prisma) — `ClaudeJob` heeft `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `actual_thinking_tokens`, `model_id`, `kind`, `finished_at`
- `ModelPrice` tabel met prijzen per 1M tokens (input/output/cache_read/cache_write)
- Prijzen worden gesynced via [scripts/sync-model-prices.ts](../../scripts/sync-model-prices.ts)
**Hergebruikbare patronen:**
- KPI-strip stijl: zie [app/(app)/insights/components/token-usage.tsx](../../app/(app)/insights/components/token-usage.tsx) (regels 43-64)
- URL-param-gestuurde filter met `useTransition` + `router.replace`: zie [app/(app)/insights/components/agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 38-62)
- Recharts BarChart pattern: zie [app/(app)/insights/components/agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 110-130)
- Cost-formule (zelfde overal): zie [lib/insights/token-stats.ts](../../lib/insights/token-stats.ts) (regels 73-79) — input + output + cache_read + cache_write + thinking, allemaal `× price_per_1m / 1_000_000`
- Server component → parallel data-fetch via `Promise.all`: zie [app/(app)/insights/page.tsx](../../app/(app)/insights/page.tsx) (regels 46-80)
## Te bouwen
### Taak 1 — Data-laag — `lib/insights/cost-analysis.ts` (nieuw)
Eén bestand met vijf functies, allemaal `(userId, period)` als parameters. Period wordt naar `WHERE cj.finished_at >= NOW() - INTERVAL '<n> days'` vertaald (MTD = `>= date_trunc('month', NOW())`).
```ts
export type Period = '7d' | '30d' | '90d' | 'mtd'
export interface CostKpi {
totalCostUsd: number
totalTokens: number
jobCount: number
avgPerDayUsd: number
cacheSavingsUsd: number // (input_price - cache_read_price) × cache_read_tokens
topModelId: string | null
topModelCostUsd: number
}
export interface CostByDayRow { day: string; costUsd: number }
export interface CostByModelRow { modelId: string; costUsd: number; jobCount: number }
export interface CostByKindRow { kind: string; costUsd: number; jobCount: number }
export interface CacheEfficiency {
cacheReadTokens: number
uncachedInputTokens: number
cacheHitRatio: number // cache_read / (cache_read + input)
savingsUsd: number
spentOnCacheWriteUsd: number
}
export async function getCostKpi(userId: string, period: Period): Promise<CostKpi>
export async function getCostByDay(userId: string, period: Period): Promise<CostByDayRow[]>
export async function getCostByModel(userId: string, period: Period): Promise<CostByModelRow[]>
export async function getCostByKind(userId: string, period: Period): Promise<CostByKindRow[]>
export async function getCacheEfficiency(userId: string, period: Period): Promise<CacheEfficiency>
```
**Belangrijke details:**
- Alle queries: `WHERE cj.user_id = ${userId} AND cj.status = 'DONE' AND cj.finished_at >= <periodStart>`
- Geen `productAccessFilter` nodig — `cj.user_id = ${userId}` filtert al op de eigenaar
- `getCostByDay` vult ontbrekende dagen op met `0` (anders breekt de chart-x-as) — vul aan client- of server-side, kies één
- Periode → days mapping inline: `7d`→7, `30d`→30, `90d`→90, `mtd`→huidige dag-van-maand
- Cache savings: `cache_read_tokens × (input_price - cache_read_price) / 1_000_000` — "wat je betaald zou hebben zonder cache, minus wat je betaalde mét cache"
### Taak 2 — UI — `app/(app)/insights/components/cost-analysis.tsx` (nieuw)
Eén client-component die de hele sectie rendert. Structuur:
```
[Period selector rechtsboven]
[KPI strip: Totaal | Cache savings | Avg/dag | Top model ($X op claude-opus-4-7)]
[grid grid-cols-1 md:grid-cols-2 gap-4]
[Daily cost line/bar chart] [Model breakdown - horizontal bar of donut]
[Job-kind breakdown - bar] [Cache efficiency - donut + label "X% hit, $Y bespaard"]
```
**Period selector:** kopieer pattern uit [agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 50-61) — `useTransition` + `router.replace` met `?period=` in URL. Default tonen als "30d".
**Charts:** Recharts (al gebruikt in `BurndownChart`, `AgentThroughputCard`, `VelocityChart`):
- Daily: `<BarChart>` met één bar (cost in USD), x-as = dag (`MM-DD`), tooltip toont `$X.XXXX`
- Model: `<BarChart layout="vertical">` met model_id labels — beperkt tot top 5
- Kind: `<BarChart layout="vertical">` met kind labels — beperkt tot top 5
- Cache: `<PieChart>` met twee segmenten (cached / uncached input) + tekst "X% cache hit · $Y bespaard"
**Empty state:** als `kpi.jobCount === 0`: render één regel "Geen jobs in deze periode."
### Taak 3 — Integratie — `app/(app)/insights/page.tsx` (edit)
Wijzigingen:
```diff
interface InsightsPageProps {
- searchParams: Promise<{ product?: string }>
+ searchParams: Promise<{ product?: string; period?: string }>
}
```
```diff
- const { product: filterProductId } = await searchParams
+ const { product: filterProductId, period: rawPeriod } = await searchParams
+ const period = (['7d','30d','90d','mtd'].includes(rawPeriod ?? '') ? rawPeriod : '30d') as Period
```
In de `Promise.all`, voeg toe:
```ts
getCostKpi(userId, period),
getCostByDay(userId, period),
getCostByModel(userId, period),
getCostByKind(userId, period),
getCacheEfficiency(userId, period),
```
Nieuwe sectie tussen Sprint Health en Plan-quality:
```tsx
<section className="space-y-3">
<h2 className="text-lg font-medium text-foreground">Cost analyse</h2>
<CostAnalysisCard
period={period}
kpi={costKpi}
byDay={costByDay}
byModel={costByModel}
byKind={costByKind}
cache={cacheEff}
/>
</section>
```
De bestaande "Token gebruik" sectie blijft staan (sprint-detail tabel).
## Bestanden
**Nieuw:**
- `lib/insights/cost-analysis.ts` — 5 query-functies + types
- `app/(app)/insights/components/cost-analysis.tsx` — client-component met period-selector + 4 charts
**Edit:**
- `app/(app)/insights/page.tsx` — period uit searchParams, parallel-fetch, nieuwe sectie
**Geen wijzigingen aan:**
- Prisma schema (alle data is er al)
- MCP server (token-data wordt al weggeschreven via `update_job_status`)
- `TokenUsageCard` (blijft als sprint-detail tabel)
## Verificatie
```bash
npm run verify && npm run build
```
**Handmatig:**
1. Open `/insights` zonder query — period default `30d`, sectie toont KPI + 4 charts
2. Wissel period via selector → URL updatet `?period=7d`, charts laden nieuwe data via `router.replace`
3. Check empty state: kies periode zonder jobs → "Geen jobs in deze periode."
4. Sanity-check KPI's tegen ruwe DB-query:
```sql
SELECT SUM(input_tokens * mp.input_price_per_1m / 1e6
+ output_tokens * mp.output_price_per_1m / 1e6
+ cache_read_tokens * mp.cache_read_price_per_1m / 1e6
+ cache_write_tokens * mp.cache_write_price_per_1m / 1e6
+ COALESCE(actual_thinking_tokens, 0) * mp.input_price_per_1m / 1e6)
FROM claude_jobs cj
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
WHERE cj.user_id = '<id>' AND cj.status = 'DONE'
AND cj.finished_at >= NOW() - INTERVAL '30 days';
```
5. Cache savings sanity: `cacheSavingsUsd ≈ cache_read_tokens × 0.9 × input_price / 1M`
(cache_read prijs = 0.1× input prijs, dus savings is 90%)

View file

@ -0,0 +1,205 @@
# Scrum4Me-Research — Zustand rearchitecture (reset + execute)
> **Scope:** dit plan is geschreven voor de research-repo
> [`madhura68/Scrum4Me-Research`](https://github.com/madhura68/Scrum4Me-Research),
> niet voor dit hoofdproject. Bestandsverwijzingen die naar
> `stores/data-store.ts`, `hooks/use-event-stream.ts`,
> `components/*-select.tsx` etc. wijzen, bestaan in de research-repo —
> niet hier. Ze staan in `code`-tags zodat de doc-link-checker ze niet
> probeert te resolven.
## Context
Het bestaande [zustand-store-rearchitecture.md](./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**: [zustand-store-rearchitecture.md](./zustand-store-rearchitecture.md) (in research-repo). Dit plan voert dat document uit; herhaalt het niet.
- **Conventies**: [CLAUDE.md](../../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` (research-repo) — mijn megastore
- `hooks/use-event-stream.ts` (research-repo) — vervangen door `use-backlog-realtime.ts`
- `hooks/use-browser-presence.ts` (research-repo) — niet in main, drop voor reset
- `app/api/realtime/events/route.ts` (research-repo) — 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` (research-repo) → leest `useProductsStore`, schrijft naar `useProductStore.setCurrentProduct`
- `components/pbi-select.tsx` (research-repo) → leest `useBacklogStore` (filter op currentProduct), `useSelectionStore.selectPbi`. Triggert fetch op product-mount via een `useBacklogLoader`-helper die initial data binnenhaalt.
- `components/story-select.tsx` (research-repo) → idem voor stories
- `components/tasks-table.tsx` (research-repo) → leest `tasksByStory[selectedStoryId]`. **Max 10 rijen, scrollbaar** (al ingebouwd, behouden)
- `components/task-detail-card.tsx` (research-repo) → fetcht task detail apart (geen full-fat backlog veld; matcht main's `tasks/[id]` route)
- `components/event-stream-panel.tsx` (research-repo) → 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 [zustand-store-rearchitecture.md](./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` (research-repo)
- `hooks/use-event-stream.ts` (research-repo)
- `hooks/use-browser-presence.ts` (research-repo) — komt deels terug in Fase B als helper voor visibility/online resync trigger
- `app/api/realtime/events/route.ts` (research-repo)
### 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 [zustand-store-rearchitecture.md §Acceptatiecriteria](./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,212 @@
---
title: "User-settings store (DB-backed user prefs)"
status: draft
audience: [contributor, ai-agent]
language: nl
last_updated: 2026-05-10
---
# User-settings store (DB-backed user prefs)
> **Locatie na approval:** verhuis dit bestand naar `docs/plans/user-settings-store.md` in de repo.
> Trigger voor dit plan: zichtbare hydratie-flits op het sprint-scherm in v1.3.3 ([PR #184](https://github.com/madhura68/Scrum4Me/pull/184)).
> De fix daar (useEffect-hydratie + `prefsLoaded`-gate) is een tijdelijke patch; deze migratie elimineert de flits volledig.
## Context
Filter- en view-prefs zitten nu verspreid over `localStorage` (en deels cookies).
Bij SSR weet de server niets van `localStorage`, dus bij users met saved-state ≠
default ontstaat één render-flits direct na hydratie. Daarnaast werken die prefs
alleen per browser — geen cross-device, en cross-tab-sync vereist `storage`-events.
Doel: **één `User.settings` JSON-veld** als single source of truth, met:
- Server-component leest het veld bij elke page-render → SSR-correct, geen flits
- Zustand-store met optimistic updates patroon (zoals `product-workspace-store`)
- Cross-tab sync via bestaande `LISTEN/NOTIFY` + SSE-bridge
- Cross-device persistence (login op andere browser/laptop ziet zelfde prefs)
---
## Scope (gefaseerd)
### Fase 0 — Infrastructuur
Aparte PR. Geen UI-wijziging; legt het fundament. Resultaat is een werkende store
zonder migraties; bestaande localStorage-flow blijft intact tot Fase 1.
| # | Bestand | Wat |
|---|---|---|
| 0.1 | `prisma/schema.prisma` | `settings Json @default("{}")` op `User` model + migration |
| 0.2 | `lib/user-settings.ts` | Zod-schema + types + `mergeSettings(prev, patch)` deep-merge helper + defaults |
| 0.3 | `actions/user-settings.ts` | `updateUserSettingsAction(patch: Partial<UserSettings>)` — auth-guard, Zod-validate, deep-merge in DB transactie, `NOTIFY scrum4me_changes 'user_settings:${userId}'` |
| 0.4 | `stores/user-settings/store.ts` | Zustand met `entities.settings: UserSettings`, `hydrate(initial)`, generieke `setPref(path, value)` met optimistic + rollback. Zelfde mutation-flow als `product-workspace-store` |
| 0.5 | `app/api/realtime/user-settings/route.ts` | SSE-route per user, `LISTEN user_settings:${userId}`, push patches |
| 0.6 | `components/shared/user-settings-bridge.tsx` | Server reads `prisma.user.findUnique({select:{settings:true}})`, geeft door als prop, client mount roept `store.hydrate()` aan + opent SSE |
| 0.7 | Mount in `app/(app)/layout.tsx` | Bridge bovenin de app-layout zodat de store altijd beschikbaar is voor alle authenticated pagina's |
| 0.8 | Tests | `__tests__/lib/user-settings.test.ts` (merge-logic), `__tests__/actions/user-settings.test.ts` (auth + validation), `__tests__/stores/user-settings.test.ts` (optimistic flow) |
**Demo/anon-fallback:** `useUserSettingsStore` detecteert `session.isDemo` of geen `userId`
en valt terug op in-memory state (geen server-write). Bridge wordt voor demo niet
gemount — defaults blijven actief, geen persistence-verwachting.
### Fase 1 — Migreer huidige flits-bronnen
| Component | localStorage-keys | → `settings`-pad |
|---|---|---|
| `components/sprint/sprint-backlog.tsx` | `scrum4me:sprint_pb_*` (6) | `views.sprintBacklog.{filterPriority,filterStatus,sort,sortDir,collapsedPbis,filterPopoverOpen}` |
| `components/backlog/pbi-list.tsx` | `scrum4me:pbi_*` (4) | `views.pbiList.{sort,filterPriority,filterStatus,sortDir}` |
| `components/backlog/story-panel.tsx` | `scrum4me:story_sort` (1) | `views.storyPanel.sort` |
| `components/jobs/jobs-column.tsx` | `${prefix}_filter_kind`, `${prefix}_filter_status` (2 dyn.) | `views.jobsColumns[prefix].{kinds,statuses}` |
| `stores/debug-store.ts` (via `status-bar-debug-toggle`) | `scrum4me:debug-mode` (1) | `devTools.debugMode` |
Per component:
- Verwijder `useState` + `useEffect`-hydratie + `useEffect`-write
- Vervang door `useUserSettingsStore(s => s.entities.settings.views.sprintBacklog?.filterStatus ?? 'OPEN')`
- Setter wordt `useUserSettingsStore.getState().setPref(['views','sprintBacklog','filterStatus'], value)`
- `prefsLoaded`-state en helpers (`readLocalStoragePref`) verdwijnen
- `lib/use-local-storage-pref.ts` wordt verwijderd (niet meer in gebruik)
**Migratie-pad voor bestaande users:** bij eerste mount, voor de eerste `setPref`-call,
leest een one-shot `useEffect` de oude localStorage-keys en pusht ze als één bulk-patch
naar de server. Daarna `localStorage.removeItem(...)` om geen verwarring te wekken.
Idempotent: als `settings.views.sprintBacklog.filterStatus` al gezet is, sla over.
### Fase 2 — Cookie-consolidatie (optioneel, later PR)
| Bron | Huidig | → `settings`-pad |
|---|---|---|
| `components/shared/split-pane.tsx` | `document.cookie` (`sp:` prefix) | `layout.splitPanePositions[cookieKey]` |
| `lib/active-sprint.ts` + `actions/active-sprint.ts` | server-side cookie per product | `layout.activeSprints[productId]` |
Server-component-lezers veranderen — apart traject met meer regression-risico.
Niet onderdeel van de eerste user-settings-PR.
### Fase 3 — Skip / al persistent
- `idea-md-editor.tsx` drafts — werk-in-progress, geen pref
- `iron-session` cookies — auth, andere zorg
- `User.active_product_id` — al in DB (kolom op model)
- Modal/popover open-state behalve `filterPopoverOpen` — ephemeral
---
## JSON-shape (Fase 1)
```ts
// lib/user-settings.ts
import { z } from 'zod'
export const UserSettingsSchema = z.object({
views: z.object({
sprintBacklog: z.object({
filterPriority: z.union([z.number().int().min(1).max(4), z.literal('all')]).optional(),
filterStatus: z.enum(['OPEN', 'IN_SPRINT', 'DONE', 'all']).optional(),
sort: z.enum(['priority', 'status', 'code']).optional(),
sortDir: z.enum(['asc', 'desc']).optional(),
collapsedPbis: z.array(z.string()).optional(),
filterPopoverOpen: z.boolean().optional(),
}).optional(),
pbiList: z.object({
sort: z.enum(['priority', 'code', 'date']).optional(),
filterPriority: z.union([z.number().int().min(1).max(4), z.literal('all')]).optional(),
filterStatus: z.enum(['ready', 'blocked', 'done', 'all']).optional(),
sortDir: z.enum(['asc', 'desc']).optional(),
}).optional(),
storyPanel: z.object({
sort: z.enum(['priority', 'code', 'date']).optional(),
}).optional(),
jobsColumns: z.record(z.string(), z.object({
kinds: z.array(z.string()),
statuses: z.array(z.string()),
})).optional(),
}).optional(),
devTools: z.object({
debugMode: z.boolean().optional(),
}).optional(),
}).strict()
export type UserSettings = z.infer<typeof UserSettingsSchema>
```
Defaults zijn impliciet (alle keys optioneel). Selectors in de store geven
fallback-waardes terug zodat consumers niet `?? 'OPEN'` hoeven te schrijven —
maar het mag, geen big deal.
---
## Realtime-notificatie
Bestaand kanaal `scrum4me_changes` blijft. Payload-conventie:
```json
{ "kind": "user_settings", "userId": "...", "patch": { "views": { ... } } }
```
`/api/realtime/user-settings/route.ts` filtert payloads op `userId === session.userId`.
Andere tabs van zelfde user krijgen patches binnen, store roept `applyServerPatch(patch)`
aan zonder optimistic flow.
---
## Verificatie (per fase)
### Fase 0
- [ ] `npm run verify && npm run build` groen
- [ ] Migration draait op fresh + bestaande DB zonder data-verlies
- [ ] `updateUserSettingsAction` weigert auth-loze calls (test)
- [ ] Zod-validatie geeft 422 bij invalid patch (test)
- [ ] Optimistic update + rollback gedraagt zich zoals `product-workspace-store` (test)
- [ ] SSE-route levert patches alleen aan zelfde user (manueel: open twee tabs als A, schrijf, zie update; tab van user B blijft stil)
### Fase 1
- [ ] Geen `localStorage.getItem` of `localStorage.setItem` meer in de gemigreerde componenten
- [ ] Sprint screen: refresh → filter direct correct, geen flits, geen hydration error in console
- [ ] Product backlog screen: idem
- [ ] Jobs page: idem (per kolom-instance)
- [ ] Two-tab test: filter wijzigen in tab A → tab B updatet binnen ~100ms
- [ ] Demo-user: filter wijzigen werkt binnen sessie, niet gepersisteerd na refresh (verwacht)
- [ ] One-shot localStorage-migratie: bestaande user met oude keys ziet bij eerste login zijn waardes terug; na refresh zijn de localStorage-keys leeg
### Fase 2
- [ ] Split-pane positie persistent en SSR-correct
- [ ] Active-sprint per product werkt zonder cookie
---
## Schatting
| Fase | Tijd |
|---|---|
| 0 — Infra | ~3 uur |
| 1 — Migratie | ~2 uur |
| 2 — Cookies | ~2 uur (apart) |
| Totaal Fase 0 + 1 | **~5 uur**, 1 PR (of 2 als we 0 en 1 splitsen) |
Aanbevolen: **Fase 0 + 1 in één PR** als de infra klein blijft, anders splitsen
per fase. Fase 2 is altijd een aparte PR.
---
## Open vragen
1. **Cross-device merge-conflict.** Twee tabs van zelfde user op verschillende
devices wijzigen tegelijk. Server-side: `last-write-wins` of `JSON_PATCH`-merge?
Voorstel: deep-merge per top-level path, dus `views.sprintBacklog.filterStatus`
en `views.pbiList.sort` botsen niet — laatste schrijver per veld wint.
2. **Storage-grens.** PostgreSQL JSON kolom kan ~1GB; we zitten op <5KB per user.
Geen concern.
3. **Schema-versionering.** Als we het JSON-schema later wijzigen: voorzichtig
migreren via Zod `.catch()` voor onbekende keys. Voor v1: start klein.
4. **One-shot localStorage-migratie weglaten?** Voor solo-dev-tool kan het
acceptabel zijn dat users hun saved filters verliezen bij de migratie. Scheelt
~30 minuten implementatie + tests.
---
## Eerste stappen na approval
1. Verhuis dit plan naar `docs/plans/user-settings-store.md` in een nieuwe branch (bv. `feat/user-settings-store`)
2. Maak via Scrum4Me-MCP een PBI met story + taken voor Fase 0 (volgens CLAUDE.md werkflow)
3. Start met taken in `sort_order`; commit per laag
4. Fase 1 als opvolg-PBI (of in dezelfde sprint, los gelabeld)