feat(PBI-76): user-settings DB-store infrastructure (Phase 0) (#185)
* 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) <noreply@anthropic.com> * feat(PBI-76): User.settings json column + migration Adds JSONB column to users table for persistent user prefs. Idempotent SQL — safe on databases where column already exists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): user-settings types and merge helpers Zod schema for User.settings shape (views/devTools), deep-merge helper that replaces arrays and merges nested objects, and a safe parser that returns defaults on invalid input. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): updateUserSettingsAction with notify Validates patch via Zod, deep-merges with current settings in a transaction, persists to DB, and emits pg_notify on scrum4me_changes for cross-tab/cross-device sync. Demo accounts get 403, unauthenticated 401, invalid input 422. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): user-settings zustand store with optimistic flow Hydrate from prop (SSR-correct), setPref via path with optimistic update + rollback on server error, applyServerPatch for SSE-driven cross-tab updates. Demo accounts skip server-write entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): SSE route for user-settings User-scoped /api/realtime/user-settings stream that filters scrum4me_changes notifications on kind=user_settings and matching userId. Forwards the patch as a data: event so other tabs can applyServerPatch without re-fetching settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): user-settings bridge mounted in app layout Hydrates the zustand store with the user's persisted settings via prop (SSR-correct, no flicker). Opens an EventSource to /api/realtime/user-settings so changes from other tabs/devices flow into the same store. Demo accounts skip the SSE subscription. Layout now selects user.settings alongside the other user fields, no extra DB roundtrip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(PBI-76): user-settings lib/action/store coverage 22 vitest cases covering merge semantics (no mutation, array replace, nested merge), Zod schema strictness, server action auth/demo/validation paths, and the optimistic store flow including rollback and demo-mode skip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(PBI-76): sync package-lock to v1.3.3 Lockfile drifted after @prisma/client reinstall during the schema regenerate. No dependency changes — just the version field tracking package.json bumped in #184. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1f8cbacb0a
commit
a0e5867857
15 changed files with 998 additions and 3 deletions
212
docs/plans/user-settings-store.md
Normal file
212
docs/plans/user-settings-store.md
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue