diff --git a/__tests__/actions/sprint-dates.test.ts b/__tests__/actions/sprint-dates.test.ts index 6adb153..af2474f 100644 --- a/__tests__/actions/sprint-dates.test.ts +++ b/__tests__/actions/sprint-dates.test.ts @@ -20,6 +20,11 @@ vi.mock('@/lib/prisma', () => ({ create: vi.fn(), update: vi.fn(), }, + user: { + findUnique: vi.fn().mockResolvedValue({ settings: {} }), + update: vi.fn().mockResolvedValue({}), + }, + $executeRaw: vi.fn().mockResolvedValue(1), }, })) diff --git a/__tests__/components/backlog/backlog-split-pane.test.tsx b/__tests__/components/backlog/backlog-split-pane.test.tsx index 4505a80..27d7626 100644 --- a/__tests__/components/backlog/backlog-split-pane.test.tsx +++ b/__tests__/components/backlog/backlog-split-pane.test.tsx @@ -1,6 +1,11 @@ // @vitest-environment jsdom -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { render, screen } from '@testing-library/react' + +vi.mock('@/actions/user-settings', () => ({ + updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), +})) + import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' diff --git a/__tests__/components/split-pane.test.tsx b/__tests__/components/split-pane.test.tsx index cd166c0..40cb515 100644 --- a/__tests__/components/split-pane.test.tsx +++ b/__tests__/components/split-pane.test.tsx @@ -1,28 +1,35 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { SplitPane } from '@/components/split-pane/split-pane' -// Helper to set a cookie -function setCookie(key: string, value: string) { - Object.defineProperty(document, 'cookie', { - writable: true, - configurable: true, - value: `sp:${key}=${encodeURIComponent(value)}`, +vi.mock('@/actions/user-settings', () => ({ + updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), +})) + +import { SplitPane } from '@/components/split-pane/split-pane' +import { useUserSettingsStore } from '@/stores/user-settings/store' + +function seedPositions(key: string, positions: number[]) { + useUserSettingsStore.setState((s) => { + s.entities.settings = { + layout: { + splitPanePositions: { [key]: positions }, + }, + } }) } -function clearCookies() { - Object.defineProperty(document, 'cookie', { - writable: true, - configurable: true, - value: '', +function resetStore() { + useUserSettingsStore.setState((s) => { + s.entities.settings = {} + s.context.hydrated = false + s.context.isDemo = false }) } describe('SplitPane', () => { beforeEach(() => { - clearCookies() + resetStore() // Default: desktop viewport Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 }) window.dispatchEvent(new Event('resize')) @@ -64,9 +71,8 @@ describe('SplitPane', () => { expect(dividers).toHaveLength(2) }) - it('restores splits from cookie on mount', () => { - const stored = JSON.stringify([40, 60]) - setCookie('test-restore', stored) + it('restores splits from user-settings store on mount', () => { + seedPositions('test-restore', [40, 60]) const { container } = render( { expect(paneDiv).toBeTruthy() }) - it('falls back to defaultSplit when cookie is invalid', () => { - setCookie('test-invalid', 'not-valid-json') + it('falls back to defaultSplit when persisted positions are invalid', () => { + // Wrong number of values for a 2-pane layout + seedPositions('test-invalid', [10, 30, 60]) const { container } = render( { 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/__tests__/lib/user-settings.test.ts b/__tests__/lib/user-settings.test.ts index 019cc33..c7cd5d9 100644 --- a/__tests__/lib/user-settings.test.ts +++ b/__tests__/lib/user-settings.test.ts @@ -109,7 +109,17 @@ describe('UserSettingsSchema', () => { jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } }, }, devTools: { debugMode: true }, + layout: { + splitPanePositions: { 'backlog-pid': [25, 35, 40] }, + activeSprints: { 'product-1': 'sprint-1' }, + }, }) expect(result.success).toBe(true) }) + + it('accepts layout-only settings', () => { + expect(UserSettingsSchema.safeParse({ + layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } }, + }).success).toBe(true) + }) }) diff --git a/actions/active-sprint.ts b/actions/active-sprint.ts index 9451190..7705206 100644 --- a/actions/active-sprint.ts +++ b/actions/active-sprint.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' -import { setActiveSprintCookie } from '@/lib/active-sprint' +import { setActiveSprintInSettings } from '@/lib/active-sprint' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -36,7 +36,7 @@ export async function setActiveSprintAction(productId: string, sprintId: string) }) if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' } - await setActiveSprintCookie(parsed.data.productId, parsed.data.sprintId) + await setActiveSprintInSettings(session.userId, parsed.data.productId, parsed.data.sprintId) revalidatePath('/', 'layout') return { success: true, sprintId: parsed.data.sprintId } } @@ -59,5 +59,5 @@ export async function syncActiveSprintCookieAction(productId: string, sprintId: }) if (!sprint) return - await setActiveSprintCookie(parsed.data.productId, parsed.data.sprintId) + await setActiveSprintInSettings(session.userId, parsed.data.productId, parsed.data.sprintId) } diff --git a/actions/sprints.ts b/actions/sprints.ts index 1be5ef5..9471b51 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -14,7 +14,7 @@ import { import { enforceUserRateLimit } from '@/lib/rate-limit' import { propagateStatusUpwards } from '@/lib/tasks-status-update' import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server' -import { setActiveSprintCookie } from '@/lib/active-sprint' +import { setActiveSprintInSettings } from '@/lib/active-sprint' import { z } from 'zod' async function getSession() { @@ -102,7 +102,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData } } - await setActiveSprintCookie(parsed.data.productId, sprint.id) + await setActiveSprintInSettings(session.userId, parsed.data.productId, sprint.id) revalidatePath(`/products/${parsed.data.productId}`, 'layout') return { success: true, sprintId: sprint.id } } @@ -409,7 +409,7 @@ export async function createSprintWithPbisAction(input: { }), ) - await setActiveSprintCookie(parsed.data.productId, sprint.id) + await setActiveSprintInSettings(session.userId, parsed.data.productId, sprint.id) revalidatePath(`/products/${parsed.data.productId}`, 'layout') return { success: true, sprintId: sprint.id } } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 424f323..15eab5a 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -47,7 +47,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod }) if (product) { activeProduct = product - const resolved = await resolveActiveSprint(product.id) + const resolved = await resolveActiveSprint(product.id, session.userId) hasActiveSprint = !!resolved } else { await prisma.user.update({ diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index bf38aa4..386fe56 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -38,7 +38,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props const [user, switcherData] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }), - getSprintSwitcherData(id), + getSprintSwitcherData(id, { userId: session.userId }), ]) const { sprintItems, buildingSprintIds, activeSprintItem } = switcherData const hasOpenSprint = sprintItems.some(s => s.status === 'open') diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index cea6993..5f0e6ab 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -1,4 +1,5 @@ import { redirect } from 'next/navigation' +import { requireSession } from '@/lib/auth-guard' import { resolveActiveSprint } from '@/lib/active-sprint' interface Props { @@ -7,7 +8,8 @@ interface Props { export default async function SprintRedirectPage({ params }: Props) { const { id } = await params - const active = await resolveActiveSprint(id) + const session = await requireSession() + const active = await resolveActiveSprint(id, session.userId) if (!active) { redirect(`/products/${id}?alert=no_sprint`) } 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/components/split-pane/split-pane.tsx b/components/split-pane/split-pane.tsx index 52d1710..1e169c7 100644 --- a/components/split-pane/split-pane.tsx +++ b/components/split-pane/split-pane.tsx @@ -3,34 +3,15 @@ import { Fragment, useRef, useState, useEffect, useCallback } from 'react' import { cn } from '@/lib/utils' import { debugProps } from '@/lib/debug' +import { useUserSettingsStore } from '@/stores/user-settings/store' -const COOKIE_PREFIX = 'sp:' -const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 - -function readSplits(cookieKey: string, n: number): number[] | null { - if (typeof document === 'undefined') return null - const match = document.cookie.match( - new RegExp(`(?:^|; )${COOKIE_PREFIX}${cookieKey}=([^;]+)`) +function isValidPositions(value: unknown, n: number): value is number[] { + return ( + Array.isArray(value) && + value.length === n && + value.every((v) => typeof v === 'number') && + Math.abs((value as number[]).reduce((a, b) => a + b, 0) - 100) <= 1 ) - if (!match) return null - try { - const parsed: unknown = JSON.parse(decodeURIComponent(match[1])) - if ( - !Array.isArray(parsed) || - parsed.length !== n || - parsed.some((v) => typeof v !== 'number') || - Math.abs((parsed as number[]).reduce((a, b) => a + b, 0) - 100) > 1 - ) return null - return parsed as number[] - } catch { - return null - } -} - -function writeSplits(cookieKey: string, splits: number[]) { - document.cookie = `${COOKIE_PREFIX}${cookieKey}=${encodeURIComponent( - JSON.stringify(splits) - )}; max-age=${COOKIE_MAX_AGE}; path=/; samesite=lax` } export interface SplitPaneProps { @@ -59,9 +40,16 @@ export function SplitPane({ const containerRef = useRef(null) const splitsRef = useRef(defaultSplit) - const [splits, setSplits] = useState(() => { - return readSplits(cookieKey, n) ?? defaultSplit - }) + const persisted = useUserSettingsStore( + (s) => s.entities.settings.layout?.splitPanePositions?.[cookieKey], + ) + const setPref = useUserSettingsStore((s) => s.setPref) + + // While dragging we keep splits in local state to avoid round-tripping every + // mousemove through the store. Outside of a drag, the store is the source of + // truth so cross-tab updates flow in automatically. + const [dragSplits, setDragSplits] = useState(null) + const splits = dragSplits ?? (isValidPositions(persisted, n) ? persisted : defaultSplit) const [dragging, setDragging] = useState(null) // divider index (0..n-2) const [isMobile, setIsMobile] = useState(false) const [internalTab, setInternalTab] = useState(0) @@ -96,20 +84,20 @@ export function SplitPane({ const newLeft = Math.min(Math.max(cursorPct - leftEdge, minPct), combinedWidth - minPct) const newRight = combinedWidth - newLeft - setSplits((prev) => { - const next = [...prev] - next[dragging] = newLeft - next[dragging + 1] = newRight - return next - }) + const base = splitsRef.current + const next = [...base] + next[dragging] = newLeft + next[dragging + 1] = newRight + setDragSplits(next) }, [dragging, minSize]) const onMouseUp = useCallback(() => { if (dragging !== null) { - writeSplits(cookieKey, splitsRef.current) + void setPref(['layout', 'splitPanePositions', cookieKey], splitsRef.current) + setDragSplits(null) setDragging(null) } - }, [dragging, cookieKey]) + }, [dragging, cookieKey, setPref]) useEffect(() => { if (dragging !== null) { diff --git a/lib/active-sprint.ts b/lib/active-sprint.ts index 5a76de6..17cf527 100644 --- a/lib/active-sprint.ts +++ b/lib/active-sprint.ts @@ -1,6 +1,10 @@ -import { cookies } from 'next/headers' -import type { SprintStatus } from '@prisma/client' +import type { Prisma, SprintStatus } from '@prisma/client' import { prisma } from '@/lib/prisma' +import { + mergeSettings, + parseUserSettings, + type UserSettings, +} from '@/lib/user-settings' export type ActiveSprint = { id: string @@ -8,43 +12,87 @@ export type ActiveSprint = { status: SprintStatus } -function cookieName(productId: string): string { - return `active_sprint_${productId}` +async function readSettings(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { settings: true }, + }) + return parseUserSettings(user?.settings) } -export async function getActiveSprintIdFromCookie( - productId: string, -): Promise { - const store = await cookies() - return store.get(cookieName(productId))?.value ?? null -} - -export async function setActiveSprintCookie( - productId: string, - sprintId: string, -): Promise { - const store = await cookies() - store.set(cookieName(productId), sprintId, { - path: '/', - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - maxAge: 60 * 60 * 24 * 365, +async function writeSettings(userId: string, next: UserSettings): Promise { + await prisma.user.update({ + where: { id: userId }, + data: { settings: next as unknown as Prisma.InputJsonValue }, }) } -export async function clearActiveSprintCookie(productId: string): Promise { - const store = await cookies() - store.delete(cookieName(productId)) +async function notifyUserSettings( + userId: string, + patch: Partial, +): Promise { + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + kind: 'user_settings', + userId, + patch, + })}::text) + ` +} + +export async function getActiveSprintIdFromSettings( + userId: string, + productId: string, +): Promise { + const settings = await readSettings(userId) + return settings.layout?.activeSprints?.[productId] ?? null +} + +export async function setActiveSprintInSettings( + userId: string, + productId: string, + sprintId: string, +): Promise { + const current = await readSettings(userId) + const patch: Partial = { + layout: { + activeSprints: { + ...(current.layout?.activeSprints ?? {}), + [productId]: sprintId, + }, + }, + } + await writeSettings(userId, mergeSettings(current, patch)) + await notifyUserSettings(userId, patch) +} + +export async function clearActiveSprintInSettings( + userId: string, + productId: string, +): Promise { + const current = await readSettings(userId) + const existing = current.layout?.activeSprints + if (!existing || !(productId in existing)) return + const nextActiveSprints = { ...existing } + delete nextActiveSprints[productId] + const next: UserSettings = { + ...current, + layout: { ...current.layout, activeSprints: nextActiveSprints }, + } + await writeSettings(userId, next) + await notifyUserSettings(userId, { + layout: { activeSprints: nextActiveSprints }, + }) } export async function resolveActiveSprint( productId: string, + userId: string, ): Promise { - const cookieId = await getActiveSprintIdFromCookie(productId) - - if (cookieId) { + const stored = await getActiveSprintIdFromSettings(userId, productId) + if (stored) { const sprint = await prisma.sprint.findFirst({ - where: { id: cookieId, product_id: productId }, + where: { id: stored, product_id: productId }, select: { id: true, code: true, status: true }, }) if (sprint) return sprint diff --git a/lib/solo-workspace-server.ts b/lib/solo-workspace-server.ts index 972c546..01e2329 100644 --- a/lib/solo-workspace-server.ts +++ b/lib/solo-workspace-server.ts @@ -17,7 +17,7 @@ export async function getSoloWorkspaceSnapshot( const product = await getAccessibleProduct(productId, userId) if (!product) return null - const active = sprintId ? { id: sprintId } : await resolveActiveSprint(productId) + const active = sprintId ? { id: sprintId } : await resolveActiveSprint(productId, userId) const sprint = active ? await prisma.sprint.findFirst({ where: { id: active.id, product_id: productId } }) : null diff --git a/lib/sprint-switcher-data.ts b/lib/sprint-switcher-data.ts index 040c19b..db170d6 100644 --- a/lib/sprint-switcher-data.ts +++ b/lib/sprint-switcher-data.ts @@ -19,7 +19,7 @@ export interface SprintSwitcherData { export async function getSprintSwitcherData( productId: string, - opts?: { activeSprintId?: string | null }, + opts?: { activeSprintId?: string | null; userId?: string }, ): Promise { const allSprints = await prisma.sprint.findMany({ where: { product_id: productId }, @@ -51,8 +51,8 @@ export async function getSprintSwitcherData( activeSprintItem = opts.activeSprintId ? sprintItems.find(s => s.id === opts.activeSprintId) ?? null : null - } else { - const resolved = await resolveActiveSprint(productId) + } else if (opts?.userId) { + const resolved = await resolveActiveSprint(productId, opts.userId) activeSprintItem = resolved ? sprintItems.find(s => s.id === resolved.id) ?? null : null 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, []) diff --git a/lib/user-settings.ts b/lib/user-settings.ts index 86621fd..597a2c4 100644 --- a/lib/user-settings.ts +++ b/lib/user-settings.ts @@ -43,9 +43,15 @@ const DevToolsPrefs = z.object({ debugMode: z.boolean().optional(), }).strict() +const LayoutPrefs = z.object({ + splitPanePositions: z.record(z.string(), z.array(z.number())).optional(), + activeSprints: z.record(z.string(), z.string()).optional(), +}).strict() + export const UserSettingsSchema = z.object({ views: ViewsPrefs.optional(), devTools: DevToolsPrefs.optional(), + layout: LayoutPrefs.optional(), }).strict() export type UserSettings = z.infer