diff --git a/AGENTS.md b/AGENTS.md index b5183a2..d935c21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,5 +18,8 @@ Then, depending on what you are doing: Two rules worth knowing before your first edit: 1. **Bun only.** `bun test`, `bun run`, `bunx`. Never npm, jest, vitest or vite. -2. **There is no browser here.** Anything visual, offline, or focus-related - cannot be verified in this environment. Say so rather than claiming it works. +2. **There IS a browser here** — `bun scripts/screenshot.ts ` + drives headless Chrome and hands you a PNG to look at. Read CLAUDE.md's + "Verification limits" first: the container's fonts lie, and offline, the + install prompt, the `.ods` in a spreadsheet, and how anything *feels* still + need a human. Say so rather than claiming it works. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e5b2d55..1dba94d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -88,6 +88,15 @@ A float, deliberately, with two rules that make it safe: **`FxRate.baseUnitsPerOne` is never rounded.** Rates are stored at six decimals and are not money; quantizing one to cents is wrong in kind. +**Decimal places are data, not a constant.** `Dataset.currencies` records each +currency's code, name, symbol and digits, because the owner can define their +own. `roundMoney` therefore takes a digit count and every call site resolves it +with `digitsFor` — required rather than defaulted, so a missed site is a +compile error instead of something that works for every 2-decimal currency and +breaks on the first zero-decimal one. `src/domain/currencyDigits.test.ts` drives +a zero-decimal currency through the whole stack, which is the only test that can +tell a real implementation from one that assumes 2. + ## Splits and finance plans compose A purchase can be split across posts *and* spread over months at once. The trick @@ -136,12 +145,22 @@ Adding a "missing" guard here is a regression, not a fix: Most logic is pure and tested directly with `bun test`. The domain carries the bulk of the coverage; the UI is thin by design. -**There is no browser in the agent environment.** Nothing has ever verified -visual appearance, real offline behaviour, the install or update prompts, -keyboard focus traversal, or the `.ods` opening in a spreadsheet. Where a check -could not be run, the honest substitute was used and recorded — for example the -`.ods` is validated structurally by unzipping it and asserting `mimetype` is -first and stored, every XML part parses, and numeric cells carry `office:value`. +**A browser IS available**, via `scripts/screenshot.ts` — headless Chrome +driven over CDP, because Chrome's own `--screenshot` fires on the load event +and captures an empty page before this app's async IndexedDB read resolves. +That script is how the month view, the carry meter and the rule editors were +first seen by anyone. It reports console errors too, which is the point: a page +that renders blank because of a thrown error looks identical to an empty one. + +Its limits are real. The container's fontconfig maps every generic family to a +monospace face, so screenshots need a `FONTCONFIG_FILE` override to be +representative; Chrome starts with an empty profile each run; and no screenshot +can judge how a design feels. Real offline behaviour, the install and update +prompts, and the `.ods` opening in a spreadsheet remain unverifiable here. +Where a check could not be run, the honest substitute was used and recorded — +the `.ods` is validated structurally by unzipping it and asserting `mimetype` +is first and stored, every XML part parses, and numeric cells carry +`office:value`. `src/ui/eventCapture.test.ts` is a *source-level* guard rather than a behavioural one, because the bug it prevents depends on real browser render diff --git a/CLAUDE.md b/CLAUDE.md index 1981b78..f01c9e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,13 +111,29 @@ For more information, read the Bun API docs in `node_modules/bun-types/docs/**.m without an explicit argument. It must be testable with no DOM. - Money is a float. Always round with `roundMoney` from `src/domain/money.ts` after any division, after any FX conversion, and before persisting. +- **`roundMoney(amount, digits)` takes a DIGIT COUNT, not a currency code.** + Decimal places live in `Dataset.currencies`, because the owner defines + currencies; resolve them with `digitsFor(dataset.currencies, code)`. The + argument is required on purpose — a default of 2 is right for every currency + the app shipped with, so a missed call site would pass every test and be + silently wrong for the first zero-decimal currency added. - When splitting an amount, never round the last part independently. Compute it as `total - sum(others)` so the parts sum exactly to the whole. - `MonthId` is the string `"YYYY-MM"`. Stored data never contains `Date` objects. +- **`Currency` is an open string code; `Dataset.currencies` is the authority.** + There is no global list of valid currencies any more. A code is IDENTITY — it + keys the FX table and every stored `Money` — so codes are normalised + uppercase, are never editable, and a currency still referenced cannot be + removed (`currencyUsage` names what refers to it). `parseDatasetJson` + validates the table and every `Money` against it; that boundary check is + what replaced the compile-time safety of the old closed union. - The envelope rollover fold has exactly one implementation, in `src/domain/fold.ts`. Year and summary views aggregate over it — they never reimplement the math. - Over-budget is always allowed. Never add validation that blocks it. +- An amount field parses its own currency: "30$" is thirty dollars. Use + `parseMoneyInput` (`src/ui/moneyInput.ts`) and keep the input `type="text"` — + a number input discards the "$" before the parser ever sees it. - Base path comes from `BUN_PUBLIC_BASE_PATH`; never hardcode a subpath, and read it through a `try/catch` accessor — see the gotcha below. - A post's allocation is a dated series, `Post.rules: RuleVersion[]`, resolved by @@ -148,8 +164,16 @@ choice. unset, the bare reference reaches the browser and throws before the app boots. A `typeof process` guard does not work — it survives inlining and discards the inlined value, silently breaking subpath deploys. -- **Never define a bare `--accent` CSS variable.** shadcn owns it; overriding it - drops hover contrast below WCAG AA app-wide. Project tokens are `--budget-*`. +- **Never define a bare `--accent` CSS variable.** shadcn owns it as a hover + BACKGROUND paired with `--accent-foreground`; setting one without the other + drops hover contrast below WCAG AA app-wide. Redefine shadcn tokens only in + pairs — `src/cssPairs.test.ts` enforces it — and put brand colour on + `--primary`, never `--accent`. Project tokens are `--budget-*`. +- **A `var(--token)` naming a token `src/index.css` does not define paints + NOTHING, silently.** An undefined custom property invalidates the whole + declaration: no warning, no throw, no fallback. Renaming `--rule` this way + killed the carry meter and no test noticed. `src/ui/cssTokens.test.ts` guards + it. - **A React error boundary does not catch event-handler throws.** Domain functions that throw must be guarded at the call site when called from one. - **Do not "fix" a domain throw by making it return a fallback.** Silently wrong @@ -167,9 +191,25 @@ Adding a "missing" guard here is a regression: ## Verification limits -There is no browser in this environment. Visual appearance, offline behaviour, -install and update prompts, keyboard focus traversal, and opening the generated -`.ods` in a spreadsheet **cannot be verified here**. Do not claim you checked -them. Substitute a real check where one exists — structural validation of the -`.ods`, curl against the dev server, pure-function tests — and state plainly -what still needs a human. +**There IS a browser here.** `google-chrome` is on PATH, and +`bun scripts/screenshot.ts ` drives it over CDP: it waits past +the async IndexedDB read (which plain `--screenshot` does not), can seed data +with `--eval-file`, click with `--click`, and reports console errors and +uncaught exceptions. Read the PNG it writes — you can see the app. Use it +before claiming anything about layout. + +Two things to know before trusting a screenshot: + +- **This container's fontconfig resolves every generic family to Fira Code, a + monospace font**, so the whole UI renders monospaced in a way no real user + sees. Point `FONTCONFIG_FILE` at an override that maps the generics to + installed sans/mono faces, or you will "fix" a font bug that does not exist. +- Chrome launches with a fresh profile each run, so IndexedDB starts empty and + the app re-seeds. Pass `--eval-file= --reload` for populated data. + +Still **not** verifiable here, so do not claim it: real offline behaviour, the +install and update prompts, opening the generated `.ods` in a spreadsheet, and +anything about how the design *feels*. A screenshot proves a layout exists and +is not broken; it does not prove it is good. Substitute a real check where one +exists — structural validation of the `.ods`, curl against the dev server, +pure-function tests — and state plainly what still needs a human. diff --git a/TODO.md b/TODO.md index b017dfa..9a5873a 100644 --- a/TODO.md +++ b/TODO.md @@ -3,10 +3,15 @@ Work queued for whoever picks this up next, human or agent. Ordered. Each item says enough to act on without re-deriving the reasoning. -**Current state:** branch `feature/versioned-rules`, 9 commits ahead of `main`, -not merged, not pushed. 288 tests passing, `bunx tsc --noEmit` clean, -`bun run build` succeeds. Versioned allocation rules are COMPLETE, all six -tasks. Neither new editing surface has been seen in a browser — see section 5. +**Current state:** merged to `main`. 419 tests passing, `bunx tsc --noEmit` +clean, `bun run build` succeeds on both the live-fetch and offline paths. +Sections 2 and 4 are DONE. Section 3 (redesign) is 1 of 4 sub-projects in. +Section 6 (currencies) is DONE. Every screen has now actually been looked at — +see section 5. + +**Schema is at version 3.** v1 -> v2 made allocation rules a dated series; +v2 -> v3 moved currencies into the dataset. Both migrations are +behaviour-preserving by construction and tested as such. --- @@ -35,9 +40,13 @@ fold). Worth a read before reviewing the diff. Not yet done for this feature: nobody has independently reviewed tasks 3-6, and no browser has seen the two new editing surfaces. -## 2. Baked FX rates — designed and approved, not built +## 2. ~~Baked FX rates~~ — DONE (`b4e1933`) So a fresh install can convert a EUR purchase on day one instead of erroring. +Built as designed, all three build paths verified (live fetch, unreachable +endpoint, `SKIP_FX_FETCH=1`), and confirmed in the real UI: Settings shows +USD 6.449532 / EUR 7.474959 as `manual · 2026-09-01` on a fresh dataset. +The reasoning below is kept because it is the reasoning, not a plan. - `build.ts` fetches DKK/USD/EUR at build time and embeds them via `define`; on any network failure it falls back to **committed constants** rather than @@ -52,50 +61,108 @@ So a fresh install can convert a EUR purchase on day one instead of erroring. **argument**; the env read lives in a new `src/store/bakedRates.ts`, in the store layer, using the `try/catch` accessor pattern (see CLAUDE.md gotchas). -## 3. Visual redesign +## 3. Visual redesign — sub-project 1 of 4 DONE The owner's words: *"right now it looks a bit boring, simple and ugly. We want -sleek, modern, and nice to use."* Sequencing was their explicit choice: finish -all six rules tasks first, then redesign everything. +sleek, modern, and nice to use."* Then, on scoping: both themes with a toggle, +a full rethink of every screen, purchases compact and grouped by date, ship a +webfont — and *"override everything you want for this step."* -`PRODUCT.md` exists and holds product truth. There is no `DESIGN.md`; the -incumbent visual world is the `--budget-*` tokens in `src/index.css` plus the -design contract summarised in ARCHITECTURE.md. A redesign should treat that as -evidence and anti-reference, not as something to preserve. +**Spec:** `docs/superpowers/specs/2026-09-02-design-foundation-design.md` -## 4. Smaller items, in no particular order +**The diagnosis, which should shape the rest:** a design system already existed +and was never wired up. `src/index.css` defined the paper/ink/teal palette with +a contract document behind it, while the app painted shadcn's default white and +near-black. The tokens reached the screen in two places only. Doing the +foundation last is how that happened; do not repeat it. -- **`confirmImport` has no `.catch`** (`src/ui/routes/settings/DataSection.tsx`). - A failed import write is an unhandled rejection with no error shown. One line. -- **PNG icons 192/512 are not shipped**, so Chrome's install prompt is - incomplete. Binary assets cannot be generated in the agent environment; a - human needs to add them. -- **`round2` in `src/store/actions.ts` hardcodes 2 decimals** rather than - reading `CURRENCY_DIGITS`. Correct for DKK/USD/EUR; silently wrong the day a - non-2dp currency is added. Same latent issue in `sliceTotal` - (`src/domain/plans.ts`). -- **`EMPTY_FIGURES` (`src/domain/fold.ts`) is exported non-frozen** and returned - by shared reference from `figuresFor`. No consumer mutates it today; - `Object.freeze` is a one-line hardening. -- **`buildWorkbook` calls `monthView` per (post, month)**, making a full ODS - export O(posts x months^2). Fine at personal-budget scale. -- **The dev-only `src/manifest.webmanifest` placeholder hardcodes `scope: "/"`.** - Harmless in production, misleading if you dev-serve under a subpath. +| # | Sub-project | State | +|---|---|---| +| 1 | Design foundation — two-theme tokens, theme toggle, self-hosted Inter + JetBrains Mono, one Section/Stat card, table furniture | DONE (`bb3c661`) | +| 2 | Month view | date-grouped purchases DONE (`a159189`); income/totals block and the carry meter's presentation still open | +| 3 | Year, Summary, Post detail | on the new foundation but not rethought. The 12-month matrix only fits by scrolling — decide whether compact number formatting replaces that | +| 4 | Dialogs and fast entry | untouched. Native ` - setDraft({ ...draft, amount: Number(event.target.value) || 0 }) - } + placeholder="30$" + value={amountText} + onChange={(event) => { + const typed = event.target.value; + setAmountText(typed); + const parsed = parseMoneyInput(typed, dataset.currencies, draft.currency); + if (parsed) { + setDraft({ ...draft, amount: parsed.amount, currency: parsed.currency }); + } + }} />
@@ -268,7 +283,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { setDraft({ ...draft, currency: event.target.value as typeof draft.currency }) } > - {CURRENCIES.map((currency) => ( + {dataset.currencies.map(({ code: currency }) => ( diff --git a/src/ui/components/Section.tsx b/src/ui/components/Section.tsx new file mode 100644 index 0000000..bed3c45 --- /dev/null +++ b/src/ui/components/Section.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; + +interface Props { + /** Omitted when the card's contents are self-explanatory (a toolbar). */ + title?: string; + /** Sits opposite the title: a primary action, a mode switch. */ + action?: ReactNode; + /** One line under the title. Long explanations belong in the body. */ + hint?: ReactNode; + children: ReactNode; + className?: string; +} + +/** + * The one card in the app. Every screen is a stack of these on the paper + * ground, which is what stops elevation, radius and padding drifting apart + * screen by screen — the previous design defined a card treatment in a + * contract document and then never applied it anywhere. + * + * The `.budget-card` class carries the visual treatment (see src/index.css) so + * light and dark elevation differ where they must: a drop shadow is invisible + * on a dark ground, where a lighter surface plus a hairline does the work. + */ +export function Section({ title, action, hint, children, className = "" }: Props) { + return ( +
+ {(title || action) && ( +
+
+ {title &&

{title}

} + {hint && ( +

{hint}

+ )} +
+ {action &&
{action}
} +
+ )} + {children} +
+ ); +} + +/** + * A single figure with its label. Used for the month's totals, where the + * numbers matter more than the words: the label is small and quiet, the figure + * is large and mono so a column of them lines up. + */ +export function Stat({ + label, + children, + tone = "default", + title, +}: { + label: string; + children: ReactNode; + tone?: "default" | "surplus" | "overspend"; + title?: string; +}) { + const toneClass = + tone === "overspend" ? "text-overspend" : tone === "surplus" ? "text-surplus" : ""; + return ( +
+
+ {label} +
+
{children}
+
+ ); +} diff --git a/src/ui/components/SplitEditor.tsx b/src/ui/components/SplitEditor.tsx index da3d5ea..8ee36c3 100644 --- a/src/ui/components/SplitEditor.tsx +++ b/src/ui/components/SplitEditor.tsx @@ -1,4 +1,6 @@ import { Button } from "@/components/ui/button"; +import { useDataset } from "../hooks/useDataset.ts"; +import { digitsFor } from "../../domain/currencies.ts"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { formatAmount } from "../format.ts"; @@ -13,7 +15,7 @@ interface Props { } export function SplitEditor({ draft, posts, onChange }: Props) { - const balance = splitBalance(draft); + const balance = splitBalance(draft, digitsFor(useDataset().currencies, draft.currency)); const unit = draft.splitMode === "percent" ? "%" : draft.currency; function updateSplit(index: number, changes: Partial) { diff --git a/src/ui/components/ThemeToggle.tsx b/src/ui/components/ThemeToggle.tsx new file mode 100644 index 0000000..e8a794b --- /dev/null +++ b/src/ui/components/ThemeToggle.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { + applyResolvedTheme, + prefersDark, + readThemePreference, + resolveTheme, + storeThemePreference, + type ThemePreference, +} from "../theme.ts"; + +const ORDER: ThemePreference[] = ["system", "light", "dark"]; + +const LABEL: Record = { + system: "Theme: follows your system", + light: "Theme: light", + dark: "Theme: dark", +}; + +/** + * Cycles system -> light -> dark. Three states rather than a two-way switch + * because "follow the system" is a real answer and the only way to get back to + * it, once you have chosen, is to have somewhere to choose it. + * + * While the preference is "system" this keeps listening, so the app follows + * the OS switching to dark at sunset without a reload. + */ +export function ThemeToggle() { + const [preference, setPreference] = useState(readThemePreference); + + useEffect(() => { + applyResolvedTheme(resolveTheme(preference, prefersDark())); + if (preference !== "system") return; + + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const follow = () => applyResolvedTheme(resolveTheme("system", media.matches)); + media.addEventListener("change", follow); + return () => media.removeEventListener("change", follow); + }, [preference]); + + function cycle() { + const next = ORDER[(ORDER.indexOf(preference) + 1) % ORDER.length]!; + setPreference(next); + storeThemePreference(next); + } + + const resolved = resolveTheme(preference, prefersDark()); + + return ( + + ); +} + +/* Inline so the app ships no icon library for three glyphs. currentColor + throughout, so they follow the theme without a second set of tokens. */ + +function SunGlyph() { + return ( + + ); +} + +function MoonGlyph() { + return ( + + ); +} + +function MonitorGlyph() { + return ( + + ); +} diff --git a/src/ui/components/YearMatrix.tsx b/src/ui/components/YearMatrix.tsx index 4b69cb2..d1631fa 100644 --- a/src/ui/components/YearMatrix.tsx +++ b/src/ui/components/YearMatrix.tsx @@ -15,61 +15,72 @@ interface Props { */ export function YearMatrix({ view, mode }: Props) { return ( -
- - - - - {view.months.map((month) => ( - - ))} - - - - - - - {view.incomeByMonth.map((income, i) => ( - - ))} - - - - {view.rows.map((row) => ( - - - {row.byMonth.map((figures, i) => { - const value = mode === "spend" ? figures.charges : figures.remaining; - return ( - - ); - })} - + + {row.byMonth.map((figures, i) => { + const value = mode === "spend" ? figures.charges : figures.remaining; + return ( + + ); + })} + + + ))} + +
Post - {month.slice(5)} - Total
Income - {formatAmount(income)} - {formatAmount(view.totalIncome)}
- - {row.post.name} - - - {formatAmount(value)} - - {formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)} + // Thirteen columns of figures cannot fit any realistic card, so the table + // scrolls. The fade tells you that: without it the matrix just appears to + // end mid-number, which reads as clipping rather than as more to come. +
+
+ + + + + {view.months.map((month) => ( + + ))} + + + + + + + {view.incomeByMonth.map((income, i) => ( + + ))} + - ))} - -
Post + {month.slice(5)} + Total
Income + {formatAmount(income)} + + {formatAmount(view.totalIncome)}
+ + {view.rows.map((row) => ( +
+ + {row.post.name} + + + {formatAmount(value)} + + {formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)} +
+
+ ); } diff --git a/src/ui/cssTokens.test.ts b/src/ui/cssTokens.test.ts new file mode 100644 index 0000000..4826458 --- /dev/null +++ b/src/ui/cssTokens.test.ts @@ -0,0 +1,38 @@ +import { test, expect } from "bun:test"; +import { Glob } from "bun"; + +/** + * Every custom property referenced from TypeScript through a CSS var() + * expression must name a token that actually exists in src/index.css. + * + * This exists because renaming `--rule` to `--budget-rule` silently broke the + * carry meter — the signature element of the month view. An undefined custom + * property inside a `linear-gradient()` does not warn, does not throw and does + * not fall back: the whole declaration is invalid, so the element simply + * paints nothing. It looked like a missing feature, not a bug, and only pixel + * sampling a screenshot found it. + * + * Tokens set by a third party at runtime are exempt — Radix writes + * `--radix-*` onto its own elements, and they are never in our stylesheet. + */ +const RUNTIME_PROVIDED = /^--radix-/; + +test("no TypeScript file references a CSS token that does not exist", async () => { + const css = await Bun.file("src/index.css").text(); + const defined = new Set( + [...css.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gim)].map((m) => m[1]!), + ); + + const missing: string[] = []; + for await (const file of new Glob("src/**/*.{ts,tsx}").scan(".")) { + const source = await Bun.file(file).text(); + for (const match of source.matchAll(/var\((--[a-z0-9-]+)\)/g)) { + const token = match[1]!; + if (RUNTIME_PROVIDED.test(token) || defined.has(token)) continue; + const line = source.slice(0, match.index).split("\n").length; + missing.push(`${file}:${line} references ${token}`); + } + } + + expect(missing).toEqual([]); +}); diff --git a/src/ui/moneyInput.test.ts b/src/ui/moneyInput.test.ts new file mode 100644 index 0000000..740dc14 --- /dev/null +++ b/src/ui/moneyInput.test.ts @@ -0,0 +1,139 @@ +import { test, expect, describe } from "bun:test"; +import { parseMoneyInput, type CurrencyOption } from "./moneyInput.ts"; + +const OPTIONS: CurrencyOption[] = [ + { code: "DKK", symbol: "kr" }, + { code: "USD", symbol: "$" }, + { code: "EUR", symbol: "€" }, +]; +const parse = (text: string, fallback = "DKK") => + parseMoneyInput(text, OPTIONS, fallback); + +describe("reading the currency out of what was typed", () => { + test("a trailing symbol", () => { + expect(parse("30$")).toEqual({ amount: 30, currency: "USD" }); + }); + + test("a leading symbol", () => { + expect(parse("$30")).toEqual({ amount: 30, currency: "USD" }); + }); + + test("a symbol with a space either side", () => { + expect(parse("30 $")).toEqual({ amount: 30, currency: "USD" }); + expect(parse("$ 30")).toEqual({ amount: 30, currency: "USD" }); + }); + + test("a three-letter code, in any case", () => { + expect(parse("30 usd")).toEqual({ amount: 30, currency: "USD" }); + expect(parse("USD 30")).toEqual({ amount: 30, currency: "USD" }); + expect(parse("30EUR")).toEqual({ amount: 30, currency: "EUR" }); + }); + + test("a multi-letter symbol", () => { + expect(parse("250 kr")).toEqual({ amount: 250, currency: "DKK" }); + expect(parse("250kr")).toEqual({ amount: 250, currency: "DKK" }); + }); + + test("no currency at all falls back to the one the field is in", () => { + expect(parse("30")).toEqual({ amount: 30, currency: "DKK" }); + expect(parse("30", "EUR")).toEqual({ amount: 30, currency: "EUR" }); + }); + + test("a code wins over a symbol when both appear", () => { + // "$30 USD" is contradictory; the explicit code is the stronger signal. + expect(parse("$30 USD")).toEqual({ amount: 30, currency: "USD" }); + }); + + test("an unknown currency is rejected rather than silently ignored", () => { + // Silently dropping "GBP" would book a GBP purchase as DKK. + expect(parse("30 GBP")).toBeNull(); + expect(parse("30 £")).toBeNull(); + }); +}); + +describe("reading the number out of what was typed", () => { + test("plain decimals", () => { + expect(parse("30.50")).toEqual({ amount: 30.5, currency: "DKK" }); + }); + + test("a comma decimal separator, as most of Europe writes it", () => { + expect(parse("30,50")).toEqual({ amount: 30.5, currency: "DKK" }); + expect(parse("€25,50")).toEqual({ amount: 25.5, currency: "EUR" }); + }); + + /** + * The genuinely ambiguous cases. The rule: when both separators appear, the + * LAST one is the decimal point. When only one appears, it is a thousands + * separator if exactly three digits follow it, and a decimal point + * otherwise. "1,500" is therefore one thousand five hundred, which is what + * someone typing a budget figure means. + */ + test("both separators: the last one is the decimal point", () => { + expect(parse("1.234,56")).toEqual({ amount: 1234.56, currency: "DKK" }); + expect(parse("1,234.56")).toEqual({ amount: 1234.56, currency: "DKK" }); + }); + + test("one separator with three digits after it groups thousands", () => { + expect(parse("1,500")).toEqual({ amount: 1500, currency: "DKK" }); + expect(parse("1.500")).toEqual({ amount: 1500, currency: "DKK" }); + }); + + test("one separator with one or two digits after it is a decimal point", () => { + expect(parse("1,5")).toEqual({ amount: 1.5, currency: "DKK" }); + expect(parse("1.50")).toEqual({ amount: 1.5, currency: "DKK" }); + }); + + test("spaces and non-breaking spaces group thousands", () => { + expect(parse("28 000")).toEqual({ amount: 28000, currency: "DKK" }); + expect(parse("28 000,50")).toEqual({ amount: 28000.5, currency: "DKK" }); + }); + + test("a negative amount is legal — a refund is a normal line", () => { + expect(parse("-500")).toEqual({ amount: -500, currency: "DKK" }); + expect(parse("-30$")).toEqual({ amount: -30, currency: "USD" }); + expect(parse("$-30")).toEqual({ amount: -30, currency: "USD" }); + }); + + test("a leading plus is accepted and means nothing", () => { + expect(parse("+30")).toEqual({ amount: 30, currency: "DKK" }); + }); +}); + +describe("what is not a money value", () => { + test.each([ + ["empty", ""], + ["only spaces", " "], + ["only a currency", "$"], + ["only a code", "USD"], + ["letters", "abc"], + ["two numbers", "30 40"], + ["a bare separator", ","], + ["a lone minus", "-"], + ])("%s is not a money value", (_label, text) => { + expect(parse(text)).toBeNull(); + }); + + test("a half-typed decimal is not yet a value, and must not become one", () => { + // Committing "30." as 30 would fight the user mid-keystroke. + expect(parse("30.")).toBeNull(); + expect(parse("30,")).toBeNull(); + }); +}); + +describe("symbols that collide", () => { + const withCollision: CurrencyOption[] = [ + { code: "USD", symbol: "$" }, + { code: "CAD", symbol: "$" }, + ]; + + test("the first currency defined with that symbol wins, and the code disambiguates", () => { + expect(parseMoneyInput("30$", withCollision, "USD")).toEqual({ + amount: 30, + currency: "USD", + }); + expect(parseMoneyInput("30 CAD", withCollision, "USD")).toEqual({ + amount: 30, + currency: "CAD", + }); + }); +}); diff --git a/src/ui/moneyInput.ts b/src/ui/moneyInput.ts new file mode 100644 index 0000000..d528bf9 --- /dev/null +++ b/src/ui/moneyInput.ts @@ -0,0 +1,143 @@ +import type { Currency } from "../domain/types.ts"; + +export interface CurrencyOption { + code: Currency; + /** "$", "kr", "€". Optional: a currency can be entered by code alone. */ + symbol?: string; +} + +export interface ParsedMoney { + amount: number; + currency: Currency; +} + +/** + * Reads an amount AND its currency out of one field, so "30$" means thirty + * dollars without touching a separate dropdown. Entry speed is a stated + * product feature; a second control per amount is the opposite of it. + * + * Returns null for anything that is not yet a money value, including a + * half-typed "30." — committing that as 30 would fight the user mid-keystroke. + * An unrecognised currency also returns null rather than falling back, because + * silently reading "30 GBP" as 30 DKK books money at the wrong currency. + */ +export function parseMoneyInput( + text: string, + options: readonly CurrencyOption[], + fallback: Currency, +): ParsedMoney | null { + const trimmed = text.trim(); + if (trimmed === "") return null; + + const currency = detectCurrency(trimmed, options); + if (currency === "unknown") return null; + + const amount = parseAmount(stripCurrency(trimmed, options)); + if (amount === null) return null; + + return { amount, currency: currency ?? fallback }; +} + +/** + * A three-letter code beats a symbol: "$30 USD" is contradictory, and the code + * is the more deliberate signal. Returns `null` when no currency was written + * at all, and "unknown" when something currency-shaped was written that this + * dataset has no currency for. + */ +function detectCurrency( + text: string, + options: readonly CurrencyOption[], +): Currency | null | "unknown" { + const codes = new Map(options.map((o) => [o.code.toUpperCase(), o.code])); + + // A bare three-letter word, not part of a longer word. + const words = text.toUpperCase().match(/[A-Z]{2,}/g) ?? []; + for (const word of words) { + const matched = codes.get(word); + if (matched) return matched; + } + + // Longest symbol first, so "kr." cannot be shadowed by "kr". + const symbols = options + .filter((o) => o.symbol) + .sort((a, b) => b.symbol!.length - a.symbol!.length); + for (const option of symbols) { + if (text.includes(option.symbol!)) return option.code; + } + + // Anything left that is neither digits, separators nor a sign is a currency + // this dataset does not define. + if (/[^\d\s.,+-]/.test(text)) return "unknown"; + + // A word that is not a known code is unknown too ("30 GBP"). + if (words.length > 0) return "unknown"; + + return null; +} + +function stripCurrency(text: string, options: readonly CurrencyOption[]): string { + let out = text; + for (const option of options) { + if (option.symbol) out = out.split(option.symbol).join(" "); + out = out.replace(new RegExp(option.code, "gi"), " "); + } + return out; +} + +/** + * Separator handling, which is where this earns its tests. When BOTH "." and + * "," appear, the last one is the decimal point — that covers "1.234,56" and + * "1,234.56" without knowing the user's locale. When only one appears, it + * groups thousands if exactly three digits follow and is a decimal point + * otherwise, so "1,500" is fifteen hundred and "1,5" is one and a half. + */ +function parseAmount(text: string): number | null { + // Normalise every kind of space to a plain one before splitting. + const spaced = text.replace(/\s+/g, " ").trim(); + if (spaced === "") return null; + + const sign = spaced.startsWith("-") ? -1 : 1; + const unsigned = spaced.replace(/^[+-]\s*/, ""); + + // A space only ever GROUPS thousands, so every group after the first must be + // exactly three digits. Without this, "30 40" silently becomes 3040 — two + // amounts typed by mistake, committed as one much larger one. + const groups = unsigned.split(" ").filter((group) => group !== ""); + if (groups.length === 0) return null; + if (groups.length > 1) { + const allGrouped = groups + .slice(1) + .every((group) => /^\d{3}([.,]\d+)?$/.test(group)); + if (!allGrouped) return null; + } + + const digits = groups.join(""); + if (!/^[\d.,]+$/.test(digits)) return null; + if (/^[.,]+$/.test(digits)) return null; + // A trailing separator means the number is still being typed. + if (/[.,]$/.test(digits)) return null; + + const lastDot = digits.lastIndexOf("."); + const lastComma = digits.lastIndexOf(","); + let decimalAt = -1; + + if (lastDot >= 0 && lastComma >= 0) { + decimalAt = Math.max(lastDot, lastComma); + } else if (lastDot >= 0 || lastComma >= 0) { + const only = Math.max(lastDot, lastComma); + if (digits.length - only - 1 !== 3) decimalAt = only; + } + + const whole = (decimalAt === -1 ? digits : digits.slice(0, decimalAt)).replace( + /[.,]/g, + "", + ); + const fraction = decimalAt === -1 ? "" : digits.slice(decimalAt + 1); + if (/[.,]/.test(fraction)) return null; + + const value = Number( + `${whole === "" ? "0" : whole}.${fraction === "" ? "0" : fraction}`, + ); + return Number.isFinite(value) ? sign * value : null; +} + diff --git a/src/ui/purchaseForm.test.ts b/src/ui/purchaseForm.test.ts index b8d7abe..f441ccf 100644 --- a/src/ui/purchaseForm.test.ts +++ b/src/ui/purchaseForm.test.ts @@ -30,7 +30,7 @@ describe("emptyDraft", () => { describe("splitBalance", () => { test("is zero when percentages total 100", () => { - expect(splitBalance(draft())).toBe(0); + expect(splitBalance(draft(), 2)).toBe(0); }); test("reports the missing percentage", () => { @@ -40,7 +40,7 @@ describe("splitBalance", () => { { postId: "events", value: 30, absorbsRemainder: false }, ], }); - expect(splitBalance(d)).toBe(10); + expect(splitBalance(d, 2)).toBe(10); }); test("reports the missing amount in fixed mode", () => { @@ -51,7 +51,7 @@ describe("splitBalance", () => { { postId: "events", value: 50, absorbsRemainder: false }, ], }); - expect(splitBalance(d)).toBe(30); + expect(splitBalance(d, 2)).toBe(30); }); }); @@ -123,7 +123,7 @@ describe("toPurchase and fromPurchase", () => { describe("finance plans", () => { test("withPlan generates equal slices from the given month", () => { - const d = withPlan(draft({ amount: 3000 }), "2026-10", 6); + const d = withPlan(draft({ amount: 3000 }), "2026-10", 6, 2); expect(d.plan!.slices).toHaveLength(6); expect(d.plan!.slices[0]).toEqual({ month: "2026-10", @@ -140,37 +140,37 @@ describe("finance plans", () => { { postId: "events", value: 30, absorbsRemainder: false }, ], }); - expect(withPlan(split, "2026-10", 2).splits).toEqual(split.splits); + expect(withPlan(split, "2026-10", 2, 2).splits).toEqual(split.splits); }); test("withoutPlan clears the schedule", () => { - expect(withoutPlan(withPlan(draft(), "2026-10", 3)).plan).toBeNull(); + expect(withoutPlan(withPlan(draft(), "2026-10", 3, 2)).plan).toBeNull(); }); test("setSliceAmount edits one slice and leaves the others alone", () => { - const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500); + const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3, 2), 0, 1500); expect(d.plan!.slices.map((s) => s.amount.amount)).toEqual([1500, 1000, 1000]); }); test("planBalance reports the difference between the slices and the total", () => { - const even = withPlan(draft({ amount: 3000 }), "2026-10", 3); - expect(planBalance(even)).toBe(0); - expect(planBalance(setSliceAmount(even, 0, 1500))).toBe(-500); + const even = withPlan(draft({ amount: 3000 }), "2026-10", 3, 2); + expect(planBalance(even, 2)).toBe(0); + expect(planBalance(setSliceAmount(even, 0, 1500), 2)).toBe(-500); }); test("planBalance is zero when there is no plan", () => { - expect(planBalance(draft())).toBe(0); + expect(planBalance(draft(), 2)).toBe(0); }); test("a plan with unbalanced slices is still valid", () => { // A deposit-then-instalments plan may legitimately not sum to the total // yet while the user is typing; the editor warns rather than blocking. - const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500); + const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3, 2), 0, 1500); expect(validatePurchase(d)).toEqual([]); }); test("a plan with zero months is rejected", () => { - expect(() => withPlan(draft(), "2026-10", 0)).toThrow(/at least one month/i); + expect(() => withPlan(draft(), "2026-10", 0, 2)).toThrow(/at least one month/i); }); test("split×plan composition: each slice divides across posts in the split ratio", () => { @@ -189,6 +189,7 @@ describe("finance plans", () => { }), "2026-10", 3, + 2, ); expect(composed.splits).toEqual([ { postId: "games", value: 70, absorbsRemainder: true }, diff --git a/src/ui/purchaseForm.ts b/src/ui/purchaseForm.ts index 70dcfb0..b76025e 100644 --- a/src/ui/purchaseForm.ts +++ b/src/ui/purchaseForm.ts @@ -45,10 +45,10 @@ export function emptyDraft(monthId: MonthId, postId: string): PurchaseDraft { } /** What the split editor must show: how far the parts are from the whole. */ -export function splitBalance(draft: PurchaseDraft): number { +export function splitBalance(draft: PurchaseDraft, digits: number): number { const target = draft.splitMode === "percent" ? 100 : draft.amount; const sum = draft.splits.reduce((total, split) => total + split.value, 0); - return roundMoney(target - sum, draft.currency); + return roundMoney(target - sum, digits); } export function validatePurchase(draft: PurchaseDraft): string[] { @@ -104,12 +104,18 @@ export function withPlan( draft: PurchaseDraft, startMonth: MonthId, months: number, + digits: number, ): PurchaseDraft { return { ...draft, plan: { startMonth, - slices: equalSlices({ amount: draft.amount, currency: draft.currency }, startMonth, months), + slices: equalSlices( + { amount: draft.amount, currency: draft.currency }, + startMonth, + months, + digits, + ), }, }; } @@ -140,9 +146,13 @@ export function setSliceAmount( * slices and compare to the total" arithmetic has exactly one implementation. * Positive means the slices fall short of the purchase total. */ -export function planBalance(draft: PurchaseDraft): number { +export function planBalance(draft: PurchaseDraft, digits: number): number { if (!draft.plan) return 0; - return slicesBalance({ amount: draft.amount, currency: draft.currency }, draft.plan.slices); + return slicesBalance( + { amount: draft.amount, currency: draft.currency }, + draft.plan.slices, + digits, + ); } export function fromPurchase(purchase: Purchase): PurchaseDraft { diff --git a/src/ui/purchaseGroups.test.ts b/src/ui/purchaseGroups.test.ts new file mode 100644 index 0000000..073ab55 --- /dev/null +++ b/src/ui/purchaseGroups.test.ts @@ -0,0 +1,82 @@ +import { test, expect, describe } from "bun:test"; +import { groupPurchasesByDate } from "./purchaseGroups.ts"; +import type { Purchase } from "../domain/types.ts"; + +function purchase(id: string, date: string): Purchase { + return { + id, + date, + description: id, + total: { amount: 100, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: "p", value: 100, absorbsRemainder: true }], + schedule: null, + }; +} + +describe("grouping a month's purchases by day", () => { + test("newest day first, and purchases keep their order within a day", () => { + const groups = groupPurchasesByDate( + [purchase("a", "2026-09-03"), purchase("b", "2026-09-12"), purchase("c", "2026-09-03")], + "2026-09", + ); + expect(groups.map((g) => g.key)).toEqual(["2026-09-12", "2026-09-03"]); + expect(groups[1]!.purchases.map((p) => p.id)).toEqual(["a", "c"]); + }); + + test("the heading names the weekday, which is what a person recognises", () => { + const [group] = groupPurchasesByDate([purchase("a", "2026-09-12")], "2026-09"); + // 2026-09-12 is a Saturday. + expect(group!.label).toBe("Sat 12 September"); + }); + + /** + * The weekday must not depend on where the machine is. Parsing + * "2026-09-01" gives UTC midnight, so a getDay() in any negative-offset + * zone reports the previous day — every heading would be wrong by one for + * users west of Greenwich. + */ + test("the weekday is timezone-independent", () => { + const label = (tz: string) => { + const original = process.env.TZ; + process.env.TZ = tz; + try { + return groupPurchasesByDate([purchase("a", "2026-09-01")], "2026-09")[0]!.label; + } finally { + process.env.TZ = original; + } + }; + expect(label("UTC")).toBe("Tue 1 September"); + expect(label("America/Los_Angeles")).toBe("Tue 1 September"); + expect(label("Pacific/Kiritimati")).toBe("Tue 1 September"); + }); + + /** + * A finance plan's slices land in months after the purchase date, so in + * those months the row's own date is not in view. Filing it under its real + * date would put "14 August" inside the September list. + */ + test("a slice from an earlier month is grouped apart, and last", () => { + const groups = groupPurchasesByDate( + [purchase("bike", "2026-08-14"), purchase("rent", "2026-09-01")], + "2026-09", + ); + expect(groups.map((g) => g.key)).toEqual(["2026-09-01", "carried"]); + expect(groups[1]!.label).toBe("Financed from earlier months"); + expect(groups[1]!.purchases.map((p) => p.id)).toEqual(["bike"]); + }); + + /** A purchase date may legally be "YYYY-MM" with no day at all. */ + test("a purchase with no day sits in its own group, after the dated ones", () => { + const groups = groupPurchasesByDate( + [purchase("vague", "2026-09"), purchase("rent", "2026-09-01")], + "2026-09", + ); + expect(groups.map((g) => g.key)).toEqual(["2026-09-01", "undated"]); + expect(groups[1]!.label).toBe("No day recorded"); + }); + + test("an empty month is an empty list, not a group of nothing", () => { + expect(groupPurchasesByDate([], "2026-09")).toEqual([]); + }); +}); diff --git a/src/ui/purchaseGroups.ts b/src/ui/purchaseGroups.ts new file mode 100644 index 0000000..d244779 --- /dev/null +++ b/src/ui/purchaseGroups.ts @@ -0,0 +1,90 @@ +import type { MonthId, Purchase } from "../domain/types.ts"; + +export interface PurchaseGroup { + /** An ISO date, or the sentinel "carried" / "undated". */ + key: string; + /** "Sat 12 September", or a sentence for the two sentinel groups. */ + label: string; + purchases: Purchase[]; +} + +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +/** + * Sorted last, in this order, after every dated group: + * - `carried`: a finance plan's later slices. The row belongs in this month + * because money moves this month, but its own date does not, so filing it + * under that date would put "14 August" inside the September list. + * - `undated`: a purchase date may legally be "YYYY-MM" with no day. + */ +const CARRIED = "carried"; +const UNDATED = "undated"; + +/** + * Weekday without a timezone. `new Date("2026-09-01")` is UTC midnight, so + * `getDay()` anywhere west of Greenwich reports the previous day and every + * heading would be off by one. Building the date in UTC and reading it back in + * UTC is the whole fix. + */ +function weekday(year: number, month: number, day: number): string { + return WEEKDAYS[new Date(Date.UTC(year, month - 1, day)).getUTCDay()]!; +} + +function dayLabel(date: string): string { + const [year, month, day] = date.split("-").map(Number) as [number, number, number]; + return `${weekday(year, month, day)} ${day} ${MONTHS[month - 1]}`; +} + +/** + * Groups a month's purchase rows under date headings, most recent day first. + * Order within a day is preserved, so a row just entered stays where it was + * put rather than jumping. + * + * Takes the rows already filtered for the month — deciding WHICH purchases + * touch a month is `sliceAmountForMonth`'s job, and this must not second-guess + * it. + */ +export function groupPurchasesByDate( + purchases: Purchase[], + monthId: MonthId, +): PurchaseGroup[] { + const byKey = new Map(); + + for (const purchase of purchases) { + const hasDay = /^\d{4}-\d{2}-\d{2}$/.test(purchase.date); + const key = !purchase.date.startsWith(monthId) + ? CARRIED + : hasDay + ? purchase.date + : UNDATED; + const bucket = byKey.get(key); + if (bucket) bucket.push(purchase); + else byKey.set(key, [purchase]); + } + + const dated = [...byKey.keys()] + .filter((key) => key !== CARRIED && key !== UNDATED) + .sort() + .reverse(); + + const order = [ + ...dated, + ...(byKey.has(UNDATED) ? [UNDATED] : []), + ...(byKey.has(CARRIED) ? [CARRIED] : []), + ]; + + return order.map((key) => ({ + key, + label: + key === CARRIED + ? "Financed from earlier months" + : key === UNDATED + ? "No day recorded" + : dayLabel(key), + purchases: byKey.get(key)!, + })); +} diff --git a/src/ui/registerSw.ts b/src/ui/registerSw.ts index fb89983..185aaaa 100644 --- a/src/ui/registerSw.ts +++ b/src/ui/registerSw.ts @@ -1,4 +1,5 @@ import { BASE_PATH, withBase } from "./basePath.ts"; +import { requestUpdate } from "./swUpdate.ts"; let waiting: ServiceWorker | null = null; @@ -32,8 +33,32 @@ export function registerServiceWorker(onUpdateReady: () => void): void { }); } +/** + * Reloads only once the new worker is actually in control. Posting + * SKIP_WAITING and reloading on the next line raced activation, and when the + * reload won, the reloaded page still found a waiting worker and showed the + * update prompt again — forever. See src/ui/swUpdate.ts for the measurement. + */ export function applyUpdate(): void { - waiting?.postMessage({ type: "SKIP_WAITING" }); + const target = waiting; waiting = null; - window.location.reload(); + + if (!target) { + window.location.reload(); + return; + } + + requestUpdate({ + postSkipWaiting: () => target.postMessage({ type: "SKIP_WAITING" }), + onControllerChange: (listener) => { + navigator.serviceWorker.addEventListener("controllerchange", listener); + return () => + navigator.serviceWorker.removeEventListener("controllerchange", listener); + }, + setTimer: (fn, ms) => { + const id = setTimeout(fn, ms); + return () => clearTimeout(id); + }, + reload: () => window.location.reload(), + }); } diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index dd6215b..530f6d8 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -9,11 +9,13 @@ import { monthView } from "../../domain/views.ts"; import { addMonths } from "../../domain/months.ts"; import { setIncome, deletePurchase, setRuleFrom } from "../../store/actions.ts"; import { ruleAt } from "../../domain/allocation.ts"; -import type { MonthId, Post, Rule } from "../../domain/types.ts"; +import type { MonthId, Post, Purchase, Rule } from "../../domain/types.ts"; import { sliceAmountForMonth } from "../../domain/charges.ts"; import { formatMoney, formatSignedMoney } from "../format.ts"; import { PostTable } from "../components/PostTable.tsx"; import { PurchaseDialog } from "../components/PurchaseDialog.tsx"; +import { Section, Stat } from "../components/Section.tsx"; +import { groupPurchasesByDate } from "../purchaseGroups.ts"; export function MonthRoute() { const { monthId = "" } = useParams(); @@ -22,61 +24,62 @@ export function MonthRoute() { const view = monthView(dataset, monthId); const base = dataset.settings.baseCurrency; const [changingRuleFor, setChangingRuleFor] = useState(null); + const monthPurchases = dataset.purchases.filter( + (purchase) => sliceAmountForMonth(purchase, monthId) !== null, + ); + const groups = groupPurchasesByDate(monthPurchases, monthId); const changingPost = changingRuleFor ? (dataset.posts.find((p) => p.id === changingRuleFor) ?? null) : null; return ( -
+
{error && ( -
+
Could not save: {error} -
)} -
- - ← {addMonths(monthId, -1)} - -

{monthId}

- - {addMonths(monthId, 1)} → - + {/* The month is the app's primary coordinate, so it gets the largest + type on the page and the two ways of moving sit either side of it. */} +
+ +

{monthLabel(monthId)}

+
-
-
- - { - // Read the DOM value NOW: mutate() defers behind the write queue, - // by which time React has reset this input to the committed value. - const amount = Number(event.target.value) || 0; - mutate((draft) => setIncome(draft, monthId, { amount, currency: base })); - }} - /> -
-
-
-
Allocated
-
{formatMoney(view.totalAllocation, base)}
+
+
+
+ + { + // Read the DOM value NOW: mutate() defers behind the write queue, + // by which time React has reset this input to the committed value. + const amount = Number(event.target.value) || 0; + mutate((draft) => setIncome(draft, monthId, { amount, currency: base })); + }} + />
-
-
Spent
-
{formatMoney(view.totalCharges, base)}
-
-
-
Unallocated
-
+ {formatMoney(view.totalAllocation, base)} + {formatMoney(view.totalCharges, base)} + {formatSignedMoney(view.unallocated, base)} -
-
-
-
+ + +
+
+
+
{changingPost && ( )} -
-
-

Purchases

- Add purchase} /> -
-
    - {dataset.purchases - .filter((purchase) => sliceAmountForMonth(purchase, monthId) !== null) - .map((purchase) => { - const slice = sliceAmountForMonth(purchase, monthId)!; - return ( -
  • - - {purchase.description} - {purchase.note && ( - {purchase.note} - )} - - {purchase.schedule && ( - financed - )} - - {formatMoney(slice.amount, slice.currency)} - - - edit - - } - /> - -
  • - ); - })} -
-
- +
Add purchase} />} + > + {groups.length === 0 ? ( +

+ Nothing recorded for this month yet. +

+ ) : ( +
+ {groups.map((group) => ( +
+

+ {group.label} +

+
    + {group.purchases.map((purchase) => ( + + mutate((data) => deletePurchase(data, purchase.id)) + } + /> + ))} +
+
+ ))} +
+ )} +
+
+ ); +} + +function PurchaseRow({ + purchase, + monthId, + onDelete, +}: { + purchase: Purchase; + monthId: MonthId; + onDelete: () => void; +}) { + const slice = sliceAmountForMonth(purchase, monthId)!; + return ( +
  • + + {purchase.description} + {purchase.note && ( + {purchase.note} + )} + + {purchase.schedule && ( + + financed + + )} + + {formatMoney(slice.amount, slice.currency)} + + {/* Held at a fixed width so revealing the actions on hover cannot shift + the amounts, which are the column being read down. */} + + + edit + + } + /> + + +
  • + ); +} + +/** "2026-09" reads as a key; "September 2026" reads as a month. */ +function monthLabel(monthId: MonthId): string { + const [year, month] = monthId.split("-"); + const names = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", + ]; + const name = names[Number(month) - 1]; + return name ? `${name} ${year}` : monthId; +} + +function MonthStep({ to, label, back = false }: { to: string; label: string; back?: boolean }) { + return ( + + {back ? `\u2190 ${label}` : `${label} \u2192`} + ); } diff --git a/src/ui/routes/PostMonthRoute.tsx b/src/ui/routes/PostMonthRoute.tsx index d021408..c3bdf34 100644 --- a/src/ui/routes/PostMonthRoute.tsx +++ b/src/ui/routes/PostMonthRoute.tsx @@ -15,7 +15,13 @@ export function PostMonthRoute() { const row = monthView(dataset, monthId).rows.find((r) => r.post.id === postId); const thisMonth = dataset.purchases.flatMap((purchase) => { - const charges = chargesForPurchaseInMonth(purchase, monthId, base, dataset.fxRates); + const charges = chargesForPurchaseInMonth( + purchase, + monthId, + base, + dataset.fxRates, + dataset.currencies, + ); const mine = charges.find((c) => c.postId === postId); return mine ? [{ purchase, amount: mine.amount }] : []; }); diff --git a/src/ui/routes/SettingsRoute.tsx b/src/ui/routes/SettingsRoute.tsx index e0cb091..e3c6e35 100644 --- a/src/ui/routes/SettingsRoute.tsx +++ b/src/ui/routes/SettingsRoute.tsx @@ -2,10 +2,16 @@ import { CurrencySection } from "./settings/CurrencySection.tsx"; import { PostsSection } from "./settings/PostsSection.tsx"; import { DataSection } from "./settings/DataSection.tsx"; +/** + * Each section is its own card. They are unrelated settings — money, posts, + * and the export/import of everything — and running them together down one + * column, as before, made the destructive import controls look like a + * continuation of the harmless ones. + */ export function SettingsRoute() { return ( -
    -

    Settings

    +
    +

    Settings

    diff --git a/src/ui/routes/SummaryRoute.tsx b/src/ui/routes/SummaryRoute.tsx index d4a9991..4f0ee44 100644 --- a/src/ui/routes/SummaryRoute.tsx +++ b/src/ui/routes/SummaryRoute.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { useDataset } from "../hooks/useDataset.ts"; import { datasetMonthSpan, summaryView } from "../../domain/views.ts"; import { formatAmount, formatMoney } from "../format.ts"; +import { Section, Stat } from "../components/Section.tsx"; export function SummaryRoute() { const dataset = useDataset(); @@ -19,9 +20,10 @@ export function SummaryRoute() { const difference = Number((view.totalIncome - view.totalCharges).toFixed(2)); return ( -
    -

    Summary

    +
    +

    Summary

    +
    @@ -41,52 +43,49 @@ export function SummaryRoute() { > All time -
    - {(["post", "month"] as const).map((option) => ( - - ))} -
    -
    -
    -
    Income
    -
    {formatMoney(view.totalIncome, base)}
    -
    -
    -
    Spent
    -
    {formatMoney(view.totalCharges, base)}
    -
    -
    -
    Difference
    -
    - {formatMoney(difference, base)} -
    -
    +
    + {formatMoney(view.totalIncome, base)} + {formatMoney(view.totalCharges, base)} + + {formatMoney(difference, base)} +
    +
    +
    + {(["post", "month"] as const).map((option) => ( + + ))} +
    + } + > - - - - - + + + + + {groupBy === "post" ? view.byPost.map((entry) => ( - - - - + + + )) : view.byMonth.map((entry) => ( - + - - +
    {groupBy === "post" ? "Post" : "Month"}SpentShare
    {groupBy === "post" ? "Post" : "Month"}SpentShare
    {entry.post.name}{formatAmount(entry.charges)} +
    {entry.post.name}{formatAmount(entry.charges)} {view.totalCharges === 0 ? "—" : `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`} @@ -94,14 +93,14 @@ export function SummaryRoute() {
    {entry.monthId} {formatAmount(entry.charges)} + {formatAmount(entry.charges)} {view.totalCharges === 0 ? "—" : `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`} @@ -110,6 +109,7 @@ export function SummaryRoute() { ))}
    -
    + +
    ); } diff --git a/src/ui/routes/YearRoute.tsx b/src/ui/routes/YearRoute.tsx index a2bde23..9b8cdb1 100644 --- a/src/ui/routes/YearRoute.tsx +++ b/src/ui/routes/YearRoute.tsx @@ -5,6 +5,7 @@ import { useDataset } from "../hooks/useDataset.ts"; import { yearView } from "../../domain/views.ts"; import { formatMoney } from "../format.ts"; import { YearMatrix } from "../components/YearMatrix.tsx"; +import { Section, Stat } from "../components/Section.tsx"; export function YearRoute() { const { year = "" } = useParams(); @@ -15,35 +16,49 @@ export function YearRoute() { const base = dataset.settings.baseCurrency; return ( -
    -
    - +
    +
    + ← {numericYear - 1} -

    {year}

    - +

    {year}

    + {numericYear + 1} → -
    - {(["spend", "balance"] as const).map((option) => ( - - ))} -
    -

    - Income {formatMoney(view.totalIncome, base)} · spent{" "} - {formatMoney(view.totalCharges, base)} -

    +
    +
    + {formatMoney(view.totalIncome, base)} + {formatMoney(view.totalCharges, base)} +
    +
    - -
    +
    + {(["spend", "balance"] as const).map((option) => ( + + ))} +
    + } + > + + + ); } diff --git a/src/ui/routes/settings/CurrencySection.tsx b/src/ui/routes/settings/CurrencySection.tsx index e1509a8..9d23cae 100644 --- a/src/ui/routes/settings/CurrencySection.tsx +++ b/src/ui/routes/settings/CurrencySection.tsx @@ -1,12 +1,21 @@ import { useState } from "react"; +import { Section } from "../../components/Section.tsx"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useDataset } from "../../hooks/useDataset.ts"; import { useMutate } from "../../hooks/useMutate.ts"; -import { setBaseCurrency, setFxRate, removeFxRate } from "../../../store/actions.ts"; +import { + addCurrency, + removeCurrency, + setBaseCurrency, + setFxRate, + removeFxRate, + updateCurrency, +} from "../../../store/actions.ts"; import { DEFAULT_FX_API_URL, fetchFxRates } from "../../../store/fxApi.ts"; -import { CURRENCIES, type Currency } from "../../../domain/types.ts"; +import { currencyUsage } from "../../../domain/currencies.ts"; +import type { Currency } from "../../../domain/types.ts"; export function CurrencySection() { const dataset = useDataset(); @@ -14,7 +23,8 @@ export function CurrencySection() { const base = dataset.settings.baseCurrency; const apiUrl = dataset.settings.fxApiUrl ?? DEFAULT_FX_API_URL; const [status, setStatus] = useState(null); - const targets = CURRENCIES.filter((c) => c !== base); + const [error, setError] = useState(null); + const targets = dataset.currencies.map((c) => c.code).filter((c) => c !== base); async function refresh() { setStatus("Fetching…"); @@ -23,95 +33,198 @@ export function CurrencySection() { mutate((draft) => { for (const rate of rates) setFxRate(draft, rate); }); - setStatus(`Updated ${rates.length} rate(s).`); - } catch (error) { + const missing = targets.filter((t) => !rates.some((r) => r.currency === t)); + setStatus( + `Updated ${rates.length} rate(s).` + + (missing.length > 0 + ? ` The service returned nothing for ${missing.join(", ")} — type those in by hand.` + : ""), + ); + } catch (cause) { // Fails soft: the cached rates are still in place, never cleared. setStatus( `Could not fetch rates (${ - error instanceof Error ? error.message : String(error) + cause instanceof Error ? cause.message : String(cause) }). Your saved rates are unchanged.`, ); } } - return ( -
    -

    Currency

    + /** Currency edits throw on bad input, and a throw from a handler is invisible. */ + function tryEdit(change: () => void) { + try { + setError(null); + change(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + } -
    - + return ( +
    + {error &&

    {error}

    } + +
    +
    - - - - - - - - - - {targets.map((currency) => { - const rate = dataset.fxRates.find((r) => r.currency === currency); - return ( - - - + + + + ); + })} + +
    Currency1 unit = ? {base}Source
    {currency} - { - // A cleared or non-positive field MUST remove the rate - // row rather than store 0: toBase() converts a stored 0 - // silently to a 0-value conversion, while an absent row - // correctly throws MissingRateError. Storing 0 here - // would turn a loud, correct error into wrong numbers - // presented as fact. - const parsed = Number(event.target.value); - mutate((draft) => { - if (Number.isFinite(parsed) && parsed > 0) { - setFxRate(draft, { - currency, - baseUnitsPerOne: parsed, - updatedAt: new Date().toISOString(), - source: "manual", +
    + + + + + + + + + + + + + {dataset.currencies.map((currency) => { + const isBase = currency.code === base; + const rate = dataset.fxRates.find((r) => r.currency === currency.code); + const used = currencyUsage(dataset, currency.code); + return ( + + + + + + - - - ); - })} - -
    CodeNameSymbolDecimals1 unit = ? {base}Source +
    {currency.code} + { + const name = event.target.value; + mutate((draft) => updateCurrency(draft, currency.code, { name })); + }} + /> + + { + const symbol = event.target.value; + mutate((draft) => updateCurrency(draft, currency.code, { symbol })); + }} + /> + + { + const digits = Number(event.target.value); + if (!Number.isInteger(digits) || digits < 0 || digits > 4) return; + mutate((draft) => updateCurrency(draft, currency.code, { digits })); + }} + /> + + {isBase ? ( + 1 + ) : ( + { + // A cleared or non-positive field MUST remove the rate + // row rather than store 0: toBase() converts a stored 0 + // silently to a 0-value conversion, while an absent row + // correctly throws MissingRateError. Storing 0 here + // would turn a loud, correct error into wrong numbers + // presented as fact. + const parsed = Number(event.target.value); + mutate((draft) => { + if (Number.isFinite(parsed) && parsed > 0) { + setFxRate(draft, { + currency: currency.code, + baseUnitsPerOne: parsed, + updatedAt: new Date().toISOString(), + source: "manual", + }); + } else { + removeFxRate(draft, currency.code); + } }); - } else { - removeFxRate(draft, currency); - } - }); - }} - /> - - {rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"} -
    + }} + /> + )} +
    + {isBase ? "—" : rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"} + + +
    +
    -
    -
    - + tryEdit(() => mutate((draft) => addCurrency(draft, def)))} /> + +
    +
    + -

    +

    {"{base}"} and {"{targets}"} are substituted. Fetching is optional — the app - works offline with the rates you type in above. + works offline with the rates you type in above, and a currency the service + does not know can always be maintained by hand.

    - {status &&

    {status}

    } + {status &&

    {status}

    }
    -
    + + ); +} + +/** + * Decimals default to 2 because almost every currency uses hundredths; the + * field is there for the ones that do not (yen at 0, dinars at 3). + */ +function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number; symbol?: string; name?: string }) => void }) { + const [code, setCode] = useState(""); + const [name, setName] = useState(""); + const [symbol, setSymbol] = useState(""); + const [digits, setDigits] = useState("2"); + + const parsedDigits = Number(digits); + const canAdd = + /^[A-Za-z]{2,8}$/.test(code.trim()) && + Number.isInteger(parsedDigits) && + parsedDigits >= 0 && + parsedDigits <= 4; + + function add() { + onAdd({ code: code.trim(), digits: parsedDigits, symbol, name }); + setCode(""); + setName(""); + setSymbol(""); + setDigits("2"); + } + + return ( +
    + { + const next = event.target.value; + setCode(next); + }} + /> + { + const next = event.target.value; + setName(next); + }} + /> + { + const next = event.target.value; + setSymbol(next); + }} + /> +
    + + { + const next = event.target.value; + setDigits(next); + }} + /> +
    + +
    ); } diff --git a/src/ui/routes/settings/DataSection.tsx b/src/ui/routes/settings/DataSection.tsx index fba6e99..82a8ef3 100644 --- a/src/ui/routes/settings/DataSection.tsx +++ b/src/ui/routes/settings/DataSection.tsx @@ -11,6 +11,7 @@ import { } 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(); @@ -76,19 +77,27 @@ export function DataSection() { // replace, never after. If the backup itself fails, abort rather than // destroy the only copy of the user's data. if (!runExport(buildJsonExport)) return; - await store.replace(pending); + // 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 ( -
    -

    Your data

    -

    - Everything lives in this browser. Export regularly — it is both your - backup and how you move your budget to another device. -

    +
    )} -
    +
    ); } diff --git a/src/ui/routes/settings/PostsSection.tsx b/src/ui/routes/settings/PostsSection.tsx index 32ebdf5..f74dbeb 100644 --- a/src/ui/routes/settings/PostsSection.tsx +++ b/src/ui/routes/settings/PostsSection.tsx @@ -4,10 +4,11 @@ import { Input } from "@/components/ui/input"; import { useDataset } from "../../hooks/useDataset.ts"; import { useMutate } from "../../hooks/useMutate.ts"; import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts"; -import { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts"; +import type { Currency, Post } from "../../../domain/types.ts"; import { ruleAt } from "../../../domain/allocation.ts"; import { currentMonth } from "../../../store/index.ts"; import { RuleHistory } from "../../components/RuleHistory.tsx"; +import { Section } from "../../components/Section.tsx"; export function PostsSection() { const dataset = useDataset(); @@ -34,22 +35,17 @@ export function PostsSection() { } return ( -
    -

    Posts

    -

    - A post's allocation is a dated series: each rule applies from its own - month onward until the next one takes over, so changing it never - rewrites what earlier months got. Click a rule to see and edit that - history. Any single month can still override its own allocation from - the month view. Percentages may total more than 100%. -

    +
    - - - - - + + + + + @@ -76,7 +72,7 @@ export function PostsSection() { mutate((draft) => updatePost(draft, post.id, { currency })); }} > - {CURRENCIES.map((currency) => ( + {dataset.currencies.map(({ code: currency }) => ( @@ -121,7 +117,7 @@ export function PostsSection() { ))}
    NameDisplay currencyRule
    NameDisplay currencyRule
    -

    +

    Archiving hides a post from new purchases while keeping its history. Posts are never deleted, because purchases reference them.

    @@ -144,6 +140,6 @@ export function PostsSection() { Add post -
    +
    ); } diff --git a/src/ui/storeWriteErrors.test.ts b/src/ui/storeWriteErrors.test.ts new file mode 100644 index 0000000..353f147 --- /dev/null +++ b/src/ui/storeWriteErrors.test.ts @@ -0,0 +1,58 @@ +import { test, expect } from "bun:test"; +import { Glob } from "bun"; + +/** + * A store write is the only step that can fail after the user has committed to + * an action, and IndexedDB fails for reasons no code can prevent — a full disk, + * a browser evicting storage, private-mode quotas. An awaited `store.*` call + * with no handler is an unhandled rejection: the button appears to do nothing + * and the user is told nothing. + * + * `useMutate` exists so most call sites cannot make this mistake. This guards + * the ones that talk to the store directly. Source-level rather than + * behavioural, like eventCapture.test.ts, because reproducing it needs a real + * failing IndexedDB. + */ +test("every awaited store write in the UI is guarded", async () => { + const offenders: string[] = []; + + for await (const file of new Glob("src/ui/**/*.{ts,tsx}").scan(".")) { + if (file.endsWith(".test.ts") || file.endsWith(".test.tsx")) continue; + // useMutate IS the guard — it wraps store.mutate in a .catch. + if (file.endsWith("hooks/useMutate.ts")) continue; + const source = await Bun.file(file).text(); + + for (const match of source.matchAll(/await\s+store\.\w+\(/g)) { + const enclosing = enclosingFunction(source, match.index); + if (!/try\s*\{/.test(enclosing) && !/\.catch\(/.test(enclosing)) { + const line = source.slice(0, match.index).split("\n").length; + offenders.push(`${file}:${line}`); + } + } + } + + expect(offenders).toEqual([]); +}); + +/** + * The body of the nearest `function` declaration containing `index`, found by + * brace matching forward from its opening brace. Falls back to the whole file + * when the call is not inside a function declaration at all. + */ +function enclosingFunction(source: string, index: number): string { + const start = source.lastIndexOf("function ", index); + if (start === -1) return source; + + let depth = 0; + let i = source.indexOf("{", start); + if (i === -1 || i > index) return source; + const open = i; + for (; i < source.length; i++) { + if (source[i] === "{") depth++; + else if (source[i] === "}") { + depth--; + if (depth === 0) break; + } + } + return index < i ? source.slice(open, i + 1) : source; +} diff --git a/src/ui/swUpdate.test.ts b/src/ui/swUpdate.test.ts new file mode 100644 index 0000000..57da326 --- /dev/null +++ b/src/ui/swUpdate.test.ts @@ -0,0 +1,100 @@ +import { test, expect, describe } from "bun:test"; +import { requestUpdate, type UpdateHandle } from "./swUpdate.ts"; + +function fakeHandle() { + const events: string[] = []; + let controllerListener: (() => void) | null = null; + let timer: { fn: () => void; ms: number } | null = null; + let unsubscribed = false; + + const handle: UpdateHandle = { + postSkipWaiting() { + events.push("postSkipWaiting"); + }, + onControllerChange(listener) { + events.push("listen"); + controllerListener = listener; + return () => { + unsubscribed = true; + }; + }, + setTimer(fn, ms) { + timer = { fn, ms }; + return () => {}; + }, + reload() { + events.push("reload"); + }, + }; + + return { + handle, + events, + get unsubscribed() { + return unsubscribed; + }, + fireControllerChange: () => controllerListener?.(), + fireTimeout: () => timer?.fn(), + get timerMs() { + return timer?.ms; + }, + }; +} + +describe("applying a waiting service worker", () => { + /** + * The bug: applyUpdate posted SKIP_WAITING and called reload() on the very + * next line. Measured in Chrome, the controller had NOT changed at that + * instant — it changed 2 ms later — so the reload raced activation. When the + * reload won, the fresh page still saw registration.waiting and showed + * "A new version is ready" again, and clicking Reload re-entered the same + * race. The prompt never went away. + */ + test("does not reload until the new worker actually takes control", () => { + const f = fakeHandle(); + requestUpdate(f.handle); + expect(f.events).toEqual(["listen", "postSkipWaiting"]); + expect(f.events).not.toContain("reload"); + }); + + test("listens before posting, or a fast activation is missed entirely", () => { + const f = fakeHandle(); + requestUpdate(f.handle); + expect(f.events.indexOf("listen")).toBeLessThan(f.events.indexOf("postSkipWaiting")); + }); + + test("reloads once the controller changes", () => { + const f = fakeHandle(); + requestUpdate(f.handle); + f.fireControllerChange(); + expect(f.events).toEqual(["listen", "postSkipWaiting", "reload"]); + }); + + /** + * A worker that ignores SKIP_WAITING — the deliberately inert one the dev + * server serves, or a genuinely broken build — must not leave the Reload + * button dead. Reload anyway; the user asked for it. + */ + test("reloads anyway if the worker never takes control", () => { + const f = fakeHandle(); + requestUpdate(f.handle, 1500); + expect(f.timerMs).toBe(1500); + f.fireTimeout(); + expect(f.events).toEqual(["listen", "postSkipWaiting", "reload"]); + }); + + test("reloads exactly once when both the change and the timeout fire", () => { + const f = fakeHandle(); + requestUpdate(f.handle); + f.fireControllerChange(); + f.fireTimeout(); + expect(f.events.filter((e) => e === "reload")).toHaveLength(1); + }); + + test("stops listening once it has reloaded", () => { + const f = fakeHandle(); + requestUpdate(f.handle); + f.fireControllerChange(); + expect(f.unsubscribed).toBe(true); + }); +}); diff --git a/src/ui/swUpdate.ts b/src/ui/swUpdate.ts new file mode 100644 index 0000000..44da19e --- /dev/null +++ b/src/ui/swUpdate.ts @@ -0,0 +1,52 @@ +/** + * Sequencing for "apply the waiting service worker, then reload". + * + * Pure, with every browser touchpoint injected, because the bug it fixes is + * entirely about ORDER and order is what a test can pin. The old code was: + * + * waiting.postMessage({ type: "SKIP_WAITING" }); + * window.location.reload(); + * + * Measured in Chrome: at the moment of that reload the new worker was still + * `installed` and the controller had not changed — it changed 2 ms later. So + * the reload raced activation. When the reload won, the fresh page load still + * found `registration.waiting` and showed "A new version is ready" again, and + * pressing Reload re-entered the same race. The prompt never went away. + * + * The window is wider than 2 ms in practice: the worker's `activate` handler + * deletes every stale cache before calling `clients.claim()`. + */ +export interface UpdateHandle { + /** Tells the waiting worker to activate now. */ + postSkipWaiting(): void; + /** Subscribes to the page's controller changing. Returns an unsubscribe. */ + onControllerChange(listener: () => void): () => void; + setTimer(fn: () => void, ms: number): () => void; + reload(): void; +} + +/** + * `timeoutMs` is a backstop, not a delay: a worker that ignores SKIP_WAITING — + * the deliberately inert one the dev server serves, or a broken build — must + * not leave the Reload button doing nothing at all. It reloads anyway, because + * that is what the user asked for. + */ +export function requestUpdate(handle: UpdateHandle, timeoutMs = 3000): void { + let reloaded = false; + + const finish = () => { + if (reloaded) return; + reloaded = true; + stopListening(); + cancelTimer(); + handle.reload(); + }; + + // Subscribe BEFORE posting. Activation can complete in a couple of + // milliseconds, and a listener attached afterwards would miss it and fall + // through to the timeout every time. + const stopListening = handle.onControllerChange(finish); + const cancelTimer = handle.setTimer(finish, timeoutMs); + + handle.postSkipWaiting(); +} diff --git a/src/ui/theme.test.ts b/src/ui/theme.test.ts new file mode 100644 index 0000000..d37adfc --- /dev/null +++ b/src/ui/theme.test.ts @@ -0,0 +1,90 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +// Bun can run several test files in one process and other files register +// happy-dom too — guard so they can all run together. +if (!GlobalRegistrator.isRegistered) { + GlobalRegistrator.register(); +} + +import { test, expect, describe, beforeEach } from "bun:test"; +import { + THEME_KEY, + applyResolvedTheme, + isThemePreference, + readThemePreference, + resolveTheme, + storeThemePreference, +} from "./theme.ts"; + +beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove("dark"); +}); + +describe("resolving a preference to an actual theme", () => { + test("an explicit choice ignores the system setting entirely", () => { + expect(resolveTheme("light", true)).toBe("light"); + expect(resolveTheme("dark", false)).toBe("dark"); + }); + + test("'system' follows the operating system", () => { + expect(resolveTheme("system", true)).toBe("dark"); + expect(resolveTheme("system", false)).toBe("light"); + }); +}); + +describe("reading the stored preference", () => { + test("defaults to following the system when nothing is stored", () => { + expect(readThemePreference()).toBe("system"); + }); + + test("round-trips each preference", () => { + for (const preference of ["light", "dark", "system"] as const) { + storeThemePreference(preference); + expect(readThemePreference()).toBe(preference); + } + }); + + // A value can only be junk if something else wrote it, or the user edited + // it. Following the system beats throwing during boot. + test("junk in storage falls back to following the system", () => { + localStorage.setItem(THEME_KEY, "solarized"); + expect(readThemePreference()).toBe("system"); + }); + + test("only the three real preferences are accepted", () => { + expect(isThemePreference("light")).toBe(true); + expect(isThemePreference("dark")).toBe(true); + expect(isThemePreference("system")).toBe(true); + expect(isThemePreference("Dark")).toBe(false); + expect(isThemePreference(null)).toBe(false); + expect(isThemePreference(undefined)).toBe(false); + }); +}); + +describe("applying a theme", () => { + test("dark adds the class shadcn's palette is keyed on, light removes it", () => { + applyResolvedTheme("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + applyResolvedTheme("light"); + expect(document.documentElement.classList.contains("dark")).toBe(false); + }); + + test("applying the same theme twice is not a toggle", () => { + applyResolvedTheme("dark"); + applyResolvedTheme("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); +}); + +/** + * index.html carries a tiny inline script that applies the stored theme before + * the stylesheet paints, so a dark-theme user never sees a white flash. It + * cannot import this module, so it repeats the key and the logic — and this is + * what stops the two drifting apart. + */ +test("the no-flash script in index.html agrees with this module", async () => { + const html = await Bun.file("src/index.html").text(); + expect(html).toContain(THEME_KEY); + expect(html).toContain("prefers-color-scheme: dark"); + expect(html).toContain("dark"); +}); diff --git a/src/ui/theme.ts b/src/ui/theme.ts new file mode 100644 index 0000000..59f1a52 --- /dev/null +++ b/src/ui/theme.ts @@ -0,0 +1,70 @@ +/** + * Theme preference: light, dark, or follow the system. + * + * Stored in localStorage rather than in the dataset, deliberately. The dataset + * is the owner's budget — it is exported, imported and versioned, and a + * per-device display choice does not belong in it. Importing a backup taken on + * a desktop should not flip a phone to dark. + * + * Every storage access is wrapped: localStorage THROWS on access in some + * configurations (a browser set to block site data, some private modes), not + * merely returning null, and a theme preference must never be the reason the + * app fails to boot. + */ + +export const THEME_KEY = "budget2:theme"; + +/** What the user chose. */ +export type ThemePreference = "light" | "dark" | "system"; + +/** What that resolves to right now. Only these two reach the DOM. */ +export type ResolvedTheme = "light" | "dark"; + +const PREFERENCES: readonly string[] = ["light", "dark", "system"]; + +export function isThemePreference(value: unknown): value is ThemePreference { + return typeof value === "string" && PREFERENCES.includes(value); +} + +export function readThemePreference(): ThemePreference { + try { + const stored = localStorage.getItem(THEME_KEY); + return isThemePreference(stored) ? stored : "system"; + } catch { + return "system"; + } +} + +export function storeThemePreference(preference: ThemePreference): void { + try { + localStorage.setItem(THEME_KEY, preference); + } catch { + // A preference that cannot be remembered is a small loss; a crash is not. + } +} + +export function prefersDark(): boolean { + try { + return window.matchMedia("(prefers-color-scheme: dark)").matches; + } catch { + return false; + } +} + +export function resolveTheme( + preference: ThemePreference, + systemPrefersDark: boolean, +): ResolvedTheme { + if (preference === "system") return systemPrefersDark ? "dark" : "light"; + return preference; +} + +/** + * shadcn's dark palette is keyed on a `.dark` class rather than a media query + * (`@custom-variant dark (&:is(.dark *))` in styles/globals.css), so this + * class is the single switch for both shadcn's variables and the app's own + * `--budget-*` tokens. + */ +export function applyResolvedTheme(theme: ResolvedTheme): void { + document.documentElement.classList.toggle("dark", theme === "dark"); +}