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>
This commit is contained in:
Janpeter Visser 2026-05-10 12:49:59 +02:00
parent a0e5867857
commit e2ecb788e5
2 changed files with 332 additions and 0 deletions

View 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')
})
})

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
}
}