// 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 = 'v2' export interface MigrationResult { patch: Partial legacyKeys: string[] legacyCookies: string[] hasData: boolean } function readCookies(): Record { if (typeof document === 'undefined') return {} const out: Record = {} for (const part of document.cookie.split(';')) { const eq = part.indexOf('=') if (eq < 0) continue const key = part.slice(0, eq).trim() const val = part.slice(eq + 1).trim() if (key) out[key] = val } return out } 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: [], legacyCookies: [], hasData: false, } if (typeof window === 'undefined') return empty if (localStorage.getItem(MIGRATION_MARKER) === CURRENT_VERSION) return empty const patch: Partial = {} const views: NonNullable = {} const layout: NonNullable = {} const legacyKeys: string[] = [] const legacyCookies: 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 } // layout from cookies (Phase 2) const cookies = readCookies() const splitPanePositions: Record = {} const activeSprints: Record = {} for (const [name, rawValue] of Object.entries(cookies)) { if (name.startsWith('sp:')) { const key = name.slice(3) try { const arr = JSON.parse(decodeURIComponent(rawValue)) if ( Array.isArray(arr) && arr.every((n) => typeof n === 'number') && Math.abs(arr.reduce((a, b) => a + b, 0) - 100) <= 1 ) { splitPanePositions[key] = arr as number[] legacyCookies.push(name) } } catch { // ignore malformed cookie } } else if (name.startsWith('active_sprint_') && rawValue) { const productId = name.slice('active_sprint_'.length) activeSprints[productId] = decodeURIComponent(rawValue) legacyCookies.push(name) } } if (Object.keys(splitPanePositions).length > 0) { layout.splitPanePositions = splitPanePositions hasData = true } if (Object.keys(activeSprints).length > 0) { layout.activeSprints = activeSprints hasData = true } if (Object.keys(layout).length > 0) { patch.layout = layout } return { patch, legacyKeys, legacyCookies, hasData } } export function clearLegacyStorage(keys: string[], cookies: string[] = []): void { if (typeof window === 'undefined') return for (const k of keys) { try { localStorage.removeItem(k) } catch { // storage quota exceeded or disabled — ignore } } for (const c of cookies) { try { document.cookie = `${c}=; max-age=0; path=/; samesite=lax` } catch { // ignore } } try { localStorage.setItem(MIGRATION_MARKER, CURRENT_VERSION) } catch { // ignore } } /** @deprecated use clearLegacyStorage */ export const clearLegacyLocalStorage = (keys: string[]) => clearLegacyStorage(keys, [])