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

+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.