feat(settings): reset everything back to a fresh install

Asked for as a reset that includes the currencies and the seed
categories. Between an empty dataset and first-run state the owner chose
first-run: the three starter posts, the DKK/USD/EUR table, the baked
rates, this month at zero income, no purchases.

store.reset() calls the same createSeedDataset(currentMonth, seedRates)
that load() uses on a first run, through the same write queue. Seeding
stays in the store rather than moving into the UI so "reset" and "a
brand-new browser" cannot drift apart, and a reset issued while a
mutation is still in flight cannot be overtaken by it.

The confirm panel reuses the import flow's shape and, more importantly,
its order of operations: the JSON backup downloads BEFORE the
destructive write, and a backup that fails aborts the reset rather than
destroying the only copy of the user's data. The write is guarded, which
src/ui/storeWriteErrors.test.ts requires of every direct store call in
the UI.

Also gives both confirm panels and the error line the top margin they
never had — they butted straight against the button row.

Verified by clicking through in real Chrome and reading IndexedDB back:
3 seed posts, DKK/USD/EUR, both baked manual rates, 0 purchases, one
month at zero income. Whether headless Chrome wrote the backup file is
not observable here, so the backup is issued rather than confirmed
openable — a gap the import flow has always had too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-02 21:37:33 +02:00
1 parent 7b71811262
commit ac3d18a483
3 files changed
+158 -3

No files matched your search

+81 -1
View File
@@ -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");
+14
View File
@@ -13,6 +13,7 @@ export interface SnapshotStore {
get(): Dataset;
mutate(fn: (draft: Dataset) => void): Promise<void>;
replace(dataset: Dataset): Promise<void>;
reset(): Promise<void>;
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);
+63 -2
View File
@@ -17,6 +17,7 @@ export function DataSection() {
const dataset = useDataset();
const fileInput = useRef<HTMLInputElement>(null);
const [pending, setPending] = useState<Dataset | null>(null);
const [confirmingReset, setConfirmingReset] = useState(false);
const [error, setError] = useState<string | null>(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() {
<Button variant="outline" onClick={() => fileInput.current?.click()}>
Import JSON…
</Button>
<Button
variant="outline"
className="text-destructive hover:text-destructive"
onClick={() => {
setError(null);
setPending(null);
setConfirmingReset(true);
}}
>
Reset everything…
</Button>
<input
ref={fileInput}
type="file"
@@ -122,10 +157,36 @@ export function DataSection() {
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
{confirmingReset && (
<div className="mt-3 space-y-2 rounded border border-destructive p-3 text-sm">
<p className="font-medium">This deletes everything in this browser.</p>
<p>
Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases,{" "}
{dataset.currencies.length} currencies.
<br />
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.
</p>
<p className="text-xs text-muted-foreground">
A backup of your current data will download first.
</p>
<div className="flex gap-2">
<Button variant="destructive" size="sm" onClick={() => void confirmReset()}>
Reset my data
</Button>
<Button variant="outline" size="sm" onClick={() => setConfirmingReset(false)}>
Cancel
</Button>
</div>
</div>
)}
{pending && (
<div className="space-y-2 rounded border border-destructive p-3 text-sm">
<div className="mt-3 space-y-2 rounded border border-destructive p-3 text-sm">
<p className="font-medium">This replaces everything currently stored.</p>
<p>
Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases.