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>
226 lines
7 KiB
TypeScript
226 lines
7 KiB
TypeScript
// 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
|
|
}
|
|
}
|