feat: add session undo and redo
Continuous Integration / lint-and-test (push) Successful in 14s
Continuous Integration / docker-build-and-push (push) Successful in 34s

This commit is contained in:
Lucas Winther
2026-09-30 01:33:29 +02:00
parent d38584396b
commit 804f32c5fb
9 changed files with 411 additions and 14 deletions
+6
View File
@@ -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
+15
View File
@@ -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:
@@ -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.
@@ -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.
+115
View File
@@ -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 });
});
});
+76 -13
View File
@@ -8,12 +8,20 @@ export interface Persistence {
export type Listener = () => void;
export interface HistoryState {
canUndo: boolean;
canRedo: boolean;
}
export interface SnapshotStore {
load(): Promise<void>;
get(): Dataset;
mutate(fn: (draft: Dataset) => void): Promise<void>;
replace(dataset: Dataset): Promise<void>;
reset(): Promise<void>;
getHistory(): HistoryState;
undo(): Promise<boolean>;
redo(): Promise<boolean>;
subscribe(listener: Listener): () => void;
}
@@ -30,6 +38,10 @@ export function createSnapshotStore(
): SnapshotStore {
let snapshot: Dataset | null = null;
const listeners = new Set<Listener>();
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<boolean> {
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) {
+3 -1
View File
@@ -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. */}
<header className="sticky top-0 z-30 border-b border-budget-rule bg-budget-paper/85 backdrop-blur-md">
<div className="mx-auto flex max-w-6xl items-center gap-1 px-4 py-2.5 sm:px-6">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-1 gap-y-1 px-4 py-2.5 sm:px-6">
<span className="mr-3 hidden text-sm font-semibold tracking-tight text-budget-ink sm:block">
Budget<span className="text-budget-accent">&nbsp;2.0</span>
</span>
@@ -54,6 +55,7 @@ export function AppRoutes() {
<NavItem to="/summary">Summary</NavItem>
</nav>
<div className="ml-auto flex items-center gap-1">
<HistoryControls />
<ThemeToggle />
<NavItem to="/settings">Settings</NavItem>
</div>
@@ -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(<HistoryControls />));
return { history, failWrites(value: boolean) { fail = value; } };
}
test("buttons follow saved history and restore data", async () => {
const { history } = await setup();
const undo = container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!;
const redo = container!.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('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");
});
+59
View File
@@ -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<string | null>(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 (
<div className="flex items-center gap-0.5 border-r border-budget-rule pr-1.5 mr-1.5">
<Button type="button" variant="ghost" size="icon-sm" aria-label="Undo"
title="Undo (Ctrl/Cmd+Z)" disabled={busy || !canUndo} onClick={() => void run("undo")}>
<Undo2 aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Redo"
title="Redo (Ctrl/Cmd+Shift+Z)" disabled={busy || !canRedo} onClick={() => void run("redo")}>
<Redo2 aria-hidden="true" />
</Button>
{error && <p role="alert" className="absolute right-4 top-full max-w-[calc(100vw-2rem)] rounded-b-md border border-t-0 border-destructive/40 bg-background px-3 py-2 text-sm text-destructive shadow-sm sm:right-6">{error}</p>}
</div>
);
}