Files
budget2.0/src/store/snapshot.ts
T
Lucas Winther 804f32c5fb
Continuous Integration / lint-and-test (push) Successful in 14s
Continuous Integration / docker-build-and-push (push) Successful in 34s
feat: add session undo and redo
2026-09-30 01:33:29 +02:00

174 lines
4.9 KiB
TypeScript

import { createSeedDataset } from "../domain/seed.ts";
import type { Dataset, FxRate, MonthId } from "../domain/types.ts";
export interface Persistence {
read(): Promise<Dataset | null>;
write(dataset: Dataset): Promise<void>;
}
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;
}
export function createSnapshotStore(
persistence: Persistence,
currentMonth: MonthId,
/**
* Rates for a FIRST RUN only. Passed in rather than read here so this module
* stays free of the ambient environment; src/store/index.ts supplies
* BAKED_FX_RATES. Loading an existing dataset never touches its rates — the
* user may have cleared one deliberately.
*/
seedRates: readonly FxRate[] = [],
): 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
// than from a stale snapshot captured before it. Without this, two
// mutations issued before either's write resolves both clone the same
// starting point and the one that commits last silently discards the
// other's change.
let queue: Promise<unknown> = Promise.resolve();
function enqueue<T>(work: () => Promise<T>): Promise<T> {
const run = queue.then(work);
// Keep the chain alive after a failure, but the caller still sees the
// rejection via `run`, which is returned unmodified below.
queue = run.catch(() => undefined);
return run;
}
function notify() {
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, 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 {
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 previous = get();
const draft = structuredClone(previous);
fn(draft);
if (JSON.stringify(draft) === JSON.stringify(previous)) return;
await commit(draft, () => {
pushHistory(undoStack, previous);
redoStack.length = 0;
updateHistoryState();
});
});
},
replace(dataset) {
return enqueue(() => commit(structuredClone(dataset), clearHistory));
},
/**
* Back to first-run state: the seed posts, the seed currency table and the
* seed rates this store was built with. Seeding lives here rather than in
* the UI so "reset" and "a brand-new browser" cannot drift apart — they
* are the same call.
*
* Goes through the queue like every other write, so a reset issued while a
* mutation is still in flight cannot be overtaken by it.
*/
reset() {
return enqueue(() => commit(createSeedDataset(currentMonth, seedRates), clearHistory));
},
getHistory() {
return historyState;
},
undo() {
return travel(undoStack, redoStack);
},
redo() {
return travel(redoStack, undoStack);
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}