Scrum4Me/docs/old/plans/user-settings-store.md
Janpeter Visser b39c3ec2e1
docs(cleanup): archief verouderde plannen, backlog en root-duplicaten (#191)
* 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>

* docs(cleanup): registreer handmatige verplaatsingen en fix referenties

- 4 plans verplaatst naar docs/old/plans/ (M10-qr-pairing-login, auto-pr-deploy-sync, docs-restructure-ai-lookup, v1-readiness)
- 3 archive-plans verplaatst naar docs/old/plans/ (archive-map nu leeg)
- ST-1114-copilot-reviews + 3 research-docs naar nieuwe docs/Ideas/ map
- Duplicaat docs/old/2026-04-27-m8-realtime-solo.md verwijderd (origineel zit in docs/old/plans/)
- Link-fixes naar nieuwe locaties:
  - CHANGELOG.md → docs/old/plans/v1-readiness.md
  - docs/runbooks/deploy-control.md → docs/old/plans/auto-pr-deploy-sync.md (2x)
  - docs/runbooks/worker-idempotency.md → docs/old/plans/auto-pr-deploy-sync.md
  - docs/plans/docs-restructure-pbi-spec.md → docs/old/plans/docs-restructure-ai-lookup.md (4x text + 2x href)
- docs/INDEX.md geregenereerd (96 docs, was 100)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:46:00 +02:00

9.7 KiB

title status audience language last_updated
User-settings store (DB-backed user prefs) draft
contributor
ai-agent
nl 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). 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.

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)

// 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:

{ "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)