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:
parent
a1e6ec35e5
commit
852945efa3
12 changed files with 475 additions and 220 deletions
|
|
@ -31,6 +31,9 @@ vi.mock('@/actions/stories', () => ({
|
||||||
}))
|
}))
|
||||||
vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) }))
|
vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) }))
|
||||||
vi.mock('@/actions/tasks', () => ({ reorderTasksAction: 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() } }))
|
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||||
|
|
||||||
// Mock dnd-kit
|
// Mock dnd-kit
|
||||||
|
|
|
||||||
106
__tests__/lib/user-settings-migration.test.ts
Normal file
106
__tests__/lib/user-settings-migration.test.ts
Normal file
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useTransition, useEffect } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
|
|
@ -30,7 +30,7 @@ import {
|
||||||
type SortDir,
|
type SortDir,
|
||||||
} from '@/components/shared/backlog-filter-popover'
|
} from '@/components/shared/backlog-filter-popover'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
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 { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||||
import { selectVisiblePbis } from '@/stores/product-workspace/selectors'
|
import { selectVisiblePbis } from '@/stores/product-workspace/selectors'
|
||||||
import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types'
|
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).
|
// voorkomt re-render op ongerelateerde store-mutaties (G2).
|
||||||
const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[]
|
const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[]
|
||||||
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
|
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
|
||||||
const [filterPriority, setFilterPriority] = useState<number | 'all'>('all')
|
const prefs = useUserSettingsStore(
|
||||||
const [filterStatus, setFilterStatus] = useState<PbiStatusFilter>('all')
|
useShallow((s) => s.entities.settings.views?.pbiList ?? {}),
|
||||||
const [sortMode, setSortMode] = useState<SortMode>('priority')
|
)
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
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 [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
|
||||||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
|
||||||
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||||
const [selectionMode, setSelectionMode] = useState(false)
|
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<SortMode>(
|
|
||||||
'scrum4me:pbi_sort',
|
|
||||||
(raw) => (raw === 'priority' || raw === 'code' || raw === 'date') ? raw : null,
|
|
||||||
'priority',
|
|
||||||
))
|
|
||||||
setFilterPriority(readLocalStoragePref<number | 'all'>(
|
|
||||||
'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<PbiStatusFilter>(
|
|
||||||
'scrum4me:pbi_filter_status',
|
|
||||||
(raw) => (raw === 'ready' || raw === 'blocked' || raw === 'done' || raw === 'all') ? raw : null,
|
|
||||||
'all',
|
|
||||||
))
|
|
||||||
setSortDir(readLocalStoragePref<SortDir>(
|
|
||||||
'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).
|
// pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order).
|
||||||
// Geen aparte order/priority maps meer — workspace-store entities zijn de waarheid.
|
// Geen aparte order/priority maps meer — workspace-store entities zijn de waarheid.
|
||||||
const pbiMap = Object.fromEntries(pbis.map(p => [p.id, p]))
|
const pbiMap = Object.fromEntries(pbis.map(p => [p.id, p]))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useTransition, useEffect } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
|
|
@ -26,6 +26,7 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||||
import { selectStoriesForActivePbi } from '@/stores/product-workspace/selectors'
|
import { selectStoriesForActivePbi } from '@/stores/product-workspace/selectors'
|
||||||
import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types'
|
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 rawStories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi)) as WorkspaceStory[]
|
||||||
const [filterStatus, setFilterStatus] = useState<string | null>(null)
|
const [filterStatus, setFilterStatus] = useState<string | null>(null)
|
||||||
const [filterPriority, setFilterPriority] = useState<number | null>(null)
|
const [filterPriority, setFilterPriority] = useState<number | null>(null)
|
||||||
const [sortMode, setSortMode] = useState<SortMode>(() => {
|
const sortMode: SortMode = useUserSettingsStore(
|
||||||
const saved = typeof window !== 'undefined' ? localStorage.getItem('scrum4me:story_sort') : null
|
(s) => s.entities.settings.views?.storyPanel?.sort ?? 'priority',
|
||||||
return (saved === 'priority' || saved === 'code' || saved === 'date') ? saved : 'priority'
|
)
|
||||||
})
|
const setPref = useUserSettingsStore((s) => s.setPref)
|
||||||
|
const setSortMode = (v: SortMode) => void setPref(['views', 'storyPanel', 'sort'], v)
|
||||||
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
|
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
|
||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
|
|
||||||
useEffect(() => { localStorage.setItem('scrum4me:story_sort', sortMode) }, [sortMode])
|
|
||||||
|
|
||||||
// rawStories komt al gesorteerd binnen via selectStoriesForActivePbi.
|
// rawStories komt al gesorteerd binnen via selectStoriesForActivePbi.
|
||||||
const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s]))
|
const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s]))
|
||||||
const orderedStories = rawStories
|
const orderedStories = rawStories
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
'use client'
|
'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 { Button } from '@/components/ui/button'
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||||
import JobCard from './job-card'
|
import JobCard from './job-card'
|
||||||
import { JOB_STATUS_LABELS } from '@/components/shared/job-status'
|
import { JOB_STATUS_LABELS } from '@/components/shared/job-status'
|
||||||
import { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status'
|
import { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status'
|
||||||
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { debugProps } from '@/lib/debug'
|
import { debugProps } from '@/lib/debug'
|
||||||
import type { JobWithRelations } from '@/actions/jobs-page'
|
import type { JobWithRelations } from '@/actions/jobs-page'
|
||||||
|
|
@ -82,20 +84,6 @@ function MultiFilterPills<T extends string>({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCsv<T extends string>(raw: string | null, allowed: Set<T>): Set<T> {
|
|
||||||
if (!raw) return new Set()
|
|
||||||
const out = new Set<T>()
|
|
||||||
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<T extends string>(s: Set<T>): string {
|
|
||||||
return Array.from(s).join(',')
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JobsColumnProps {
|
interface JobsColumnProps {
|
||||||
title: string
|
title: string
|
||||||
jobs: JobWithRelations[]
|
jobs: JobWithRelations[]
|
||||||
|
|
@ -115,45 +103,50 @@ export default function JobsColumn({
|
||||||
statusOptions,
|
statusOptions,
|
||||||
emptyText,
|
emptyText,
|
||||||
}: JobsColumnProps) {
|
}: JobsColumnProps) {
|
||||||
const kindKey = `${storageKeyPrefix}_filter_kind`
|
const allowedStatuses = useMemo(
|
||||||
const statusKey = `${storageKeyPrefix}_filter_status`
|
|
||||||
|
|
||||||
const statusValues = useMemo(
|
|
||||||
() => new Set<ClaudeJobStatusApi>(statusOptions.map((o) => o.value)),
|
() => new Set<ClaudeJobStatusApi>(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<Set<ClaudeJobKind>>(() => new Set())
|
const filterKinds = useMemo<Set<ClaudeJobKind>>(() => {
|
||||||
const [filterStatuses, setFilterStatuses] = useState<Set<ClaudeJobStatusApi>>(() => new Set())
|
const out = new Set<ClaudeJobKind>()
|
||||||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
for (const v of colPrefs?.kinds ?? []) {
|
||||||
|
if (KIND_VALUES.has(v as ClaudeJobKind)) out.add(v as ClaudeJobKind)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [colPrefs?.kinds])
|
||||||
|
|
||||||
useEffect(() => {
|
const filterStatuses = useMemo<Set<ClaudeJobStatusApi>>(() => {
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
const out = new Set<ClaudeJobStatusApi>()
|
||||||
setFilterKinds(parseCsv<ClaudeJobKind>(localStorage.getItem(kindKey), KIND_VALUES))
|
for (const v of colPrefs?.statuses ?? []) {
|
||||||
setFilterStatuses(parseCsv<ClaudeJobStatusApi>(localStorage.getItem(statusKey), statusValues))
|
if (allowedStatuses.has(v as ClaudeJobStatusApi)) out.add(v as ClaudeJobStatusApi)
|
||||||
setPrefsLoaded(true)
|
}
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
return out
|
||||||
}, [kindKey, statusKey, statusValues])
|
}, [colPrefs?.statuses, allowedStatuses])
|
||||||
|
|
||||||
useEffect(() => { if (prefsLoaded) localStorage.setItem(kindKey, setToCsv(filterKinds)) }, [filterKinds, prefsLoaded, kindKey])
|
function persist(kinds: Set<ClaudeJobKind>, statuses: Set<ClaudeJobStatusApi>) {
|
||||||
useEffect(() => { if (prefsLoaded) localStorage.setItem(statusKey, setToCsv(filterStatuses)) }, [filterStatuses, prefsLoaded, statusKey])
|
void setPref(['views', 'jobsColumns', storageKeyPrefix], {
|
||||||
|
kinds: Array.from(kinds),
|
||||||
function toggleKind(v: ClaudeJobKind) {
|
statuses: Array.from(statuses),
|
||||||
setFilterKinds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(v)) next.delete(v)
|
|
||||||
else next.add(v)
|
|
||||||
return next
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function toggleStatus(v: ClaudeJobStatusApi) {
|
||||||
setFilterStatuses((prev) => {
|
const next = new Set(filterStatuses)
|
||||||
const next = new Set(prev)
|
if (next.has(v)) next.delete(v)
|
||||||
if (next.has(v)) next.delete(v)
|
else next.add(v)
|
||||||
else next.add(v)
|
persist(filterKinds, next)
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = jobs.filter((j) => {
|
const filtered = jobs.filter((j) => {
|
||||||
|
|
@ -207,14 +200,14 @@ export default function JobsColumn({
|
||||||
options={KIND_OPTIONS}
|
options={KIND_OPTIONS}
|
||||||
selected={filterKinds}
|
selected={filterKinds}
|
||||||
onToggle={toggleKind}
|
onToggle={toggleKind}
|
||||||
onClear={() => setFilterKinds(new Set())}
|
onClear={() => persist(new Set(), filterStatuses)}
|
||||||
/>
|
/>
|
||||||
<MultiFilterPills
|
<MultiFilterPills
|
||||||
label="Status"
|
label="Status"
|
||||||
options={statusOptions}
|
options={statusOptions}
|
||||||
selected={filterStatuses}
|
selected={filterStatuses}
|
||||||
onToggle={toggleStatus}
|
onToggle={toggleStatus}
|
||||||
onClear={() => setFilterStatuses(new Set())}
|
onClear={() => persist(filterKinds, new Set())}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end pt-1 border-t border-border">
|
<div className="flex justify-end pt-1 border-t border-border">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -223,10 +216,7 @@ export default function JobsColumn({
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
disabled={activeFilterCount === 0}
|
disabled={activeFilterCount === 0}
|
||||||
onClick={() => {
|
onClick={() => persist(new Set(), new Set())}
|
||||||
setFilterKinds(new Set())
|
|
||||||
setFilterStatuses(new Set())
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Wis filters
|
Wis filters
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,24 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useDebugStore } from '@/stores/debug-store'
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
|
|
||||||
export function DebugToggle() {
|
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(() => {
|
useEffect(() => {
|
||||||
hydrate(localStorage.getItem('scrum4me:debug-mode') === 'true')
|
if (!hydrated) return
|
||||||
}, [hydrate])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!_hydrated) return
|
|
||||||
localStorage.setItem('scrum4me:debug-mode', String(debugMode))
|
|
||||||
document.body.classList.toggle('debug-mode', debugMode)
|
document.body.classList.toggle('debug-mode', debugMode)
|
||||||
}, [debugMode, _hydrated])
|
}, [debugMode, hydrated])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleDebugMode}
|
onClick={() => void setPref(['devTools', 'debugMode'], !debugMode)}
|
||||||
aria-label="Debug-modus togglen"
|
aria-label="Debug-modus togglen"
|
||||||
aria-pressed={debugMode}
|
aria-pressed={debugMode}
|
||||||
data-active={debugMode}
|
data-active={debugMode}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { updateUserSettingsAction } from '@/actions/user-settings'
|
||||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
import type { UserSettings } from '@/lib/user-settings'
|
import type { UserSettings } from '@/lib/user-settings'
|
||||||
|
import {
|
||||||
|
buildMigrationPatch,
|
||||||
|
clearLegacyLocalStorage,
|
||||||
|
} from '@/lib/user-settings-migration'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initial: UserSettings
|
initial: UserSettings
|
||||||
|
|
@ -23,6 +28,29 @@ export function UserSettingsBridge({ initial, isDemo }: Props) {
|
||||||
hydrate(initial, isDemo)
|
hydrate(initial, isDemo)
|
||||||
}, [hydrate, initial, isDemo])
|
}, [hydrate, initial, isDemo])
|
||||||
|
|
||||||
|
// One-shot migration: read legacy localStorage prefs, push to server, clear.
|
||||||
|
// Idempotent via marker; demo accounts skip (no server-write).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDemo) return
|
||||||
|
const result = buildMigrationPatch()
|
||||||
|
if (!result.hasData) {
|
||||||
|
clearLegacyLocalStorage([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
const res = await updateUserSettingsAction(result.patch)
|
||||||
|
if (cancelled) return
|
||||||
|
if ('success' in res && res.success) {
|
||||||
|
applyServerPatch(result.patch)
|
||||||
|
clearLegacyLocalStorage(result.legacyKeys)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isDemo, applyServerPatch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDemo) return
|
if (isDemo) return
|
||||||
const es = new EventSource('/api/realtime/user-settings')
|
const es = new EventSource('/api/realtime/user-settings')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useTransition, useEffect } from 'react'
|
import { useMemo, useState, useTransition } from 'react'
|
||||||
import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, Pencil } from 'lucide-react'
|
import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, Pencil } from 'lucide-react'
|
||||||
import { useDroppable, useDraggable } from '@dnd-kit/core'
|
import { useDroppable, useDraggable } from '@dnd-kit/core'
|
||||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||||
|
|
@ -9,7 +9,7 @@ import { toast } from 'sonner'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { CodeBadge } from '@/components/shared/code-badge'
|
import { CodeBadge } from '@/components/shared/code-badge'
|
||||||
import { readLocalStoragePref } from '@/lib/use-local-storage-pref'
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
import {
|
import {
|
||||||
BacklogFilterPopover,
|
BacklogFilterPopover,
|
||||||
PRIORITY_LABELS as SHARED_PRIORITY_LABELS,
|
PRIORITY_LABELS as SHARED_PRIORITY_LABELS,
|
||||||
|
|
@ -453,75 +453,43 @@ interface SprintBacklogRightProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, productId, onAdd }: SprintBacklogRightProps) {
|
export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, productId, onAdd }: SprintBacklogRightProps) {
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
const prefs = useUserSettingsStore(
|
||||||
|
useShallow((s) => s.entities.settings.views?.sprintBacklog ?? {}),
|
||||||
|
)
|
||||||
|
const setPref = useUserSettingsStore((s) => s.setPref)
|
||||||
|
|
||||||
|
const filterPriority = prefs.filterPriority ?? 'all'
|
||||||
|
const filterStatus: StoryStatusFilter = prefs.filterStatus ?? 'OPEN'
|
||||||
|
const sort: PbiSort = prefs.sort ?? 'code'
|
||||||
|
const sortDir: SortDir = prefs.sortDir ?? 'asc'
|
||||||
|
const filterPopoverOpen = prefs.filterPopoverOpen ?? false
|
||||||
|
|
||||||
|
const collapsed = useMemo<Set<string>>(() => {
|
||||||
|
if (prefs.collapsedPbis !== undefined) return new Set(prefs.collapsedPbis)
|
||||||
|
// Default: auto-collapse PBIs whose stories are all DONE.
|
||||||
const auto = new Set<string>()
|
const auto = new Set<string>()
|
||||||
for (const pbi of pbisWithStories) {
|
for (const pbi of pbisWithStories) {
|
||||||
if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) {
|
if (pbi.stories.length > 0 && pbi.stories.every((s) => s.status === 'DONE')) {
|
||||||
auto.add(pbi.id)
|
auto.add(pbi.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return auto
|
return auto
|
||||||
})
|
}, [prefs.collapsedPbis, pbisWithStories])
|
||||||
const [filterPriority, setFilterPriority] = useState<number | 'all'>('all')
|
|
||||||
const [filterStatus, setFilterStatus] = useState<StoryStatusFilter>('OPEN')
|
const setFilterPriority = (v: number | 'all') =>
|
||||||
const [sort, setSort] = useState<PbiSort>('code')
|
void setPref(['views', 'sprintBacklog', 'filterPriority'], v)
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
const setFilterStatus = (v: StoryStatusFilter) =>
|
||||||
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
|
void setPref(['views', 'sprintBacklog', 'filterStatus'], v)
|
||||||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
const setSort = (v: PbiSort) => void setPref(['views', 'sprintBacklog', 'sort'], v)
|
||||||
|
const setSortDir = (v: SortDir) => void setPref(['views', 'sprintBacklog', 'sortDir'], v)
|
||||||
|
const setFilterPopoverOpen = (v: boolean) =>
|
||||||
|
void setPref(['views', 'sprintBacklog', 'filterPopoverOpen'], v)
|
||||||
|
const setCollapsedArray = (next: Set<string>) =>
|
||||||
|
void setPref(['views', 'sprintBacklog', 'collapsedPbis'], Array.from(next))
|
||||||
|
|
||||||
const [pbiDialogState, setPbiDialogState] = useState<PbiDialogState | null>(null)
|
const [pbiDialogState, setPbiDialogState] = useState<PbiDialogState | null>(null)
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' })
|
const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' })
|
||||||
|
|
||||||
// Hydrate prefs from localStorage post-mount. SSR & first client render use
|
|
||||||
// defaults — matched HTML so no hydration error. After mount we apply saved
|
|
||||||
// values; users with saved == default see no visible change, others see one
|
|
||||||
// filter update during hydration.
|
|
||||||
useEffect(() => {
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
|
||||||
setFilterPriority(readLocalStoragePref<number | 'all'>(
|
|
||||||
'scrum4me:sprint_pb_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<StoryStatusFilter>(
|
|
||||||
'scrum4me:sprint_pb_filter_status',
|
|
||||||
(raw) => (raw === 'OPEN' || raw === 'IN_SPRINT' || raw === 'DONE' || raw === 'all') ? raw : null,
|
|
||||||
'OPEN',
|
|
||||||
))
|
|
||||||
setSort(readLocalStoragePref<PbiSort>(
|
|
||||||
'scrum4me:sprint_pb_sort',
|
|
||||||
(raw) => (raw === 'priority' || raw === 'status' || raw === 'code') ? raw : null,
|
|
||||||
'code',
|
|
||||||
))
|
|
||||||
setSortDir(readLocalStoragePref<SortDir>(
|
|
||||||
'scrum4me:sprint_pb_sort_dir',
|
|
||||||
(raw) => (raw === 'asc' || raw === 'desc') ? raw : null,
|
|
||||||
'asc',
|
|
||||||
))
|
|
||||||
const savedCollapsed = localStorage.getItem('scrum4me:sprint_pb_collapsed')
|
|
||||||
if (savedCollapsed) {
|
|
||||||
try {
|
|
||||||
const arr = JSON.parse(savedCollapsed)
|
|
||||||
if (Array.isArray(arr)) {
|
|
||||||
setCollapsed(new Set(arr.filter((x): x is string => typeof x === 'string')))
|
|
||||||
}
|
|
||||||
} catch { /* ignore malformed JSON */ }
|
|
||||||
}
|
|
||||||
setFilterPopoverOpen(localStorage.getItem('scrum4me:sprint_pb_filter_popover_open') === 'true')
|
|
||||||
setPrefsLoaded(true)
|
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_priority', String(filterPriority)) }, [filterPriority, prefsLoaded])
|
|
||||||
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_status', filterStatus) }, [filterStatus, prefsLoaded])
|
|
||||||
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_sort', sort) }, [sort, prefsLoaded])
|
|
||||||
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_sort_dir', sortDir) }, [sortDir, prefsLoaded])
|
|
||||||
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_collapsed', JSON.stringify(Array.from(collapsed))) }, [collapsed, prefsLoaded])
|
|
||||||
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_popover_open', String(filterPopoverOpen)) }, [filterPopoverOpen, prefsLoaded])
|
|
||||||
|
|
||||||
const filteredPbis = pbisWithStories
|
const filteredPbis = pbisWithStories
|
||||||
.map(pbi => ({
|
.map(pbi => ({
|
||||||
...pbi,
|
...pbi,
|
||||||
|
|
@ -538,19 +506,18 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr
|
||||||
(filterStatus !== 'OPEN' ? 1 : 0)
|
(filterStatus !== 'OPEN' ? 1 : 0)
|
||||||
|
|
||||||
function toggle(pbiId: string) {
|
function toggle(pbiId: string) {
|
||||||
setCollapsed(prev => {
|
const next = new Set(collapsed)
|
||||||
const next = new Set(prev)
|
if (next.has(pbiId)) next.delete(pbiId)
|
||||||
if (next.has(pbiId)) { next.delete(pbiId) } else { next.add(pbiId) }
|
else next.add(pbiId)
|
||||||
return next
|
setCollapsedArray(next)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseAll() {
|
function collapseAll() {
|
||||||
setCollapsed(new Set(filteredPbis.map(p => p.id)))
|
setCollapsedArray(new Set(filteredPbis.map((p) => p.id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandAll() {
|
function expandAll() {
|
||||||
setCollapsed(new Set())
|
setCollapsedArray(new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerActions = (
|
const headerActions = (
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
226
lib/user-settings-migration.ts
Normal file
226
lib/user-settings-migration.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,7 +49,9 @@ function headingSlugs(filePath) {
|
||||||
return slugs;
|
return slugs;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LINK_RE = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
|
// Match `[label](url)` where url may contain one level of balanced parens
|
||||||
|
// (e.g. Next.js route groups like `app/(app)/...`).
|
||||||
|
const LINK_RE = /\[(?:[^\]]*)\]\(((?:[^()]+|\([^()]*\))+)\)/g;
|
||||||
|
|
||||||
function checkFile(filePath) {
|
function checkFile(filePath) {
|
||||||
const content = readFileSync(filePath, 'utf8');
|
const content = readFileSync(filePath, 'utf8');
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { create } from 'zustand'
|
|
||||||
|
|
||||||
type DebugStore = {
|
|
||||||
debugMode: boolean
|
|
||||||
_hydrated: boolean
|
|
||||||
hydrate: (value: boolean) => void
|
|
||||||
toggleDebugMode: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useDebugStore = create<DebugStore>((set, get) => ({
|
|
||||||
debugMode: false,
|
|
||||||
_hydrated: false,
|
|
||||||
hydrate: (v) => set({ debugMode: v, _hydrated: true }),
|
|
||||||
toggleDebugMode: () => set({ debugMode: !get().debugMode }),
|
|
||||||
}))
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue