From e2ecb788e5513c7a5cbe245d24fc855ee168c957 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 12:49:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(PBI-76):=20one-shot=20localStorage?= =?UTF-8?q?=E2=86=92user-settings=20migration=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- __tests__/lib/user-settings-migration.test.ts | 106 ++++++++ lib/user-settings-migration.ts | 226 ++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 __tests__/lib/user-settings-migration.test.ts create mode 100644 lib/user-settings-migration.ts diff --git a/__tests__/lib/user-settings-migration.test.ts b/__tests__/lib/user-settings-migration.test.ts new file mode 100644 index 0000000..d292868 --- /dev/null +++ b/__tests__/lib/user-settings-migration.test.ts @@ -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') + }) +}) diff --git a/lib/user-settings-migration.ts b/lib/user-settings-migration.ts new file mode 100644 index 0000000..c769190 --- /dev/null +++ b/lib/user-settings-migration.ts @@ -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 + 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 + } +}