feat(PBI-76): migrate localStorage prefs to user-settings store (Phase 1) (#188)

* feat(PBI-76): one-shot localStorage→user-settings migration helper

Reads all legacy keys (sprint_pb_*, pbi_*, story_sort, debug-mode,
and dynamic *_filter_kind/*_filter_status for jobs columns) and
returns a typed UserSettings patch plus the keys to clear.
Idempotent via scrum4me:settings_migrated=v1 marker. Skips invalid
values silently so existing corrupt entries do not block migration.

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

* feat(PBI-76): bridge runs one-shot localStorage migration

After hydrate, scans legacy localStorage keys via buildMigrationPatch
and, if any data is found, pushes one bulk patch to the server,
applies it locally, then removes the legacy keys. Demo accounts skip
the migration entirely. Cancellable on unmount to avoid setState on
unmounted component.

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

* feat(PBI-76): migrate sprint-backlog to user-settings store

Replaces six useState+useEffect+localStorage flows with selectors
from useUserSettingsStore. Defaults are applied at the selector
level (filterStatus 'OPEN', sort 'code', etc) so the component
matches its previous behaviour. The collapsed Set is derived from
the persisted array, falling back to auto-collapse-DONE when no
preference exists yet. setPref calls are fire-and-forget — the
optimistic flow handles the local state update.

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

* feat(PBI-76): migrate pbi-list to user-settings store

Same pattern as sprint-backlog: replaces local useState +
localStorage hydration/persist with selectors from
useUserSettingsStore. filterPopoverOpen blijft lokaal — die
was nooit gepersisteerd in pbi-list.

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

* feat(PBI-76): migrate story-panel sort to user-settings store

Single pref (sortMode) — replaces sync localStorage useState
initializer with a selector. Default 'priority' applied at
the read site.

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

* feat(PBI-76): migrate jobs-column to user-settings store

Per-instance filter state (kinds + statuses) now lives under
views.jobsColumns[storageKeyPrefix] in user-settings. Removes
the local CSV-encoding helpers — store keeps arrays natively.
A single persist() call writes both fields together so the
two arrays cannot drift in optimistic mid-flight updates.

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

* feat(PBI-76): migrate debug-mode to user-settings store

DebugToggle reads debugMode from user-settings.devTools and
toggles via setPref. Removes the standalone stores/debug-store.ts
(no consumers left). Body classlist update only fires after the
store is hydrated to avoid a flash on initial paint.

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

* chore(PBI-76): remove unused readLocalStoragePref helper

No consumers left after migrating sprint-backlog, pbi-list,
story-panel, jobs-column, and debug-store to user-settings.

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

* test(PBI-76): mock user-settings action in backlog integration test

PbiList now imports the user-settings store, which transitively
loads actions/user-settings.ts → lib/prisma. The vitest jsdom
environment has no DATABASE_URL, so we add a mock alongside the
existing action mocks.

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

* fix(docs): allow balanced parens in markdown link URLs

Previously the link-checker regex stopped at the first ')',
breaking on Next.js route-group paths like `app/(app)/...`. The
new regex matches one level of balanced parens inside the URL.

Caught by CI on PR #188 — pre-existing breakage from PBI-78 plan
doc that was already merged on main.

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:
Janpeter Visser 2026-05-10 15:13:39 +02:00 committed by GitHub
parent a1e6ec35e5
commit 852945efa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 475 additions and 220 deletions

View file

@ -1,22 +0,0 @@
/**
* SSR-safe synchronous read of a localStorage value with a typed parser.
*
* Use inside `useState(() => readLocalStoragePref(...))` so the first render
* already has the persisted value no useEffect-driven re-render flicker.
*
* On the server `window` is undefined returns `fallback`. On the client the
* raw value is parsed; if the parser returns `null` the fallback is used.
* Hydration mismatches between server-rendered HTML (default) and the
* client-rendered tree (persisted) are accepted: React adapts the DOM in the
* same hydration pass without a visible flicker for matching values.
*/
export function readLocalStoragePref<T>(
key: string,
parse: (raw: string) => T | null,
fallback: T,
): T {
if (typeof window === 'undefined') return fallback
const raw = window.localStorage.getItem(key)
if (raw === null) return fallback
return parse(raw) ?? fallback
}

View file

@ -0,0 +1,226 @@
// PBI-76 Phase 1: one-shot migratie van legacy localStorage-prefs → user.settings.
//
// `UserSettingsBridge` roept dit eenmaal na hydratie aan. Bestaande users
// behouden zo hun saved filters; nieuwe users hebben simpelweg geen legacy keys
// en de helper is een no-op. De marker (`scrum4me:settings_migrated`) zorgt
// dat de migratie idempotent is — tweede call na succes returnt `null`.
import type { UserSettings } from './user-settings'
const MIGRATION_MARKER = 'scrum4me:settings_migrated'
const CURRENT_VERSION = 'v1'
export interface MigrationResult {
patch: Partial<UserSettings>
legacyKeys: string[]
hasData: boolean
}
function readJsonArray(key: string): string[] | null {
const raw = localStorage.getItem(key)
if (!raw) return null
try {
const arr = JSON.parse(raw)
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string') : null
} catch {
return null
}
}
function readPriority(key: string): number | 'all' | null {
const raw = localStorage.getItem(key)
if (raw === null) return null
if (raw === 'all') return 'all'
const n = parseInt(raw, 10)
return Number.isInteger(n) && n >= 1 && n <= 4 ? n : null
}
function readEnum<T extends string>(key: string, allowed: readonly T[]): T | null {
const raw = localStorage.getItem(key)
return raw && (allowed as readonly string[]).includes(raw) ? (raw as T) : null
}
function readBoolean(key: string): boolean | null {
const raw = localStorage.getItem(key)
if (raw === 'true') return true
if (raw === 'false') return false
return null
}
function setIfNotNull<T>(target: Record<string, unknown>, key: string, value: T | null): boolean {
if (value === null) return false
target[key] = value
return true
}
export function buildMigrationPatch(): MigrationResult {
const empty: MigrationResult = { patch: {}, legacyKeys: [], hasData: false }
if (typeof window === 'undefined') return empty
if (localStorage.getItem(MIGRATION_MARKER) === CURRENT_VERSION) return empty
const patch: Partial<UserSettings> = {}
const views: NonNullable<UserSettings['views']> = {}
const legacyKeys: string[] = []
let hasData = false
// sprint_pb_*
const sprintBacklog: Record<string, unknown> = {}
const SPRINT_KEYS = {
filterPriority: 'scrum4me:sprint_pb_filter_priority',
filterStatus: 'scrum4me:sprint_pb_filter_status',
sort: 'scrum4me:sprint_pb_sort',
sortDir: 'scrum4me:sprint_pb_sort_dir',
collapsed: 'scrum4me:sprint_pb_collapsed',
popover: 'scrum4me:sprint_pb_filter_popover_open',
}
if (
setIfNotNull(sprintBacklog, 'filterPriority', readPriority(SPRINT_KEYS.filterPriority)) ||
[SPRINT_KEYS.filterPriority].some((k) => localStorage.getItem(k) !== null)
) {
legacyKeys.push(SPRINT_KEYS.filterPriority)
}
if (
setIfNotNull(
sprintBacklog,
'filterStatus',
readEnum(SPRINT_KEYS.filterStatus, ['OPEN', 'IN_SPRINT', 'DONE', 'all'] as const),
)
) {
legacyKeys.push(SPRINT_KEYS.filterStatus)
}
if (
setIfNotNull(
sprintBacklog,
'sort',
readEnum(SPRINT_KEYS.sort, ['priority', 'status', 'code'] as const),
)
) {
legacyKeys.push(SPRINT_KEYS.sort)
}
if (
setIfNotNull(
sprintBacklog,
'sortDir',
readEnum(SPRINT_KEYS.sortDir, ['asc', 'desc'] as const),
)
) {
legacyKeys.push(SPRINT_KEYS.sortDir)
}
const collapsed = readJsonArray(SPRINT_KEYS.collapsed)
if (collapsed !== null) {
sprintBacklog.collapsedPbis = collapsed
legacyKeys.push(SPRINT_KEYS.collapsed)
}
if (setIfNotNull(sprintBacklog, 'filterPopoverOpen', readBoolean(SPRINT_KEYS.popover))) {
legacyKeys.push(SPRINT_KEYS.popover)
}
if (Object.keys(sprintBacklog).length > 0) {
views.sprintBacklog = sprintBacklog as NonNullable<typeof views.sprintBacklog>
hasData = true
}
// pbi_*
const pbiList: Record<string, unknown> = {}
const PBI_KEYS = {
sort: 'scrum4me:pbi_sort',
filterPriority: 'scrum4me:pbi_filter_priority',
filterStatus: 'scrum4me:pbi_filter_status',
sortDir: 'scrum4me:pbi_sort_dir',
}
if (
setIfNotNull(pbiList, 'sort', readEnum(PBI_KEYS.sort, ['priority', 'code', 'date'] as const))
) {
legacyKeys.push(PBI_KEYS.sort)
}
if (setIfNotNull(pbiList, 'filterPriority', readPriority(PBI_KEYS.filterPriority))) {
legacyKeys.push(PBI_KEYS.filterPriority)
}
if (
setIfNotNull(
pbiList,
'filterStatus',
readEnum(PBI_KEYS.filterStatus, ['ready', 'blocked', 'done', 'all'] as const),
)
) {
legacyKeys.push(PBI_KEYS.filterStatus)
}
if (setIfNotNull(pbiList, 'sortDir', readEnum(PBI_KEYS.sortDir, ['asc', 'desc'] as const))) {
legacyKeys.push(PBI_KEYS.sortDir)
}
if (Object.keys(pbiList).length > 0) {
views.pbiList = pbiList as NonNullable<typeof views.pbiList>
hasData = true
}
// story_sort
const storyPanel: Record<string, unknown> = {}
const STORY_KEY = 'scrum4me:story_sort'
if (
setIfNotNull(storyPanel, 'sort', readEnum(STORY_KEY, ['priority', 'code', 'date'] as const))
) {
legacyKeys.push(STORY_KEY)
}
if (Object.keys(storyPanel).length > 0) {
views.storyPanel = storyPanel as NonNullable<typeof views.storyPanel>
hasData = true
}
// jobs-column dynamic prefixes: <prefix>_filter_kind, <prefix>_filter_status
const jobsColumns: Record<string, { kinds: string[]; statuses: string[] }> = {}
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (!key) continue
const kindMatch = key.match(/^(.+)_filter_kind$/)
const statusMatch = key.match(/^(.+)_filter_status$/)
// Skip the legacy sprint/pbi keys we already handled above
if (key.startsWith('scrum4me:')) continue
if (kindMatch) {
const prefix = kindMatch[1]
const csv = localStorage.getItem(key) ?? ''
const kinds = csv.split(',').map((s) => s.trim()).filter(Boolean)
jobsColumns[prefix] = { ...(jobsColumns[prefix] ?? { kinds: [], statuses: [] }), kinds }
legacyKeys.push(key)
} else if (statusMatch) {
const prefix = statusMatch[1]
const csv = localStorage.getItem(key) ?? ''
const statuses = csv.split(',').map((s) => s.trim()).filter(Boolean)
jobsColumns[prefix] = { ...(jobsColumns[prefix] ?? { kinds: [], statuses: [] }), statuses }
legacyKeys.push(key)
}
}
if (Object.keys(jobsColumns).length > 0) {
views.jobsColumns = jobsColumns
hasData = true
}
if (Object.keys(views).length > 0) {
patch.views = views
}
// devTools.debugMode
const DEBUG_KEY = 'scrum4me:debug-mode'
const debug = readBoolean(DEBUG_KEY)
if (debug !== null) {
patch.devTools = { debugMode: debug }
legacyKeys.push(DEBUG_KEY)
hasData = true
}
return { patch, legacyKeys, hasData }
}
export function clearLegacyLocalStorage(keys: string[]): void {
if (typeof window === 'undefined') return
for (const k of keys) {
try {
localStorage.removeItem(k)
} catch {
// storage quota exceeded or disabled — ignore
}
}
try {
localStorage.setItem(MIGRATION_MARKER, CURRENT_VERSION)
} catch {
// ignore
}
}