From 852945efa3d57c1ac74c9668526a29150bba320e Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Sun, 10 May 2026 15:13:39 +0200 Subject: [PATCH] feat(PBI-76): migrate localStorage prefs to user-settings store (Phase 1) (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../components/backlog/integration.test.tsx | 3 + __tests__/lib/user-settings-migration.test.ts | 106 ++++++++ components/backlog/pbi-list.tsx | 61 ++--- components/backlog/story-panel.tsx | 14 +- components/jobs/jobs-column.tsx | 92 ++++--- components/shared/status-bar-debug-toggle.tsx | 19 +- components/shared/user-settings-bridge.tsx | 28 +++ components/sprint/sprint-backlog.tsx | 105 +++----- lib/use-local-storage-pref.ts | 22 -- lib/user-settings-migration.ts | 226 ++++++++++++++++++ scripts/check-doc-links.mjs | 4 +- stores/debug-store.ts | 15 -- 12 files changed, 475 insertions(+), 220 deletions(-) create mode 100644 __tests__/lib/user-settings-migration.test.ts delete mode 100644 lib/use-local-storage-pref.ts create mode 100644 lib/user-settings-migration.ts delete mode 100644 stores/debug-store.ts diff --git a/__tests__/components/backlog/integration.test.tsx b/__tests__/components/backlog/integration.test.tsx index 38d73d3..703d229 100644 --- a/__tests__/components/backlog/integration.test.tsx +++ b/__tests__/components/backlog/integration.test.tsx @@ -31,6 +31,9 @@ vi.mock('@/actions/stories', () => ({ })) vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) })) vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) +vi.mock('@/actions/user-settings', () => ({ + updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), +})) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) // Mock dnd-kit diff --git a/__tests__/lib/user-settings-migration.test.ts b/__tests__/lib/user-settings-migration.test.ts new file mode 100644 index 0000000..d292868 --- /dev/null +++ b/__tests__/lib/user-settings-migration.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + buildMigrationPatch, + clearLegacyLocalStorage, +} from '@/lib/user-settings-migration' + +beforeEach(() => { + localStorage.clear() +}) + +afterEach(() => { + localStorage.clear() +}) + +describe('buildMigrationPatch', () => { + it('returns no data when nothing is stored', () => { + const result = buildMigrationPatch() + expect(result.hasData).toBe(false) + expect(result.patch).toEqual({}) + expect(result.legacyKeys).toEqual([]) + }) + + it('skips after marker is set', () => { + localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') + localStorage.setItem('scrum4me:settings_migrated', 'v1') + const result = buildMigrationPatch() + expect(result.hasData).toBe(false) + }) + + it('extracts sprint backlog prefs into nested patch', () => { + localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') + localStorage.setItem('scrum4me:sprint_pb_sort', 'priority') + localStorage.setItem('scrum4me:sprint_pb_sort_dir', 'desc') + localStorage.setItem('scrum4me:sprint_pb_collapsed', JSON.stringify(['pbi-1', 'pbi-2'])) + localStorage.setItem('scrum4me:sprint_pb_filter_popover_open', 'true') + + const result = buildMigrationPatch() + + expect(result.hasData).toBe(true) + expect(result.patch.views?.sprintBacklog).toEqual({ + filterStatus: 'all', + sort: 'priority', + sortDir: 'desc', + collapsedPbis: ['pbi-1', 'pbi-2'], + filterPopoverOpen: true, + }) + expect(result.legacyKeys).toContain('scrum4me:sprint_pb_filter_status') + expect(result.legacyKeys).toContain('scrum4me:sprint_pb_collapsed') + }) + + it('extracts pbi-list prefs', () => { + localStorage.setItem('scrum4me:pbi_sort', 'date') + localStorage.setItem('scrum4me:pbi_filter_priority', '2') + + const result = buildMigrationPatch() + expect(result.patch.views?.pbiList).toEqual({ sort: 'date', filterPriority: 2 }) + }) + + it('extracts story_sort', () => { + localStorage.setItem('scrum4me:story_sort', 'code') + const result = buildMigrationPatch() + expect(result.patch.views?.storyPanel).toEqual({ sort: 'code' }) + }) + + it('extracts debug-mode', () => { + localStorage.setItem('scrum4me:debug-mode', 'true') + const result = buildMigrationPatch() + expect(result.patch.devTools).toEqual({ debugMode: true }) + }) + + it('extracts jobs-column dynamic prefixes from CSV values', () => { + localStorage.setItem('queue_filter_kind', 'TASK_IMPLEMENTATION,SPRINT_IMPLEMENTATION') + localStorage.setItem('queue_filter_status', 'queued,running') + + const result = buildMigrationPatch() + expect(result.patch.views?.jobsColumns?.['queue']).toEqual({ + kinds: ['TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION'], + statuses: ['queued', 'running'], + }) + }) + + it('ignores invalid enum values', () => { + localStorage.setItem('scrum4me:sprint_pb_filter_status', 'BOGUS') + const result = buildMigrationPatch() + expect(result.hasData).toBe(false) + }) +}) + +describe('clearLegacyLocalStorage', () => { + it('removes given keys and sets the marker', () => { + localStorage.setItem('scrum4me:sprint_pb_sort', 'code') + localStorage.setItem('scrum4me:pbi_sort', 'priority') + + clearLegacyLocalStorage(['scrum4me:sprint_pb_sort', 'scrum4me:pbi_sort']) + + expect(localStorage.getItem('scrum4me:sprint_pb_sort')).toBeNull() + expect(localStorage.getItem('scrum4me:pbi_sort')).toBeNull() + expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v1') + }) + + it('sets marker even with empty keys list (no-op migration)', () => { + clearLegacyLocalStorage([]) + expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v1') + }) +}) diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index 896fa9f..48b007e 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useTransition, useEffect } from 'react' +import { useState, useTransition } from 'react' import { DndContext, DragEndEvent, @@ -30,7 +30,7 @@ import { type SortDir, } from '@/components/shared/backlog-filter-popover' import { useShallow } from 'zustand/react/shallow' -import { readLocalStoragePref } from '@/lib/use-local-storage-pref' +import { useUserSettingsStore } from '@/stores/user-settings/store' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import { selectVisiblePbis } from '@/stores/product-workspace/selectors' import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types' @@ -199,12 +199,21 @@ export function PbiList({ productId, isDemo }: PbiListProps) { // voorkomt re-render op ongerelateerde store-mutaties (G2). const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[] const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) - const [filterPriority, setFilterPriority] = useState('all') - const [filterStatus, setFilterStatus] = useState('all') - const [sortMode, setSortMode] = useState('priority') - const [sortDir, setSortDir] = useState('asc') + const prefs = useUserSettingsStore( + useShallow((s) => s.entities.settings.views?.pbiList ?? {}), + ) + const setPref = useUserSettingsStore((s) => s.setPref) + const filterPriority = prefs.filterPriority ?? 'all' + const filterStatus: PbiStatusFilter = prefs.filterStatus ?? 'all' + const sortMode: SortMode = prefs.sort ?? 'priority' + const sortDir: SortDir = prefs.sortDir ?? 'asc' + const setFilterPriority = (v: number | 'all') => + void setPref(['views', 'pbiList', 'filterPriority'], v) + const setFilterStatus = (v: PbiStatusFilter) => + void setPref(['views', 'pbiList', 'filterStatus'], v) + const setSortMode = (v: SortMode) => void setPref(['views', 'pbiList', 'sort'], v) + const setSortDir = (v: SortDir) => void setPref(['views', 'pbiList', 'sortDir'], v) const [filterPopoverOpen, setFilterPopoverOpen] = useState(false) - const [prefsLoaded, setPrefsLoaded] = useState(false) const [dialogState, setDialogState] = useState(null) const [activeDragId, setActiveDragId] = useState(null) const [selectionMode, setSelectionMode] = useState(false) @@ -226,44 +235,6 @@ export function PbiList({ productId, isDemo }: PbiListProps) { }) } - // Hydrate prefs post-mount; SSR + first client render use defaults so no - // hydration mismatch. Users with saved == default see no change; others see - // one filter update right after hydration. - useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect */ - setSortMode(readLocalStoragePref( - 'scrum4me:pbi_sort', - (raw) => (raw === 'priority' || raw === 'code' || raw === 'date') ? raw : null, - 'priority', - )) - setFilterPriority(readLocalStoragePref( - 'scrum4me:pbi_filter_priority', - (raw) => { - if (raw === 'all') return 'all' - const n = parseInt(raw, 10) - return Number.isInteger(n) && n >= 1 && n <= 4 ? n : null - }, - 'all', - )) - setFilterStatus(readLocalStoragePref( - 'scrum4me:pbi_filter_status', - (raw) => (raw === 'ready' || raw === 'blocked' || raw === 'done' || raw === 'all') ? raw : null, - 'all', - )) - setSortDir(readLocalStoragePref( - 'scrum4me:pbi_sort_dir', - (raw) => (raw === 'asc' || raw === 'desc') ? raw : null, - 'asc', - )) - setPrefsLoaded(true) - /* eslint-enable react-hooks/set-state-in-effect */ - }, []) - - useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode, prefsLoaded]) - useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_priority', String(filterPriority)) }, [filterPriority, prefsLoaded]) - useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_status', filterStatus) }, [filterStatus, prefsLoaded]) - useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_sort_dir', sortDir) }, [sortDir, prefsLoaded]) - // pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order). // Geen aparte order/priority maps meer — workspace-store entities zijn de waarheid. const pbiMap = Object.fromEntries(pbis.map(p => [p.id, p])) diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index 87db38d..e16d23a 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useTransition, useEffect } from 'react' +import { useState, useTransition } from 'react' import { DndContext, DragEndEvent, @@ -26,6 +26,7 @@ import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { useShallow } from 'zustand/react/shallow' +import { useUserSettingsStore } from '@/stores/user-settings/store' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import { selectStoriesForActivePbi } from '@/stores/product-workspace/selectors' import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types' @@ -132,16 +133,15 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) { const rawStories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi)) as WorkspaceStory[] const [filterStatus, setFilterStatus] = useState(null) const [filterPriority, setFilterPriority] = useState(null) - const [sortMode, setSortMode] = useState(() => { - const saved = typeof window !== 'undefined' ? localStorage.getItem('scrum4me:story_sort') : null - return (saved === 'priority' || saved === 'code' || saved === 'date') ? saved : 'priority' - }) + const sortMode: SortMode = useUserSettingsStore( + (s) => s.entities.settings.views?.storyPanel?.sort ?? 'priority', + ) + const setPref = useUserSettingsStore((s) => s.setPref) + const setSortMode = (v: SortMode) => void setPref(['views', 'storyPanel', 'sort'], v) const [storyDialogState, setStoryDialogState] = useState(null) const [activeDragId, setActiveDragId] = useState(null) const [, startTransition] = useTransition() - useEffect(() => { localStorage.setItem('scrum4me:story_sort', sortMode) }, [sortMode]) - // rawStories komt al gesorteerd binnen via selectStoriesForActivePbi. const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s])) const orderedStories = rawStories diff --git a/components/jobs/jobs-column.tsx b/components/jobs/jobs-column.tsx index acb0d45..cf19f4a 100644 --- a/components/jobs/jobs-column.tsx +++ b/components/jobs/jobs-column.tsx @@ -1,11 +1,13 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' import { Button } from '@/components/ui/button' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import JobCard from './job-card' import { JOB_STATUS_LABELS } from '@/components/shared/job-status' import { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status' +import { useUserSettingsStore } from '@/stores/user-settings/store' import { cn } from '@/lib/utils' import { debugProps } from '@/lib/debug' import type { JobWithRelations } from '@/actions/jobs-page' @@ -82,20 +84,6 @@ function MultiFilterPills({ ) } -function parseCsv(raw: string | null, allowed: Set): Set { - if (!raw) return new Set() - const out = new Set() - for (const part of raw.split(',')) { - const v = part.trim() - if (v && allowed.has(v as T)) out.add(v as T) - } - return out -} - -function setToCsv(s: Set): string { - return Array.from(s).join(',') -} - interface JobsColumnProps { title: string jobs: JobWithRelations[] @@ -115,45 +103,50 @@ export default function JobsColumn({ statusOptions, emptyText, }: JobsColumnProps) { - const kindKey = `${storageKeyPrefix}_filter_kind` - const statusKey = `${storageKeyPrefix}_filter_status` - - const statusValues = useMemo( + const allowedStatuses = useMemo( () => new Set(statusOptions.map((o) => o.value)), - [statusOptions] + [statusOptions], ) + const colPrefs = useUserSettingsStore( + useShallow((s) => s.entities.settings.views?.jobsColumns?.[storageKeyPrefix]), + ) + const setPref = useUserSettingsStore((s) => s.setPref) - const [filterKinds, setFilterKinds] = useState>(() => new Set()) - const [filterStatuses, setFilterStatuses] = useState>(() => new Set()) - const [prefsLoaded, setPrefsLoaded] = useState(false) + const filterKinds = useMemo>(() => { + const out = new Set() + for (const v of colPrefs?.kinds ?? []) { + if (KIND_VALUES.has(v as ClaudeJobKind)) out.add(v as ClaudeJobKind) + } + return out + }, [colPrefs?.kinds]) - useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect */ - setFilterKinds(parseCsv(localStorage.getItem(kindKey), KIND_VALUES)) - setFilterStatuses(parseCsv(localStorage.getItem(statusKey), statusValues)) - setPrefsLoaded(true) - /* eslint-enable react-hooks/set-state-in-effect */ - }, [kindKey, statusKey, statusValues]) + const filterStatuses = useMemo>(() => { + const out = new Set() + for (const v of colPrefs?.statuses ?? []) { + if (allowedStatuses.has(v as ClaudeJobStatusApi)) out.add(v as ClaudeJobStatusApi) + } + return out + }, [colPrefs?.statuses, allowedStatuses]) - useEffect(() => { if (prefsLoaded) localStorage.setItem(kindKey, setToCsv(filterKinds)) }, [filterKinds, prefsLoaded, kindKey]) - useEffect(() => { if (prefsLoaded) localStorage.setItem(statusKey, setToCsv(filterStatuses)) }, [filterStatuses, prefsLoaded, statusKey]) - - function toggleKind(v: ClaudeJobKind) { - setFilterKinds((prev) => { - const next = new Set(prev) - if (next.has(v)) next.delete(v) - else next.add(v) - return next + function persist(kinds: Set, statuses: Set) { + void setPref(['views', 'jobsColumns', storageKeyPrefix], { + kinds: Array.from(kinds), + statuses: Array.from(statuses), }) } + function toggleKind(v: ClaudeJobKind) { + const next = new Set(filterKinds) + if (next.has(v)) next.delete(v) + else next.add(v) + persist(next, filterStatuses) + } + function toggleStatus(v: ClaudeJobStatusApi) { - setFilterStatuses((prev) => { - const next = new Set(prev) - if (next.has(v)) next.delete(v) - else next.add(v) - return next - }) + const next = new Set(filterStatuses) + if (next.has(v)) next.delete(v) + else next.add(v) + persist(filterKinds, next) } const filtered = jobs.filter((j) => { @@ -207,14 +200,14 @@ export default function JobsColumn({ options={KIND_OPTIONS} selected={filterKinds} onToggle={toggleKind} - onClear={() => setFilterKinds(new Set())} + onClear={() => persist(new Set(), filterStatuses)} /> setFilterStatuses(new Set())} + onClear={() => persist(filterKinds, new Set())} />
diff --git a/components/shared/status-bar-debug-toggle.tsx b/components/shared/status-bar-debug-toggle.tsx index e1af527..f2be375 100644 --- a/components/shared/status-bar-debug-toggle.tsx +++ b/components/shared/status-bar-debug-toggle.tsx @@ -1,25 +1,24 @@ 'use client' import { useEffect } from 'react' -import { useDebugStore } from '@/stores/debug-store' +import { useUserSettingsStore } from '@/stores/user-settings/store' export function DebugToggle() { - const { debugMode, _hydrated, hydrate, toggleDebugMode } = useDebugStore() + const debugMode = useUserSettingsStore( + (s) => s.entities.settings.devTools?.debugMode ?? false, + ) + const hydrated = useUserSettingsStore((s) => s.context.hydrated) + const setPref = useUserSettingsStore((s) => s.setPref) useEffect(() => { - hydrate(localStorage.getItem('scrum4me:debug-mode') === 'true') - }, [hydrate]) - - useEffect(() => { - if (!_hydrated) return - localStorage.setItem('scrum4me:debug-mode', String(debugMode)) + if (!hydrated) return document.body.classList.toggle('debug-mode', debugMode) - }, [debugMode, _hydrated]) + }, [debugMode, hydrated]) return (