Files
budget2.0/src/ui/routes/settings/DataSection.tsx
T
Lucas WintherandClaude Opus 5 bb3c661ef6 feat(ui): design foundation — two themes, shipped type, one card
Sub-project 1 of the redesign. The diagnosis that shaped it: a design system
already existed and was never wired up. src/index.css defined --paper, --ink
and a muted teal with a contract document behind them, while the app painted
itself with shadcn's default white and near-black. Those tokens reached the
screen in exactly two places — the carry meter and the +/- number colours.

TOKENS. Two layers: --budget-* is the app's own vocabulary, defined once per
theme, and shadcn's whole variable set points at it, so every primitive
inherits both themes rather than being fought. Light is cool paper with ink
navy; dark is deep slate, never pure black, because a black ground makes the
hairlines and the carry meter float.

The brand teal is --primary, NOT --accent. That is precisely what the old bug
got wrong: shadcn's --accent is a subtle hover BACKGROUND paired with
--accent-foreground, so a saturated colour there turns every hover into a
coloured slab with unreadable text. src/cssPairs.test.ts now makes the rule
mechanical — redefining either half of a shadcn pair without the other fails
the build, in any block.

CONTRAST was computed, not eyeballed: the weakest text pair is 5.50:1 against
a 4.5:1 requirement, and field borders clear the 3:1 non-text requirement on
both of their backgrounds. Verified afterwards by reading the tokens back out
of the live browser in both themes rather than trusting the arithmetic.

TYPE. Inter and JetBrains Mono, self-hosted (SIL OFL, licences committed
alongside), latin + latin-ext only. Not in index.css: Bun's CSS bundler
resolves every url() from disk and inlines woff2 as base64 — relative or
absolute, and a "file" loader override does not stop it — which made the
stylesheet 303 KB of render-blocking bytes and defeated the unicode-range
subsetting entirely. They are injected at boot from one definition instead,
which is the only arrangement identical in dev and in a subpath build.

THEME TOGGLE cycles system / light / dark, remembers the choice in
localStorage (a per-device display preference does not belong in the exported
budget), keeps following the OS while set to "system", and is applied by a
tiny inline script before first paint so a dark-theme user never sees a white
flash. Every storage access is wrapped: localStorage throws outright in some
configurations, and a theme preference must never stop the app booting.

A REGRESSION I CAUSED AND CAUGHT: renaming --rule to --budget-rule silently
killed the carry meter. An undefined custom property inside linear-gradient()
does not warn, does not throw and does not fall back — the declaration is
invalid, so the element paints nothing. It looked like a missing feature. Only
pixel-sampling a screenshot found it. src/ui/cssTokens.test.ts now fails if any
TypeScript file references a token src/index.css does not define.

Also: one Section/Stat card treatment applied across month, year, summary and
settings; quiet uppercase table headers; row hover; the rule-editing and
purchase-row actions revealed on hover instead of six always-on links; a scroll
fade on the year matrix so it reads as "more to come" rather than clipped; and
"September 2026" instead of "2026-09" as the month heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 15:11:43 +02:00

153 lines
5.4 KiB
TypeScript

import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { useDataset } from "../../hooks/useDataset.ts";
import { downloadBlob } from "../../download.ts";
import { store, currentMonth } from "../../../store/index.ts";
import {
describeDataset,
exportDatasetJson,
exportFilename,
parseDatasetJson,
} from "../../../export/json.ts";
import { buildOds, odsFilename } from "../../../export/ods.ts";
import type { Dataset } from "../../../domain/types.ts";
import { Section } from "../../components/Section.tsx";
export function DataSection() {
const dataset = useDataset();
const fileInput = useRef<HTMLInputElement>(null);
const [pending, setPending] = useState<Dataset | null>(null);
const [error, setError] = useState<string | null>(null);
// Shared by every export button and by the pre-import backup below.
// `buildOds` in particular can throw (e.g. MissingRateError when a
// currency in use has no configured rate), and a throw out of a plain
// onClick handler is invisible to React's error boundary — so every
// export path funnels through here and reports failure via the same
// error state the import flow already uses. Returns whether it succeeded,
// so callers that depend on the export (the import backup) can tell.
function runExport(build: () => { filename: string; blob: Blob }): boolean {
try {
setError(null);
const { filename, blob } = build();
downloadBlob(filename, blob);
return true;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
setError(`Could not export: ${message}`);
return false;
}
}
function buildJsonExport() {
return {
filename: exportFilename(currentMonth),
blob: new Blob([exportDatasetJson(dataset)], { type: "application/json" }),
};
}
function exportJson() {
runExport(buildJsonExport);
}
function exportOds() {
runExport(() => ({
filename: odsFilename(currentMonth),
blob: new Blob([buildOds(dataset) as BlobPart], {
type: "application/vnd.oasis.opendocument.spreadsheet",
}),
}));
}
async function chooseFile(file: File) {
setError(null);
try {
setPending(parseDatasetJson(await file.text()));
} catch (cause) {
setPending(null);
const message = cause instanceof Error ? cause.message : String(cause);
setError(`Could not import: ${message}`);
}
}
async function confirmImport() {
if (!pending) return;
// Back up first: import replaces everything, and this is the user's only
// copy. Order matters — the backup must download before the destructive
// replace, never after. If the backup itself fails, abort rather than
// destroy the only copy of the user's data.
if (!runExport(buildJsonExport)) return;
// The write itself can still fail — a full disk, evicted storage, a
// private-mode quota. Unguarded it is an unhandled rejection: the button
// appears to do nothing and the dialog stays open with no explanation.
// `pending` is deliberately kept on failure so the user can retry.
try {
await store.replace(pending);
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
setError(`Could not import: ${message}. Your data has not been changed.`);
return;
}
setPending(null);
}
const counts = describeDataset(dataset);
return (
<Section
title="Your data"
hint="Everything lives in this browser. Export regularly — it is both your backup and how you move your budget to another device."
>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={exportJson}>
Export JSON
</Button>
<Button variant="outline" onClick={exportOds}>
Export ODS (spreadsheet)
</Button>
<Button variant="outline" onClick={() => fileInput.current?.click()}>
Import JSON…
</Button>
<input
ref={fileInput}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void chooseFile(file);
event.target.value = "";
}}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{pending && (
<div className="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.
<br />
After import: {describeDataset(pending).posts} posts,{" "}
{describeDataset(pending).months} months,{" "}
{describeDataset(pending).purchases} purchases.
</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 confirmImport()}>
Replace my data
</Button>
<Button variant="outline" size="sm" onClick={() => setPending(null)}>
Cancel
</Button>
</div>
</div>
)}
</Section>
);
}