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:
parent
a0e5867857
commit
e2ecb788e5
2 changed files with 332 additions and 0 deletions
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')
|
||||
})
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue