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 (