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:
1 parent
7b71811262
commit
ac3d18a483
3 files changed
+158
-3
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user