diff --git a/apps/web/src/features/resume/builder/draft.test.ts b/apps/web/src/features/resume/builder/draft.test.ts index 894ecd762..064d070b9 100644 --- a/apps/web/src/features/resume/builder/draft.test.ts +++ b/apps/web/src/features/resume/builder/draft.test.ts @@ -10,6 +10,8 @@ import { parseResumeData } from "@reactive-resume/schema/resume/data"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; import { isEditableElementFocused, + readUnsavedResumeData, + shouldCoalesceEdit, useBuilderResumeUpdateSubscription, useResumeCleanup, useResumeStore, @@ -420,9 +422,9 @@ describe("builder resume autosave", () => { expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ id: initial.id, data: latest.data }); }); - it("keeps the latest draft data and shows a persistent toast when saving fails", async () => { + it("keeps the latest draft on this device and reports Not saved when saving fails", async () => { const initial = makeResume("resume-failure"); - orpcMocks.updateResume.mockRejectedValue(new Error("network down")); + orpcMocks.updateResume.mockRejectedValue(new Error("server down")); useResumeStore.getState().initialize(initial); useResumeStore.getState().updateResumeData((draft) => { @@ -433,11 +435,65 @@ describe("builder resume autosave", () => { await flushMicrotasks(); expect(useResumeStore.getState().resume?.data.basics.name).toBe("Unsaved Name"); - expect(toastMocks.add).toHaveBeenCalledWith( - expect.objectContaining({ type: "error", description: "Your latest changes could not be saved.", timeout: 0 }), - ); + expect(useResumeStore.getState().saveStatus).toBe("error"); + expect(readUnsavedResumeData("resume-failure")?.basics.name).toBe("Unsaved Name"); expect(orpcMocks.patchResume).not.toHaveBeenCalled(); }); + + it("reports offline, keeps the draft on this device and sends it when the connection returns", async () => { + const initial = makeResume("resume-offline"); + const onLine = vi.spyOn(navigator, "onLine", "get").mockReturnValue(false); + orpcMocks.updateResume.mockRejectedValueOnce(new TypeError("Failed to fetch")); + useResumeStore.getState().initialize(initial); + + useResumeStore.getState().updateResumeData((draft) => { + draft.basics.name = "Written offline"; + }); + vi.advanceTimersByTime(500); + await flushMicrotasks(); + + expect(useResumeStore.getState().saveStatus).toBe("offline"); + expect(readUnsavedResumeData("resume-offline")?.basics.name).toBe("Written offline"); + + onLine.mockReturnValue(true); + orpcMocks.updateResume.mockImplementation((input: { id: string; data: ResumeData }) => + Promise.resolve({ ...makeResume(input.id), data: input.data }), + ); + window.dispatchEvent(new Event("online")); + await flushMicrotasks(); + await flushMicrotasks(); + + expect(orpcMocks.updateResume).toHaveBeenLastCalledWith( + expect.objectContaining({ id: "resume-offline" }), + expect.anything(), + ); + expect(useResumeStore.getState().saveStatus).toBe("saved"); + expect(readUnsavedResumeData("resume-offline")).toBeUndefined(); + onLine.mockRestore(); + }); + + it("restores changes kept on this device when the editor opens again", async () => { + const initial = makeResume("resume-restore"); + window.localStorage.setItem( + "reactive-resume:unsaved:resume-restore", + JSON.stringify({ data: withBasicsName(initial, "Kept locally").data, storedAt: 1 }), + ); + orpcMocks.updateResume.mockImplementation((input: { id: string; data: ResumeData }) => + Promise.resolve({ ...makeResume(input.id), data: input.data }), + ); + + useResumeStore.getState().initialize(initial); + await flushMicrotasks(); + + expect(useResumeStore.getState().resume?.data.basics.name).toBe("Kept locally"); + expect(orpcMocks.updateResume).toHaveBeenCalledWith( + expect.objectContaining({ id: "resume-restore" }), + expect.anything(), + ); + expect(toastMocks.add).toHaveBeenCalledWith( + expect.objectContaining({ description: "Restored changes that hadn't been saved yet." }), + ); + }); }); describe("editable focus detection", () => { @@ -538,6 +594,86 @@ describe("builder resume undo/redo", () => { expect(store().canRedo).toBe(false); }); + it("merges typing in one field into one step, but not edits to different fields", () => { + const store = useResumeStore.getState; + store().initialize(makeResume("undo-fields")); + + store().updateResumeData( + (draft) => { + draft.basics.name = "J"; + }, + { coalesceKey: "basics.name" }, + ); + store().updateResumeData( + (draft) => { + draft.basics.name = "Jo"; + }, + { coalesceKey: "basics.name" }, + ); + store().updateResumeData( + (draft) => { + draft.basics.headline = "Designer"; + }, + { coalesceKey: "basics.headline" }, + ); + + expect(store().undoStack.length).toBe(2); + store().undo(); + expect(store().resume?.data.basics.headline).toBe(defaultResumeData.basics.headline); + expect(store().resume?.data.basics.name).toBe("Jo"); + store().undo(); + expect(store().resume?.data.basics.name).toBe(defaultResumeData.basics.name); + }); + + it("keeps structural actions as steps of their own", () => { + const store = useResumeStore.getState; + store().initialize(makeResume("undo-structural")); + + store().updateResumeData((draft) => { + draft.basics.name = "A"; + }); + store().updateResumeData( + (draft) => { + draft.sections.skills.hidden = true; + }, + { newStep: true }, + ); + store().updateResumeData((draft) => { + draft.basics.name = "B"; + }); + + expect(store().undoStack.length).toBe(3); + }); + + it("keeps 200 steps and drops the oldest beyond that", () => { + const store = useResumeStore.getState; + store().initialize(makeResume("undo-depth")); + + for (let index = 0; index < 205; index++) { + store().updateResumeData( + (draft) => { + draft.basics.name = `Name ${index}`; + }, + { newStep: true }, + ); + } + + expect(store().undoStack.length).toBe(200); + }); + + it("stores steps as shared references instead of deep copies", () => { + const store = useResumeStore.getState; + store().initialize(makeResume("undo-shared")); + const before = store().resume?.data; + + store().updateResumeData((draft) => { + draft.basics.name = "Changed"; + }); + + expect(store().undoStack[0]).toBe(before); + expect(store().resume?.data.sections).toBe(before?.sections); + }); + it("restores the exact authored Experience order with one undo after a one-shot sort", () => { const store = useResumeStore.getState; const initial = makeResume("sort-undo"); @@ -805,3 +941,26 @@ describe("resume update stream subscription", () => { expect(useResumeStore.getState().resume?.data.metadata.stylesheet).toEqual(remote.data.metadata.stylesheet); }); }); + +describe("shouldCoalesceEdit", () => { + const previous = { at: 1_000, key: "basics.name", canCoalesce: true }; + + it("merges the same field within a second", () => { + expect(shouldCoalesceEdit(previous, { at: 1_900, key: "basics.name", newStep: false })).toBe(true); + expect(shouldCoalesceEdit(previous, { at: 2_100, key: "basics.name", newStep: false })).toBe(false); + }); + + it("never merges across fields, into a new step, or after a structural action", () => { + expect(shouldCoalesceEdit(previous, { at: 1_100, key: "basics.headline", newStep: false })).toBe(false); + expect(shouldCoalesceEdit(previous, { at: 1_100, key: "basics.name", newStep: true })).toBe(false); + expect( + shouldCoalesceEdit({ ...previous, canCoalesce: false }, { at: 1_100, key: "basics.name", newStep: false }), + ).toBe(false); + }); + + it("keeps the shorter window for edits without a field key", () => { + const keyless = { at: 1_000, key: undefined, canCoalesce: true }; + expect(shouldCoalesceEdit(keyless, { at: 1_400, key: undefined, newStep: false })).toBe(true); + expect(shouldCoalesceEdit(keyless, { at: 1_600, key: undefined, newStep: false })).toBe(false); + }); +}); diff --git a/apps/web/src/features/resume/builder/draft.ts b/apps/web/src/features/resume/builder/draft.ts index a390b59f8..a12704e56 100644 --- a/apps/web/src/features/resume/builder/draft.ts +++ b/apps/web/src/features/resume/builder/draft.ts @@ -29,14 +29,30 @@ export type Resume = { type ResumeUpdateMutation = "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete"; type ResumeUpdateEvent = { mutation: ResumeUpdateMutation }; -type SaveStatus = "idle" | "saving" | "saved" | "error"; +/** + * `offline`: the browser is offline; changes are kept on this device and sent when it reconnects. + * `error`: the server rejected or couldn't be reached while online; changes are kept on this device + * and the editor offers Retry. + */ +type SaveStatus = "idle" | "saving" | "saved" | "offline" | "error"; + +type UpdateResumeDataOptions = { + /** + * Identifies the field being edited. Consecutive edits to the same field within a second merge into + * one undo step, so typing a sentence undoes as a whole. + */ + coalesceKey?: string; + /** Always a step of its own (structural actions such as adding, moving or deleting). */ + newStep?: boolean; +}; type ResumeStoreState = { resume: Resume | null; resumeId?: string; isReady: boolean; saveStatus: SaveStatus; - // Client-side undo/redo stacks holding whole-`ResumeData` snapshots (see recordHistory helpers below). + // Undo/redo stacks of `ResumeData` references. Immer's immutable updates share structure, so a + // step costs only the parts that changed. undoStack: ResumeData[]; redoStack: ResumeData[]; canUndo: boolean; @@ -48,12 +64,14 @@ type ResumeStoreActions = { reset: () => void; replaceResumeDraft: (resume: Resume) => void; replaceResumeFromServer: (resume: Resume) => void; - updateResumeData: (fn: (draft: WritableDraft) => void) => void; + updateResumeData: (fn: (draft: WritableDraft) => void, options?: UpdateResumeDataOptions) => void; patchResume: (fn: (draft: WritableDraft) => void) => void; mergeResumeMetadata: (resume: Resume) => void; setSaveStatus: (status: SaveStatus) => void; undo: () => void; redo: () => void; + /** Sends the latest unsaved changes again, e.g. from the "Not saved · Retry" status. */ + retrySave: () => void; }; type ResumeStore = ResumeStoreState & ResumeStoreActions; @@ -64,10 +82,12 @@ type Runtime = { hasPendingLocalChanges: boolean; isSaving: boolean; pendingResume?: Resume; - syncErrorToastId?: string; + /** The last save failed; don't loop on it. Cleared by the next edit, Retry, reconnecting or success. */ + saveFailed: boolean; slowSaveToastId?: string; syncResume: ReturnType void>>; beforeUnloadHandler?: () => void; + onlineHandler?: () => void; deferredRemoteResume?: Resume; deferredFocusHandler?: () => void; }; @@ -80,21 +100,71 @@ type ResumeUpdateSubscriptionOptions = { const SAVE_DEBOUNCE_MS = 500; const NAVIGATION_SAVE_WAIT_MS = 10_000; -// Rapid edits within this window coalesce into a single undo step (e.g. typing a word / dragging). +// Edits without a field key coalesce within this window (e.g. dragging); keyed edits to the same field +// coalesce within FIELD_COALESCE_MS (typing a sentence). const HISTORY_COALESCE_MS = 500; -// Bounded stacks: keep undo/redo memory (whole-resume snapshots) predictable during a long session. -const MAX_HISTORY_ENTRIES = 50; +const FIELD_COALESCE_MS = 1000; +// Bounded stacks. Entries are shared-structure references, so 200 steps stay cheap. +const MAX_HISTORY_ENTRIES = 200; +const UNSAVED_STORAGE_PREFIX = "reactive-resume:unsaved:"; const runtimes = new Map(); // Coalescing bookkeeping. Not reactive — only decides whether the next edit opens a new undo step. let historyLastEditAt = 0; +let historyLastKey: string | undefined; let historyCanCoalesce = false; function resetHistoryRuntime() { historyLastEditAt = 0; + historyLastKey = undefined; historyCanCoalesce = false; } +/** Whether the next edit merges into the current undo step. Exported for tests. */ +export function shouldCoalesceEdit( + previous: { at: number; key: string | undefined; canCoalesce: boolean }, + next: { at: number; key: string | undefined; newStep: boolean }, +): boolean { + if (next.newStep || !previous.canCoalesce || previous.key !== next.key) return false; + const window = next.key === undefined ? HISTORY_COALESCE_MS : FIELD_COALESCE_MS; + return next.at - previous.at < window; +} + +// Unsaved changes survive a reload or a closed tab: they're kept on this device until a save succeeds. +function storeUnsavedResume(resume: Resume) { + try { + window.localStorage.setItem( + `${UNSAVED_STORAGE_PREFIX}${resume.id}`, + JSON.stringify({ data: resume.data, storedAt: Date.now() }), + ); + } catch { + // Storage can be full or blocked; the in-memory copy still retries. + } +} + +function clearUnsavedResume(id: string) { + try { + window.localStorage.removeItem(`${UNSAVED_STORAGE_PREFIX}${id}`); + } catch { + // Nothing to clear. + } +} + +export function readUnsavedResumeData(id: string): ResumeData | undefined { + try { + const raw = window.localStorage.getItem(`${UNSAVED_STORAGE_PREFIX}${id}`); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as { data?: ResumeData }; + return parsed.data; + } catch { + return undefined; + } +} + +function isBrowserOffline() { + return typeof navigator !== "undefined" && navigator.onLine === false; +} + let lockedToastId: string | undefined; function getResumeQueryKey(id: string): QueryKey { @@ -216,29 +286,24 @@ async function flushResumeSave(id: string) { } } - if (runtime.syncErrorToastId !== undefined) { - toast.close(runtime.syncErrorToastId); - runtime.syncErrorToastId = undefined; - } + runtime.saveFailed = false; + if (!runtime.pendingResume && !runtime.hasPendingLocalChanges) clearUnsavedResume(submitted.id); } catch (error: unknown) { if (error instanceof DOMException && error.name === "AbortError") return; runtime.pendingResume ??= submitted; runtime.hasPendingLocalChanges = true; - useResumeStore.getState().setSaveStatus("error"); - runtime.syncErrorToastId = toast.add({ - type: "error", - description: t`Your latest changes could not be saved.`, - id: runtime.syncErrorToastId, - timeout: 0, - }); + runtime.saveFailed = true; + storeUnsavedResume(useResumeStore.getState().resume ?? submitted); + // The editor bar shows the state ("Offline · saved on this device" or "Not saved · Retry"). + useResumeStore.getState().setSaveStatus(isBrowserOffline() ? "offline" : "error"); } finally { if (runtime.slowSaveToastId !== undefined) { toast.close(runtime.slowSaveToastId); runtime.slowSaveToastId = undefined; } runtime.isSaving = false; - if (runtime.pendingResume && runtime.syncErrorToastId === undefined) void flushResumeSave(id); + if (runtime.pendingResume && !runtime.saveFailed) void flushResumeSave(id); } } @@ -246,6 +311,7 @@ function queueResumeSave(resume: Resume) { const runtime = getRuntime(resume.id); runtime.pendingResume = cloneResume(resume); runtime.hasPendingLocalChanges = true; + runtime.saveFailed = false; void flushResumeSave(resume.id); } @@ -264,12 +330,20 @@ function createRuntime(): Runtime { abortController, hasPendingLocalChanges: false, isSaving: false, + saveFailed: false, syncResume, }; if (typeof window !== "undefined") { runtime.beforeUnloadHandler = () => runtime.syncResume.flush(); window.addEventListener("beforeunload", runtime.beforeUnloadHandler); + // Changes made offline are sent as soon as the connection comes back. + runtime.onlineHandler = () => { + const current = useResumeStore.getState().resume; + if (!runtime.hasPendingLocalChanges || !current) return; + queueResumeSave(current); + }; + window.addEventListener("online", runtime.onlineHandler); } return runtime; @@ -303,6 +377,10 @@ function cleanupRuntime(id: string) { window.removeEventListener("beforeunload", runtime.beforeUnloadHandler); } + if (runtime.onlineHandler && typeof window !== "undefined") { + window.removeEventListener("online", runtime.onlineHandler); + } + if (runtime.deferredFocusHandler && typeof document !== "undefined") { document.removeEventListener("focusout", runtime.deferredFocusHandler, true); } @@ -332,8 +410,13 @@ export const useResumeStore = create()( if (resume) setRuntimeBaseline(resume); resetHistoryRuntime(); + // Changes that couldn't be saved last time (offline, closed tab) come back and are sent again. + const unsaved = resume ? readUnsavedResumeData(resume.id) : undefined; + const restored = resume && unsaved && !isEqual(unsaved, resume.data) ? { ...resume, data: unsaved } : null; + if (resume && unsaved && !restored) clearUnsavedResume(resume.id); + set((state) => { - state.resume = resume; + state.resume = restored ?? resume; state.resumeId = resume?.id; state.isReady = resume !== null; state.undoStack = []; @@ -341,6 +424,15 @@ export const useResumeStore = create()( state.canUndo = false; state.canRedo = false; }); + + if (restored) { + toast.add({ + type: "info", + description: t`Restored changes that hadn't been saved yet.`, + id: "resume-restored-unsaved", + }); + queueResumeSave(restored); + } }, reset: () => { @@ -422,7 +514,7 @@ export const useResumeStore = create()( }); }, - updateResumeData: (fn) => { + updateResumeData: (fn, options = {}) => { const currentResume = get().resume; if (!currentResume) return; @@ -435,13 +527,18 @@ export const useResumeStore = create()( return; } - // Coalesce bursts: only the first edit of a burst opens a new undo step by snapshotting the - // pre-edit state. Edits within HISTORY_COALESCE_MS of the previous one fold into that step. + // Coalesce bursts: only the first edit of a burst opens a new undo step, holding the pre-edit + // state. Later edits to the same field fold into it (see shouldCoalesceEdit). const now = Date.now(); - const coalesce = historyCanCoalesce && now - historyLastEditAt < HISTORY_COALESCE_MS; - const snapshotBefore = coalesce ? undefined : cloneResumeData(currentResume.data); + const newStep = options.newStep ?? false; + const coalesce = shouldCoalesceEdit( + { at: historyLastEditAt, key: historyLastKey, canCoalesce: historyCanCoalesce }, + { at: now, key: options.coalesceKey, newStep }, + ); + const snapshotBefore = coalesce ? undefined : currentResume.data; historyLastEditAt = now; - historyCanCoalesce = true; + historyLastKey = options.coalesceKey; + historyCanCoalesce = !newStep; set((state) => { if (!state.resume) return; @@ -470,6 +567,15 @@ export const useResumeStore = create()( redo: () => { applyHistoryStep(get, set, "redo"); }, + + retrySave: () => { + const current = get().resume; + if (!current) return; + set((state) => { + state.saveStatus = "saving"; + }); + queueResumeSave(current); + }, })), ); @@ -497,7 +603,7 @@ function applyHistoryStep(get: StoreGet, set: ImmerSet, direction: "undo" | "red // The next edit after an undo/redo must start a brand-new undo step. resetHistoryRuntime(); - const current = cloneResumeData(currentResume.data); + const current = currentResume.data; set((draft) => { if (!draft.resume) return; @@ -579,10 +685,10 @@ export function useUpdateResumeData() { const updateResumeData = useResumeStore((state) => state.updateResumeData); return useCallback( - (fn: (draft: WritableDraft) => void) => { + (fn: (draft: WritableDraft) => void, options?: UpdateResumeDataOptions) => { if (!resumeId) return; bindRuntimeQueryClient(resumeId, queryClient); - updateResumeData(fn); + updateResumeData(fn, options); }, [queryClient, resumeId, updateResumeData], ); @@ -687,7 +793,7 @@ function saveResumeBeforeLeaving(id: string): boolean | Promise { resolve(saved); }; const unsubscribe = useResumeStore.subscribe((state) => { - if (state.resume?.id !== id || state.saveStatus === "error") { + if (state.resume?.id !== id || state.saveStatus === "error" || state.saveStatus === "offline") { finish(false); } else if (state.saveStatus === "saved" && !runtime.hasPendingLocalChanges) { finish(true);