From 4819bcb2e1dec67f8008c4e9a9aa05b2e4c4073f Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 11:28:16 +0200 Subject: [PATCH] docs(PBI-76): plan for user-settings DB-store Persists view/filter prefs in User.settings (Json) instead of localStorage. SSR-correct hydration, cross-tab sync via LISTEN/NOTIFY + SSE, cross-device persistence. Phased: 0=infra, 1=migrate flicker sources, 2=cookie consolidation. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/INDEX.md | 1 + docs/plans/user-settings-store.md | 212 ++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 docs/plans/user-settings-store.md diff --git a/docs/INDEX.md b/docs/INDEX.md index ef5e696..8e7a080 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -62,6 +62,7 @@ Auto-generated on 2026-05-10 from front-matter and headings. | [ST-1114 — Copilot reviews op dashboard](./plans/ST-1114-copilot-reviews.md) | active | 2026-05-03 | | [Plan: wekelijkse sync van `model_prices` (PBI-66 / ST-1296)](./plans/sync-model-prices.md) | — | — | | [Tweede Claude Agent — Planning Agent](./plans/tweede-claude-agent-planning.md) | proposal | 2026-05-03 | +| [User-settings store (DB-backed user prefs)](./plans/user-settings-store.md) | draft | 2026-05-10 | | [Scrum4Me — v1.0 readiness](./plans/v1-readiness.md) | active | 2026-05-04 | | [Zustand store rearchitecture - active context, realtime en resync](./plans/zustand-store-rearchitecture.md) | ready-to-execute | 2026-05-09 | | [Zustand workspace-store implementatieplan (PBI-74)](./plans/zustand-workspace-store-implementation.md) | in-progress | 2026-05-10 | diff --git a/docs/plans/user-settings-store.md b/docs/plans/user-settings-store.md new file mode 100644 index 0000000..ea8ee91 --- /dev/null +++ b/docs/plans/user-settings-store.md @@ -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)` — 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 +``` + +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)