diff --git a/src/store/snapshot.test.ts b/src/store/snapshot.test.ts index c9869dc..598dd95 100644 --- a/src/store/snapshot.test.ts +++ b/src/store/snapshot.test.ts @@ -1,7 +1,7 @@ import { test, expect, describe } from "bun:test"; import { createSnapshotStore, type Persistence } from "./snapshot.ts"; import { createSeedDataset } from "../domain/seed.ts"; -import type { Dataset } from "../domain/types.ts"; +import type { Dataset, FxRate } from "../domain/types.ts"; function fakePersistence(initial: Dataset | null = null) { const writes: Dataset[] = []; @@ -163,6 +163,86 @@ test("replace swaps the whole dataset, as JSON import needs", async () => { expect(fake.stored!.settings.foldStartMonth).toBe("2020-01"); }); +describe("reset", () => { + test("puts back exactly what a first run would have produced", async () => { + const fake = fakePersistence(null); + const rates: FxRate[] = [ + { currency: "USD", baseUnitsPerOne: 6.449532, updatedAt: "2026-09-01", source: "manual" }, + ]; + const store = createSnapshotStore(fake.persistence, "2026-09", rates); + await store.load(); + + // Diverge from the seed in every direction a reset has to undo: an added + // currency, a renamed post, recorded income and a purchase. + await store.mutate((draft) => { + draft.currencies.push({ code: "JPY", digits: 0, symbol: "\u00a5", name: "Japanese yen" }); + draft.posts[0]!.name = "Renamed"; + draft.posts.push({ + id: "extra", + name: "Extra", + order: 9, + archived: false, + currency: "DKK", + rules: [], + }); + draft.months[0]!.income = { amount: 28000, currency: "DKK" }; + draft.purchases.push({ + id: "p1", + date: "2026-09-04", + description: "Groceries", + total: { amount: 100, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: draft.posts[0]!.id, value: 100, absorbsRemainder: true }], + schedule: null, + }); + }); + + let notifications = 0; + store.subscribe(() => notifications++); + await store.reset(); + + const after = store.get(); + // Post IDs are freshly generated, so compare on everything else. + expect(after.posts.map((p) => p.name)).toEqual([ + "Video Games", + "Food", + "Events and Social", + ]); + expect(after.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]); + expect(after.fxRates).toEqual(rates); + expect(after.purchases).toEqual([]); + expect(after.months).toHaveLength(1); + expect(after.months[0]!.income.amount).toBe(0); + expect(notifications).toBe(1); + }); + + test("persists, so the reset survives a reload", async () => { + const fake = fakePersistence(null); + const store = createSnapshotStore(fake.persistence, "2026-09"); + await store.load(); + await store.mutate((draft) => { + draft.posts.length = 0; + }); + await store.reset(); + expect(fake.stored!.posts).toHaveLength(3); + }); + + test("leaves the snapshot untouched when the write fails", 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 = "Renamed"; + }); + + fake.persistence.write = async () => { + throw new Error("quota exceeded"); + }; + await expect(store.reset()).rejects.toThrow("quota exceeded"); + expect(store.get().posts[0]!.name).toBe("Renamed"); + }); +}); + test("unsubscribe stops notifications", async () => { const fake = fakePersistence(createSeedDataset("2026-09")); const store = createSnapshotStore(fake.persistence, "2026-09"); diff --git a/src/store/snapshot.ts b/src/store/snapshot.ts index 94f6e84..256f330 100644 --- a/src/store/snapshot.ts +++ b/src/store/snapshot.ts @@ -13,6 +13,7 @@ export interface SnapshotStore { get(): Dataset; mutate(fn: (draft: Dataset) => void): Promise; replace(dataset: Dataset): Promise; + reset(): Promise; subscribe(listener: Listener): () => void; } @@ -88,6 +89,19 @@ export function createSnapshotStore( return enqueue(() => commit(dataset)); }, + /** + * 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))); + }, + subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); diff --git a/src/ui/routes/settings/DataSection.tsx b/src/ui/routes/settings/DataSection.tsx index 82a8ef3..df63ba0 100644 --- a/src/ui/routes/settings/DataSection.tsx +++ b/src/ui/routes/settings/DataSection.tsx @@ -17,6 +17,7 @@ export function DataSection() { const dataset = useDataset(); const fileInput = useRef(null); const [pending, setPending] = useState(null); + const [confirmingReset, setConfirmingReset] = useState(false); const [error, setError] = useState(null); // Shared by every export button and by the pre-import backup below. @@ -61,6 +62,7 @@ export function DataSection() { async function chooseFile(file: File) { setError(null); + setConfirmingReset(false); try { setPending(parseDatasetJson(await file.text())); } catch (cause) { @@ -91,6 +93,28 @@ export function DataSection() { setPending(null); } + /** + * Back to a brand-new browser: the seed posts, the seed currency table and + * the baked rates. `store.reset()` is the same call `load()` makes on a + * first run, so the two cannot drift apart. + * + * Order of operations is the import flow's, for the import flow's reason: + * this is the user's only copy, so the backup must download BEFORE the + * destructive write, and a backup that fails aborts the reset rather than + * destroying data no one has a copy of. + */ + async function confirmReset() { + if (!runExport(buildJsonExport)) return; + try { + await store.reset(); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + setError(`Could not reset: ${message}. Your data has not been changed.`); + return; + } + setConfirmingReset(false); + } + const counts = describeDataset(dataset); return ( @@ -109,6 +133,17 @@ export function DataSection() { + - {error &&

{error}

} + {error &&

{error}

} + + {confirmingReset && ( +
+

This deletes everything in this browser.

+

+ Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases,{" "} + {dataset.currencies.length} currencies. +
+ After reset: the three starter posts (Video Games, Food, Events and Social), the + DKK / USD / EUR currency table with the built-in exchange rates, this month at + zero income, and no purchases. Your own posts, currencies, income and allocation + history are all replaced. +

+

+ A backup of your current data will download first. +

+
+ + +
+
+ )} {pending && ( -
+

This replaces everything currently stored.

Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases.