From 68ee6cacb06f5f106b86a1a3892bbb65be845e78 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 21 Oct 2026 21:07:23 +0200 Subject: [PATCH] feat(dashboard): manage account layout drafts and recovery --- job-tracker-ui/src/dashboardLayout.test.ts | 28 ++++ job-tracker-ui/src/dashboardLayout.ts | 42 ++++++ .../src/hooks/useDashboardLayout.test.tsx | 116 ++++++++++++++++ .../src/hooks/useDashboardLayout.ts | 125 ++++++++++++++++++ 4 files changed, 311 insertions(+) create mode 100644 job-tracker-ui/src/dashboardLayout.test.ts create mode 100644 job-tracker-ui/src/dashboardLayout.ts create mode 100644 job-tracker-ui/src/hooks/useDashboardLayout.test.tsx create mode 100644 job-tracker-ui/src/hooks/useDashboardLayout.ts diff --git a/job-tracker-ui/src/dashboardLayout.test.ts b/job-tracker-ui/src/dashboardLayout.test.ts new file mode 100644 index 0000000..e57caae --- /dev/null +++ b/job-tracker-ui/src/dashboardLayout.test.ts @@ -0,0 +1,28 @@ +import { defaultDashboardLayout, normalizeDashboardLayout, WIDGETS } from './dashboardLayout'; + +test('default catalogue preserves every section and supported widths', () => { + const layout = defaultDashboardLayout(); + expect(layout.widgets.map(w => w.id)).toEqual(['cards', 'attention', 'funnel', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']); + expect(layout.widgets.every(w => w.visible)).toBe(true); + expect(WIDGETS[0].sizes).toEqual(['full']); + layout.widgets[0].visible = false; + expect(defaultDashboardLayout().widgets[0].visible).toBe(true); +}); + +test('saved layouts retain known order, reject duplicates and append missing sections hidden', () => { + const result = normalizeDashboardLayout({ version: 1, revision: 7, widgets: [ + { id: 'skills', visible: false, size: 'full' }, + { id: 'unknown', visible: true, size: 'full' }, + { id: 'skills', visible: true, size: 'standard' }, + { id: 'cards', visible: 'yes', size: 'standard' }, + ] }); + expect(result.revision).toBe(7); + expect(result.widgets[0]).toEqual({ id: 'skills', visible: false, size: 'full' }); + expect(result.widgets[1]).toEqual({ id: 'cards', visible: false, size: 'full' }); + expect(result.widgets.slice(2).every(w => !w.visible)).toBe(true); + expect(result.widgets).toHaveLength(9); +}); + +test.each([null, {}, { version: 2, revision: 8, widgets: [] }, { version: 1, revision: -1, widgets: [] }])('invalid input has safe defaults: %j', input => { + expect(normalizeDashboardLayout(input)).toEqual(defaultDashboardLayout()); +}); diff --git a/job-tracker-ui/src/dashboardLayout.ts b/job-tracker-ui/src/dashboardLayout.ts new file mode 100644 index 0000000..854e963 --- /dev/null +++ b/job-tracker-ui/src/dashboardLayout.ts @@ -0,0 +1,42 @@ +export type WidgetSize = 'standard' | 'full'; + +export const WIDGETS = [ + { id: 'cards', titleKey: 'dashboardSummaryCards', defaultSize: 'full', sizes: ['full'] }, + { id: 'attention', titleKey: 'dashboardTodayTitle', defaultSize: 'standard', sizes: ['standard', 'full'] }, + { id: 'funnel', titleKey: 'dashboardCurrentPipeline', defaultSize: 'standard', sizes: ['standard', 'full'] }, + { id: 'activity', titleKey: 'dashboardApplicationActivity', defaultSize: 'full', sizes: ['standard', 'full'] }, + { id: 'stageAge', titleKey: 'dashboardStageAge', defaultSize: 'standard', sizes: ['standard', 'full'] }, + { id: 'sources', titleKey: 'dashboardResponseSources', defaultSize: 'standard', sizes: ['standard', 'full'] }, + { id: 'salary', titleKey: 'dashboardSalaryInsights', defaultSize: 'full', sizes: ['standard', 'full'] }, + { id: 'companies', titleKey: 'dashboardTopCompaniesByActivity', defaultSize: 'standard', sizes: ['standard', 'full'] }, + { id: 'skills', titleKey: 'dashboardJobTags', defaultSize: 'standard', sizes: ['standard', 'full'] }, +] as const; + +export type WidgetId = typeof WIDGETS[number]['id']; +export type DashboardWidget = { id: WidgetId; visible: boolean; size: WidgetSize }; +export type DashboardLayout = { version: 1; revision: number; widgets: DashboardWidget[] }; + +export function defaultDashboardLayout(): DashboardLayout { + return { version: 1, revision: 0, widgets: WIDGETS.map(w => ({ id: w.id, visible: true, size: w.defaultSize })) }; +} + +export function normalizeDashboardLayout(input: unknown): DashboardLayout { + if (!input || typeof input !== 'object') return defaultDashboardLayout(); + const value = input as Record; + if (value.version !== 1 || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || !Array.isArray(value.widgets)) return defaultDashboardLayout(); + const revision = value.revision as number; + const widgets: DashboardWidget[] = []; + const seen = new Set(); + for (const item of value.widgets) { + if (!item || typeof item !== 'object') continue; + const definition = WIDGETS.find(w => w.id === item.id); + if (!definition || seen.has(definition.id)) continue; + seen.add(definition.id); + widgets.push({ id: definition.id, visible: typeof item.visible === 'boolean' ? item.visible : revision === 0, + size: (definition.sizes as readonly string[]).includes(item.size) ? item.size : definition.defaultSize }); + } + for (const definition of WIDGETS) { + if (!seen.has(definition.id)) widgets.push({ id: definition.id, visible: revision === 0, size: definition.defaultSize }); + } + return { version: 1, revision, widgets }; +} diff --git a/job-tracker-ui/src/hooks/useDashboardLayout.test.tsx b/job-tracker-ui/src/hooks/useDashboardLayout.test.tsx new file mode 100644 index 0000000..41ae9f4 --- /dev/null +++ b/job-tracker-ui/src/hooks/useDashboardLayout.test.tsx @@ -0,0 +1,116 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { api } from '../api'; +import { setAuthUserKey } from '../auth'; +import { defaultDashboardLayout } from '../dashboardLayout'; +import { useDashboardLayout } from './useDashboardLayout'; + +jest.mock('../api', () => ({ api: { get: jest.fn(), put: jest.fn() } })); +const get = api.get as jest.Mock; +const put = api.put as jest.Mock; +const unsaved = { version: 1, revision: 0, widgets: [] }; +beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + setAuthUserKey('a', false); + get.mockResolvedValue({ data: unsaved }); +}); + +test('loads once, migrates only owner legacy flags, and cancels without writing', async () => { + localStorage.setItem('dashboardPrefs:a', JSON.stringify({ cards: false, activity: false })); + const { result, rerender } = renderHook(() => useDashboardLayout(true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.layout.widgets[0].visible).toBe(false); + expect(result.current.layout.widgets[1].visible).toBe(true); + act(() => result.current.startEditing()); + act(() => result.current.setDraft(d => d && ({ ...d, widgets: [...d.widgets].reverse() }))); + rerender(); + expect(result.current.dirty).toBe(true); + expect(get).toHaveBeenCalledTimes(1); + act(() => result.current.cancelEditing()); + expect(result.current.draft).toBeNull(); + expect(put).not.toHaveBeenCalled(); +}); + +test('save waits for acknowledgement and retains draft on conflict until explicit reload', async () => { + const { result } = renderHook(() => useDashboardLayout(true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.startEditing()); + act(() => result.current.setDraft(d => d && ({ ...d, widgets: d.widgets.map(w => ({ ...w, visible: false })) }))); + put.mockRejectedValueOnce({ response: { status: 409 } }); + await act(async () => { expect(await result.current.save()).toBe(false); }); + expect(result.current.conflict).toBe(true); + expect(result.current.draft?.widgets.every(w => !w.visible)).toBe(true); + expect(result.current.layout.widgets.every(w => w.visible)).toBe(true); + get.mockResolvedValueOnce({ data: { ...defaultDashboardLayout(), revision: 3 } }); + await act(async () => result.current.reload()); + expect(result.current.editing).toBe(true); + expect(result.current.draft?.revision).toBe(3); + expect(result.current.conflict).toBe(false); + put.mockResolvedValueOnce({ data: { ...defaultDashboardLayout(), revision: 4 } }); + await act(async () => { expect(await result.current.save()).toBe(true); }); + expect(result.current.layout.revision).toBe(4); + expect(result.current.editing).toBe(false); +}); + +test('failed or future-version reads cannot enable editing or saves', async () => { + get.mockRejectedValueOnce(new Error('offline')); + const { result } = renderHook(() => useDashboardLayout(true)); + await waitFor(() => expect(result.current.loadError).toBe('request')); + act(() => result.current.startEditing()); + expect(result.current.editing).toBe(false); + get.mockResolvedValueOnce({ data: { version: 2, revision: 5, widgets: [] } }); + await act(async () => result.current.reload()); + expect(result.current.loadError).toBe('unsupportedVersion'); + act(() => result.current.startEditing()); + await act(async () => { expect(await result.current.save()).toBe(false); }); + expect(put).not.toHaveBeenCalled(); +}); + +test('old save handlers and delayed reads cannot affect a new owner', async () => { + let resolveRead!: (value: unknown) => void; + const { result } = renderHook(() => useDashboardLayout(true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.startEditing()); + const oldSave = result.current.save; + get.mockImplementationOnce(() => new Promise(resolve => { resolveRead = resolve; })); + let oldRead!: Promise; + act(() => { oldRead = result.current.reload(); }); + act(() => setAuthUserKey('b')); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { resolveRead({ data: { ...defaultDashboardLayout(), revision: 99 } }); await oldRead; }); + await act(async () => { expect(await oldSave()).toBe(false); }); + expect(result.current.layout.revision).toBe(0); + expect(result.current.editing).toBe(false); + expect(put).not.toHaveBeenCalled(); +}); + +test('pending save acknowledgement is ignored after identity changes', async () => { + let acknowledge!: (value: unknown) => void; + put.mockImplementationOnce(() => new Promise(resolve => { acknowledge = resolve; })); + const { result } = renderHook(() => useDashboardLayout(true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.startEditing()); + let save!: Promise; + act(() => { save = result.current.save(); }); + expect(result.current.saving).toBe(true); + expect(result.current.layout.revision).toBe(0); + act(() => setAuthUserKey('b')); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + acknowledge({ data: { ...defaultDashboardLayout(), revision: 10 } }); + expect(await save).toBe(false); + }); + expect(result.current.layout.revision).toBe(0); + expect(result.current.saving).toBe(false); +}); + +test('does not import anonymous legacy flags or replace saved server choices', async () => { + localStorage.setItem('dashboardPrefs:anon', JSON.stringify({ cards: false })); + const { result } = renderHook(() => useDashboardLayout(true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.layout.widgets[0].visible).toBe(true); + localStorage.setItem('dashboardPrefs:a', JSON.stringify({ cards: false })); + get.mockResolvedValueOnce({ data: { ...defaultDashboardLayout(), revision: 1 } }); + await act(async () => result.current.reload()); + expect(result.current.layout.widgets[0].visible).toBe(true); +}); diff --git a/job-tracker-ui/src/hooks/useDashboardLayout.ts b/job-tracker-ui/src/hooks/useDashboardLayout.ts new file mode 100644 index 0000000..598de15 --- /dev/null +++ b/job-tracker-ui/src/hooks/useDashboardLayout.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'; +import { api } from '../api'; +import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from '../auth'; +import { getUserKeyFromToken } from '../themePrefs'; +import { defaultDashboardLayout, normalizeDashboardLayout, type DashboardLayout } from '../dashboardLayout'; + +type LayoutError = 'request' | 'unsupportedVersion' | null; +type LayoutState = { + owner: string; layout: DashboardLayout; draft: DashboardLayout | null; editing: boolean; + loading: boolean; loaded: boolean; loadError: LayoutError; saveError: LayoutError; conflict: boolean; saving: boolean; +}; + +function initialState(owner: string, loading: boolean): LayoutState { + return { owner, layout: defaultDashboardLayout(), draft: null, editing: false, loading, loaded: false, + loadError: null, saveError: null, conflict: false, saving: false }; +} + +function readLayout(input: unknown): DashboardLayout { + const value = input as Partial | null; + if (value && value.version !== 1) throw new Error('unsupportedVersion'); + if (!value || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || !Array.isArray(value.widgets)) throw new Error('request'); + const layout = normalizeDashboardLayout(input); + if (layout.revision === 0 && layout.widgets.length && value.widgets.length === 0 && getUserKeyFromToken() !== 'anon') { + try { + const legacy = JSON.parse(localStorage.getItem(`dashboardPrefs:${getUserKeyFromToken()}`) || 'null'); + for (const widget of layout.widgets) { + if (['cards', 'activity', 'funnel', 'companies', 'skills'].includes(widget.id) && typeof legacy?.[widget.id] === 'boolean') widget.visible = legacy[widget.id]; + } + } catch { /* Corrupt or unavailable local preferences leave defaults intact. */ } + } + return layout; +} + +const clone = (layout: DashboardLayout): DashboardLayout => ({ ...layout, widgets: layout.widgets.map(w => ({ ...w })) }); + +export function useDashboardLayout(enabled: boolean) { + const [state, setState] = useState(() => initialState(getAuthUserKey(), enabled)); + const mounted = useRef(false); + const generation = useRef(0); + const controller = useRef(null); + const savePending = useRef(false); + const stateOwner = useRef(state.owner); + stateOwner.current = state.owner; + + const reload = useCallback(async () => { + const owner = getAuthUserKey(); + const request = ++generation.current; + controller.current?.abort(); + const abort = new AbortController(); + controller.current = abort; + savePending.current = false; + setState(s => enabled && s.owner === owner ? { ...s, loading: true, loaded: false, loadError: null, saveError: null, conflict: false, saving: false } : initialState(owner, enabled)); + if (!enabled) return; + const current = () => mounted.current && generation.current === request && getAuthUserKey() === owner; + try { + const response = await api.get('/auth/preferences/dashboard', { signal: abort.signal }); + if (!current()) return; + const layout = readLayout(response.data); + setState(s => ({ ...s, layout, draft: s.editing ? clone(layout) : null, loading: false, loaded: true })); + } catch (error) { + if (current()) setState(s => ({ ...s, loading: false, loaded: false, loadError: error instanceof Error && error.message === 'unsupportedVersion' ? 'unsupportedVersion' : 'request' })); + } + }, [enabled]); + + useEffect(() => { + mounted.current = true; + void reload(); + const identityChanged = () => { + if (getAuthUserKey() !== stateOwner.current) { + stateOwner.current = getAuthUserKey(); + void reload(); + } + }; + window.addEventListener(AUTH_USER_CHANGED_EVENT, identityChanged); + window.addEventListener('auth-changed', identityChanged); + return () => { + mounted.current = false; + controller.current?.abort(); + window.removeEventListener(AUTH_USER_CHANGED_EVENT, identityChanged); + window.removeEventListener('auth-changed', identityChanged); + }; + }, [reload]); + + const startEditing = () => { + if (enabled && state.loaded && !state.loading && !state.saving && state.owner === getAuthUserKey()) setState(s => ({ ...s, draft: clone(s.layout), editing: true, saveError: null, conflict: false })); + }; + const setDraft: Dispatch> = update => { + setState(s => s.editing && !s.saving && s.owner === getAuthUserKey() + ? { ...s, draft: (typeof update === 'function' ? update(s.draft) : update) ?? s.draft } : s); + }; + const cancelEditing = () => { + if (!savePending.current) setState(s => ({ ...s, draft: null, editing: false, saveError: null, conflict: false })); + }; + const save = async (): Promise => { + if (!enabled || !state.loaded || state.loading || !state.editing || !state.draft || state.conflict || savePending.current || state.owner !== getAuthUserKey()) return false; + const owner = state.owner; + const request = ++generation.current; + savePending.current = true; + const abort = new AbortController(); + controller.current?.abort(); + controller.current = abort; + const current = () => mounted.current && generation.current === request && getAuthUserKey() === owner; + setState(s => ({ ...s, saving: true, saveError: null })); + try { + const response = await api.put('/auth/preferences/dashboard', { ...normalizeDashboardLayout(state.draft), revision: state.layout.revision }, { signal: abort.signal }); + if (!current()) return false; + const layout = readLayout(response.data); + setState(s => ({ ...s, layout, draft: null, editing: false, saving: false, conflict: false })); + return true; + } catch (error) { + if (current()) { + const conflict = (error as { response?: { status?: number } })?.response?.status === 409; + setState(s => ({ ...s, saving: false, conflict, saveError: error instanceof Error && error.message === 'unsupportedVersion' ? 'unsupportedVersion' : 'request' })); + } + return false; + } finally { + if (current()) savePending.current = false; + } + }; + + return { layout: state.layout, draft: state.draft, editing: state.editing, loading: state.loading, + loadError: state.loadError, saveError: state.saveError, conflict: state.conflict, saving: state.saving, + dirty: state.editing && JSON.stringify(state.draft) !== JSON.stringify(state.layout), + startEditing, setDraft, cancelEditing, save, reload }; +}