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 committed 2026-09-30 01:33:29 +02:00
1 parent d38584396b
commit 804f32c5fb
9 files changed
+411 -14

No files matched your search

+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>
);
}