diff --git a/README.md b/README.md index 56252dd..152e17b 100755 --- a/README.md +++ b/README.md @@ -59,10 +59,16 @@ Everything is stored in this browser's IndexedDB. **Export regularly** from Settings → Your data: the JSON export is both your backup and how you move your budget to another device. +Use the header's Undo and Redo controls to reverse saved edits in the current +session. Ctrl/Cmd+Z undoes; Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redoes. While typing +in a field, those shortcuts use the browser's text undo. History holds up to +50 edits and disappears when the page reloads. + Both destructive actions there — Import, and "Reset everything" — download a backup of your current data before replacing it, and abort if that download fails. Reset puts the app back to a brand-new install: the three starter posts, the DKK/USD/EUR currency table, this month at zero income, no purchases. +Import and reset clear session undo history after they succeed. ## Known limitations diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a8bbf2f..fb3ee41 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -101,6 +101,21 @@ uses on a first run, so "reset" and "a brand-new browser" cannot drift apart. Both are preceded in the UI by a JSON backup download that must succeed before the destructive write is attempted. +## Session undo and redo + +The snapshot store keeps up to 50 prior saved datasets in memory. A changed +`mutate` adds one step after its write succeeds; an unchanged mutation adds +none. Undo and redo run through the same queue and persist their target dataset +before changing the visible snapshot or either history stack. A failed write +therefore leaves both the data and the available undo/redo actions intact. + +Import and reset clear the stacks only after their write succeeds. They are +whole-dataset replacement flows with backup-first protection, not ordinary +undoable edits. History is not in `Dataset` or IndexedDB and vanishes on reload. +The header reads a stable history capability snapshot from the store to enable +its controls; keyboard shortcuts skip editable fields so browser text undo +still works. + ## Money A float, deliberately, with two rules that make it safe: diff --git a/docs/plans/2026-09-30-session-undo-redo.md b/docs/plans/2026-09-30-session-undo-redo.md new file mode 100644 index 0000000..c773956 --- /dev/null +++ b/docs/plans/2026-09-30-session-undo-redo.md @@ -0,0 +1,12 @@ +# Session undo and redo implementation + +1. Extend snapshot-store tests with changed and unchanged edits, undo/redo + ordering, a new edit after undo, the 50-step bound, queued writes, failed + writes, import/reset boundaries, and session reload behavior. Run them red. +2. Add history state and queued undo/redo operations in `snapshot.ts`. Ensure + history changes only after persistence commits. Run the store tests green. +3. Add header controls and keyboard handling with an interaction test for + disabled states, error display, and native editable shortcuts. Run it red, + implement, then run it green. +4. Update architecture and user-facing documentation. Run `bun test`, + `bunx tsc --noEmit`, build, and inspect desktop/mobile browser captures. diff --git a/docs/specs/2026-09-30-session-undo-redo-design.md b/docs/specs/2026-09-30-session-undo-redo-design.md new file mode 100644 index 0000000..4e71f65 --- /dev/null +++ b/docs/specs/2026-09-30-session-undo-redo-design.md @@ -0,0 +1,31 @@ +# Session undo and redo + +The owner needs to reverse an accidental saved edit without reconstructing a +budget from an export. History lasts for the current page session only; a reload +starts with the saved dataset and empty history. + +## Behavior + +- Every successful, changed `mutate` produces one undo step. Unchanged edits + produce none. The last 50 prior datasets are retained. +- Undo and redo restore complete datasets, including IDs and dated rules, by + writing through the store's existing serialization queue. A failed write + changes neither the displayed dataset nor either history stack. +- A new successful edit after undo clears redo. A failed edit leaves redo + available. +- Import and reset are destructive whole-dataset operations. Their existing + backup-first flow remains mandatory. On success they clear both history + stacks; on failure they leave history intact. Neither can be undone with the + session controls. +- The header offers Undo and Redo buttons. Ctrl/Cmd+Z undoes and + Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y redoes when focus is outside an editable + control. Native text editing shortcuts remain native. +- A failed undo or redo shows its persistence error near the controls. + +## Implementation boundary + +History belongs to `src/store/snapshot.ts`, where edits already serialize and +only become visible after persistence succeeds. The UI reads stable capability +state from the same store and invokes its methods; no domain or dataset schema +change is needed. History is deliberately memory-only, so exports contain only +the current dataset and IndexedDB needs no upgrade. diff --git a/src/store/snapshot.test.ts b/src/store/snapshot.test.ts index 02ed8e7..4366587 100644 --- a/src/store/snapshot.test.ts +++ b/src/store/snapshot.test.ts @@ -286,3 +286,118 @@ describe("seed rates", () => { expect(store.get().fxRates).toEqual([]); }); }); + +describe("session history", () => { + test("undo and redo persist complete prior snapshots in order", async () => { + const fake = fakePersistence(createSeedDataset("2026-09")); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + const originalId = store.get().posts[0]!.id; + await store.mutate(draft => { draft.posts[0]!.name = "First"; }); + await store.mutate(draft => { draft.posts[0]!.name = "Second"; }); + + expect(store.getHistory()).toEqual({ canUndo: true, canRedo: false }); + expect(await store.undo()).toBe(true); + expect(store.get().posts[0]).toMatchObject({ id: originalId, name: "First" }); + expect(fake.stored!.posts[0]!.name).toBe("First"); + expect(await store.undo()).toBe(true); + expect(store.get().posts[0]!.name).toBe("Video Games"); + expect(await store.undo()).toBe(false); + expect(store.getHistory()).toEqual({ canUndo: false, canRedo: true }); + expect(await store.redo()).toBe(true); + expect(await store.redo()).toBe(true); + expect(await store.redo()).toBe(false); + expect(store.get().posts[0]!.name).toBe("Second"); + expect(fake.stored!.posts[0]!.name).toBe("Second"); + }); + + test("an unchanged edit creates no step and does not discard redo", async () => { + const store = createSnapshotStore(fakePersistence(createSeedDataset("2026-09")).persistence, "2026-09"); + await store.load(); + await store.mutate(draft => { draft.posts[0]!.name = "First"; }); + await store.undo(); + await store.mutate(draft => { draft.posts[0]!.name = "Video Games"; }); + expect(store.getHistory()).toEqual({ canUndo: false, canRedo: true }); + expect(await store.redo()).toBe(true); + expect(store.get().posts[0]!.name).toBe("First"); + }); + + test("a new saved edit after undo discards redo", async () => { + const store = createSnapshotStore(fakePersistence(createSeedDataset("2026-09")).persistence, "2026-09"); + await store.load(); + await store.mutate(draft => { draft.posts[0]!.name = "First"; }); + await store.undo(); + await store.mutate(draft => { draft.posts[1]!.name = "Changed"; }); + expect(store.getHistory()).toEqual({ canUndo: true, canRedo: false }); + expect(await store.redo()).toBe(false); + }); + + test("a rejected undo leaves data and history available for retry", async () => { + const fake = fakePersistence(createSeedDataset("2026-09")); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + await store.mutate(draft => { draft.posts[0]!.name = "First"; }); + fake.persistence.write = async () => { throw new Error("disk full"); }; + await expect(store.undo()).rejects.toThrow("disk full"); + expect(store.get().posts[0]!.name).toBe("First"); + expect(store.getHistory()).toEqual({ canUndo: true, canRedo: false }); + fake.persistence.write = async () => {}; + expect(await store.undo()).toBe(true); + }); + + test("a rejected new edit preserves redo", async () => { + const fake = fakePersistence(createSeedDataset("2026-09")); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + await store.mutate(draft => { draft.posts[0]!.name = "First"; }); + await store.undo(); + fake.persistence.write = async () => { throw new Error("disk full"); }; + await expect(store.mutate(draft => { draft.posts[1]!.name = "Fail"; })).rejects.toThrow("disk full"); + expect(store.getHistory()).toEqual({ canUndo: false, canRedo: true }); + }); + + test("undo runs after already queued edits", async () => { + const fake = fakePersistence(createSeedDataset("2026-09")); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + const first = store.mutate(draft => { draft.posts[0]!.name = "First"; }); + const second = store.mutate(draft => { draft.posts[0]!.name = "Second"; }); + const undo = store.undo(); + await Promise.all([first, second, undo]); + expect(store.get().posts[0]!.name).toBe("First"); + expect(fake.stored!.posts[0]!.name).toBe("First"); + }); + + test("import and reset clear history only after their writes succeed", async () => { + const fake = fakePersistence(createSeedDataset("2026-09")); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + await store.mutate(draft => { draft.posts[0]!.name = "First"; }); + const incoming = createSeedDataset("2020-01"); + fake.persistence.write = async () => { throw new Error("disk full"); }; + await expect(store.replace(incoming)).rejects.toThrow("disk full"); + expect(store.getHistory().canUndo).toBe(true); + fake.persistence.write = async () => {}; + await store.replace(incoming); + expect(store.getHistory()).toEqual({ canUndo: false, canRedo: false }); + await store.mutate(draft => { draft.posts[0]!.name = "New"; }); + await store.reset(); + expect(store.getHistory()).toEqual({ canUndo: false, canRedo: false }); + }); + + test("history is limited to fifty edits and disappears on reload", async () => { + const fake = fakePersistence(createSeedDataset("2026-09")); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + for (let n = 1; n <= 51; n++) { + await store.mutate(draft => { draft.posts[0]!.name = `Edit ${n}`; }); + } + for (let n = 0; n < 50; n++) expect(await store.undo()).toBe(true); + expect(store.get().posts[0]!.name).toBe("Edit 1"); + expect(await store.undo()).toBe(false); + const reopened = createSnapshotStore(fake.persistence, "2026-09"); + await reopened.load(); + expect(reopened.get().posts[0]!.name).toBe("Edit 1"); + expect(reopened.getHistory()).toEqual({ canUndo: false, canRedo: false }); + }); +}); diff --git a/src/store/snapshot.ts b/src/store/snapshot.ts index 256f330..7dc87c1 100644 --- a/src/store/snapshot.ts +++ b/src/store/snapshot.ts @@ -8,12 +8,20 @@ export interface Persistence { export type Listener = () => void; +export interface HistoryState { + canUndo: boolean; + canRedo: boolean; +} + export interface SnapshotStore { load(): Promise; get(): Dataset; mutate(fn: (draft: Dataset) => void): Promise; replace(dataset: Dataset): Promise; reset(): Promise; + getHistory(): HistoryState; + undo(): Promise; + redo(): Promise; subscribe(listener: Listener): () => void; } @@ -30,6 +38,10 @@ export function createSnapshotStore( ): SnapshotStore { let snapshot: Dataset | null = null; const listeners = new Set(); + const undoStack: Dataset[] = []; + const redoStack: Dataset[] = []; + let historyState: HistoryState = { canUndo: false, canRedo: false }; + const historyLimit = 50; // All mutations run through this chain, one at a time, so a mutation // always clones from the snapshot the previous mutation committed rather @@ -51,42 +63,81 @@ export function createSnapshotStore( for (const listener of listeners) listener(); } + function updateHistoryState() { + historyState = { canUndo: undoStack.length > 0, canRedo: redoStack.length > 0 }; + } + + function pushHistory(stack: Dataset[], dataset: Dataset) { + stack.push(dataset); + if (stack.length > historyLimit) stack.shift(); + } + + function clearHistory() { + undoStack.length = 0; + redoStack.length = 0; + updateHistoryState(); + } + function get(): Dataset { if (!snapshot) throw new Error("Snapshot store is not loaded yet"); return snapshot; } - async function commit(next: Dataset) { + async function commit(next: Dataset, afterWrite?: () => void) { // Write first: if persistence fails, the in-memory snapshot must not have // moved, or the UI would show data that was never saved. await persistence.write(next); snapshot = next; + afterWrite?.(); notify(); } + function travel(from: Dataset[], to: Dataset[]): Promise { + return enqueue(async () => { + const target = from.at(-1); + if (!target) return false; + const previous = get(); + await commit(structuredClone(target), () => { + from.pop(); + pushHistory(to, previous); + updateHistoryState(); + }); + return true; + }); + } + return { - async load() { - const stored = await persistence.read(); - if (stored) { - snapshot = stored; - notify(); - return; - } - await commit(createSeedDataset(currentMonth, seedRates)); + load() { + return enqueue(async () => { + const stored = await persistence.read(); + if (stored) { + snapshot = stored; + clearHistory(); + notify(); + return; + } + await commit(createSeedDataset(currentMonth, seedRates), clearHistory); + }); }, get, mutate(fn) { return enqueue(async () => { - const draft = structuredClone(get()); + const previous = get(); + const draft = structuredClone(previous); fn(draft); - await commit(draft); + if (JSON.stringify(draft) === JSON.stringify(previous)) return; + await commit(draft, () => { + pushHistory(undoStack, previous); + redoStack.length = 0; + updateHistoryState(); + }); }); }, replace(dataset) { - return enqueue(() => commit(dataset)); + return enqueue(() => commit(structuredClone(dataset), clearHistory)); }, /** @@ -99,7 +150,19 @@ export function createSnapshotStore( * mutation is still in flight cannot be overtaken by it. */ reset() { - return enqueue(() => commit(createSeedDataset(currentMonth, seedRates))); + return enqueue(() => commit(createSeedDataset(currentMonth, seedRates), clearHistory)); + }, + + getHistory() { + return historyState; + }, + + undo() { + return travel(undoStack, redoStack); + }, + + redo() { + return travel(redoStack, undoStack); }, subscribe(listener) { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8abec1a..8c41b25 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -10,6 +10,7 @@ import { SettingsRoute } from "./routes/SettingsRoute.tsx"; import { UpdatePrompt } from "./components/UpdatePrompt.tsx"; import { RouteErrorBoundaryReset } from "./components/ErrorBoundary.tsx"; import { ThemeToggle } from "./components/ThemeToggle.tsx"; +import { HistoryControls } from "./components/HistoryControls.tsx"; /** * One nav item. The active route is marked with weight and an accent @@ -44,7 +45,7 @@ export function AppRoutes() { it. Translucent with a blur so the paper ground still reads as one continuous sheet scrolling underneath. */}
-
+
Budget 2.0 @@ -54,6 +55,7 @@ export function AppRoutes() { Summary
+ Settings
diff --git a/src/ui/components/HistoryControls.test.tsx b/src/ui/components/HistoryControls.test.tsx new file mode 100644 index 0000000..fb760a7 --- /dev/null +++ b/src/ui/components/HistoryControls.test.tsx @@ -0,0 +1,94 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { createSeedDataset } from "../../domain/seed.ts"; +import { createSnapshotStore } from "../../store/snapshot.ts"; +import { store } from "../../store/index.ts"; +import { HistoryControls } from "./HistoryControls.tsx"; + +const original = { getHistory: store.getHistory, subscribe: store.subscribe, undo: store.undo, redo: store.redo }; +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + if (root) act(() => root!.unmount()); + root = null; + container?.remove(); + container = null; + Object.assign(store, original); +}); + +async function setup() { + let saved = createSeedDataset("2026-09"); + let fail = false; + const history = createSnapshotStore({ + async read() { return saved; }, + async write(next) { + if (fail) throw new Error("Disk full"); + saved = next; + }, + }, "2026-09"); + await history.load(); + Object.assign(store, { + getHistory: history.getHistory, + subscribe: history.subscribe, + undo: history.undo, + redo: history.redo, + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + act(() => root!.render()); + return { history, failWrites(value: boolean) { fail = value; } }; +} + +test("buttons follow saved history and restore data", async () => { + const { history } = await setup(); + const undo = container!.querySelector('button[aria-label="Undo"]')!; + const redo = container!.querySelector('button[aria-label="Redo"]')!; + expect(undo.disabled).toBe(true); + expect(redo.disabled).toBe(true); + + await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); }); + expect(undo.disabled).toBe(false); + await act(async () => { undo.click(); }); + expect(history.get().posts[0]!.name).toBe("Video Games"); + expect(redo.disabled).toBe(false); + await act(async () => { redo.click(); }); + expect(history.get().posts[0]!.name).toBe("Changed"); +}); + +test("a failed undo shows an error and permits retry", async () => { + const { history, failWrites } = await setup(); + await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); }); + failWrites(true); + await act(async () => { container!.querySelector('button[aria-label="Undo"]')!.click(); }); + expect(container!.querySelector('[role="alert"]')?.textContent).toContain("Disk full"); + expect(history.get().posts[0]!.name).toBe("Changed"); + failWrites(false); + await act(async () => { container!.querySelector('button[aria-label="Undo"]')!.click(); }); + expect(container!.querySelector('[role="alert"]')).toBeNull(); +}); + +test("keyboard shortcut reverses saved edits outside editable controls", async () => { + const { history } = await setup(); + await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); }); + const field = document.createElement("input"); + container!.append(field); + await act(async () => { + field.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true })); + }); + expect(history.get().posts[0]!.name).toBe("Changed"); + await act(async () => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true })); + }); + expect(history.get().posts[0]!.name).toBe("Video Games"); + await act(async () => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true })); + }); + expect(history.get().posts[0]!.name).toBe("Changed"); +}); diff --git a/src/ui/components/HistoryControls.tsx b/src/ui/components/HistoryControls.tsx new file mode 100644 index 0000000..ab5ced9 --- /dev/null +++ b/src/ui/components/HistoryControls.tsx @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { Redo2, Undo2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { store } from "../../store/index.ts"; + +/** Session history controls. Dataset writes remain serialized by the store. */ +export function HistoryControls() { + const { canUndo, canRedo } = useSyncExternalStore(store.subscribe, store.getHistory); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const busyRef = useRef(false); + + const run = useCallback(async (direction: "undo" | "redo") => { + if (busyRef.current) return; + busyRef.current = true; + setBusy(true); + try { + await store[direction](); + setError(null); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + setError(`Could not ${direction}: ${message}. Your data has not changed.`); + } finally { + busyRef.current = false; + setBusy(false); + } + }, []); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented || event.altKey || !(event.ctrlKey || event.metaKey)) return; + const target = event.target; + if (target instanceof Element && target.closest("input, textarea, select, [contenteditable]")) return; + const key = event.key.toLowerCase(); + const direction = key === "z" + ? (event.shiftKey ? "redo" : "undo") + : key === "y" && !event.shiftKey ? "redo" : null; + if (!direction) return; + event.preventDefault(); + void run(direction); + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [run]); + + return ( +
+ + + {error &&

{error}

} +
+ ); +}