// 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 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(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(target: Record, 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 = {} const views: NonNullable = {} const legacyKeys: string[] = [] let hasData = false // sprint_pb_* const sprintBacklog: Record = {} 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 hasData = true } // pbi_* const pbiList: Record = {} 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 hasData = true } // story_sort const storyPanel: Record = {} 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 hasData = true } // jobs-column dynamic prefixes: _filter_kind, _filter_status const jobsColumns: Record = {} 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 } }