From 58dcb034201ac582de83e921f6eff1629e4a28c5 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 15:25:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(PBI-76):=20migration=20helper=20v2=20?= =?UTF-8?q?=E2=80=94=20handle=20legacy=20cookies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps marker version to 'v2'. buildMigrationPatch now also scans document.cookie for `sp:*` (split-pane positions) and `active_sprint_*` (active sprint per product) and lifts them into layout.splitPanePositions / layout.activeSprints. clearLegacyStorage replaces clearLegacyLocalStorage and clears both keys and cookies. clearLegacyLocalStorage stays as a deprecated alias so the bridge upgrade is a single rename. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/lib/user-settings-migration.test.ts | 65 +++++++++++++--- components/shared/user-settings-bridge.tsx | 6 +- lib/user-settings-migration.ts | 78 ++++++++++++++++++- 3 files changed, 130 insertions(+), 19 deletions(-) diff --git a/__tests__/lib/user-settings-migration.test.ts b/__tests__/lib/user-settings-migration.test.ts index d292868..38346b4 100644 --- a/__tests__/lib/user-settings-migration.test.ts +++ b/__tests__/lib/user-settings-migration.test.ts @@ -2,15 +2,25 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { buildMigrationPatch, - clearLegacyLocalStorage, + clearLegacyStorage, } from '@/lib/user-settings-migration' +function clearAllCookies() { + for (const part of document.cookie.split(';')) { + const eq = part.indexOf('=') + const name = (eq < 0 ? part : part.slice(0, eq)).trim() + if (name) document.cookie = `${name}=; max-age=0; path=/` + } +} + beforeEach(() => { localStorage.clear() + clearAllCookies() }) afterEach(() => { localStorage.clear() + clearAllCookies() }) describe('buildMigrationPatch', () => { @@ -21,11 +31,42 @@ describe('buildMigrationPatch', () => { expect(result.legacyKeys).toEqual([]) }) - it('skips after marker is set', () => { + it('skips after marker is set to current version', () => { + localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') + localStorage.setItem('scrum4me:settings_migrated', 'v2') + const result = buildMigrationPatch() + expect(result.hasData).toBe(false) + }) + + it('still runs when only the v1 marker is set (re-migration)', () => { localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') localStorage.setItem('scrum4me:settings_migrated', 'v1') const result = buildMigrationPatch() - expect(result.hasData).toBe(false) + expect(result.hasData).toBe(true) + }) + + it('extracts split-pane cookies into layout', () => { + document.cookie = `sp:backlog-p1=${encodeURIComponent(JSON.stringify([25, 35, 40]))}; path=/` + const result = buildMigrationPatch() + expect(result.patch.layout?.splitPanePositions).toEqual({ 'backlog-p1': [25, 35, 40] }) + expect(result.legacyCookies).toContain('sp:backlog-p1') + }) + + it('ignores split-pane cookies that do not sum to 100', () => { + document.cookie = `sp:bad=${encodeURIComponent(JSON.stringify([10, 20]))}; path=/` + const result = buildMigrationPatch() + expect(result.patch.layout).toBeUndefined() + }) + + it('extracts active-sprint cookies into layout.activeSprints', () => { + document.cookie = `active_sprint_prod-1=sprint-abc; path=/` + document.cookie = `active_sprint_prod-2=sprint-xyz; path=/` + const result = buildMigrationPatch() + expect(result.patch.layout?.activeSprints).toEqual({ + 'prod-1': 'sprint-abc', + 'prod-2': 'sprint-xyz', + }) + expect(result.legacyCookies).toContain('active_sprint_prod-1') }) it('extracts sprint backlog prefs into nested patch', () => { @@ -87,20 +128,20 @@ describe('buildMigrationPatch', () => { }) }) -describe('clearLegacyLocalStorage', () => { - it('removes given keys and sets the marker', () => { +describe('clearLegacyStorage', () => { + it('removes given keys and cookies and sets the v2 marker', () => { localStorage.setItem('scrum4me:sprint_pb_sort', 'code') - localStorage.setItem('scrum4me:pbi_sort', 'priority') + document.cookie = 'sp:x=foo; path=/' - clearLegacyLocalStorage(['scrum4me:sprint_pb_sort', 'scrum4me:pbi_sort']) + clearLegacyStorage(['scrum4me:sprint_pb_sort'], ['sp:x']) expect(localStorage.getItem('scrum4me:sprint_pb_sort')).toBeNull() - expect(localStorage.getItem('scrum4me:pbi_sort')).toBeNull() - expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v1') + expect(document.cookie).not.toContain('sp:x=foo') + expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v2') }) - it('sets marker even with empty keys list (no-op migration)', () => { - clearLegacyLocalStorage([]) - expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v1') + it('sets marker even with empty lists (no-op migration)', () => { + clearLegacyStorage([], []) + expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v2') }) }) diff --git a/components/shared/user-settings-bridge.tsx b/components/shared/user-settings-bridge.tsx index 71bd436..581c832 100644 --- a/components/shared/user-settings-bridge.tsx +++ b/components/shared/user-settings-bridge.tsx @@ -6,7 +6,7 @@ import { useUserSettingsStore } from '@/stores/user-settings/store' import type { UserSettings } from '@/lib/user-settings' import { buildMigrationPatch, - clearLegacyLocalStorage, + clearLegacyStorage, } from '@/lib/user-settings-migration' interface Props { @@ -34,7 +34,7 @@ export function UserSettingsBridge({ initial, isDemo }: Props) { if (isDemo) return const result = buildMigrationPatch() if (!result.hasData) { - clearLegacyLocalStorage([]) + clearLegacyStorage([], []) return } let cancelled = false @@ -43,7 +43,7 @@ export function UserSettingsBridge({ initial, isDemo }: Props) { if (cancelled) return if ('success' in res && res.success) { applyServerPatch(result.patch) - clearLegacyLocalStorage(result.legacyKeys) + clearLegacyStorage(result.legacyKeys, result.legacyCookies) } })() return () => { diff --git a/lib/user-settings-migration.ts b/lib/user-settings-migration.ts index c769190..14d2028 100644 --- a/lib/user-settings-migration.ts +++ b/lib/user-settings-migration.ts @@ -8,14 +8,28 @@ import type { UserSettings } from './user-settings' const MIGRATION_MARKER = 'scrum4me:settings_migrated' -const CURRENT_VERSION = 'v1' +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 @@ -54,13 +68,20 @@ function setIfNotNull(target: Record, key: string, value: T } export function buildMigrationPatch(): MigrationResult { - const empty: MigrationResult = { patch: {}, legacyKeys: [], hasData: false } + 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_* @@ -206,10 +227,48 @@ export function buildMigrationPatch(): MigrationResult { hasData = true } - return { patch, legacyKeys, hasData } + // 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 clearLegacyLocalStorage(keys: string[]): void { +export function clearLegacyStorage(keys: string[], cookies: string[] = []): void { if (typeof window === 'undefined') return for (const k of keys) { try { @@ -218,9 +277,20 @@ export function clearLegacyLocalStorage(keys: string[]): void { // 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, [])