Merge: TODO sweep, design foundation, service worker fixes, user-defined currencies

Sections 2 and 4 of TODO.md complete, section 3 (redesign) one sub-project in,
plus two features requested mid-flight. Schema moves to version 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-02 15:53:03 +02:00
commit 151cd530f5
97 files changed
+4837 -681

No files matched your search

+5 -2
View File
@@ -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 <url> <out.png>`
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.
+25 -6
View File
@@ -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
+48 -8
View File
@@ -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 <url> <out.png>` 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=<seed.js> --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.
+108 -41
View File
@@ -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 `<select>` elements are still browser-default, and `up`/`down`/`archive` are bare text buttons |
## 5. Needs a human with a browser
**Watch for:** the brand teal is `--primary`, never `--accent` — shadcn's
`--accent` is a hover *background* whose paired foreground must move with it.
`src/cssPairs.test.ts` enforces the pairing, and `src/ui/cssTokens.test.ts`
fails if a component names a token `index.css` does not define. That second
guard exists because renaming `--rule` silently made the carry meter paint
nothing: an undefined custom property inside `linear-gradient()` invalidates
the whole declaration without warning.
Nothing in the agent environment can run a browser, so these have never been
seen by anyone:
## 4. ~~Smaller items~~ — ALL DONE
- the **carry meter** under each post row in the month view — the signature
design element, verified only as a CSS gradient expression;
- the **rule-history editor** in Settings and the **"change from here"** control
in the month view. Their arithmetic is verified through the real fold, and
both badge predicates were driven as pure functions, but nobody has seen the
expand/collapse, the layout, the disabled states, or the focus order;
- **offline behaviour**, the **install prompt**, and the **update prompt**;
All six, `b9be882` / `71e8fc9` / `f50c65b`. Three were worse than recorded:
- `confirmImport` now guards its write, and a source-level check covers the
whole UI (`b9be882`).
- The PNG icons **were** generatable here — Chrome is on PATH and rasterizes
the SVG exactly. `scripts/make-icons.ts` produces them; they are committed,
precached, and pixel-verified against the source (`f50c65b`).
- Split values now round by what they MEAN — money in "fixed" mode, a
percentage in "percent" mode. Narrower than the note claimed: `round2` was
always right for `Rule.percent`, which is not money (`b9be882`).
- `EMPTY_FIGURES` is frozen (`b9be882`).
- The ODS export was **not** "fine at personal-budget scale": 30 posts over 10
years took 7983 ms synchronously on the main thread. Now 135 ms, with
byte-identical output (`71e8fc9`).
- The dev manifest did not merely hardcode `scope: "/"` — it was never served
at all; the catch-all answered with the HTML shell. One definition now feeds
the build and the dev server (`b9be882`).
## 6. ~~User-defined currencies, and amounts that carry their currency~~ — DONE
Both `cdece63`. `Currency` is an open string code and `Dataset.currencies` is
the authority on validity, symbols and decimal places; the purchase total
field reads "30$" as USD 30.
**The part worth knowing before touching money again:** `roundMoney(amount,
digits)` takes a digit count, and its ~29 call sites each resolve digits from
the dataset with `digitsFor`. The argument is required deliberately — every
currency the app shipped with uses two decimals, so a default of 2 would leave
a missed call site passing every test and silently wrong for the first
zero-decimal currency added. `src/domain/currencyDigits.test.ts` is the only
test that can tell a real implementation from one that assumes 2: it drives a
JPY-style currency through rounding, both FX directions, both split modes,
plan division, the fold and the month view.
Left undone, deliberately:
- **The income field and the split editor's fixed-amount fields do not parse a
currency yet** — only the purchase total does. Same helper
(`parseMoneyInput`), same three-line pattern; the income field is arguably
wrong to make multi-currency at all, since a month has one income.
- **No currency picker convenience.** Adding one means typing the code, name,
symbol and decimals by hand. A bundled ISO 4217 list would be nicer, and is
pure data.
- **Symbol collisions resolve to the first currency defined with that symbol.**
Fine for one "$" currency; if the owner adds two, typing the code
disambiguates and nothing warns them. Tested, documented, not solved.
## 5. Still needs a human
`scripts/screenshot.ts` drives headless Chrome, so the list here is much
shorter than it was. **Seen and confirmed working:** the carry meter (it
renders, and shows red where a post is overspent), the rule-history editor in
Settings, the "change from here" control, the baked FX rates arriving in a
fresh dataset, and every route at desktop and mobile widths.
Genuinely outstanding:
- **How it looks and feels.** A screenshot proves a layout is not broken. It
cannot tell you the design is good, and the container's fonts are not the
owner's fonts.
- **Offline behaviour** and the **install prompt**. The service worker itself
is no longer unverified: it demonstrably installs, activates, claims the
page, precaches all 11 assets, and applies an update cleanly (`e6da85e`) —
before that fix it had never installed once, in any environment, because
`cache.addAll` rejects on a duplicate request. What is still unverified is
a real offline load with the network actually cut, and Chrome's install
prompt appearing.
- the generated **`.ods` opening in a real spreadsheet** (validated
structurally: mimetype first and stored, well-formed XML, numeric cells);
structurally: mimetype first and stored, well-formed XML, numeric cells).
- **keyboard flow in fast entry** — Tab traversal, Backspace-to-remove, and
focus placement after a row auto-appends;
focus placement after a row auto-appends. Drivable over CDP in principle;
not yet done.
- **GitHub Actions** has never been executed.
+101 -23
View File
@@ -2,11 +2,72 @@ import tailwind from "bun-plugin-tailwind";
import { rm } from "node:fs/promises";
import path from "node:path";
import { normalizeBase } from "./src/ui/basePath.ts";
import { buildManifest } from "./src/manifest.ts";
import { FONT_FILES } from "./src/fontCss.ts";
import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./src/store/fxApi.ts";
import { FALLBACK_FX_RATES } from "./src/store/bakedRates.ts";
import type { Currency, FxRate } from "./src/domain/types.ts";
const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH);
const outdir = path.join(process.cwd(), "dist");
const version = process.env.BUILD_VERSION ?? String(Date.now());
const FX_BASE: Currency = "DKK";
const FX_TARGETS: Currency[] = ["USD", "EUR"];
const FX_TIMEOUT_MS = 8_000;
/**
* Exchange rates embedded at build time so a fresh install can convert a EUR
* purchase on day one instead of erroring. They seed a new dataset only — see
* src/store/bakedRates.ts for why they must never backstop a missing rate.
*
* A network failure must NOT fail the build: it falls back to the committed
* constants, which are labelled "manual" so Settings never claims a hardcoded
* number came from a rate service. Fetched rates carry the build date rather
* than the date the browser happens to run, which is why parseFxResponse
* takes an explicit `updatedAt`.
*
* SKIP_FX_FETCH=1 forces the constants, for offline or reproducible builds.
* FX_API_URL overrides the endpoint.
*/
async function bakeFxRates(): Promise<readonly FxRate[]> {
const buildDate = new Date().toISOString().slice(0, 10);
const describe = (rates: readonly FxRate[]) =>
rates.map((r) => `${r.currency} ${r.baseUnitsPerOne}`).join(", ");
if (process.env.SKIP_FX_FETCH === "1") {
console.log(`Baking the committed FX constants (SKIP_FX_FETCH=1): ${describe(FALLBACK_FX_RATES)}`);
return FALLBACK_FX_RATES;
}
const template = process.env.FX_API_URL ?? DEFAULT_FX_API_URL;
try {
const response = await fetch(buildFxUrl(template, FX_BASE, FX_TARGETS), {
signal: AbortSignal.timeout(FX_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`rate service returned ${response.status}`);
const rates = parseFxResponse(await response.json(), FX_BASE, FX_TARGETS, buildDate);
// A partial response would leave one currency unconvertible on a fresh
// install, which is the whole problem this solves. Prefer the complete
// constants over an incomplete live answer.
const covered = new Set(rates.map((r) => r.currency));
const missing = FX_TARGETS.filter((c) => !covered.has(c));
if (missing.length > 0) throw new Error(`response omitted ${missing.join(", ")}`);
console.log(`Baked live FX rates for ${buildDate}: ${describe(rates)}`);
return rates;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
console.warn(
`Could not fetch FX rates (${message}); baking the committed constants instead: ${describe(FALLBACK_FX_RATES)}`,
);
return FALLBACK_FX_RATES;
}
}
const bakedFxRates = await bakeFxRates();
await rm(outdir, { recursive: true, force: true });
const result = await Bun.build({
@@ -43,6 +104,9 @@ const result = await Bun.build({
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
"process.env.BUN_PUBLIC_BASE_PATH": JSON.stringify(basePath),
// Double-encoded on purpose: the inlined literal must be a JSON *string*
// for parseBakedRates to parse at runtime.
"process.env.BUN_PUBLIC_BAKED_FX_RATES": JSON.stringify(JSON.stringify(bakedFxRates)),
},
});
@@ -51,11 +115,28 @@ if (!result.success) {
throw new Error("Build failed");
}
/**
* Written to dist below rather than emitted by the bundler, so they are absent
* from `result.outputs` and have to be listed for the precache by name.
* Without them an installed app cannot render its own icon offline, and the
* manifest 404s.
*/
const STATIC_ASSETS = [
"manifest.webmanifest",
"icon.svg",
"icon-192.png",
"icon-512.png",
...FONT_FILES.map((file) => `fonts/${file}`),
];
// The precache list comes from the build's own outputs, so hashed filenames can
// never drift out of sync with the service worker.
const precache = result.outputs
.filter((output) => !output.path.endsWith(".map"))
.map((output) => basePath + path.relative(outdir, output.path).replaceAll(path.sep, "/"));
const precache = [
...result.outputs
.filter((output) => !output.path.endsWith(".map"))
.map((output) => basePath + path.relative(outdir, output.path).replaceAll(path.sep, "/")),
...STATIC_ASSETS.map((file) => basePath + file),
];
const swResult = await Bun.build({
entrypoints: ["src/sw.ts"],
@@ -75,25 +156,17 @@ if (!swResult.success) {
throw new Error("Service worker build failed");
}
export function buildManifest(base: string): string {
return JSON.stringify(
{
name: "Budget 2.0",
short_name: "Budget",
start_url: base,
scope: base,
display: "standalone",
background_color: "#0f172a",
theme_color: "#0f172a",
icons: [{ src: `${base}icon.svg`, sizes: "any", type: "image/svg+xml" }],
},
null,
2,
);
}
await Bun.write(path.join(outdir, "manifest.webmanifest"), buildManifest(basePath));
await Bun.write(path.join(outdir, "icon.svg"), Bun.file("src/icon.svg"));
for (const size of [192, 512]) {
await Bun.write(
path.join(outdir, `icon-${size}.png`),
Bun.file(`src/icon-${size}.png`),
);
}
for (const file of FONT_FILES) {
await Bun.write(path.join(outdir, "fonts", file), Bun.file(`src/fonts/${file}`));
}
// This is an SPA: every route serves the same shell, so the *document* URL
// can be several segments deep (e.g. /budget2.0/month/2026-09). Relative
@@ -105,9 +178,14 @@ await Bun.write(path.join(outdir, "icon.svg"), Bun.file("src/icon.svg"));
// rather than hardcoded in src/index.html so the dev server (which always
// serves from "/") keeps working unchanged.
let indexHtml = await Bun.file(path.join(outdir, "index.html")).text();
indexHtml = indexHtml
.replace('href="manifest.webmanifest"', `href="${basePath}manifest.webmanifest"`)
.replace('href="icon.svg"', `href="${basePath}icon.svg"`);
// src/index.html references these relatively, because the dev server's HTML
// bundler resolves every <link href> from disk and cannot be told to skip an
// absolute one. Relative hrefs would 404 on any route deeper than the base
// (/budget2.0/month/2026-09 resolves them against /budget2.0/month/), so
// rewrite them absolute here. At "/" each replacement is a no-op.
for (const asset of ["manifest.webmanifest", "icon.svg"]) {
indexHtml = indexHtml.replace(`href="${asset}"`, `href="${basePath}${asset}"`);
}
await Bun.write(path.join(outdir, "index.html"), indexHtml);
// GitHub Pages has no rewrite rules, so a deep link on a cold load lands on
@@ -0,0 +1,152 @@
# Design foundation
Sub-project 1 of the visual redesign. Owner's brief: *"right now it looks a bit
boring, simple and ugly. We want sleek, modern, and nice to use."* Owner's
answers to the four scoping questions: both themes with a toggle, a full
rethink of every screen, purchases compact and grouped by date, and a
self-hosted webfont. Owner then delegated the specifics: *"Override everything
you want for this step. This needs to look good."*
## The diagnosis that shaped everything else
A design system already existed and was never wired up. `src/index.css`
defined `--paper` (annotated "cool statement-paper ground, NOT cream"),
`--ink`, a muted teal, and surplus/overspend colours, with a design-contract
document behind them. The app painted itself with shadcn's defaults —
`bg-background text-foreground`, i.e. white and near-black. Those tokens
reached the screen in exactly two places: the carry meter, and the sign
colours on figures.
So "boring, simple and ugly" was not a missing design. It was a design that was
specified and then bypassed. That is why the foundation comes first in the
decomposition, and why it re-themes shadcn rather than styling screens: doing
it screen by screen is how the bypass happened.
## Decomposition
The redesign is four sub-projects, each its own spec → plan → implementation:
1. **Design foundation** (this document) — tokens, themes, type, shared furniture.
2. **Month view** — income and totals, post rows and carry meter, purchases grouped by date.
3. **Year, Summary, Post detail** — the reporting screens, including whether the 12-month matrix stays a scrolling table.
4. **Dialogs and fast entry** — purchase dialog, split editor, plan editor, bulk entry.
A single spec covering all four would have meant dozens of layout decisions
made on the owner's behalf under one approval.
## Token architecture
Two layers, deliberately separate.
**Layer 1, `--budget-*`**: the app's own vocabulary — paper, raised, ink,
ink-muted, rule, rule-strong, accent, accent-ink, tint — plus `--surplus` and
`--overspend`, which shadcn has no concept of. Defined once per theme. The only
colours app components may name.
**Layer 2, shadcn**: its entire variable set points at layer 1. Every primitive
(dialog, select, badge, button) inherits both themes for free.
### The brand teal is `--primary`, never `--accent`
CLAUDE.md's hardest-won rule is "never define a bare `--accent`", paid for with
an app-wide contrast bug. The mechanism matters: shadcn uses `--accent` as a
hover **background** with `--accent-foreground` as the text on it. Setting one
without the other repaints the background and leaves the text behind.
Two conclusions follow, and both are enforced rather than remembered:
- Every shadcn pair is redefined **together**, in both themes.
`src/cssPairs.test.ts` fails the build if either half of a pair appears
without the other, in any block.
- The saturated teal belongs on `--primary`. Putting a brand colour on
`--accent` — which is what "override `--accent`" invites — turns every hover
into a coloured slab with dark text on it. `--accent` stays a tint of the
surface.
### Contrast was computed, not chosen
Weakest text pair: 5.50:1 (muted on paper, light) against a 4.5:1 requirement.
Field borders were iterated until they cleared the 3:1 non-text requirement on
**both** backgrounds they appear against — the first two candidates measured
2.17 and 2.55 and were rejected. The final palette was then read back out of
the live browser in both themes, so the check is on what actually rendered
rather than on the arithmetic.
## Typography
Inter for text, JetBrains Mono for figures, both variable, both self-hosted,
latin + latin-ext only. SIL OFL 1.1; licences committed beside the files.
Self-hosted rather than CDN-linked because PRODUCT.md's position is that the
data never leaves the browser and the app needs no third party — a font request
to another origin on every cold load quietly contradicts that.
`.font-money` carries tabular figures and a slashed zero, so columns align on
the decimal and `0` cannot be misread as `O`.
### Rejected: @font-face in the bundled stylesheet
The obvious home. Bun's CSS bundler resolves every `url()` from disk and inlines
woff2 as base64 — **relative or absolute**, and `loader: { ".woff2": "file" }`
does not change it. Measured cost: `index.css` went from 70 KB to 303 KB of
render-blocking bytes, and the `unicode-range` subsetting became pointless,
since a subset embedded in the stylesheet downloads whether or not one
character needs it.
Also rejected: a `<link rel="stylesheet" href="/fonts.css">`. It works in a
production build (`external: ["*fonts.css"]`), but the dev server's HTML
bundler resolves every `<link href>` from disk and has no `external` option, so
dev would serve a placeholder and screenshots would lie about typography.
Making the manifest and icon hrefs root-absolute for the same reason 500'd the
dev server outright.
**Chosen**: `src/fontCss.ts` is the one definition, injected as a `<style>` at
boot by `frontend.tsx`. Identical in dev and in a subpath build, no placeholder
file, no href rewriting. The cost is that faces are requested after the JS
parses rather than from the HTML; `font-display: swap` keeps text readable
throughout, and the service worker precaches all four files.
## Theme switching
Cycles system → light → dark. Three states because "follow the system" is a
real answer and, once you have chosen otherwise, there must be a way back to
it. While set to "system" the app keeps listening, so it follows the OS
switching at sunset without a reload.
Stored in `localStorage`, not in the dataset: the dataset is the owner's budget,
which is exported, imported and schema-versioned, and importing a backup taken
on a desktop must not flip a phone to dark. Every storage access is wrapped —
`localStorage` **throws** on access in some configurations rather than
returning null, and a display preference must never be why the app fails to
boot.
A tiny inline script in `index.html` applies the saved theme before first
paint, so a dark-theme user never sees a flash of the light palette. It cannot
import the module (it has to run before any module loads), so it repeats the
key and the fallback logic; `src/ui/theme.test.ts` asserts the two agree.
## The regression this produced, and the guard it earned
Renaming `--rule` to `--budget-rule` silently killed the carry meter — the
month view's signature element. An undefined custom property inside
`linear-gradient()` does not warn, does not throw, and does not fall back: the
whole declaration is invalid, so the element paints nothing. It looked like a
missing feature rather than a bug, and no test noticed. Pixel-sampling a
screenshot found it.
`src/ui/cssTokens.test.ts` now fails if any TypeScript file references a custom
property `src/index.css` does not define. This is the second guard of its kind
in the repo, after `eventCapture.test.ts`, and for the same reason: the failure
is invisible in review and invisible in a green suite.
## Verification, and its limits
`scripts/screenshot.ts` drives headless Chrome over CDP, which is how any of
this was seen at all. Both themes were rendered with real data on every route
at desktop and mobile widths; the resolved tokens were read out of the browser;
the carry meter's presence was confirmed by pixel sampling rather than by eye.
Not verified, and not verifiable here: how it feels, real offline behaviour,
the install prompt, and typography on the owner's actual platform — this
container has no Inter installed for the fallback stack and its fontconfig maps
every generic family to a monospace face, so screenshots need a
`FONTCONFIG_FILE` override to be representative at all.
+63
View File
@@ -0,0 +1,63 @@
/**
* Downloads the self-hosted webfonts into src/fonts/.
*
* bun scripts/fetch-fonts.ts
*
* Self-hosted, not linked from a CDN, because PRODUCT.md's position is that the
* app works offline and needs no third party: a font request to another origin
* on every cold load contradicts that, and would leak the fact that the app was
* opened. The files are COMMITTED so neither the build nor a fresh checkout
* needs network.
*
* Only the `latin` and `latin-ext` subsets are taken. Danish needs æ, ø and å
* (U+00E6, U+00F8, U+00E5), all inside `latin`; `latin-ext` covers the rest of
* European diacritics for post names. Cyrillic, Greek and Vietnamese are
* dropped, which is most of the weight.
*
* Both faces are SIL Open Font License 1.1, which permits self-hosting as long
* as the licence travels with them — hence src/fonts/OFL.txt.
*/
import { mkdir } from "node:fs/promises";
const UA =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";
const FAMILIES = [
{ css: "Inter:wght@100..900", slug: "inter" },
{ css: "JetBrains+Mono:wght@400..700", slug: "jetbrains-mono" },
];
const WANTED = new Set(["latin", "latin-ext"]);
await mkdir("src/fonts", { recursive: true });
for (const family of FAMILIES) {
const url = `https://fonts.googleapis.com/css2?family=${family.css}&display=swap`;
const css = await (await fetch(url, { headers: { "user-agent": UA } })).text();
// Google labels each @font-face block with a /* subset */ comment above it.
const blocks = css.split("/*").slice(1);
let taken = 0;
for (const block of blocks) {
const subset = block.slice(0, block.indexOf("*/")).trim();
if (!WANTED.has(subset)) continue;
const src = /url\((https:\/\/[^)]+\.woff2)\)/.exec(block)?.[1];
const range = /unicode-range:\s*([^;]+);/.exec(block)?.[1]?.trim();
if (!src || !range) throw new Error(`${family.slug}/${subset}: no src or unicode-range`);
const response = await fetch(src, { headers: { "user-agent": UA } });
if (!response.ok) throw new Error(`${src} returned ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
const file = `src/fonts/${family.slug}-${subset}.woff2`;
await Bun.write(file, bytes);
console.log(`${file} ${bytes.length} bytes`);
console.log(` unicode-range: ${range}`);
taken++;
}
if (taken !== WANTED.size) {
throw new Error(`${family.slug}: took ${taken} subsets, expected ${WANTED.size}`);
}
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Rasterizes src/icon.svg into the PNG sizes Chrome's install prompt wants.
*
* bun scripts/make-icons.ts
*
* The output is COMMITTED rather than produced by build.ts, because this needs
* a Chrome binary on PATH and the build must not. Re-run it whenever
* src/icon.svg changes; src/icons.test.ts checks the committed files are real
* images of the right dimensions, but it cannot tell you they are STALE.
*
* Chrome is the rasterizer because it is the only one present in this
* environment — no ImageMagick, no rsvg-convert, no cairosvg. It renders the
* SVG in a page sized exactly to the target, on a transparent backdrop, so the
* icon's own rounded corners stay transparent.
*/
import { rm, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
const SIZES = [192, 512];
const CHROME = process.env.CHROME ?? "google-chrome";
/** Chrome has been seen not to exit on its own here, so never wait forever. */
const TIMEOUT_MS = 60_000;
const work = await mkdtemp(path.join(tmpdir(), "budget-icons-"));
try {
await Bun.write(path.join(work, "icon.svg"), Bun.file("src/icon.svg"));
await Bun.write(
path.join(work, "wrap.html"),
`<!doctype html><meta charset="utf-8">
<style>html,body{margin:0;padding:0;background:transparent}
img{display:block;width:100vw;height:100vh}</style>
<img src="icon.svg">`,
);
for (const size of SIZES) {
const out = path.join(work, `icon-${size}.png`);
const chrome = Bun.spawn(
[
CHROME,
"--headless",
"--disable-gpu",
"--no-sandbox",
"--no-first-run",
"--hide-scrollbars",
"--force-device-scale-factor=1",
"--default-background-color=00000000",
`--screenshot=${out}`,
`--window-size=${size},${size}`,
`file://${path.join(work, "wrap.html")}`,
],
{ stdout: "ignore", stderr: "ignore" },
);
const timer = setTimeout(() => chrome.kill(), TIMEOUT_MS);
await chrome.exited;
clearTimeout(timer);
if (!(await Bun.file(out).exists())) {
throw new Error(`${CHROME} wrote no screenshot for ${size}px`);
}
const bytes = new Uint8Array(await Bun.file(out).arrayBuffer());
const view = new DataView(bytes.buffer);
const width = view.getUint32(16);
const height = view.getUint32(20);
if (width !== size || height !== size) {
throw new Error(`Expected ${size}x${size}, got ${width}x${height}`);
}
const dest = `src/icon-${size}.png`;
await Bun.write(dest, bytes);
console.log(`${dest} ${width}x${height} ${bytes.length} bytes`);
}
} finally {
await rm(work, { recursive: true, force: true });
}
+221
View File
@@ -0,0 +1,221 @@
/**
* Screenshots a running page with headless Chrome, and reports whatever the
* page logged to the console while doing it.
*
* bun scripts/screenshot.ts <url> <out.png> [--w=1280] [--h=900]
* [--wait=1500] [--full] [--click=<selector>]
* [--eval=<js>|--eval-file=<path>] [--dark] [--reload]
* [--profile=<dir>] [--eval-after-file=<path>]
*
* Why this exists: ARCHITECTURE.md and TODO.md both say nothing here can run a
* browser, so appearance has never been verified. Chrome IS available, but its
* plain `--screenshot` flag fires on the load event — before this app's async
* IndexedDB read resolves, which captures an empty page. Driving CDP directly
* lets us wait, act, and read console errors.
*
* This does NOT replace a human: it cannot judge feel, and it renders with one
* font stack on one platform. It does prove a layout exists and is not broken.
*/
const [url, out, ...flags] = process.argv.slice(2);
if (!url || !out) {
console.error("usage: bun scripts/screenshot.ts <url> <out.png> [--w=] [--h=] [--wait=] [--full] [--click=] [--eval=] [--dark]");
process.exit(2);
}
const flag = (name: string, fallback?: string) =>
flags.find((f) => f.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
const has = (name: string) => flags.includes(`--${name}`);
const width = Number(flag("w", "1280"));
const height = Number(flag("h", "900"));
const waitMs = Number(flag("wait", "1500"));
const chrome = Bun.spawn(
[
process.env.CHROME ?? "google-chrome",
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--no-first-run",
"--no-default-browser-check",
"--hide-scrollbars",
"--force-device-scale-factor=1",
"--remote-debugging-port=0",
// A fresh profile each run means empty IndexedDB, empty caches and NO
// registered service worker — which makes anything stateful across loads
// (an update prompt, a migration, an install) impossible to reproduce.
...(flag("profile") ? [`--user-data-dir=${flag("profile")}`] : []),
`--window-size=${width},${height}`,
...(has("dark") ? ["--force-dark-mode", "--enable-features=WebContentsForceDark"] : []),
"about:blank",
],
{ stdout: "ignore", stderr: "pipe" },
);
/** Chrome announces its debugging endpoint on stderr, once, at startup. */
async function debuggerUrl(): Promise<string> {
const reader = chrome.stderr.getReader();
const decoder = new TextDecoder();
let buffer = "";
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const match = /ws:\/\/[^\s]+/.exec(buffer);
if (match) return match[0];
}
throw new Error("Chrome never printed a DevTools endpoint");
}
const browserWs = await debuggerUrl();
const host = new URL(browserWs).host;
// The freshly launched about:blank tab is the one to drive.
const targets = (await (await fetch(`http://${host}/json/list`)).json()) as Array<{
type: string;
webSocketDebuggerUrl: string;
}>;
const page = targets.find((t) => t.type === "page");
if (!page) throw new Error("No page target to attach to");
const socket = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
socket.onopen = resolve;
socket.onerror = reject;
});
let nextId = 0;
const pending = new Map<number, (result: unknown) => void>();
const events: Array<{ method: string; params: any }> = [];
socket.onmessage = (event) => {
const message = JSON.parse(String(event.data));
if (message.id !== undefined) {
const settle = pending.get(message.id);
pending.delete(message.id);
settle?.(message);
} else {
events.push({ method: message.method, params: message.params });
}
};
/** Rejects on a CDP error rather than resolving undefined, which is otherwise
* indistinguishable from a command that legitimately returns nothing. */
function send(method: string, params: Record<string, unknown> = {}): Promise<any> {
const id = ++nextId;
// Register BEFORE sending: a reply that arrives first would otherwise find
// no handler and the promise would never settle.
const reply = new Promise<any>((resolve, reject) => {
pending.set(id, (message: any) => {
if (message.error) reject(new Error(`${method}: ${message.error.message}`));
else resolve(message.result);
});
});
socket.send(JSON.stringify({ id, method, params }));
return reply;
}
await send("Page.enable");
await send("Runtime.enable");
await send("Log.enable");
await send("Emulation.setDeviceMetricsOverride", {
width,
height,
deviceScaleFactor: 1,
mobile: width < 700,
});
if (has("dark")) {
await send("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-color-scheme", value: "dark" }],
});
// shadcn's dark palette is class-based (`.dark`), not media-query based, so
// emulating the media feature alone changes nothing. Add the class too.
await send("Page.addScriptToEvaluateOnNewDocument", {
source: `document.addEventListener("DOMContentLoaded", () =>
document.documentElement.classList.add("dark"));`,
});
}
await send("Page.navigate", { url });
// Wait for the load event, then for the app's own async work (the IndexedDB
// read, which plain --screenshot never waits for).
const loadDeadline = Date.now() + 20_000;
while (!events.some((e) => e.method === "Page.loadEventFired") && Date.now() < loadDeadline) {
await Bun.sleep(50);
}
await Bun.sleep(waitMs);
const evalFile = flag("eval-file");
const evalJs = evalFile ? await Bun.file(evalFile).text() : flag("eval");
if (evalJs) {
const result = await send("Runtime.evaluate", { expression: evalJs, awaitPromise: true });
console.log("eval:", JSON.stringify(result?.result?.value ?? result?.result?.description ?? null));
await Bun.sleep(400);
}
// --reload exists for --eval scripts that seed IndexedDB: the app reads its
// dataset once at boot, so it has to boot again to see what was written.
if (has("reload")) {
events.length = 0;
await send("Page.navigate", { url });
const again = Date.now() + 20_000;
while (!events.some((e) => e.method === "Page.loadEventFired") && Date.now() < again) {
await Bun.sleep(50);
}
await Bun.sleep(waitMs);
}
const clickSelector = flag("click");
if (clickSelector) {
const clicked = await send("Runtime.evaluate", {
expression: `(() => { const el = document.querySelector(${JSON.stringify(clickSelector)});
if (!el) return "not found"; el.click(); return "clicked"; })()`,
});
console.log(`click ${clickSelector}: ${clicked?.result?.value}`);
await Bun.sleep(700);
}
// Runs after the reload, so a flow that ends in a navigation can be observed
// on the other side of it — an update prompt applying itself, a migration.
const evalAfterFile = flag("eval-after-file");
if (evalAfterFile) {
const source = await Bun.file(evalAfterFile).text();
const result = await send("Runtime.evaluate", {
expression: source,
awaitPromise: true,
});
console.log(
"eval-after:",
JSON.stringify(result?.result?.value ?? result?.result?.description ?? null),
);
}
const shot = await send("Page.captureScreenshot", {
format: "png",
captureBeyondViewport: has("full"),
});
await Bun.write(out, Buffer.from(shot.data, "base64"));
// Console output is the point of doing this over a plain --screenshot: a page
// that renders blank because of a thrown error looks identical to an empty one.
const logs = events
.filter((e) => e.method === "Runtime.consoleAPICalled" || e.method === "Log.entryAdded")
.map((e) =>
e.method === "Log.entryAdded"
? `[${e.params.entry.level}] ${e.params.entry.text}`
: `[${e.params.type}] ${(e.params.args ?? [])
.map((a: any) => a.value ?? a.description ?? a.type)
.join(" ")}`,
);
const exceptions = events
.filter((e) => e.method === "Runtime.exceptionThrown")
.map((e) => `[uncaught] ${e.params.exceptionDetails?.exception?.description ?? e.params.exceptionDetails?.text}`);
console.log(`wrote ${out} (${width}x${height})`);
for (const line of [...exceptions, ...logs]) console.log(" " + line);
if (exceptions.length === 0 && logs.length === 0) console.log(" (console clean)");
socket.close();
chrome.kill();
await chrome.exited;
+80
View File
@@ -0,0 +1,80 @@
import { test, expect, describe } from "bun:test";
/**
* CLAUDE.md's hardest-won rule, made mechanical.
*
* shadcn uses several tokens as a BACKGROUND with a paired FOREGROUND for the
* text on it. Redefining one without the other repaints the background and
* leaves the text at the old colour — which is how overriding a bare
* `--accent` once dropped hover contrast below AA across the whole app. The
* damage is invisible in code review and invisible in a passing test suite.
*
* So: in any block that redefines one of these, its pair must be redefined in
* the same block.
*/
const PAIRS = [
["--background", "--foreground"],
["--card", "--card-foreground"],
["--popover", "--popover-foreground"],
["--primary", "--primary-foreground"],
["--secondary", "--secondary-foreground"],
["--muted", "--muted-foreground"],
["--accent", "--accent-foreground"],
] as const;
/** Top-level `selector { ... }` blocks. Nested at-rules are not used here. */
function blocks(css: string): { selector: string; body: string }[] {
const found: { selector: string; body: string }[] = [];
const re = /([^{}]+)\{([^{}]*)\}/g;
for (const match of css.matchAll(re)) {
found.push({ selector: match[1]!.trim(), body: match[2]! });
}
return found;
}
export function findUnpairedTokens(css: string): string[] {
const problems: string[] = [];
for (const { selector, body } of blocks(css)) {
const defines = (token: string) =>
new RegExp(`(^|[;\\s])${token}\\s*:`).test(body);
for (const [background, foreground] of PAIRS) {
if (defines(background) && !defines(foreground)) {
problems.push(`${selector} sets ${background} without ${foreground}`);
}
if (defines(foreground) && !defines(background)) {
problems.push(`${selector} sets ${foreground} without ${background}`);
}
}
}
return problems;
}
describe("the paired-token guard itself", () => {
test("catches a background redefined without its foreground", () => {
expect(findUnpairedTokens(":root { --accent: #eee; }")).toEqual([
":root sets --accent without --accent-foreground",
]);
});
test("catches a foreground redefined without its background", () => {
expect(findUnpairedTokens(".dark { --primary-foreground: #fff; }")).toEqual([
".dark sets --primary-foreground without --primary",
]);
});
test("accepts a pair defined together", () => {
expect(
findUnpairedTokens(":root { --accent: #eee; --accent-foreground: #111; }"),
).toEqual([]);
});
test("is not fooled by a token whose name merely ends the same way", () => {
// --sidebar-accent must not be read as --accent.
expect(findUnpairedTokens(":root { --sidebar-accent: #eee; }")).toEqual([]);
});
});
test("src/index.css never redefines a shadcn token without its pair", async () => {
const css = await Bun.file("src/index.css").text();
expect(findUnpairedTokens(css)).toEqual([]);
});
+13 -4
View File
@@ -10,6 +10,13 @@ import {
import { foldBalances, figuresFor } from "./fold.ts";
import type { Dataset, FxRate, Post, Rule } from "./types.ts";
/** Digits for the currencies these tests use; all real-world 2dp. */
const TEST_CURRENCIES = [
{ code: "DKK", digits: 2, symbol: "kr" },
{ code: "USD", digits: 2, symbol: "$" },
{ code: "EUR", digits: 2, symbol: "\u20ac" },
];
const rates: FxRate[] = [
{ currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" },
{ currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" },
@@ -36,6 +43,7 @@ function post(overrides: Partial<Post> = {}): Post {
function dataset(overrides: Partial<Dataset> = {}): Dataset {
return {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 },
currencies: TEST_CURRENCIES,
fxRates: rates,
posts: [post()],
months: [
@@ -48,21 +56,21 @@ function dataset(overrides: Partial<Dataset> = {}): Dataset {
describe("resolveRule", () => {
test("a fixed rule in base currency is its amount", () => {
expect(resolveRule(fixed(400), 20000, "DKK", rates)).toBe(400);
expect(resolveRule(fixed(400), 20000, "DKK", rates, TEST_CURRENCIES)).toBe(400);
});
test("a fixed rule in a foreign currency converts to base", () => {
expect(
resolveRule({ kind: "fixed", amount: { amount: 50, currency: "EUR" } }, 20000, "DKK", rates),
resolveRule({ kind: "fixed", amount: { amount: 50, currency: "EUR" } }, 20000, "DKK", rates, TEST_CURRENCIES),
).toBe(373);
});
test("a percentage rule resolves against income", () => {
expect(resolveRule(pct(25), 20000, "DKK", rates)).toBe(5000);
expect(resolveRule(pct(25), 20000, "DKK", rates, TEST_CURRENCIES)).toBe(5000);
});
test("a percentage above 100 is permitted", () => {
expect(resolveRule(pct(150), 1000, "DKK", rates)).toBe(1500);
expect(resolveRule(pct(150), 1000, "DKK", rates, TEST_CURRENCIES)).toBe(1500);
});
});
@@ -177,6 +185,7 @@ describe("the fold across a rule change", () => {
// rollover carrying correctly across the boundary.
const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-05", schemaVersion: 2 },
currencies: TEST_CURRENCIES,
fxRates: [],
posts: [
post({
+12 -3
View File
@@ -1,8 +1,10 @@
import { toBase } from "./fx.ts";
import { roundMoney } from "./money.ts";
import { digitsFor } from "./currencies.ts";
import { compareMonths } from "./months.ts";
import type {
Currency,
CurrencyDef,
Dataset,
FxRate,
Month,
@@ -19,11 +21,12 @@ export function resolveRule(
income: number,
baseCurrency: Currency,
rates: FxRate[],
currencies: readonly CurrencyDef[],
): number {
if (rule.kind === "fixed") {
return toBase(rule.amount, baseCurrency, rates);
return toBase(rule.amount, baseCurrency, rates, currencies);
}
return roundMoney((income * rule.percent) / 100, baseCurrency);
return roundMoney((income * rule.percent) / 100, digitsFor(currencies, baseCurrency));
}
/**
@@ -65,7 +68,12 @@ export function isOverridden(month: Month | undefined, postId: PostId): boolean
export function incomeFor(dataset: Dataset, monthId: MonthId): number {
const month = dataset.months.find((m) => m.id === monthId);
if (!month) return 0;
return toBase(month.income, dataset.settings.baseCurrency, dataset.fxRates);
return toBase(
month.income,
dataset.settings.baseCurrency,
dataset.fxRates,
dataset.currencies,
);
}
export function allocationFor(
@@ -83,5 +91,6 @@ export function allocationFor(
incomeFor(dataset, monthId),
dataset.settings.baseCurrency,
dataset.fxRates,
dataset.currencies,
);
}
+25 -13
View File
@@ -8,6 +8,13 @@ import {
import { roundMoney } from "./money.ts";
import type { Dataset, FxRate, Purchase } from "./types.ts";
/** Digits for the currencies these tests use; all real-world 2dp. */
const TEST_CURRENCIES = [
{ code: "DKK", digits: 2, symbol: "kr" },
{ code: "USD", digits: 2, symbol: "$" },
{ code: "EUR", digits: 2, symbol: "\u20ac" },
];
const rates: FxRate[] = [
{ currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" },
{ currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" },
@@ -28,7 +35,7 @@ function purchase(overrides: Partial<Purchase> = {}): Purchase {
describe("splitPartsOfTotal", () => {
test("a single split takes the whole total", () => {
expect(splitPartsOfTotal(purchase())).toEqual([200]);
expect(splitPartsOfTotal(purchase(), 2)).toEqual([200]);
});
test("percent mode divides proportionally", () => {
@@ -39,7 +46,8 @@ describe("splitPartsOfTotal", () => {
{ postId: "events", value: 40, absorbsRemainder: false },
],
}),
);
2,
);
expect(parts).toEqual([120, 80]);
});
@@ -52,7 +60,8 @@ describe("splitPartsOfTotal", () => {
{ postId: "events", value: 50, absorbsRemainder: true },
],
}),
);
2,
);
expect(parts).toEqual([120, 80]);
});
@@ -66,8 +75,9 @@ describe("splitPartsOfTotal", () => {
{ postId: "c", value: 33.34, absorbsRemainder: true },
],
}),
);
expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(100);
2,
);
expect(roundMoney(parts.reduce((a, b) => a + b, 0), 2)).toBe(100);
});
});
@@ -121,14 +131,14 @@ describe("sliceAmountForMonth", () => {
describe("chargesForPurchaseInMonth", () => {
test("a simple purchase charges one post", () => {
expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates)).toEqual([
expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates, TEST_CURRENCIES)).toEqual([
{ postId: "food", amount: 200 },
]);
});
test("converts a foreign-currency purchase into base", () => {
const eur = purchase({ total: { amount: 10, currency: "EUR" } });
expect(chargesForPurchaseInMonth(eur, "2026-09", "DKK", rates)).toEqual([
expect(chargesForPurchaseInMonth(eur, "2026-09", "DKK", rates, TEST_CURRENCIES)).toEqual([
{ postId: "food", amount: 74.6 },
]);
});
@@ -147,11 +157,11 @@ describe("chargesForPurchaseInMonth", () => {
],
},
});
expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates)).toEqual([
expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates, TEST_CURRENCIES)).toEqual([
{ postId: "games", amount: 700 },
{ postId: "events", amount: 300 },
]);
expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates)).toEqual([
expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates, TEST_CURRENCIES)).toEqual([
{ postId: "games", amount: 1400 },
{ postId: "events", amount: 600 },
]);
@@ -169,14 +179,14 @@ describe("chargesForPurchaseInMonth", () => {
slices: [{ month: "2026-10", amount: { amount: 600, currency: "DKK" } }],
},
});
expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates)).toEqual([
expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates, TEST_CURRENCIES)).toEqual([
{ postId: "games", amount: 400 },
{ postId: "events", amount: 200 },
]);
});
test("charges in a month the purchase does not touch are empty", () => {
expect(chargesForPurchaseInMonth(purchase(), "2026-01", "DKK", rates)).toEqual([]);
expect(chargesForPurchaseInMonth(purchase(), "2026-01", "DKK", rates, TEST_CURRENCIES)).toEqual([]);
});
test("slice charges always sum to the slice total", () => {
@@ -188,8 +198,8 @@ describe("chargesForPurchaseInMonth", () => {
{ postId: "c", value: 1, absorbsRemainder: true },
],
});
const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates);
expect(roundMoney(charges.reduce((sum, c) => sum + c.amount, 0))).toBe(100);
const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates, TEST_CURRENCIES);
expect(roundMoney(charges.reduce((sum, c) => sum + c.amount, 0), 2)).toBe(100);
});
});
@@ -197,6 +207,7 @@ describe("chargesForMonth", () => {
test("sums charges per post across all purchases", () => {
const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
currencies: TEST_CURRENCIES,
fxRates: rates,
posts: [],
months: [],
@@ -214,6 +225,7 @@ describe("chargesForMonth", () => {
test("a month with no purchases yields an empty map", () => {
const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
currencies: TEST_CURRENCIES,
fxRates: rates,
posts: [],
months: [],
+15 -8
View File
@@ -1,8 +1,10 @@
import { toBase } from "./fx.ts";
import { digitsFor } from "./currencies.ts";
import { distributeByAmount, distributeByWeight, roundMoney } from "./money.ts";
import { compareMonths, monthOf } from "./months.ts";
import type {
Currency,
CurrencyDef,
Dataset,
FxRate,
Money,
@@ -31,14 +33,13 @@ export function remainderIndexOf(purchase: Purchase): number {
* How the whole purchase divides across its posts, in the purchase's own
* currency. Parts sum exactly to the total.
*/
export function splitPartsOfTotal(purchase: Purchase): number[] {
export function splitPartsOfTotal(purchase: Purchase, digits: number): number[] {
const index = remainderIndexOf(purchase);
const values = purchase.splits.map((s) => s.value);
const total = purchase.total.amount;
const currency = purchase.total.currency;
return purchase.splitMode === "percent"
? distributeByWeight(total, values, index, currency)
: distributeByAmount(total, values, index, currency);
? distributeByWeight(total, values, index, digits)
: distributeByAmount(total, values, index, digits);
}
/**
@@ -72,14 +73,18 @@ export function chargesForPurchaseInMonth(
monthId: MonthId,
baseCurrency: Currency,
rates: FxRate[],
currencies: readonly CurrencyDef[],
): Charge[] {
const slice = sliceAmountForMonth(purchase, monthId);
if (!slice) return [];
const sliceBase = toBase(slice, baseCurrency, rates);
const sliceBase = toBase(slice, baseCurrency, rates, currencies);
const index = remainderIndexOf(purchase);
const weights = splitPartsOfTotal(purchase);
const parts = distributeByWeight(sliceBase, weights, index, baseCurrency);
// Split proportions are computed in the PURCHASE's own currency, then the
// month's slice is distributed to those proportions in the BASE currency —
// so the two roundings use different digits.
const weights = splitPartsOfTotal(purchase, digitsFor(currencies, purchase.total.currency));
const parts = distributeByWeight(sliceBase, weights, index, digitsFor(currencies, baseCurrency));
return purchase.splits.map((split, i) => ({
postId: split.postId,
@@ -93,18 +98,20 @@ export function chargesForMonth(
monthId: MonthId,
): Map<PostId, number> {
const totals = new Map<PostId, number>();
const baseDigits = digitsFor(dataset.currencies, dataset.settings.baseCurrency);
for (const purchase of dataset.purchases) {
const charges = chargesForPurchaseInMonth(
purchase,
monthId,
dataset.settings.baseCurrency,
dataset.fxRates,
dataset.currencies,
);
for (const charge of charges) {
const previous = totals.get(charge.postId) ?? 0;
totals.set(
charge.postId,
roundMoney(previous + charge.amount, dataset.settings.baseCurrency),
roundMoney(previous + charge.amount, baseDigits),
);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { test, expect, describe } from "bun:test";
import { digitsFor, currencyCodes, findCurrency, normalizeCurrencyCode } from "./currencies.ts";
import type { CurrencyDef } from "./types.ts";
const TABLE: CurrencyDef[] = [
{ code: "DKK", digits: 2, symbol: "kr" },
{ code: "JPY", digits: 0, symbol: "¥" },
{ code: "KWD", digits: 3 },
];
describe("digitsFor", () => {
test("reads the digits the owner recorded, not an assumed 2", () => {
expect(digitsFor(TABLE, "DKK")).toBe(2);
expect(digitsFor(TABLE, "JPY")).toBe(0);
expect(digitsFor(TABLE, "KWD")).toBe(3);
});
/**
* Deep inside the fold a throw would take the whole balance view down over
* a data problem the import validator already rejects at the boundary. Two
* places is right for almost every currency in existence.
*/
test("an unknown currency falls back to two places rather than throwing", () => {
expect(digitsFor(TABLE, "XYZ")).toBe(2);
expect(digitsFor([], "DKK")).toBe(2);
});
});
describe("normalizeCurrencyCode", () => {
test("codes are identity, so case and padding cannot fork one currency in two", () => {
expect(normalizeCurrencyCode(" usd ")).toBe("USD");
expect(normalizeCurrencyCode("Jpy")).toBe("JPY");
});
});
test("currencyCodes keeps the owner's order", () => {
expect(currencyCodes(TABLE)).toEqual(["DKK", "JPY", "KWD"]);
});
test("findCurrency returns the definition, or nothing", () => {
expect(findCurrency(TABLE, "JPY")?.symbol).toBe("¥");
expect(findCurrency(TABLE, "XYZ")).toBeUndefined();
});
+92
View File
@@ -0,0 +1,92 @@
import {
DEFAULT_CURRENCY_DIGITS,
type Currency,
type CurrencyDef,
type Dataset,
} from "./types.ts";
/**
* How many decimals a currency rounds to.
*
* Digits used to be a module-level constant table, which made "add a
* currency" a code change. They now live in the dataset, so every rounding
* site has to be told which digits to use — that threading is deliberate, and
* the reason `roundMoney` takes a number rather than a code.
*
* An undefined currency falls back to two places rather than throwing. A
* missing definition is a data problem the import validator rejects at the
* boundary; deep inside the fold, two decimals is right for almost every
* currency and a throw would take the whole balance view down.
*/
export function digitsFor(
currencies: readonly CurrencyDef[],
code: Currency,
): number {
const found = currencies.find((currency) => currency.code === code);
return found ? found.digits : DEFAULT_CURRENCY_DIGITS;
}
/** The codes, in the order the owner arranged them. */
export function currencyCodes(currencies: readonly CurrencyDef[]): Currency[] {
return currencies.map((currency) => currency.code);
}
export function findCurrency(
currencies: readonly CurrencyDef[],
code: Currency,
): CurrencyDef | undefined {
return currencies.find((currency) => currency.code === code);
}
/**
* Uppercased and trimmed. Codes are identity here — they key the FX table and
* every stored Money — so "usd" and "USD" must never become two currencies.
*/
export function normalizeCurrencyCode(raw: string): Currency {
return raw.trim().toUpperCase();
}
/**
* Everywhere `code` is referenced by stored data, described for a human.
*
* The same reasoning as posts being archived rather than deleted: stored Money
* carries the code, so removing a currency in use would leave amounts pointing
* at a definition that no longer exists — and `parseDatasetJson` would then
* refuse the owner's own exported file. Returning WHERE it is used lets the UI
* explain the refusal instead of just denying it.
*/
export function currencyUsage(dataset: Dataset, code: Currency): string[] {
const used: string[] = [];
if (dataset.settings.baseCurrency === code) used.push("the base currency");
for (const post of dataset.posts) {
if (post.currency === code) used.push(`post "${post.name}"`);
for (const version of post.rules) {
if (version.rule.kind === "fixed" && version.rule.amount.currency === code) {
used.push(`post "${post.name}" rule from ${version.from}`);
}
}
}
for (const month of dataset.months) {
if (month.income.currency === code) used.push(`month ${month.id}`);
for (const [postId, rule] of Object.entries(month.ruleOverrides)) {
if (rule.kind === "fixed" && rule.amount.currency === code) {
const name = dataset.posts.find((p) => p.id === postId)?.name ?? postId;
used.push(`${month.id} override for "${name}"`);
}
}
}
for (const purchase of dataset.purchases) {
if (purchase.total.currency === code) used.push(`purchase "${purchase.description}"`);
for (const slice of purchase.schedule?.slices ?? []) {
if (slice.amount.currency === code) {
used.push(`purchase "${purchase.description}" slice ${slice.month}`);
}
}
}
return [...new Set(used)];
}
+119
View File
@@ -0,0 +1,119 @@
import { test, expect, describe } from "bun:test";
import { roundMoney, distributeByWeight, distributeByAmount } from "./money.ts";
import { toBase, fromBase } from "./fx.ts";
import { equalSlices, sliceTotal } from "./plans.ts";
import { resolveRule } from "./allocation.ts";
import { foldBalances, figuresFor } from "./fold.ts";
import { monthView } from "./views.ts";
import type { CurrencyDef, Dataset } from "./types.ts";
/**
* Every currency the app shipped with has two decimal places, so code that
* hardcodes 2 is indistinguishable from code that reads the dataset. Now that
* the owner can define currencies, that stops being true — and a zero-decimal
* currency is the only thing that can prove digits actually flow from the
* dataset to each rounding site rather than being assumed.
*
* JPY is the real-world case: yen have no minor unit, so ¥3333.3 is not a
* number that can exist.
*/
const JPY: CurrencyDef = { code: "JPY", digits: 0, symbol: "¥" };
const USD: CurrencyDef = { code: "USD", digits: 2, symbol: "$" };
const CURRENCIES = [JPY, USD];
describe("rounding takes its digits from the dataset", () => {
test("roundMoney rounds to the digits it is given", () => {
expect(roundMoney(3333.3, 0)).toBe(3333);
expect(roundMoney(3333.335, 2)).toBe(3333.34);
// 1.234, not 1.235: the double nearest 1.2345 is slightly BELOW it, and
// toFixed rounds the double's actual value. That is the documented
// trade-off in money.ts — predictable, if occasionally surprising.
expect(roundMoney(1.2345, 3)).toBe(1.234);
});
test("a percentage allocation in a zero-decimal currency yields whole units", () => {
const rate = resolveRule(
{ kind: "percentOfIncome", percent: 33.333 },
10_000,
"JPY",
[],
CURRENCIES,
);
expect(rate).toBe(3333);
expect(Number.isInteger(rate)).toBe(true);
});
test("an FX conversion into a zero-decimal base yields whole units", () => {
const rates = [
{ currency: "USD", baseUnitsPerOne: 151.234567, updatedAt: "2026-01-01", source: "manual" as const },
];
expect(toBase({ amount: 10, currency: "USD" }, "JPY", rates, CURRENCIES)).toBe(1512);
// And back out: 1512 / 151.234567 = 9.9977..., rounded to USD's TWO
// decimals, so 10 — not the three-decimal 9.998.
expect(fromBase(1512, "USD", "JPY", rates, CURRENCIES)).toBe(10);
});
test("a split distributed in a zero-decimal currency still sums exactly", () => {
const parts = distributeByWeight(1000, [1, 1, 1], 2, 0);
expect(parts).toEqual([333, 333, 334]);
expect(parts.reduce((a, b) => a + b, 0)).toBe(1000);
expect(parts.every(Number.isInteger)).toBe(true);
});
test("fixed amounts distributed in a zero-decimal currency still sum exactly", () => {
const parts = distributeByAmount(1000, [333.4, 333.4, 0], 2, 0);
expect(parts.reduce((a, b) => a + b, 0)).toBe(1000);
expect(parts.every(Number.isInteger)).toBe(true);
});
test("a finance plan divides into whole units and sums exactly", () => {
const slices = equalSlices({ amount: 1000, currency: "JPY" }, "2026-01", 3, 0);
expect(slices.map((s) => s.amount.amount)).toEqual([333, 333, 334]);
expect(sliceTotal(slices, 0)).toBe(1000);
});
});
describe("the fold in a zero-decimal currency", () => {
function dataset(): Dataset {
return {
settings: { baseCurrency: "JPY", foldStartMonth: "2026-01", schemaVersion: 3 },
currencies: CURRENCIES,
fxRates: [],
posts: [
{
id: "food",
name: "Food",
order: 0,
archived: false,
currency: "JPY",
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 33.333 } }],
},
],
months: [
{ id: "2026-01", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} },
{ id: "2026-02", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} },
],
purchases: [],
};
}
test("no figure the fold produces has a fractional yen in it", () => {
const fold = foldBalances(dataset(), "2026-02");
for (const month of ["2026-01", "2026-02"]) {
const figures = figuresFor(fold, "food", month);
for (const [name, value] of Object.entries(figures)) {
expect(Number.isInteger(value)).toBe(true);
expect(`${month} ${name} = ${value}`).toBe(`${month} ${name} = ${Math.round(value)}`);
}
}
expect(figuresFor(fold, "food", "2026-01").allocation).toBe(3333);
expect(figuresFor(fold, "food", "2026-02").carriedIn).toBe(3333);
});
test("the month view's totals are whole units too", () => {
const view = monthView(dataset(), "2026-01");
expect(Number.isInteger(view.totalAllocation)).toBe(true);
expect(Number.isInteger(view.unallocated)).toBe(true);
expect(view.unallocated).toBe(10_000 - 3333);
});
});
+24
View File
@@ -2,6 +2,13 @@ import { test, expect, describe } from "bun:test";
import { foldBalances, figuresFor } from "./fold.ts";
import type { Dataset, Post, Purchase } from "./types.ts";
/** Digits for the currencies these tests use; all real-world 2dp. */
const TEST_CURRENCIES = [
{ code: "DKK", digits: 2, symbol: "kr" },
{ code: "USD", digits: 2, symbol: "$" },
{ code: "EUR", digits: 2, symbol: "\u20ac" },
];
function post(id: string, fixed: number, archived = false): Post {
return {
id,
@@ -28,6 +35,7 @@ function spend(id: string, postId: string, amount: number, date: string): Purcha
function dataset(posts: Post[], purchases: Purchase[]): Dataset {
return {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
currencies: TEST_CURRENCIES,
fxRates: [],
posts,
months: [
@@ -147,3 +155,19 @@ test("figuresFor returns zeroes for a month outside the fold", () => {
remaining: 0,
});
});
describe("the empty-figures fallback", () => {
// figuresFor returns EMPTY_FIGURES by SHARED REFERENCE for any (post, month)
// the fold has no entry for. One consumer mutating that object would move
// every other unknown row with it — in a budgeting app, silently.
test("cannot be mutated through the value figuresFor hands back", () => {
const fold = foldBalances(dataset([post("food", 500)], []), "2026-01");
const missing = figuresFor(fold, "no-such-post", "2026-01");
expect(() => {
(missing as { allocation: number }).allocation = 999;
}).toThrow(TypeError);
expect(figuresFor(fold, "another-missing-post", "2026-01").allocation).toBe(0);
});
});
+10 -3
View File
@@ -1,4 +1,5 @@
import { allocationFor } from "./allocation.ts";
import { digitsFor } from "./currencies.ts";
import { chargesForMonth } from "./charges.ts";
import { roundMoney } from "./money.ts";
import { compareMonths, monthRange } from "./months.ts";
@@ -12,12 +13,17 @@ export interface PostMonthFigures {
remaining: number;
}
export const EMPTY_FIGURES: PostMonthFigures = {
/**
* Frozen because `figuresFor` hands this back by shared reference for every
* (post, month) the fold has no entry for. Unfrozen, one consumer mutating
* its result would move every other unknown row with it.
*/
export const EMPTY_FIGURES: PostMonthFigures = Object.freeze({
carriedIn: 0,
allocation: 0,
charges: 0,
remaining: 0,
};
});
export type Fold = Map<MonthId, Map<PostId, PostMonthFigures>>;
@@ -31,6 +37,7 @@ export type Fold = Map<MonthId, Map<PostId, PostMonthFigures>>;
*/
export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold {
const { foldStartMonth, baseCurrency } = dataset.settings;
const baseDigits = digitsFor(dataset.currencies, baseCurrency);
const fold: Fold = new Map();
if (compareMonths(upToMonth, foldStartMonth) < 0) return fold;
@@ -45,7 +52,7 @@ export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold {
const carriedIn = carried.get(post.id) ?? 0;
const allocation = allocationFor(dataset, post.id, monthId);
const spent = charges.get(post.id) ?? 0;
const remaining = roundMoney(carriedIn + allocation - spent, baseCurrency);
const remaining = roundMoney(carriedIn + allocation - spent, baseDigits);
monthFigures.set(post.id, {
carriedIn,
+14 -7
View File
@@ -2,6 +2,13 @@ import { test, expect, describe } from "bun:test";
import { toBase, fromBase, MissingRateError } from "./fx.ts";
import type { FxRate } from "./types.ts";
/** Digits for the currencies these tests use; all real-world 2dp. */
const TEST_CURRENCIES = [
{ code: "DKK", digits: 2, symbol: "kr" },
{ code: "USD", digits: 2, symbol: "$" },
{ code: "EUR", digits: 2, symbol: "\u20ac" },
];
const rates: FxRate[] = [
{ currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" },
{ currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" },
@@ -9,23 +16,23 @@ const rates: FxRate[] = [
describe("toBase", () => {
test("passes base-currency values through untouched", () => {
expect(toBase({ amount: 100, currency: "DKK" }, "DKK", rates)).toBe(100);
expect(toBase({ amount: 100, currency: "DKK" }, "DKK", rates, TEST_CURRENCIES)).toBe(100);
});
test("converts using baseUnitsPerOne", () => {
expect(toBase({ amount: 10, currency: "EUR" }, "DKK", rates)).toBe(74.6);
expect(toBase({ amount: 10, currency: "EUR" }, "DKK", rates, TEST_CURRENCIES)).toBe(74.6);
});
test("rounds the conversion result", () => {
expect(toBase({ amount: 3.33, currency: "USD" }, "DKK", rates)).toBe(22.98);
expect(toBase({ amount: 3.33, currency: "USD" }, "DKK", rates, TEST_CURRENCIES)).toBe(22.98);
});
test("throws MissingRateError naming the currency", () => {
expect(() => toBase({ amount: 1, currency: "USD" }, "DKK", [])).toThrow(
expect(() => toBase({ amount: 1, currency: "USD" }, "DKK", [], TEST_CURRENCIES)).toThrow(
MissingRateError,
);
try {
toBase({ amount: 1, currency: "USD" }, "DKK", []);
toBase({ amount: 1, currency: "USD" }, "DKK", [], TEST_CURRENCIES);
} catch (error) {
expect((error as MissingRateError).currency).toBe("USD");
}
@@ -34,10 +41,10 @@ describe("toBase", () => {
describe("fromBase", () => {
test("passes base through untouched", () => {
expect(fromBase(100, "DKK", "DKK", rates)).toBe(100);
expect(fromBase(100, "DKK", "DKK", rates, TEST_CURRENCIES)).toBe(100);
});
test("inverts the conversion", () => {
expect(fromBase(74.6, "EUR", "DKK", rates)).toBe(10);
expect(fromBase(74.6, "EUR", "DKK", rates, TEST_CURRENCIES)).toBe(10);
});
});
+10 -5
View File
@@ -1,5 +1,6 @@
import { roundMoney } from "./money.ts";
import type { Currency, FxRate, Money } from "./types.ts";
import { digitsFor } from "./currencies.ts";
import type { Currency, CurrencyDef, FxRate, Money } from "./types.ts";
export class MissingRateError extends Error {
constructor(public readonly currency: Currency) {
@@ -19,11 +20,13 @@ export function toBase(
value: Money,
baseCurrency: Currency,
rates: FxRate[],
currencies: readonly CurrencyDef[],
): number {
const digits = digitsFor(currencies, baseCurrency);
if (value.currency === baseCurrency) {
return roundMoney(value.amount, baseCurrency);
return roundMoney(value.amount, digits);
}
return roundMoney(value.amount * rateFor(value.currency, rates), baseCurrency);
return roundMoney(value.amount * rateFor(value.currency, rates), digits);
}
/** Converts a base-currency amount into `target`, rounded. */
@@ -32,7 +35,9 @@ export function fromBase(
target: Currency,
baseCurrency: Currency,
rates: FxRate[],
currencies: readonly CurrencyDef[],
): number {
if (target === baseCurrency) return roundMoney(amount, target);
return roundMoney(amount / rateFor(target, rates), target);
const digits = digitsFor(currencies, target);
if (target === baseCurrency) return roundMoney(amount, digits);
return roundMoney(amount / rateFor(target, rates), digits);
}
+17 -17
View File
@@ -7,62 +7,62 @@ import {
describe("roundMoney", () => {
test("rounds to 2 decimal places", () => {
expect(roundMoney(1.234)).toBe(1.23);
expect(roundMoney(1.235)).toBe(1.24);
expect(roundMoney(0.1 + 0.2)).toBe(0.3);
expect(roundMoney(1.234, 2)).toBe(1.23);
expect(roundMoney(1.235, 2)).toBe(1.24);
expect(roundMoney(0.1 + 0.2, 2)).toBe(0.3);
});
test("handles negatives symmetrically", () => {
expect(roundMoney(-1.235)).toBe(-1.24);
expect(roundMoney(-1.235, 2)).toBe(-1.24);
});
});
describe("distributeByWeight", () => {
test("splits proportionally", () => {
expect(distributeByWeight(200, [60, 40], 0)).toEqual([120, 80]);
expect(distributeByWeight(200, [60, 40], 0, 2)).toEqual([120, 80]);
});
test("thirds sum exactly to the total", () => {
const parts = distributeByWeight(100, [1, 1, 1], 2);
const parts = distributeByWeight(100, [1, 1, 1], 2, 2);
expect(parts).toEqual([33.33, 33.33, 33.34]);
expect(roundMoney(parts[0]! + parts[1]! + parts[2]!)).toBe(100);
expect(roundMoney(parts[0]! + parts[1]! + parts[2]!, 2)).toBe(100);
});
test("the remainder index absorbs the rounding drift", () => {
const parts = distributeByWeight(100, [1, 1, 1], 0);
const parts = distributeByWeight(100, [1, 1, 1], 0, 2);
expect(parts).toEqual([33.34, 33.33, 33.33]);
});
test("a single split takes the whole total", () => {
expect(distributeByWeight(99.99, [100], 0)).toEqual([99.99]);
expect(distributeByWeight(99.99, [100], 0, 2)).toEqual([99.99]);
});
test("weights that do not sum to 100 are still proportional", () => {
expect(distributeByWeight(100, [1, 3], 1)).toEqual([25, 75]);
expect(distributeByWeight(100, [1, 3], 1, 2)).toEqual([25, 75]);
});
test("all-zero weights put everything on the remainder rather than throwing", () => {
// Reachable from a zero-value purchase; must not divide by zero.
expect(distributeByWeight(100, [0, 0], 1)).toEqual([0, 100]);
expect(distributeByWeight(0, [0, 0], 0)).toEqual([0, 0]);
expect(distributeByWeight(100, [0, 0], 1, 2)).toEqual([0, 100]);
expect(distributeByWeight(0, [0, 0], 0, 2)).toEqual([0, 0]);
});
});
describe("distributeByAmount", () => {
test("uses the given amounts as-is", () => {
expect(distributeByAmount(200, [120, 80], 1)).toEqual([120, 80]);
expect(distributeByAmount(200, [120, 80], 1, 2)).toEqual([120, 80]);
});
test("the remainder index absorbs a shortfall", () => {
expect(distributeByAmount(200, [120, 50], 1)).toEqual([120, 80]);
expect(distributeByAmount(200, [120, 50], 1, 2)).toEqual([120, 80]);
});
test("the remainder index absorbs an excess, even going negative", () => {
expect(distributeByAmount(100, [120, 50], 1)).toEqual([120, -20]);
expect(distributeByAmount(100, [120, 50], 1, 2)).toEqual([120, -20]);
});
test("parts always sum exactly to the total", () => {
const parts = distributeByAmount(10, [3.33, 3.33, 0], 2);
expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(10);
const parts = distributeByAmount(10, [3.33, 3.33, 0], 2, 2);
expect(roundMoney(parts.reduce((a, b) => a + b, 0), 2)).toBe(10);
});
});
+22 -14
View File
@@ -1,13 +1,21 @@
import { CURRENCY_DIGITS, type Currency } from "./types.ts";
/**
* Money is a float. Two rules make that safe, and both are about WHERE
* rounding happens rather than how.
*
* Digits are passed in, never looked up from a table here. They live in the
* dataset because the owner can define currencies, so every call site has to
* resolve them with `digitsFor` — see src/domain/currencies.ts. That is
* deliberate friction: a default of 2 would silently mis-round every
* zero-decimal currency and no existing test would notice.
*/
/**
* Rounds a float to its currency's minor unit. `toFixed` rounds the double's
* actual decimal value, which is the most predictable option available for
* binary floats. Call this after every division and FX conversion, and before
* Rounds to a currency's minor unit. `toFixed` rounds the double's actual
* decimal value, which is the most predictable option available for binary
* floats. Call this after every division and FX conversion, and before
* persisting.
*/
export function roundMoney(amount: number, currency: Currency = "DKK"): number {
const digits = CURRENCY_DIGITS[currency];
export function roundMoney(amount: number, digits: number): number {
return Number(amount.toFixed(digits));
}
@@ -15,7 +23,7 @@ function withRemainder(
total: number,
parts: number[],
remainderIndex: number,
currency: Currency,
digits: number,
): number[] {
const result = [...parts];
let othersSum = 0;
@@ -24,7 +32,7 @@ function withRemainder(
}
// Never round this independently — subtraction is what makes the parts sum
// exactly to the total.
result[remainderIndex] = roundMoney(total - othersSum, currency);
result[remainderIndex] = roundMoney(total - othersSum, digits);
return result;
}
@@ -33,7 +41,7 @@ export function distributeByWeight(
total: number,
weights: number[],
remainderIndex: number,
currency: Currency = "DKK",
digits: number,
): number[] {
const weightSum = weights.reduce((a, b) => a + b, 0);
// All-zero weights would divide by zero. A zero-total purchase is legal (and
@@ -42,9 +50,9 @@ export function distributeByWeight(
const parts = weights.map((w, i) =>
i === remainderIndex || weightSum === 0
? 0
: roundMoney((total * w) / weightSum, currency),
: roundMoney((total * w) / weightSum, digits),
);
return withRemainder(total, parts, remainderIndex, currency);
return withRemainder(total, parts, remainderIndex, digits);
}
/** Uses `amounts` as-is; `remainderIndex` absorbs any shortfall or excess. */
@@ -52,10 +60,10 @@ export function distributeByAmount(
total: number,
amounts: number[],
remainderIndex: number,
currency: Currency = "DKK",
digits: number,
): number[] {
const parts = amounts.map((a, i) =>
i === remainderIndex ? 0 : roundMoney(a, currency),
i === remainderIndex ? 0 : roundMoney(a, digits),
);
return withRemainder(total, parts, remainderIndex, currency);
return withRemainder(total, parts, remainderIndex, digits);
}
+24 -8
View File
@@ -1,36 +1,52 @@
import { test, expect } from "bun:test";
import { equalSlices, sliceTotal, slicesBalance } from "./plans.ts";
/**
* Digits are passed in rather than looked up from a table, so a currency
* with a different minor unit is expressed directly instead of by
* monkey-patching a constant, which is what this file used to have to do.
*/
const DKK = 2;
test("equal slices divide the total across consecutive months", () => {
const slices = equalSlices({ amount: 3000, currency: "DKK" }, "2026-10", 6);
const slices = equalSlices({ amount: 3000, currency: "DKK" }, "2026-10", 6, DKK);
expect(slices).toHaveLength(6);
expect(slices[0]).toEqual({ month: "2026-10", amount: { amount: 500, currency: "DKK" } });
expect(slices[5]!.month).toBe("2027-03");
});
test("uneven totals still sum exactly, the last slice absorbing the drift", () => {
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 3);
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 3, DKK);
expect(slices.map((s) => s.amount.amount)).toEqual([33.33, 33.33, 33.34]);
expect(sliceTotal(slices)).toBe(100);
expect(sliceTotal(slices, DKK)).toBe(100);
});
test("a single slice takes the whole total", () => {
const slices = equalSlices({ amount: 99.99, currency: "DKK" }, "2026-01", 1);
const slices = equalSlices({ amount: 99.99, currency: "DKK" }, "2026-01", 1, DKK);
expect(slices).toEqual([
{ month: "2026-01", amount: { amount: 99.99, currency: "DKK" } },
]);
});
test("a count below one is rejected", () => {
expect(() => equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 0)).toThrow(
expect(() => equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 0, DKK)).toThrow(
/at least one month/i,
);
});
test("slicesBalance reports the shortfall the editor must show", () => {
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 2);
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(0);
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 2, DKK);
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices, DKK)).toBe(0);
slices[0]!.amount.amount = 30;
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(20);
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices, DKK)).toBe(20);
});
test("sliceTotal rounds to the digits it is given, not always to 2 places", () => {
const slices = [
{ month: "2026-01", amount: { amount: 10.4, currency: "EUR" } },
{ month: "2026-02", amount: { amount: 10.4, currency: "EUR" } },
];
expect(sliceTotal(slices, 0)).toBe(21);
expect(sliceTotal(slices, 2)).toBe(20.8);
});
+16 -10
View File
@@ -7,28 +7,34 @@ export function equalSlices(
total: Money,
startMonth: MonthId,
count: number,
digits: number,
): ScheduleSlice[] {
if (!Number.isInteger(count) || count < 1) {
throw new Error("A finance plan needs at least one month");
}
const weights = new Array(count).fill(1);
const amounts = distributeByWeight(
total.amount,
weights,
count - 1,
total.currency,
);
const amounts = distributeByWeight(total.amount, weights, count - 1, digits);
return amounts.map((amount, i) => ({
month: addMonths(startMonth, i),
amount: { amount, currency: total.currency },
}));
}
export function sliceTotal(slices: ScheduleSlice[]): number {
return roundMoney(slices.reduce((sum, s) => sum + s.amount.amount, 0));
/**
* Every slice of a schedule carries the purchase's currency, so the first one
* names the currency to round in. Rounding to a hardcoded 2 places would be
* wrong for any currency whose minor unit is not hundredths.
*/
export function sliceTotal(slices: ScheduleSlice[], digits: number): number {
const sum = slices.reduce((total, s) => total + s.amount.amount, 0);
return roundMoney(sum, digits);
}
/** total - sum(slices). Positive means the plan is short of the total. */
export function slicesBalance(total: Money, slices: ScheduleSlice[]): number {
return roundMoney(total.amount - sliceTotal(slices), total.currency);
export function slicesBalance(
total: Money,
slices: ScheduleSlice[],
digits: number,
): number {
return roundMoney(total.amount - sliceTotal(slices, digits), digits);
}
+16 -6
View File
@@ -1,6 +1,7 @@
import type { Dataset, MonthId, Post } from "./types.ts";
import { SEED_CURRENCIES } from "./types.ts";
import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts";
export const SCHEMA_VERSION = 2;
export const SCHEMA_VERSION = 3;
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
@@ -9,10 +10,16 @@ export function newId(): string {
}
/**
* The first-run dataset. Takes the start month as an argument because the
* domain layer must not read the ambient clock.
* The first-run dataset. Takes the start month AND the seed exchange rates as
* arguments because the domain layer must read neither the ambient clock nor
* the ambient environment. The store passes `BAKED_FX_RATES`; callers that
* want a bare dataset pass nothing.
*/
export function createSeedDataset(startMonth: MonthId): Dataset {
export function createSeedDataset(
startMonth: MonthId,
fxRates: readonly FxRate[] = [],
currencies: readonly CurrencyDef[] = SEED_CURRENCIES,
): Dataset {
const posts: Post[] = SEED_POST_NAMES.map((name, order) => ({
id: newId(),
name,
@@ -30,7 +37,10 @@ export function createSeedDataset(startMonth: MonthId): Dataset {
foldStartMonth: startMonth,
schemaVersion: SCHEMA_VERSION,
},
fxRates: [],
// Copied, so a caller passing shared constants cannot have them edited
// out from under it the first time the user changes a rate or a currency.
currencies: currencies.map((currency) => ({ ...currency })),
fxRates: fxRates.map((rate) => ({ ...rate })),
posts,
months: [
{ id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} },
+18 -6
View File
@@ -1,12 +1,24 @@
import { test, expect } from "bun:test";
import { CURRENCIES, CURRENCY_DIGITS } from "./types.ts";
import { DEFAULT_CURRENCY_DIGITS, SEED_CURRENCIES } from "./types.ts";
test("supported currencies are exactly DKK, USD, EUR", () => {
expect(CURRENCIES).toEqual(["DKK", "USD", "EUR"]);
/**
* This file used to assert "the supported currencies are exactly DKK, USD,
* EUR". That is no longer a property of the code: the owner defines
* currencies, so the dataset is the authority and there is nothing global to
* assert. What remains true is what a NEW dataset starts with.
*/
test("a new dataset starts with the three currencies the app shipped with", () => {
expect(SEED_CURRENCIES.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]);
});
test("every supported currency has 2 decimal digits", () => {
for (const c of CURRENCIES) {
expect(CURRENCY_DIGITS[c]).toBe(2);
test("each seeded currency carries its decimals and a symbol to type", () => {
for (const currency of SEED_CURRENCIES) {
expect(currency.digits).toBe(2);
expect(currency.symbol).toBeTruthy();
expect(currency.name).toBeTruthy();
}
});
test("the fallback for an undefined currency is two places", () => {
expect(DEFAULT_CURRENCY_DIGITS).toBe(2);
});
+39 -7
View File
@@ -1,11 +1,37 @@
export const CURRENCIES = ["DKK", "USD", "EUR"] as const;
export type Currency = (typeof CURRENCIES)[number];
/**
* A currency CODE, uppercase, normally ISO 4217 ("DKK", "JPY"). Open rather
* than a closed union because the owner can define their own currencies:
* a union would make that a code change.
*
* The safety a union gave up is replaced by validation at the boundaries —
* `parseDatasetJson` rejects a code no currency defines, and the UI only ever
* offers codes from the dataset's own table.
*/
export type Currency = string;
export const CURRENCY_DIGITS: Record<Currency, number> = {
DKK: 2,
USD: 2,
EUR: 2,
};
/** What the owner has told the app about one currency. */
export interface CurrencyDef {
code: Currency;
/** Decimal places in its minor unit: 2 for DKK, 0 for JPY, 3 for KWD. */
digits: number;
/** "kr", "$", "€". Optional — a currency can be typed by code alone. */
symbol?: string;
/** "Danish krone". Shown in Settings; never used for identity. */
name?: string;
}
/**
* The currencies a brand-new dataset starts with. NOT the set of valid
* currencies — that lives in the dataset, because the owner can add more.
*/
export const SEED_CURRENCIES: readonly CurrencyDef[] = [
{ code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" },
{ code: "USD", digits: 2, symbol: "$", name: "US dollar" },
{ code: "EUR", digits: 2, symbol: "\u20ac", name: "Euro" },
];
/** Used when a currency has no definition. Two places is right almost always. */
export const DEFAULT_CURRENCY_DIGITS = 2;
/** A float amount paired with its currency. See CLAUDE.md for rounding rules. */
export interface Money {
@@ -106,6 +132,12 @@ export interface Settings {
export interface Dataset {
settings: Settings;
/**
* Every currency this dataset knows, including the base. The owner can add
* to it, so it — not a compile-time union — is the authority on what codes
* are valid, what their symbols are, and how many decimals they round to.
*/
currencies: CurrencyDef[];
fxRates: FxRate[];
posts: Post[];
months: Month[];
+8
View File
@@ -2,6 +2,13 @@ import { test, expect, describe } from "bun:test";
import { monthView, yearView, summaryView, datasetMonthSpan } from "./views.ts";
import type { Dataset, Post, Purchase } from "./types.ts";
/** Digits for the currencies these tests use; all real-world 2dp. */
const TEST_CURRENCIES = [
{ code: "DKK", digits: 2, symbol: "kr" },
{ code: "USD", digits: 2, symbol: "$" },
{ code: "EUR", digits: 2, symbol: "\u20ac" },
];
function post(id: string, order: number, percent: number, archived = false): Post {
return {
id,
@@ -27,6 +34,7 @@ function spend(id: string, postId: string, amount: number, date: string): Purcha
const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
currencies: TEST_CURRENCIES,
fxRates: [],
posts: [post("food", 0, 20), post("games", 1, 10)],
months: [
+15 -11
View File
@@ -1,4 +1,5 @@
import { incomeFor, isOverridden } from "./allocation.ts";
import { digitsFor } from "./currencies.ts";
import { figuresFor, foldBalances, type Fold, type PostMonthFigures } from "./fold.ts";
import { roundMoney } from "./money.ts";
import { compareMonths, monthOf, monthRange, monthsOfYear } from "./months.ts";
@@ -44,13 +45,14 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
}
const base = dataset.settings.baseCurrency;
const baseDigits = digitsFor(dataset.currencies, base);
const totalAllocation = roundMoney(
rows.reduce((sum, r) => sum + r.figures.allocation, 0),
base,
baseDigits,
);
const totalCharges = roundMoney(
rows.reduce((sum, r) => sum + r.figures.charges, 0),
base,
baseDigits,
);
return {
@@ -58,7 +60,7 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
income,
totalAllocation,
totalCharges,
unallocated: roundMoney(income - totalAllocation, base),
unallocated: roundMoney(income - totalAllocation, baseDigits),
rows,
};
}
@@ -86,6 +88,7 @@ export function yearView(dataset: Dataset, year: number): YearViewModel {
const months = monthsOfYear(year);
const fold = foldBalances(dataset, months[11]!);
const base = dataset.settings.baseCurrency;
const baseDigits = digitsFor(dataset.currencies, base);
const incomeByMonth = months.map((m) => incomeFor(dataset, m));
@@ -96,11 +99,11 @@ export function yearView(dataset: Dataset, year: number): YearViewModel {
byMonth,
totalAllocation: roundMoney(
byMonth.reduce((sum, f) => sum + f.allocation, 0),
base,
baseDigits,
),
totalCharges: roundMoney(
byMonth.reduce((sum, f) => sum + f.charges, 0),
base,
baseDigits,
),
closingBalance: byMonth[11]!.remaining,
};
@@ -112,11 +115,11 @@ export function yearView(dataset: Dataset, year: number): YearViewModel {
incomeByMonth,
totalIncome: roundMoney(
incomeByMonth.reduce((a, b) => a + b, 0),
base,
baseDigits,
),
totalCharges: roundMoney(
rows.reduce((sum, r) => sum + r.totalCharges, 0),
base,
baseDigits,
),
rows,
};
@@ -138,6 +141,7 @@ export function summaryView(
): SummaryViewModel {
const months = monthRange(from, to);
const base = dataset.settings.baseCurrency;
const baseDigits = digitsFor(dataset.currencies, base);
const fold: Fold =
months.length > 0 ? foldBalances(dataset, months[months.length - 1]!) : new Map();
@@ -145,7 +149,7 @@ export function summaryView(
post,
charges: roundMoney(
months.reduce((sum, m) => sum + figuresFor(fold, post.id, m).charges, 0),
base,
baseDigits,
),
}));
@@ -156,7 +160,7 @@ export function summaryView(
(sum, p) => sum + figuresFor(fold, p.id, monthId).charges,
0,
),
base,
baseDigits,
),
}));
@@ -167,11 +171,11 @@ export function summaryView(
byMonth,
totalCharges: roundMoney(
byMonth.reduce((sum, m) => sum + m.charges, 0),
base,
baseDigits,
),
totalIncome: roundMoney(
months.reduce((sum, m) => sum + incomeFor(dataset, m), 0),
base,
baseDigits,
),
};
}
+82 -11
View File
@@ -1,4 +1,4 @@
import { CURRENCIES, type Currency, type Dataset, type MonthId } from "../domain/types.ts";
import type { Currency, CurrencyDef, Dataset, MonthId } from "../domain/types.ts";
import { migrate } from "../store/migrations.ts";
export class ImportValidationError extends Error {
@@ -36,11 +36,80 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/;
*/
const PURCHASE_DATE = /^\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?$/;
function requireCurrency(value: unknown, where: string): Currency {
if (!CURRENCIES.includes(value as Currency)) {
throw new ImportValidationError(`Unsupported currency ${String(value)} in ${where}`);
/**
* `Currency` is an open string now, so this — not the type system — is what
* stops a code the dataset never defined reaching the fold, where it would
* throw MissingRateError or round at a guessed number of decimals.
*/
function requireCurrency(
value: unknown,
where: string,
defined: ReadonlySet<Currency>,
): Currency {
if (typeof value !== "string" || !defined.has(value)) {
throw new ImportValidationError(
`Unsupported currency ${String(value)} in ${where}. Define it under Settings first.`,
);
}
return value as Currency;
return value;
}
/** Codes are identity: they key the FX table and every stored Money. */
const CURRENCY_CODE = /^[A-Z]{2,8}$/;
/**
* Validates the currency table itself, before anything is checked against it.
* A table with a duplicate code would make `digitsFor` resolve arbitrarily,
* and a non-integer or absurd `digits` would round money to nonsense.
*/
function requireCurrencyTable(data: Record<string, unknown>): CurrencyDef[] {
const raw = data.currencies;
if (!Array.isArray(raw)) {
throw new ImportValidationError('Dataset field "currencies" is missing or not an array');
}
if (raw.length === 0) {
throw new ImportValidationError("A dataset must define at least one currency");
}
const seen = new Set<string>();
const table: CurrencyDef[] = [];
for (const entry of raw as Array<Record<string, unknown>>) {
const code = entry?.code;
if (typeof code !== "string" || !CURRENCY_CODE.test(code)) {
throw new ImportValidationError(
`Currency code ${JSON.stringify(code)} is not 2-8 uppercase letters`,
);
}
if (seen.has(code)) {
throw new ImportValidationError(
`Currency "${code}" is defined twice; which decimals apply would be arbitrary`,
);
}
seen.add(code);
const digits = entry.digits;
if (typeof digits !== "number" || !Number.isInteger(digits) || digits < 0 || digits > 4) {
throw new ImportValidationError(
`Currency "${code}" has invalid decimal places ${String(digits)}; expected an integer 0-4`,
);
}
if (entry.symbol !== undefined && typeof entry.symbol !== "string") {
throw new ImportValidationError(`Currency "${code}" has a non-string symbol`);
}
if (entry.name !== undefined && typeof entry.name !== "string") {
throw new ImportValidationError(`Currency "${code}" has a non-string name`);
}
table.push({
code,
digits,
...(typeof entry.symbol === "string" ? { symbol: entry.symbol } : {}),
...(typeof entry.name === "string" ? { name: entry.name } : {}),
});
}
return table;
}
function requireArray(data: Record<string, unknown>, key: string): unknown[] {
@@ -75,12 +144,14 @@ export function parseDatasetJson(text: string): Dataset {
}
const data = dataset as unknown as Record<string, unknown>;
const currencies = requireCurrencyTable(data);
const defined = new Set(currencies.map((currency) => currency.code));
requireArray(data, "posts");
requireArray(data, "months");
requireArray(data, "purchases");
requireArray(data, "fxRates");
requireCurrency(dataset.settings.baseCurrency, "settings.baseCurrency");
requireCurrency(dataset.settings.baseCurrency, "settings.baseCurrency", defined);
if (!MONTH_ID.test(dataset.settings.foldStartMonth)) {
throw new ImportValidationError(
`Invalid foldStartMonth "${dataset.settings.foldStartMonth}"`,
@@ -89,7 +160,7 @@ export function parseDatasetJson(text: string): Dataset {
const postIds = new Set(dataset.posts.map((p) => p.id));
for (const post of dataset.posts) {
requireCurrency(post.currency, `post "${post.name}"`);
requireCurrency(post.currency, `post "${post.name}"`, defined);
const rules = (post as unknown as Record<string, unknown>).rules;
if (!Array.isArray(rules)) {
@@ -120,7 +191,7 @@ export function parseDatasetJson(text: string): Dataset {
amount?: { currency?: unknown };
};
if (rule.kind === "fixed") {
requireCurrency(rule.amount?.currency, `post "${post.name}" rule from ${from}`);
requireCurrency(rule.amount?.currency, `post "${post.name}" rule from ${from}`, defined);
} else if (rule.kind !== "percentOfIncome") {
throw new ImportValidationError(
`Post "${post.name}" has a rule from "${from}" of unknown kind "${String(rule.kind)}"`,
@@ -133,12 +204,12 @@ export function parseDatasetJson(text: string): Dataset {
if (!MONTH_ID.test(month.id)) {
throw new ImportValidationError(`Invalid month id "${month.id}"`);
}
requireCurrency(month.income.currency, `month ${month.id} income`);
requireCurrency(month.income.currency, `month ${month.id} income`, defined);
}
for (const purchase of dataset.purchases) {
const label = `purchase "${purchase.description}"`;
requireCurrency(purchase.total.currency, label);
requireCurrency(purchase.total.currency, label, defined);
if (!PURCHASE_DATE.test(purchase.date)) {
throw new ImportValidationError(`${label} has an invalid date "${purchase.date}"`);
@@ -163,7 +234,7 @@ export function parseDatasetJson(text: string): Dataset {
if (!MONTH_ID.test(slice.month)) {
throw new ImportValidationError(`${label} has invalid slice month "${slice.month}"`);
}
requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`);
requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`, defined);
}
}
+38 -13
View File
@@ -2,7 +2,10 @@ import { zipSync, strToU8 } from "fflate";
import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../domain/charges.ts";
import { monthRange } from "../domain/months.ts";
import { roundMoney } from "../domain/money.ts";
import { digitsFor } from "../domain/currencies.ts";
import { datasetMonthSpan, monthView, summaryView } from "../domain/views.ts";
import type { MonthViewModel } from "../domain/views.ts";
import type { PostMonthFigures } from "../domain/fold.ts";
import type { Dataset, MonthId } from "../domain/types.ts";
import {
buildContentXml,
@@ -18,20 +21,18 @@ export function odsFilename(monthId: MonthId): string {
return `budget-${monthId}.ods`;
}
function summarySheet(dataset: Dataset, months: MonthId[]): Sheet {
function summarySheet(dataset: Dataset, months: MonthId[], byMonth: FiguresByMonth): Sheet {
const base = dataset.settings.baseCurrency;
const rows = [
[text(`Post (${base})`), ...months.map((m) => text(m)), text("Total")],
];
for (const post of [...dataset.posts].sort((a, b) => a.order - b.order)) {
const perMonth = months.map(
(m) => monthView(dataset, m).rows.find((r) => r.post.id === post.id)?.figures.charges ?? 0,
);
const perMonth = months.map((m) => byMonth.get(m)?.get(post.id)?.charges ?? 0);
rows.push([
text(post.name),
...perMonth.map(num),
num(roundMoney(perMonth.reduce((a, b) => a + b, 0), base)),
num(roundMoney(perMonth.reduce((a, b) => a + b, 0), digitsFor(dataset.currencies, base))),
]);
}
@@ -44,12 +45,12 @@ function summarySheet(dataset: Dataset, months: MonthId[]): Sheet {
return { name: "Summary", rows };
}
function monthsSheet(dataset: Dataset, months: MonthId[]): Sheet {
function monthsSheet(months: MonthId[], views: Map<MonthId, MonthViewModel>): Sheet {
const rows = [
[text("Month"), text("Income"), text("Allocated"), text("Spent"), text("Unallocated")],
];
for (const monthId of months) {
const view = monthView(dataset, monthId);
const view = views.get(monthId)!;
rows.push([
text(monthId),
num(view.income),
@@ -82,7 +83,13 @@ function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet {
for (const purchase of dataset.purchases) {
const slice = sliceAmountForMonth(purchase, monthId);
if (!slice) continue;
const charges = chargesForPurchaseInMonth(purchase, monthId, base, dataset.fxRates);
const charges = chargesForPurchaseInMonth(
purchase,
monthId,
base,
dataset.fxRates,
dataset.currencies,
);
for (const charge of charges) {
rows.push([
text(monthId),
@@ -100,12 +107,17 @@ function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet {
return { name: "Purchases", rows };
}
function postSheet(dataset: Dataset, postId: string, name: string, months: MonthId[]): Sheet {
function postSheet(
postId: string,
name: string,
months: MonthId[],
byMonth: FiguresByMonth,
): Sheet {
const rows = [
[text("Month"), text("Carried in"), text("Allocated"), text("Spent"), text("Remaining")],
];
for (const monthId of months) {
const figures = monthView(dataset, monthId).rows.find((r) => r.post.id === postId)?.figures;
const figures = byMonth.get(monthId)?.get(postId);
rows.push([
text(monthId),
num(figures?.carriedIn ?? 0),
@@ -117,17 +129,30 @@ function postSheet(dataset: Dataset, postId: string, name: string, months: Month
return { name, rows };
}
/** Every post's figures for one month, keyed by post id. */
type FiguresByMonth = Map<MonthId, Map<string, PostMonthFigures>>;
export function buildWorkbook(dataset: Dataset): Sheet[] {
const { from, to } = datasetMonthSpan(dataset);
const months = monthRange(from, to);
// monthView folds from the start of the dataset, so it is O(months) each
// time. Calling it once per (post, month) — as the summary and per-post
// sheets each used to — made a full export O(posts x months^2). One pass
// here, indexed by post, leaves the whole workbook O(months^2) with the
// sheets doing map lookups. Output is unchanged.
const views = new Map(months.map((m) => [m, monthView(dataset, m)]));
const byMonth: FiguresByMonth = new Map(
months.map((m) => [m, new Map(views.get(m)!.rows.map((r) => [r.post.id, r.figures]))]),
);
return [
summarySheet(dataset, months),
monthsSheet(dataset, months),
summarySheet(dataset, months, byMonth),
monthsSheet(months, views),
purchasesSheet(dataset, months),
...[...dataset.posts]
.sort((a, b) => a.order - b.order)
.map((post) => postSheet(dataset, post.id, post.name, months)),
.map((post) => postSheet(post.id, post.name, months, byMonth)),
];
}
+48
View File
@@ -0,0 +1,48 @@
import { test, expect, describe } from "bun:test";
import { FONT_FILES, buildFontCss } from "./fontCss.ts";
describe("the font stylesheet", () => {
test("every url is absolute from the deploy base", () => {
const css = buildFontCss("/budget2.0/");
const urls = [...css.matchAll(/url\("([^"]+)"\)/g)].map((m) => m[1]);
expect(urls).toEqual(FONT_FILES.map((f) => `/budget2.0/fonts/${f}`));
});
test("a root deploy is not a special case", () => {
const urls = [...buildFontCss("/").matchAll(/url\("([^"]+)"\)/g)].map((m) => m[1]);
expect(urls).toEqual(FONT_FILES.map((f) => `/fonts/${f}`));
});
/**
* Relative urls are what this must never emit. The stylesheet is served from
* the deploy root but the DOCUMENT can be several segments deep
* (/budget2.0/month/2026-09), and a relative font url resolves against
* whichever of the two the browser used — silently 404ing on deep routes.
*/
test("no url is relative", () => {
const css = buildFontCss("/budget2.0/");
expect(css).not.toContain('url("./');
expect(css).not.toContain('url("fonts/');
});
test("each face declares a weight range, not a single weight", () => {
// Both faces are variable; one file must cover the whole scale.
const css = buildFontCss("/");
expect(css).toContain("font-weight: 100 900");
expect(css).toContain("font-weight: 400 700");
expect(css.match(/@font-face/g)).toHaveLength(FONT_FILES.length);
});
test("every declared file is actually committed", async () => {
for (const file of FONT_FILES) {
expect(await Bun.file(`src/fonts/${file}`).exists()).toBe(true);
}
});
test("the licences travel with the fonts, as the OFL requires", async () => {
for (const licence of ["OFL-Inter.txt", "OFL-JetBrainsMono.txt"]) {
const text = await Bun.file(`src/fonts/${licence}`).text();
expect(text).toContain("SIL Open Font License");
}
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* The one definition of the app's @font-face rules, shared by `build.ts`
* (which writes it to dist/fonts.css) and the dev server (which generates it
* per request) — the same shape as src/manifest.ts, and for the same reason:
* two copies is how the manifest came to hardcode `scope: "/"`.
*
* Why this is NOT in src/index.css, where it belongs on the face of it: Bun's
* CSS bundler inlines every woff2 it finds behind a `url()` as base64,
* unconditionally — `loader: { ".woff2": "file" }` does not change it. That
* turned the stylesheet into 303 KB of render-blocking bytes AND defeated the
* `unicode-range` subsetting, since a subset embedded in the stylesheet is
* downloaded whether or not a single character needs it. Served as files, the
* browser fetches only the subsets a glyph actually requires.
*/
/** Ordered, and the order is asserted by src/fontCss.test.ts. */
export const FONT_FILES = [
"inter-latin.woff2",
"inter-latin-ext.woff2",
"jetbrains-mono-latin.woff2",
"jetbrains-mono-latin-ext.woff2",
] as const;
/** Google's subset ranges, kept verbatim so the browser can skip a whole file. */
const LATIN =
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, " +
"U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, " +
"U+2212, U+2215, U+FEFF, U+FFFD";
const LATIN_EXT =
"U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, " +
"U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, " +
"U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF";
interface Face {
family: string;
weight: string;
file: (typeof FONT_FILES)[number];
range: string;
}
const FACES: Face[] = [
{ family: "Inter", weight: "100 900", file: "inter-latin.woff2", range: LATIN },
{ family: "Inter", weight: "100 900", file: "inter-latin-ext.woff2", range: LATIN_EXT },
{ family: "JetBrains Mono", weight: "400 700", file: "jetbrains-mono-latin.woff2", range: LATIN },
{ family: "JetBrains Mono", weight: "400 700", file: "jetbrains-mono-latin-ext.woff2", range: LATIN_EXT },
];
/**
* `base` always ends in "/" (see normalizeBase). Urls must be absolute from
* it: this stylesheet lives at the deploy root but the document can be several
* segments deep, and a relative url would resolve against the document and
* 404 on every route deeper than the base.
*
* `font-display: swap` because the first paint is a table of figures the owner
* wants to read now; blocking that on a font download is the wrong trade.
*/
export function buildFontCss(base: string): string {
const header =
"/* Generated by src/fontCss.ts. Do not edit: build.ts writes this file\n" +
" and the dev server serves the same output. SIL OFL 1.1 — see\n" +
" src/fonts/OFL-Inter.txt and src/fonts/OFL-JetBrainsMono.txt. */\n";
return (
header +
FACES.map(
(face) => `
@font-face {
font-family: "${face.family}";
font-style: normal;
font-weight: ${face.weight};
font-display: swap;
src: url("${base}fonts/${face.file}") format("woff2");
unicode-range: ${face.range};
}`,
).join("\n") +
"\n"
);
}
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -13,6 +13,14 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import { App } from "./ui/App.tsx";
import { store } from "./store/index.ts";
import { buildFontCss } from "./fontCss.ts";
import { BASE_PATH } from "./ui/basePath.ts";
// Injected rather than imported: see the note in src/index.css. Done before
// the store loads so the faces are already in flight while IndexedDB is read.
const fontStyle = document.createElement("style");
fontStyle.textContent = buildFontCss(BASE_PATH);
document.head.appendChild(fontStyle);
const container = document.getElementById("root") ?? document.body;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+73
View File
@@ -0,0 +1,73 @@
import { test, expect, describe } from "bun:test";
import { inflateSync } from "node:zlib";
import { buildManifest } from "./manifest.ts";
/**
* Chrome's install prompt wants raster icons at 192 and 512; an SVG alone
* leaves it incomplete. These are generated from src/icon.svg by
* `bun scripts/make-icons.ts` — see that file for why they are committed
* rather than built.
*/
const SIZES = [192, 512] as const;
/** Width and height straight out of the IHDR chunk, which is always first. */
function pngHeader(bytes: Uint8Array) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const signature = [...bytes.slice(0, 8)];
return {
isPng: String.fromCharCode(...signature.slice(1, 4)) === "PNG",
width: view.getUint32(16),
height: view.getUint32(20),
bitDepth: bytes[24],
colorType: bytes[25],
};
}
/** Concatenated IDAT payloads, inflated. */
function pixelBytes(bytes: Uint8Array): Buffer {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const chunks: Uint8Array[] = [];
let i = 8;
while (i < bytes.length) {
const length = view.getUint32(i);
const type = String.fromCharCode(...bytes.slice(i + 4, i + 8));
if (type === "IDAT") chunks.push(bytes.slice(i + 8, i + 8 + length));
i += 12 + length;
}
return inflateSync(Buffer.concat(chunks));
}
describe("the app icons", () => {
for (const size of SIZES) {
test(`icon-${size}.png is a real ${size}x${size} image, not a placeholder`, async () => {
const bytes = new Uint8Array(await Bun.file(`src/icon-${size}.png`).arrayBuffer());
const header = pngHeader(bytes);
expect(header.isPng).toBe(true);
expect(header.width).toBe(size);
expect(header.height).toBe(size);
expect(header.bitDepth).toBe(8);
expect(header.colorType).toBe(6); // RGBA
// One filter byte plus 4 channels per pixel, per row. Proves the pixel
// data is really present and really that size, so a truncated or
// stub file cannot pass.
expect(pixelBytes(bytes).length).toBe(size * (1 + size * 4));
});
}
test("the manifest offers both raster sizes as well as the SVG", () => {
const icons = JSON.parse(buildManifest("/budget/")).icons as Array<{
src: string;
sizes: string;
type: string;
}>;
expect(icons.map((i) => i.sizes)).toEqual(["any", "192x192", "512x512"]);
expect(icons.map((i) => i.src)).toEqual([
"/budget/icon.svg",
"/budget/icon-192.png",
"/budget/icon-512.png",
]);
expect(icons.every((i) => i.src.startsWith("/budget/"))).toBe(true);
});
});
+173 -30
View File
@@ -1,41 +1,163 @@
@import "../styles/globals.css";
/*
* Design contract tokens (Tasks 18-23).
* See .superpowers/sdd/2026-09-01-budget-app/design-contract.md
*
* Namespaced as --budget-accent (not --accent): shadcn already defines a
* bare --accent/--accent-foreground pair in styles/globals.css for hover
* backgrounds on outline/ghost buttons, select items, badges, and the
* dialog close button. A same-named override here would repaint that
* background without repainting its paired foreground, breaking contrast
* on every one of those controls. None of the other five tokens below
* collide with a shadcn token name (checked against styles/globals.css).
*
* Unlayered (not inside @layer base) so these predictably win the cascade
* over anything layered added later, without needing !important.
*/
:root {
--paper: #f2f4f6; /* cool statement-paper ground, NOT cream */
--ink: #16212b; /* ink navy, primary text */
--rule: #d8dee4; /* hairlines, table borders */
--budget-accent: #0f6e63; /* muted teal: links, primary actions, spend fill */
--surplus: #2f6f4e; /* carried-forward surplus */
--overspend: #a6402b; /* ochre-red: negative balances only */
/* The @font-face rules are deliberately NOT here. Bun's CSS bundler resolves
every url() from disk and inlines woff2 as base64 — relative OR absolute,
and `loader: { ".woff2": "file" }` does not change it. That made this file
303 KB of render-blocking bytes and defeated the unicode-range subsetting,
since a subset embedded in the stylesheet downloads whether or not one
character needs it. They live in src/fontCss.ts and are injected at boot by
src/frontend.tsx, which is the only arrangement that is identical in dev and
in a subpath production build without a placeholder file. The cost is that
the faces are requested after the JS parses rather than from the HTML;
`font-display: swap` keeps text readable throughout. */
--font-display: ui-sans-serif, "Segoe UI Variable Display", system-ui, sans-serif;
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-money: ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", monospace;
/*
* ============================================================================
* Budget 2.0 design foundation
* ============================================================================
*
* Two layers, deliberately separate:
*
* 1. `--budget-*` semantic tokens, defined once per theme below. These are
* the app's own vocabulary — paper, ink, rule, surplus, overspend — and
* are the ONLY colours the app's own components should name.
* 2. shadcn's variable set, pointed at layer 1. Re-theming shadcn rather
* than fighting it means every primitive (dialog, select, badge, button)
* inherits both themes for free.
*
* WHY THE PAIRS MATTER. CLAUDE.md's rule is "never define a bare `--accent`",
* and it was paid for with an app-wide contrast bug: shadcn uses `--accent` as
* a hover BACKGROUND and `--accent-foreground` as the text on it, so setting
* one without the other repaints the background and leaves the text behind.
* Every shadcn pair below is therefore redefined TOGETHER, in both themes.
*
* The brand teal is `--primary`, NOT `--accent`. That is the mapping the
* earlier bug got wrong: shadcn's `--accent` is a subtle hover tint, and a
* saturated teal there turns every hover into a coloured slab with dark text
* on it. `--accent` stays a tint of the surface.
*
* Every text pair here was measured, not eyeballed: the weakest is 5.50:1
* (muted on paper, light theme) against a 4.5:1 requirement, and field
* borders clear the 3:1 non-text requirement on both of their backgrounds.
*/
:root {
/* --- Layer 1: the app's own vocabulary (light) --- */
--budget-paper: #f3f5f7; /* page ground: cool statement paper, NOT cream */
--budget-raised: #ffffff; /* cards and rows sitting above the paper */
--budget-ink: #16212b; /* primary text: ink navy, not black */
--budget-ink-muted: #546575; /* labels, captions, secondary figures */
--budget-rule: #dce3e9; /* hairlines between rows */
--budget-rule-strong: #768899; /* field borders; needs 3:1, hairlines do not */
--budget-accent: #0d6b60; /* muted teal: links, primary actions, spend fill */
--budget-accent-ink: #ffffff; /* text ON the teal */
--budget-tint: #e8edf1; /* hover wash, table header fill */
--surplus: #1f6640; /* carried-forward surplus */
--overspend: #a5382a; /* negative balances only */
/* Elevation is light and cool, never a grey drop shadow. */
--budget-shadow-card: 0 1px 2px rgb(22 33 43 / 0.04), 0 8px 24px -12px rgb(22 33 43 / 0.12);
--budget-shadow-raised: 0 1px 1px rgb(22 33 43 / 0.05), 0 2px 6px -2px rgb(22 33 43 / 0.08);
--font-display: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-body: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-money: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
/* --- Layer 2: shadcn, pointed at layer 1. Pairs, always. --- */
--radius: 0.5rem; /* crisper than shadcn's 0.625rem default */
--background: var(--budget-paper);
--foreground: var(--budget-ink);
--card: var(--budget-raised);
--card-foreground: var(--budget-ink);
--popover: var(--budget-raised);
--popover-foreground: var(--budget-ink);
--primary: var(--budget-accent);
--primary-foreground: var(--budget-accent-ink);
--secondary: var(--budget-tint);
--secondary-foreground: var(--budget-ink);
--muted: var(--budget-tint);
--muted-foreground: var(--budget-ink-muted);
--accent: var(--budget-tint); /* a TINT. see the note above. */
--accent-foreground: var(--budget-ink); /* its pair, never omitted. */
--destructive: var(--overspend);
--border: var(--budget-rule);
--input: var(--budget-rule-strong);
--ring: var(--budget-accent);
}
.dark {
/* --- Layer 1 (dark). Deep slate, never pure black: a pure-black ground
makes the hairlines and the carry meter float. --- */
--budget-paper: #0c1319;
--budget-raised: #141e28;
--budget-ink: #e8eef4;
--budget-ink-muted: #94a7b8;
--budget-rule: #22303e;
--budget-rule-strong: #647c92;
--budget-accent: #3ab5a4; /* lifted: the light teal is unreadable on slate */
--budget-accent-ink: #08110f; /* dark text on the lifted teal, not white */
--budget-tint: #1c2836;
--surplus: #57bb83;
--overspend: #f0866a;
/* Dark elevation reads as a lighter surface plus a hairline, not a shadow:
shadows are invisible on a dark ground. */
--budget-shadow-card: 0 1px 0 rgb(255 255 255 / 0.04) inset, 0 12px 32px -16px rgb(0 0 0 / 0.7);
--budget-shadow-raised: 0 1px 0 rgb(255 255 255 / 0.03) inset;
/* --- Layer 2 (dark). Same pairs, same rule. --- */
--background: var(--budget-paper);
--foreground: var(--budget-ink);
--card: var(--budget-raised);
--card-foreground: var(--budget-ink);
--popover: var(--budget-raised);
--popover-foreground: var(--budget-ink);
--primary: var(--budget-accent);
--primary-foreground: var(--budget-accent-ink);
--secondary: var(--budget-tint);
--secondary-foreground: var(--budget-ink);
--muted: var(--budget-tint);
--muted-foreground: var(--budget-ink-muted);
--accent: var(--budget-tint);
--accent-foreground: var(--budget-ink);
--destructive: var(--overspend);
--border: var(--budget-rule);
--input: var(--budget-rule-strong);
--ring: var(--budget-accent);
}
/* Makes `font-sans` / `font-mono` utilities and shadcn primitives inherit the
shipped faces instead of the OS stack. */
@theme inline {
--font-sans: var(--font-body);
--font-mono: var(--font-money);
--color-budget-accent: var(--budget-accent);
--color-budget-paper: var(--budget-paper);
--color-budget-raised: var(--budget-raised);
--color-budget-rule: var(--budget-rule);
--color-budget-ink: var(--budget-ink);
--color-budget-ink-muted: var(--budget-ink-muted);
--color-surplus: var(--surplus);
--color-overspend: var(--overspend);
}
@layer base {
:root {
@apply font-sans;
color-scheme: light;
}
.dark {
color-scheme: dark;
}
body {
@apply grid place-items-center min-w-[320px] min-h-screen relative m-0 bg-background text-foreground;
/* Was `grid place-items-center`, which centred short pages VERTICALLY —
the month view floated in the middle of the viewport on an empty month.
The app shell owns horizontal centring now, with a max width. */
@apply m-0 min-w-[320px] min-h-screen bg-background text-foreground antialiased;
font-family: var(--font-body);
font-feature-settings: "cv11"; /* Inter's single-storey a-alternates off */
}
h1,
@@ -45,16 +167,28 @@
h5,
h6 {
font-family: var(--font-display);
font-weight: 650;
letter-spacing: -0.02em;
font-weight: 640;
letter-spacing: -0.019em;
text-wrap: balance;
}
/* One visible, consistent focus treatment. Keyboard users get a ring on the
accent colour, which clears 3:1 on every surface in both themes. */
:focus-visible {
outline: 2px solid var(--budget-accent);
outline-offset: 2px;
border-radius: 2px;
}
}
@layer utilities {
/* Applied to every money figure: mono face, tabular digits. */
/* Applied to every money figure: mono face, tabular digits so columns of
numbers align on the decimal without a monospaced layout everywhere. */
.font-money {
font-family: var(--font-money);
font-variant-numeric: tabular-nums;
font-feature-settings: "zero"; /* slashed zero: 0 vs O at a glance */
letter-spacing: -0.01em;
}
.text-overspend {
@@ -64,6 +198,14 @@
.text-surplus {
color: var(--surplus);
}
/* The one card treatment, so elevation cannot drift screen to screen. */
.budget-card {
background: var(--budget-raised);
border: 1px solid var(--budget-rule);
border-radius: var(--radius);
box-shadow: var(--budget-shadow-card);
}
}
@keyframes spin {
@@ -80,5 +222,6 @@
::before,
::after {
animation: none !important;
transition: none !important;
}
}
+21
View File
@@ -4,9 +4,30 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Budget 2.0</title>
<!-- Relative, NOT root-absolute: the dev server's HTML bundler resolves
every <link href> from disk and has no `external` option, so "/x"
fails to resolve and the whole page 500s. build.ts rewrites these to
the deploy base for production. -->
<link rel="manifest" href="manifest.webmanifest" />
<link rel="icon" href="icon.svg" type="image/svg+xml" />
<meta name="theme-color" content="#0f172a" />
<!--
Applies the saved theme BEFORE the stylesheet paints, so a dark-theme
user never sees a flash of the light palette. It cannot import
src/ui/theme.ts (it has to run before any module loads), so it repeats
the key and the fallback; src/ui/theme.test.ts fails if the two drift.
Wrapped because localStorage throws outright in some configurations.
-->
<script>
try {
var saved = localStorage.getItem("budget2:theme");
var dark =
saved === "dark" ||
((saved === null || saved === "system") &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
} catch (e) {}
</script>
<script type="module" src="./frontend.tsx" async></script>
</head>
<body>
+63
View File
@@ -1,8 +1,71 @@
import { serve } from "bun";
import index from "./index.html";
import { normalizeBase } from "./ui/basePath.ts";
import { buildManifest } from "./manifest.ts";
import { FONT_FILES } from "./fontCss.ts";
const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH);
const server = serve({
routes: {
// index.html references both of these with a plain <link href>. Without
// explicit routes the "/*" catch-all below answers them with the HTML
// shell, so the manifest arrives as text/html (a parse error, and no
// install prompt) and the icon never loads. Generated rather than served
// from a committed file so the dev manifest cannot drift from the built
// one — a stale copy is exactly how it came to hardcode scope "/".
[`${basePath}manifest.webmanifest`]: () =>
new Response(buildManifest(basePath), {
headers: { "content-type": "application/manifest+json" },
}),
[`${basePath}icon.svg`]: () =>
new Response(Bun.file(new URL("./icon.svg", import.meta.url)), {
headers: { "content-type": "image/svg+xml" },
}),
[`${basePath}icon-192.png`]: () =>
new Response(Bun.file(new URL("./icon-192.png", import.meta.url)), {
headers: { "content-type": "image/png" },
}),
[`${basePath}icon-512.png`]: () =>
new Response(Bun.file(new URL("./icon-512.png", import.meta.url)), {
headers: { "content-type": "image/png" },
}),
// registerServiceWorker() asks for sw.js on every boot. Without this the
// catch-all answers with the HTML shell and the browser logs
// "The script has an unsupported MIME type ('text/html')" — caught and
// harmless, but noise that hides real errors. A worker that claims no
// clients and handles no fetches keeps registration honest in dev without
// caching anything, which would fight HMR.
[`${basePath}sw.js`]: () =>
new Response(
// Caches nothing — that would fight HMR — but it DOES honour the
// lifecycle, so the update prompt's Reload button behaves in dev the
// way it behaves in production instead of only ever hitting the
// timeout backstop.
[
"/* dev worker: no caching, real lifecycle */",
'self.addEventListener("install", () => self.skipWaiting());',
'self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));',
'self.addEventListener("message", (e) => {',
' if (e.data && e.data.type === "SKIP_WAITING") self.skipWaiting();',
"});",
"",
].join("\n"),
{
headers: { "content-type": "text/javascript", "cache-control": "no-cache" },
},
),
// The @font-face rules are injected by frontend.tsx; these serve the
// files those rules point at.
...Object.fromEntries(
FONT_FILES.map((file) => [
`${basePath}fonts/${file}`,
() =>
new Response(Bun.file(new URL(`./fonts/${file}`, import.meta.url)), {
headers: { "content-type": "font/woff2", "cache-control": "no-cache" },
}),
]),
),
"/*": index,
},
development: process.env.NODE_ENV !== "production" && {
+31
View File
@@ -0,0 +1,31 @@
import { test, expect } from "bun:test";
import { buildManifest } from "./manifest.ts";
test("scope, start_url and the icon all follow the base path", () => {
const manifest = JSON.parse(buildManifest("/budget2.0/"));
expect(manifest.scope).toBe("/budget2.0/");
expect(manifest.start_url).toBe("/budget2.0/");
expect(manifest.icons[0].src).toBe("/budget2.0/icon.svg");
});
test("a root deploy is the same shape, not a special case", () => {
const manifest = JSON.parse(buildManifest("/"));
expect(manifest.scope).toBe("/");
expect(manifest.icons[0].src).toBe("/icon.svg");
});
/**
* `src/manifest.webmanifest` exists only so Bun's dev-server HTML bundler can
* resolve the `<link rel="manifest">` href in src/index.html — Bun.serve has
* no `external` option, unlike Bun.build. Nothing ever serves its contents:
* the dev server answers that path from `buildManifest`, and build.ts writes
* dist/manifest.webmanifest from it too.
*
* So the file must stay valueless. Real values here are unreachable, and a
* hardcoded "scope": "/" in this file is exactly what made a subpath dev
* deploy misleading.
*/
test("the dev resolution stub holds no manifest values that could go stale", async () => {
const stub = await Bun.file("src/manifest.webmanifest").json();
expect(Object.keys(stub)).toEqual(["$comment"]);
});
+30
View File
@@ -0,0 +1,30 @@
/**
* The one definition of the web app manifest, shared by `build.ts` (which
* writes it into dist/) and the dev server (which generates it per request).
* Two copies is how the dev placeholder came to hardcode `scope: "/"` while
* production correctly used the base path.
*
* `base` always ends in "/" — see `normalizeBase` in src/ui/basePath.ts.
*/
export function buildManifest(base: string): string {
return JSON.stringify(
{
name: "Budget 2.0",
short_name: "Budget",
start_url: base,
scope: base,
display: "standalone",
background_color: "#0f172a",
theme_color: "#0f172a",
// The SVG first for anything that will scale it; the raster sizes
// because Chrome's install prompt wants 192 and 512 specifically.
icons: [
{ src: `${base}icon.svg`, sizes: "any", type: "image/svg+xml" },
{ src: `${base}icon-192.png`, sizes: "192x192", type: "image/png" },
{ src: `${base}icon-512.png`, sizes: "512x512", type: "image/png" },
],
},
null,
2,
);
}
+1 -8
View File
@@ -1,10 +1,3 @@
{
"name": "Budget 2.0",
"short_name": "Budget",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"icons": [{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }]
"$comment": "Not served, and deliberately valueless. This file exists only so Bun's dev-server HTML bundler can resolve the <link rel=\"manifest\"> href in src/index.html; Bun.serve has no `external` option, unlike Bun.build. The manifest actually served comes from src/manifest.ts: the dev server generates it per request, and build.ts writes dist/manifest.webmanifest from it. Adding real values here does nothing except let them go stale - src/manifest.test.ts fails if you do."
}
+78 -1
View File
@@ -3,8 +3,29 @@ import * as actions from "./actions.ts";
import { createSeedDataset } from "../domain/seed.ts";
import type { Dataset } from "../domain/types.ts";
/**
* Digits now live in the dataset, so a currency with a different minor unit is
* expressed by DEFINING one — no monkey-patching a module constant, which is
* what this file used to need.
*/
function withDigits(currency: string, digits: number, body: () => void) {
const original = zeroDigitDataset;
zeroDigitDataset = { currency, digits };
try {
body();
} finally {
zeroDigitDataset = original;
}
}
let zeroDigitDataset: { currency: string; digits: number } | null = null;
function draft(): Dataset {
return createSeedDataset("2026-09");
const data = createSeedDataset("2026-09");
if (zeroDigitDataset) {
const target = data.currencies.find((c) => c.code === zeroDigitDataset!.currency);
if (target) target.digits = zeroDigitDataset.digits;
}
return data;
}
describe("months", () => {
@@ -378,3 +399,59 @@ describe("rule versions", () => {
);
});
});
describe("split values are rounded by what they MEAN", () => {
// In "fixed" mode a split value is money in the purchase's currency, so it
// must round to that currency's minor unit. In "percent" mode it is a
// percentage and has nothing to do with any currency.
test("a fixed-mode split rounds to the purchase currency's minor unit", () => {
withDigits("EUR", 0, () => {
const data = draft();
const purchase = actions.addPurchase(data, {
date: "2026-09-14",
description: "Sofa",
total: { amount: 100, currency: "EUR" },
splitMode: "fixed",
splits: [
{ postId: data.posts[0]!.id, value: 10.44, absorbsRemainder: false },
{ postId: data.posts[1]!.id, value: 89.56, absorbsRemainder: true },
],
schedule: null,
});
expect(purchase.splits[0]!.value).toBe(10);
});
});
test("a fixed-mode split still rounds by currency when only splits change", () => {
withDigits("EUR", 0, () => {
const data = draft();
const purchase = actions.addPurchase(data, {
date: "2026-09-14",
description: "Sofa",
total: { amount: 100, currency: "EUR" },
splitMode: "fixed",
splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
schedule: null,
});
actions.updatePurchase(data, purchase.id, {
splits: [{ postId: data.posts[0]!.id, value: 42.61, absorbsRemainder: true }],
});
expect(data.purchases[0]!.splits[0]!.value).toBe(43);
});
});
test("a percent-mode split keeps 2 decimals whatever the currency does", () => {
withDigits("EUR", 0, () => {
const data = draft();
const purchase = actions.addPurchase(data, {
date: "2026-09-14",
description: "Sofa",
total: { amount: 100, currency: "EUR" },
splitMode: "percent",
splits: [{ postId: data.posts[0]!.id, value: 33.3333, absorbsRemainder: true }],
schedule: null,
});
expect(purchase.splits[0]!.value).toBe(33.33);
});
});
});
+107 -17
View File
@@ -1,8 +1,10 @@
import { newId } from "../domain/seed.ts";
import { compareMonths, monthOf } from "../domain/months.ts";
import { roundMoney } from "../domain/money.ts";
import { currencyUsage, digitsFor, normalizeCurrencyCode } from "../domain/currencies.ts";
import type {
Currency,
CurrencyDef,
Dataset,
FxRate,
Money,
@@ -46,28 +48,47 @@ function round2(value: number): number {
return Number(value.toFixed(2));
}
function roundMoneyValue(money: Money): Money {
return { amount: roundMoney(money.amount, money.currency), currency: money.currency };
function roundMoneyValue(draft: Dataset, money: Money): Money {
return {
amount: roundMoney(money.amount, digitsFor(draft.currencies, money.currency)),
currency: money.currency,
};
}
function roundRule(rule: Rule): Rule {
function roundRule(draft: Dataset, rule: Rule): Rule {
if (rule.kind === "fixed") {
return { kind: "fixed", amount: roundMoneyValue(rule.amount) };
return { kind: "fixed", amount: roundMoneyValue(draft, rule.amount) };
}
return { kind: "percentOfIncome", percent: round2(rule.percent) };
}
function roundSplits(splits: Split[]): Split[] {
return splits.map((split) => ({ ...split, value: round2(split.value) }));
/**
* A split's `value` is a percentage in "percent" mode and MONEY in the
* purchase's currency in "fixed" mode, so the two round differently: a
* percentage to 2 decimals, money to its currency's minor unit. Rounding
* both to a hardcoded 2 places is correct only while every supported
* currency happens to use hundredths.
*/
function roundSplits(
draft: Dataset,
splits: Split[],
splitMode: Purchase["splitMode"],
currency: Currency,
): Split[] {
const digits = digitsFor(draft.currencies, currency);
return splits.map((split) => ({
...split,
value: splitMode === "fixed" ? roundMoney(split.value, digits) : round2(split.value),
}));
}
function roundSchedule(schedule: Schedule | null): Schedule | null {
function roundSchedule(draft: Dataset, schedule: Schedule | null): Schedule | null {
if (!schedule) return null;
return {
...schedule,
slices: schedule.slices.map((slice) => ({
month: slice.month,
amount: roundMoneyValue(slice.amount),
amount: roundMoneyValue(draft, slice.amount),
})),
};
}
@@ -86,7 +107,7 @@ export function ensureMonth(draft: Dataset, monthId: MonthId): Month {
}
export function setIncome(draft: Dataset, monthId: MonthId, income: Money): void {
ensureMonth(draft, monthId).income = roundMoneyValue(income);
ensureMonth(draft, monthId).income = roundMoneyValue(draft, income);
}
export function setRuleOverride(
@@ -100,7 +121,7 @@ export function setRuleOverride(
delete month.ruleOverrides[postId];
return;
}
month.ruleOverrides[postId] = roundRule(rule);
month.ruleOverrides[postId] = roundRule(draft, rule);
}
export function addPost(draft: Dataset, name: string, currency: Currency): Post {
@@ -158,7 +179,7 @@ export function setRuleFrom(
rule: Rule,
): void {
const post = requirePost(draft, postId);
const version: RuleVersion = { from, rule: roundRule(rule) };
const version: RuleVersion = { from, rule: roundRule(draft, rule) };
const existing = post.rules.findIndex((v) => v.from === from);
if (existing === -1) post.rules.push(version);
else post.rules[existing] = version;
@@ -181,9 +202,9 @@ export function addPurchase(
const created: Purchase = {
...purchase,
id: newId(),
total: roundMoneyValue(purchase.total),
splits: roundSplits(purchase.splits),
schedule: roundSchedule(purchase.schedule),
total: roundMoneyValue(draft, purchase.total),
splits: roundSplits(draft, purchase.splits, purchase.splitMode, purchase.total.currency),
schedule: roundSchedule(draft, purchase.schedule),
};
draft.purchases.push(created);
// Make sure every month the purchase touches exists, so income can be
@@ -203,13 +224,21 @@ export function updatePurchase(
const purchase = requirePurchase(draft, purchaseId);
const resolved: Partial<Omit<Purchase, "id">> = { ...changes };
if (changes.total) {
resolved.total = roundMoneyValue(changes.total);
resolved.total = roundMoneyValue(draft, changes.total);
}
if (changes.splits) {
resolved.splits = roundSplits(changes.splits);
// Either field may be absent from a partial update; the stored purchase
// supplies whichever one is, so the mode and currency always agree with
// the values being rounded.
resolved.splits = roundSplits(
draft,
changes.splits,
changes.splitMode ?? purchase.splitMode,
(resolved.total ?? purchase.total).currency,
);
}
if (changes.schedule !== undefined) {
resolved.schedule = roundSchedule(changes.schedule);
resolved.schedule = roundSchedule(draft, changes.schedule);
}
Object.assign(purchase, resolved);
}
@@ -235,6 +264,67 @@ export function cancelScheduleFrom(
* kept at six decimal places (see fxApi.ts's toFixed(6)), and rounding to a
* currency's minor unit would quantize a rate below 0.01 straight to zero.
*/
/** Codes are identity, so they are validated the same way on every path. */
const CURRENCY_CODE = /^[A-Z]{2,8}$/;
function requireCurrencyDef(def: CurrencyDef): CurrencyDef {
const code = normalizeCurrencyCode(def.code);
if (!CURRENCY_CODE.test(code)) {
throw new Error(`Currency code "${def.code}" must be 2-8 letters`);
}
if (!Number.isInteger(def.digits) || def.digits < 0 || def.digits > 4) {
throw new Error(
`Currency "${code}" needs a whole number of decimal places between 0 and 4`,
);
}
return {
code,
digits: def.digits,
...(def.symbol?.trim() ? { symbol: def.symbol.trim() } : {}),
...(def.name?.trim() ? { name: def.name.trim() } : {}),
};
}
export function addCurrency(draft: Dataset, def: CurrencyDef): CurrencyDef {
const currency = requireCurrencyDef(def);
if (draft.currencies.some((existing) => existing.code === currency.code)) {
throw new Error(`Currency ${currency.code} is already defined`);
}
draft.currencies.push(currency);
return currency;
}
export function updateCurrency(
draft: Dataset,
code: Currency,
changes: Partial<Omit<CurrencyDef, "code">>,
): void {
const existing = draft.currencies.find((currency) => currency.code === code);
if (!existing) throw new Error(`Unknown currency: ${code}`);
// The code is identity — it keys the FX table and every stored Money — so it
// is deliberately not editable. Renaming one means adding the new code,
// moving what refers to it, and removing the old.
Object.assign(existing, requireCurrencyDef({ ...existing, ...changes, code }));
}
/**
* Refuses to remove a currency anything still refers to, naming what refers to
* it. Its exchange rate goes with it, so no orphan row survives.
*/
export function removeCurrency(draft: Dataset, code: Currency): void {
const used = currencyUsage(draft, code);
if (used.length > 0) {
const isBase = draft.settings.baseCurrency === code;
throw new Error(
isBase
? `${code} is the base currency; choose another base before removing it`
: `${code} is in use by ${used.slice(0, 3).join(", ")}${used.length > 3 ? ` and ${used.length - 3} more` : ""}`,
);
}
draft.currencies = draft.currencies.filter((currency) => currency.code !== code);
draft.fxRates = draft.fxRates.filter((rate) => rate.currency !== code);
}
export function setFxRate(draft: Dataset, rate: FxRate): void {
const index = draft.fxRates.findIndex((r) => r.currency === rate.currency);
if (index === -1) draft.fxRates.push(rate);
+81
View File
@@ -0,0 +1,81 @@
import { test, expect, describe } from "bun:test";
import { FALLBACK_FX_RATES, parseBakedRates } from "./bakedRates.ts";
import { createSeedDataset } from "../domain/seed.ts";
import type { FxRate } from "../domain/types.ts";
import { MissingRateError, toBase } from "../domain/fx.ts";
describe("the committed fallback rates", () => {
test("cover every non-base currency and are honestly labelled manual", () => {
expect(FALLBACK_FX_RATES.map((r) => r.currency).sort()).toEqual(["EUR", "USD"]);
// "api" would claim a hardcoded number came from a rate service. Settings
// shows this field to the user.
expect(FALLBACK_FX_RATES.every((r) => r.source === "manual")).toBe(true);
expect(FALLBACK_FX_RATES.every((r) => r.baseUnitsPerOne > 0)).toBe(true);
});
});
describe("reading what the build baked in", () => {
const baked: FxRate[] = [
{ currency: "USD", baseUnitsPerOne: 6.812345, updatedAt: "2026-09-02", source: "api" },
{ currency: "EUR", baseUnitsPerOne: 7.456789, updatedAt: "2026-09-02", source: "api" },
];
test("an unset variable falls back to the committed constants", () => {
expect(parseBakedRates(undefined)).toEqual(FALLBACK_FX_RATES);
});
test("a well-formed payload is used as-is", () => {
expect(parseBakedRates(JSON.stringify(baked))).toEqual(baked);
});
test("six decimal places survive — a rate is not money", () => {
const [usd] = parseBakedRates(JSON.stringify(baked));
expect(usd!.baseUnitsPerOne).toBe(6.812345);
});
test.each([
["not JSON at all", "{oh dear"],
["a non-array", JSON.stringify({ USD: 6.8 })],
["an unsupported currency", JSON.stringify([{ currency: "GBP", baseUnitsPerOne: 8.6, updatedAt: "2026-09-02", source: "api" }])],
["a non-positive rate", JSON.stringify([{ currency: "USD", baseUnitsPerOne: 0, updatedAt: "2026-09-02", source: "api" }])],
["a missing rate value", JSON.stringify([{ currency: "USD", updatedAt: "2026-09-02", source: "api" }])],
["an empty array", JSON.stringify([])],
])("%s falls back rather than booting with junk", (_label, payload) => {
expect(parseBakedRates(payload)).toEqual(FALLBACK_FX_RATES);
});
});
describe("what baked rates are allowed to do", () => {
test("they seed a new dataset", () => {
const data = createSeedDataset("2026-09", FALLBACK_FX_RATES);
expect(data.fxRates).toEqual([...FALLBACK_FX_RATES]);
});
test("seeding without them still yields no rates at all", () => {
expect(createSeedDataset("2026-09").fxRates).toEqual([]);
});
test("the seeded rows are copies, so editing one cannot rewrite the constants", () => {
const data = createSeedDataset("2026-09", FALLBACK_FX_RATES);
data.fxRates[0]!.baseUnitsPerOne = 999;
expect(FALLBACK_FX_RATES[0]!.baseUnitsPerOne).not.toBe(999);
});
/**
* The decision this guards: clearing a rate REMOVES the row so
* MissingRateError surfaces, instead of silently converting money at a
* number the user never chose. Baked rates seed; they never backstop.
*/
test("they do NOT backstop a rate the user has cleared", () => {
const data = createSeedDataset("2026-09", FALLBACK_FX_RATES);
data.fxRates = data.fxRates.filter((r) => r.currency !== "EUR");
expect(() =>
toBase(
{ amount: 100, currency: "EUR" },
data.settings.baseCurrency,
data.fxRates,
data.currencies,
),
).toThrow(MissingRateError);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { SEED_CURRENCIES, type Currency, type FxRate } from "../domain/types.ts";
/**
* Exchange rates baked in at build time so a fresh install can convert a EUR
* purchase on day one instead of erroring.
*
* They seed a NEW dataset and nothing else. They deliberately do NOT act as a
* fallback whenever a rate is missing: clearing a rate removes its row so
* `MissingRateError` surfaces, rather than silently converting money at a
* number the user never chose. `src/store/bakedRates.test.ts` pins that.
*
* This lives in the store layer, not the domain, because reading the ambient
* environment is exactly what `src/domain/` may not do. `createSeedDataset`
* takes the rates as an argument instead.
*/
/**
* The last-resort rates, committed so a build with no network still produces a
* working app. DKK per one unit, six decimals, never rounded to money.
*
* Labelled "manual" rather than "api" on purpose: Settings shows this field,
* and a hardcoded constant must not claim it came from a rate service. The
* values are real (frankfurter.app, DKK base, 2026-09-01); EUR barely moves
* because DKK is pegged to it, so it ages well. USD does move — a stale USD
* number here is the reason the user can always edit or re-fetch a rate.
*/
export const FALLBACK_FX_RATES: readonly FxRate[] = Object.freeze([
Object.freeze({
currency: "USD" as Currency,
baseUnitsPerOne: 6.449532,
updatedAt: "2026-09-01",
source: "manual" as const,
}),
Object.freeze({
currency: "EUR" as Currency,
baseUnitsPerOne: 7.474959,
updatedAt: "2026-09-01",
source: "manual" as const,
}),
]);
/**
* The currencies a baked payload must cover: the seed table minus the base.
* Only the SEED currencies — a dataset that already exists is never seeded, so
* currencies the owner added later are irrelevant here.
*/
const REQUIRED: Currency[] = SEED_CURRENCIES.map((c) => c.code).filter((c) => c !== "DKK");
const SEED_CODES: Currency[] = SEED_CURRENCIES.map((c) => c.code);
function isUsableRate(value: unknown): value is FxRate {
if (typeof value !== "object" || value === null) return false;
const rate = value as Record<string, unknown>;
return (
SEED_CODES.includes(rate.currency as Currency) &&
typeof rate.baseUnitsPerOne === "number" &&
Number.isFinite(rate.baseUnitsPerOne) &&
rate.baseUnitsPerOne > 0 &&
typeof rate.updatedAt === "string" &&
(rate.source === "api" || rate.source === "manual")
);
}
/**
* Parses what `build.ts` embedded, falling back to the committed constants on
* anything unusable — an unset variable (a plain `bun --hot`), a build whose
* fetch failed, or a malformed payload. Falling back beats throwing: this runs
* during first-run seeding, and an app that will not boot is worse than an app
* with a slightly stale USD rate the user can edit.
*/
export function parseBakedRates(raw: string | undefined): readonly FxRate[] {
if (!raw) return FALLBACK_FX_RATES;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return FALLBACK_FX_RATES;
}
if (!Array.isArray(parsed) || !parsed.every(isUsableRate)) return FALLBACK_FX_RATES;
// A partial payload would leave one currency unconvertible on a fresh
// install, which is the whole problem this feature exists to solve.
const covered = new Set(parsed.map((rate) => rate.currency));
if (!REQUIRED.every((currency) => covered.has(currency))) return FALLBACK_FX_RATES;
return parsed;
}
/**
* try/catch, not a `typeof process` guard. Bun inlines the literal only when
* the variable is set; unset, the bare reference reaches the browser and
* throws before the app boots. A `typeof` test survives inlining and discards
* the inlined value. See `readBasePathEnv` in src/ui/basePath.ts.
*/
export function readBakedRatesEnv(): string | undefined {
try {
return process.env.BUN_PUBLIC_BAKED_FX_RATES;
} catch {
return undefined;
}
}
export const BAKED_FX_RATES = parseBakedRates(readBakedRatesEnv());
+128
View File
@@ -0,0 +1,128 @@
import { test, expect, describe } from "bun:test";
import * as actions from "./actions.ts";
import { createSeedDataset } from "../domain/seed.ts";
import { currencyUsage } from "../domain/currencies.ts";
import type { Dataset } from "../domain/types.ts";
const draft = (): Dataset => createSeedDataset("2026-09");
describe("adding a currency", () => {
test("appends it with the digits and symbol given", () => {
const data = draft();
actions.addCurrency(data, { code: "JPY", digits: 0, symbol: "¥", name: "Japanese yen" });
expect(data.currencies.at(-1)).toEqual({
code: "JPY",
digits: 0,
symbol: "¥",
name: "Japanese yen",
});
});
test("the code is normalised, because a code is identity", () => {
// "usd" and "USD" must never become two currencies: the code keys the FX
// table and every stored Money.
const data = draft();
actions.addCurrency(data, { code: " jpy ", digits: 0 });
expect(data.currencies.at(-1)!.code).toBe("JPY");
});
test("a duplicate is refused rather than shadowing the original", () => {
const data = draft();
expect(() => actions.addCurrency(data, { code: "usd", digits: 2 })).toThrow(/USD/);
expect(data.currencies.filter((c) => c.code === "USD")).toHaveLength(1);
});
test.each([
["not letters", "US1"],
["too short", "U"],
["empty", " "],
])("a code that is %s is refused", (_label, code) => {
expect(() => actions.addCurrency(draft(), { code, digits: 2 })).toThrow();
});
test.each([[-1], [5], [1.5]])("invalid decimal places (%p) are refused", (digits) => {
expect(() => actions.addCurrency(draft(), { code: "JPY", digits })).toThrow(/decimal/i);
});
});
describe("editing a currency", () => {
test("changes the digits, symbol and name but never the code", () => {
const data = draft();
actions.updateCurrency(data, "USD", { digits: 3, symbol: "US$" });
const usd = data.currencies.find((c) => c.code === "USD")!;
expect(usd).toEqual({ code: "USD", digits: 3, symbol: "US$", name: "US dollar" });
});
test("an unknown code throws", () => {
expect(() => actions.updateCurrency(draft(), "JPY", { digits: 0 })).toThrow(/JPY/);
});
});
describe("removing a currency", () => {
test("removes one nothing references", () => {
const data = draft();
actions.addCurrency(data, { code: "JPY", digits: 0 });
actions.removeCurrency(data, "JPY");
expect(data.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]);
});
test("its exchange rate goes with it, so no orphan row is left behind", () => {
const data = draft();
actions.addCurrency(data, { code: "JPY", digits: 0 });
actions.setFxRate(data, {
currency: "JPY",
baseUnitsPerOne: 0.045,
updatedAt: "2026-09-01",
source: "manual",
});
actions.removeCurrency(data, "JPY");
expect(data.fxRates.find((r) => r.currency === "JPY")).toBeUndefined();
});
test("the base currency cannot be removed", () => {
expect(() => actions.removeCurrency(draft(), "DKK")).toThrow(/base/i);
});
/**
* The same reasoning as posts being archived rather than deleted: stored
* Money references the code. Removing a currency in use would leave amounts
* pointing at a definition that no longer exists — and the import validator
* would then refuse the owner's own exported file.
*/
test("a currency still referenced by a purchase cannot be removed", () => {
const data = draft();
actions.addPurchase(data, {
date: "2026-09-04",
description: "Indie bundle",
total: { amount: 27.5, currency: "EUR" },
splitMode: "percent",
splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
schedule: null,
});
expect(() => actions.removeCurrency(data, "EUR")).toThrow(/in use/i);
});
test("a currency referenced only by a post's display setting cannot be removed", () => {
const data = draft();
actions.updatePost(data, data.posts[0]!.id, { currency: "USD" });
expect(() => actions.removeCurrency(data, "USD")).toThrow(/in use/i);
});
});
describe("currencyUsage", () => {
test("names where a currency is referenced, so the UI can say why", () => {
const data = draft();
actions.setIncome(data, "2026-10", { amount: 100, currency: "USD" });
expect(currencyUsage(data, "USD")).toContain("month 2026-10");
expect(currencyUsage(data, "JPY")).toEqual([]);
});
test("a fixed allocation rule counts as a reference", () => {
const data = draft();
actions.setRuleFrom(data, data.posts[0]!.id, "2026-09", {
kind: "fixed",
amount: { amount: 50, currency: "EUR" },
});
expect(currencyUsage(data, "EUR").join(" ")).toMatch(/rule/i);
});
});
+18 -7
View File
@@ -1,6 +1,9 @@
import { test, expect, describe } from "bun:test";
import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./fxApi.ts";
/** What the caller asked the service for, and so what it will accept back. */
const ALLOWED = ["DKK", "USD", "EUR"];
describe("buildFxUrl", () => {
test("substitutes the base and target placeholders", () => {
expect(buildFxUrl(DEFAULT_FX_API_URL, "DKK", ["USD", "EUR"])).toBe(
@@ -13,28 +16,36 @@ describe("parseFxResponse", () => {
test("inverts the rates into base units per one unit", () => {
// The API returns how many target units 1 base unit buys; we store the
// inverse, so 1 EUR = 7.46 DKK becomes baseUnitsPerOne 7.46.
const rates = parseFxResponse({ rates: { EUR: 0.134048, USD: 0.144928 } }, "DKK");
const rates = parseFxResponse({ rates: { EUR: 0.134048, USD: 0.144928 } }, "DKK", ALLOWED);
expect(rates.find((r) => r.currency === "EUR")!.baseUnitsPerOne).toBeCloseTo(7.46, 2);
expect(rates.every((r) => r.source === "api")).toBe(true);
expect(rates.every((r) => r.updatedAt !== "")).toBe(true);
});
test("skips the base currency's own row if the API returns one", () => {
const rates = parseFxResponse({ rates: { DKK: 1, EUR: 0.134 } }, "DKK");
const rates = parseFxResponse({ rates: { DKK: 1, EUR: 0.134 } }, "DKK", ALLOWED);
expect(rates.map((r) => r.currency)).toEqual(["EUR"]);
});
test("ignores unsupported currencies", () => {
const rates = parseFxResponse({ rates: { GBP: 0.11, EUR: 0.134 } }, "DKK");
test("ignores currencies this dataset does not define", () => {
const rates = parseFxResponse({ rates: { GBP: 0.11, EUR: 0.134 } }, "DKK", ALLOWED);
expect(rates.map((r) => r.currency)).toEqual(["EUR"]);
});
test("rejects a response with no rates object", () => {
expect(() => parseFxResponse({}, "DKK")).toThrow(/rates/);
expect(() => parseFxResponse("nope", "DKK")).toThrow(/rates/);
expect(() => parseFxResponse({}, "DKK", ALLOWED)).toThrow(/rates/);
expect(() => parseFxResponse("nope", "DKK", ALLOWED)).toThrow(/rates/);
});
test("rejects a zero or negative rate rather than storing an infinity", () => {
expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK")).toThrow(/EUR/);
expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK", ALLOWED)).toThrow(/EUR/);
});
});
test("an explicit updatedAt is used instead of the clock", () => {
// The build bakes rates in with the BUILD date, not the date the browser
// happens to run parseFxResponse.
const rates = parseFxResponse({ rates: { EUR: 0.13378, USD: 0.15505 } }, "DKK", ALLOWED, "2026-09-01");
expect(rates.map((r) => r.updatedAt)).toEqual(["2026-09-01", "2026-09-01"]);
expect(rates.every((r) => r.source === "api")).toBe(true);
});
+16 -5
View File
@@ -1,4 +1,4 @@
import { CURRENCIES, type Currency, type FxRate } from "../domain/types.ts";
import type { Currency, FxRate } from "../domain/types.ts";
/** Frankfurter needs no API key. The URL is user-editable in settings. */
export const DEFAULT_FX_API_URL = "https://api.frankfurter.app/latest?from={base}&to={targets}";
@@ -13,7 +13,18 @@ export function buildFxUrl(
.replace("{targets}", targets.join(","));
}
export function parseFxResponse(body: unknown, base: Currency): FxRate[] {
export function parseFxResponse(
body: unknown,
base: Currency,
/**
* The currencies this dataset knows. Anything else in the response is
* skipped: a rate service returns dozens, and storing one for a currency the
* owner has not defined would put an unreachable row in their table.
*/
allowed: readonly Currency[],
/** Defaults to now. The build passes its own date when baking rates in. */
updatedAt: string = new Date().toISOString(),
): FxRate[] {
const rates =
typeof body === "object" && body !== null
? (body as Record<string, unknown>).rates
@@ -22,12 +33,11 @@ export function parseFxResponse(body: unknown, base: Currency): FxRate[] {
throw new Error("Exchange rate response has no `rates` object");
}
const updatedAt = new Date().toISOString();
const out: FxRate[] = [];
for (const [currency, value] of Object.entries(rates as Record<string, unknown>)) {
if (currency === base) continue;
if (!CURRENCIES.includes(currency as Currency)) continue;
if (!allowed.includes(currency)) continue;
if (typeof value !== "number" || !(value > 0)) {
throw new Error(`Exchange rate for ${currency} is not a positive number`);
}
@@ -52,5 +62,6 @@ export async function fetchFxRates(
if (!response.ok) {
throw new Error(`Rate service returned ${response.status}`);
}
return parseFxResponse(await response.json(), base);
// The targets we asked for are exactly the ones we will accept back.
return parseFxResponse(await response.json(), base, targets);
}
+4
View File
@@ -1,6 +1,7 @@
import { monthOf } from "../domain/months.ts";
import { readDataset, writeDataset } from "./db.ts";
import { createSnapshotStore } from "./snapshot.ts";
import { BAKED_FX_RATES } from "./bakedRates.ts";
/** The current month, read at module load. The UI owns the clock; the domain does not. */
export const currentMonth = monthOf(new Date().toISOString().slice(0, 10));
@@ -8,6 +9,9 @@ export const currentMonth = monthOf(new Date().toISOString().slice(0, 10));
export const store = createSnapshotStore(
{ read: readDataset, write: writeDataset },
currentMonth,
// First run only, so a fresh install can convert a EUR purchase on day one.
// Never a backstop for a rate the user has cleared.
BAKED_FX_RATES,
);
export * from "./snapshot.ts";
+100 -2
View File
@@ -1,6 +1,6 @@
import { test, expect, describe } from "bun:test";
import { SCHEMA_VERSION, createSeedDataset } from "../domain/seed.ts";
import { migrate, UnsupportedSchemaError } from "./migrations.ts";
import { createSeedDataset, SCHEMA_VERSION } from "../domain/seed.ts";
describe("migrate", () => {
test("passes a current-version dataset through unchanged", () => {
@@ -63,7 +63,10 @@ function v1Dataset() {
describe("v1 -> v2: rules become a dated series", () => {
test("each standingRule becomes one version starting at the fold start", () => {
const migrated = migrate(v1Dataset());
expect(migrated.settings.schemaVersion).toBe(2);
// migrate() runs every step to the current SCHEMA_VERSION, so this lands
// at 3 now that currencies became data. The rule conversion below is what
// this test is actually about.
expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION);
expect(migrated.posts[0]!.rules).toEqual([
{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 20 } },
]);
@@ -135,3 +138,98 @@ describe("v1 -> v2: rules become a dated series", () => {
]);
});
});
describe("v2 -> v3: currencies become data", () => {
/** A realistic v2 dataset: no `currencies` field existed. */
function v2(): any {
return {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 },
fxRates: [
{ currency: "EUR", baseUnitsPerOne: 7.4, updatedAt: "2026-01-01", source: "manual" },
],
posts: [
{
id: "p1",
name: "Food",
order: 0,
archived: false,
currency: "DKK",
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 12 } }],
},
],
months: [
{ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
],
purchases: [],
};
}
test("the three shipped currencies are added, with their decimals", () => {
const migrated = migrate(v2());
expect(migrated.settings.schemaVersion).toBe(3);
expect(migrated.currencies).toEqual([
{ code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" },
{ code: "USD", digits: 2, symbol: "$", name: "US dollar" },
{ code: "EUR", digits: 2, symbol: "€", name: "Euro" },
]);
});
/**
* The whole point of the migration being safe: every currency that existed
* had two decimal places, and the seeded table says two, so not one stored
* amount can round differently than it did before.
*/
test("no amount can round differently than it did under v2", () => {
const migrated = migrate(v2());
for (const currency of migrated.currencies) {
expect(currency.digits).toBe(2);
}
});
test("nothing else about the dataset is touched", () => {
const before = v2();
const migrated = migrate(structuredClone(before));
expect(migrated.posts).toEqual(before.posts);
expect(migrated.months).toEqual(before.months);
expect(migrated.fxRates).toEqual(before.fxRates);
expect(migrated.settings.baseCurrency).toBe("DKK");
expect(migrated.settings.foldStartMonth).toBe("2026-01");
});
test("a v1 dataset migrates all the way through to v3", () => {
const v1 = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
fxRates: [],
posts: [
{
id: "p1",
name: "Food",
order: 0,
archived: false,
currency: "DKK",
standingRule: { kind: "fixed", amount: { amount: 500, currency: "DKK" } },
},
],
months: [],
purchases: [],
};
const migrated = migrate(v1);
expect(migrated.settings.schemaVersion).toBe(3);
expect(migrated.posts[0]!.rules).toEqual([
{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 500, currency: "DKK" } } },
]);
expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]);
});
/**
* A dataset can only reach v3 through this step, but an owner could have a
* hand-edited file that already has the field. Overwriting it would silently
* discard currencies they added.
*/
test("an existing currencies field is kept, not overwritten", () => {
const data = v2();
data.currencies = [{ code: "DKK", digits: 2 }, { code: "JPY", digits: 0 }];
const migrated = migrate(data);
expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "JPY"]);
});
});
+19
View File
@@ -1,4 +1,5 @@
import { SCHEMA_VERSION } from "../domain/seed.ts";
import { SEED_CURRENCIES } from "../domain/types.ts";
import type { Dataset } from "../domain/types.ts";
export class UnsupportedSchemaError extends Error {
@@ -37,6 +38,24 @@ const MIGRATIONS: Array<(data: any) => any> = [
};
}),
}),
// 2 -> 3: currencies become data. Until now the three supported currencies
// and their decimal places were compile-time constants, so adding one meant
// changing code; they now live in the dataset, which is also what lets the
// owner define their own.
//
// Behaviour-preserving by construction: every currency that existed had two
// decimal places, and the seeded table records two, so not one stored amount
// can round differently than it did before. An existing `currencies` field
// is left alone rather than overwritten — a hand-edited file could already
// carry currencies the owner added, and discarding them would be data loss.
(data: any) => ({
...data,
settings: { ...data.settings, schemaVersion: 3 },
currencies: Array.isArray(data.currencies)
? data.currencies
: SEED_CURRENCIES.map((currency) => ({ ...currency })),
}),
];
export function migrate(raw: unknown): Dataset {
+25
View File
@@ -180,3 +180,28 @@ test("get before load throws rather than returning empty data", () => {
const store = createSnapshotStore(fake.persistence, "2026-09");
expect(() => store.get()).toThrow(/not loaded/i);
});
describe("seed rates", () => {
test("a first run seeds the rates it was given", async () => {
const fake = fakePersistence(null);
const rates = [
{ currency: "EUR" as const, baseUnitsPerOne: 7.474959, updatedAt: "2026-09-01", source: "manual" as const },
];
const store = createSnapshotStore(fake.persistence, "2026-09", rates);
await store.load();
expect(store.get().fxRates).toEqual(rates);
});
test("an existing dataset is never given seed rates", async () => {
// Loading stored data must not touch fxRates — the user may have
// deliberately cleared one, and re-adding it would convert money at a
// number they never chose.
const stored = createSeedDataset("2026-01");
const fake = fakePersistence(stored);
const store = createSnapshotStore(fake.persistence, "2026-09", [
{ currency: "EUR", baseUnitsPerOne: 7.474959, updatedAt: "2026-09-01", source: "manual" },
]);
await store.load();
expect(store.get().fxRates).toEqual([]);
});
});
+9 -2
View File
@@ -1,5 +1,5 @@
import { createSeedDataset } from "../domain/seed.ts";
import type { Dataset, MonthId } from "../domain/types.ts";
import type { Dataset, FxRate, MonthId } from "../domain/types.ts";
export interface Persistence {
read(): Promise<Dataset | null>;
@@ -19,6 +19,13 @@ export interface SnapshotStore {
export function createSnapshotStore(
persistence: Persistence,
currentMonth: MonthId,
/**
* Rates for a FIRST RUN only. Passed in rather than read here so this module
* stays free of the ambient environment; src/store/index.ts supplies
* BAKED_FX_RATES. Loading an existing dataset never touches its rates — the
* user may have cleared one deliberately.
*/
seedRates: readonly FxRate[] = [],
): SnapshotStore {
let snapshot: Dataset | null = null;
const listeners = new Set<Listener>();
@@ -64,7 +71,7 @@ export function createSnapshotStore(
notify();
return;
}
await commit(createSeedDataset(currentMonth));
await commit(createSeedDataset(currentMonth, seedRates));
},
get,
+5 -1
View File
@@ -1,6 +1,8 @@
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
import { precacheUrls } from "./swPrecache.ts";
// Both are replaced at build time by Bun.build's `define`.
const PRECACHE: string[] = JSON.parse(process.env.PRECACHE_MANIFEST ?? "[]");
const VERSION = process.env.SW_VERSION ?? "dev";
@@ -10,8 +12,10 @@ const CACHE = `budget2-${VERSION}`;
const SHELL = `${BASE}index.html`;
self.addEventListener("install", (event) => {
// Deduped: addAll() rejects the whole install on a repeated request, and the
// build's own outputs already contain the shell. See src/swPrecache.ts.
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll([...PRECACHE, SHELL])),
caches.open(CACHE).then((cache) => cache.addAll(precacheUrls(PRECACHE, SHELL))),
);
});
+53
View File
@@ -0,0 +1,53 @@
import { test, expect, describe } from "bun:test";
import { precacheUrls } from "./swPrecache.ts";
describe("the service worker's precache list", () => {
/**
* The bug this exists for: `cache.addAll()` rejects the WHOLE install with
* InvalidStateError if the list contains the same request twice. The build's
* precache list is derived from its own outputs, which already include
* index.html, and the worker then appended the shell — index.html again. So
* install failed every time, the worker went redundant, and no service
* worker ever activated. Nothing reported it: registration itself succeeds,
* and the failure is inside the install event.
*/
test("the shell is not repeated when the build already emitted it", () => {
expect(precacheUrls(["/index.html", "/index-abc.js"], "/index.html")).toEqual([
"/index.html",
"/index-abc.js",
]);
});
test("the shell is added when the build did not emit it", () => {
expect(precacheUrls(["/index-abc.js"], "/index.html")).toEqual([
"/index-abc.js",
"/index.html",
]);
});
test("any duplicate is removed, not just the shell", () => {
expect(precacheUrls(["/a.js", "/b.css", "/a.js"], "/index.html")).toEqual([
"/a.js",
"/b.css",
"/index.html",
]);
});
test("order is preserved, so the shell is not fetched before the assets", () => {
expect(precacheUrls(["/z.js", "/a.css"], "/index.html")).toEqual([
"/z.js",
"/a.css",
"/index.html",
]);
});
test("a subpath deploy is not a special case", () => {
expect(
precacheUrls(["/budget2.0/index.html", "/budget2.0/x.js"], "/budget2.0/index.html"),
).toEqual(["/budget2.0/index.html", "/budget2.0/x.js"]);
});
test("an empty build still precaches the shell", () => {
expect(precacheUrls([], "/index.html")).toEqual(["/index.html"]);
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* The exact list the service worker precaches on install.
*
* Separate from src/sw.ts so it can be tested: importing sw.ts outside a
* worker would run its `self.addEventListener` calls.
*
* Deduplication is the whole point. `cache.addAll()` rejects the ENTIRE
* install with InvalidStateError if the same request appears twice, and the
* build's precache list is derived from its own outputs — which already
* include index.html — while the worker also needs the shell by name. Those
* two facts collided silently: install failed on every load, the worker went
* redundant, and no service worker ever activated. Registration itself
* succeeds, so nothing surfaced it.
*
* Deduping here rather than in build.ts means no future addition to either
* list can reintroduce the collision.
*/
export function precacheUrls(precache: readonly string[], shell: string): string[] {
return [...new Set([...precache, shell])];
}
+46 -15
View File
@@ -9,26 +9,57 @@ import { SummaryRoute } from "./routes/SummaryRoute.tsx";
import { SettingsRoute } from "./routes/SettingsRoute.tsx";
import { UpdatePrompt } from "./components/UpdatePrompt.tsx";
import { RouteErrorBoundaryReset } from "./components/ErrorBoundary.tsx";
import { ThemeToggle } from "./components/ThemeToggle.tsx";
/**
* One nav item. The active route is marked with weight and an accent
* underline rather than colour alone, so it does not depend on colour vision,
* and the underline is drawn on a fixed-height pseudo-row so switching tabs
* never shifts the header's layout.
*/
function NavItem({ to, children }: { to: string; children: React.ReactNode }) {
return (
<NavLink
to={to}
className={({ isActive }) =>
[
"relative rounded-md px-2.5 py-1.5 transition-colors",
"after:absolute after:inset-x-2.5 after:-bottom-px after:h-0.5 after:rounded-full",
isActive
? "font-semibold text-budget-ink after:bg-budget-accent"
: "text-budget-ink-muted hover:text-budget-ink hover:bg-accent after:bg-transparent",
].join(" ")
}
>
{children}
</NavLink>
);
}
export function AppRoutes() {
const year = currentMonth.slice(0, 4);
return (
<div className="min-h-screen bg-background text-foreground">
<nav className="flex gap-4 border-b px-6 py-3 text-sm">
<NavLink to={`/month/${currentMonth}`} className="hover:underline">
Month
</NavLink>
<NavLink to={`/year/${year}`} className="hover:underline">
Year
</NavLink>
<NavLink to="/summary" className="hover:underline">
Summary
</NavLink>
<NavLink to="/settings" className="ml-auto hover:underline">
Settings
</NavLink>
</nav>
<main className="p-6">
{/* Sticky because the month view is long and the nav is how you leave
it. Translucent with a blur so the paper ground still reads as one
continuous sheet scrolling underneath. */}
<header className="sticky top-0 z-30 border-b border-budget-rule bg-budget-paper/85 backdrop-blur-md">
<div className="mx-auto flex max-w-6xl items-center gap-1 px-4 py-2.5 sm:px-6">
<span className="mr-3 hidden text-sm font-semibold tracking-tight text-budget-ink sm:block">
Budget<span className="text-budget-accent">&nbsp;2.0</span>
</span>
<nav className="flex items-center gap-1 text-sm">
<NavItem to={`/month/${currentMonth}`}>Month</NavItem>
<NavItem to={`/year/${year}`}>Year</NavItem>
<NavItem to="/summary">Summary</NavItem>
</nav>
<div className="ml-auto flex items-center gap-1">
<ThemeToggle />
<NavItem to="/settings">Settings</NavItem>
</div>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<RouteErrorBoundaryReset>
<Routes>
<Route path="/month/:monthId" element={<MonthRoute />} />
+3 -3
View File
@@ -45,15 +45,15 @@ describe("blank lines", () => {
describe("bulkTotal", () => {
test("sums the filled lines", () => {
expect(bulkTotal(draft({ lines: [line("Milk", 25), line("Bread", 30.5), line("", 0)] }))).toBe(55.5);
expect(bulkTotal(draft({ lines: [line("Milk", 25), line("Bread", 30.5), line("", 0)] }), 2)).toBe(55.5);
});
test("is zero when there is nothing filled in", () => {
expect(bulkTotal(draft())).toBe(0);
expect(bulkTotal(draft(), 2)).toBe(0);
});
test("rounds the sum, so float tails never surface", () => {
expect(bulkTotal(draft({ lines: [line("a", 0.1), line("b", 0.2)] }))).toBe(0.3);
expect(bulkTotal(draft({ lines: [line("a", 0.1), line("b", 0.2)] }), 2)).toBe(0.3);
});
});
+2 -2
View File
@@ -69,10 +69,10 @@ export function filledLines(draft: BulkDraft): BulkLine[] {
return draft.lines.filter((line) => !isBlankLine(line));
}
export function bulkTotal(draft: BulkDraft): number {
export function bulkTotal(draft: BulkDraft, digits: number): number {
return roundMoney(
filledLines(draft).reduce((sum, line) => sum + line.amount, 0),
draft.currency,
digits,
);
}
+4 -1
View File
@@ -1,4 +1,6 @@
import { useRef } from "react";
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 { formatMoney } from "../format.ts";
@@ -24,6 +26,7 @@ interface Props {
* on touch devices too. That is why there is no "Add line" button.
*/
export function BulkLines({ draft, onChange }: Props) {
const digits = digitsFor(useDataset().currencies, draft.currency);
const descriptionRefs = useRef<Array<HTMLInputElement | null>>([]);
const noteRefs = useRef<Array<HTMLInputElement | null>>([]);
@@ -125,7 +128,7 @@ export function BulkLines({ draft, onChange }: Props) {
<p className="text-right text-xs text-muted-foreground">
{filledCount} line{filledCount === 1 ? "" : "s"} &middot;{" "}
<span className="font-money">{formatMoney(bulkTotal(draft), draft.currency)}</span>
<span className="font-money">{formatMoney(bulkTotal(draft, digits), draft.currency)}</span>
</p>
</fieldset>
);
+8 -3
View File
@@ -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";
@@ -28,7 +30,10 @@ function hasValidDate(draft: PurchaseDraft): boolean {
}
export function PlanEditor({ draft, onChange }: Props) {
const balance = planBalance(draft);
// The purchase's own currency decides the rounding, not the base currency:
// a plan for a JPY purchase must divide into whole yen.
const digits = digitsFor(useDataset().currencies, draft.currency);
const balance = planBalance(draft, digits);
if (!draft.plan) {
const dateReady = hasValidDate(draft);
@@ -41,7 +46,7 @@ export function PlanEditor({ draft, onChange }: Props) {
disabled={!dateReady}
onClick={() => {
if (!dateReady) return;
onChange(withPlan(draft, monthOf(draft.date), 3));
onChange(withPlan(draft, monthOf(draft.date), 3, digits));
}}
>
Spread this over several months
@@ -70,7 +75,7 @@ export function PlanEditor({ draft, onChange }: Props) {
onChange={(event) => {
const months = Number(event.target.value);
if (Number.isInteger(months) && months >= 1) {
onChange(withPlan(draft, draft.plan!.startMonth, months));
onChange(withPlan(draft, draft.plan!.startMonth, months, digits));
}
}}
/>
+17 -17
View File
@@ -26,7 +26,7 @@ interface Props {
* - --surplus : carried-in surplus (omitted when carriedIn <= 0)
* - --budget-accent : spend within this month's allocation, filled from
* the track's left edge
* - --rule : the unspent remainder of the allocation track
* - --budget-rule : the unspent remainder of the allocation track
* - --overspend : spend beyond the allocation, past the track's end
*/
function carryMeterBackground(figures: PostMonthFigures): string {
@@ -34,7 +34,7 @@ function carryMeterBackground(figures: PostMonthFigures): string {
const scale = surplus + Math.max(figures.allocation, figures.charges);
if (scale <= 0) {
return "var(--rule)";
return "var(--budget-rule)";
}
const pSurplus = (surplus / scale) * 100;
@@ -45,7 +45,7 @@ function carryMeterBackground(figures: PostMonthFigures): string {
`linear-gradient(to right, ` +
`var(--surplus) 0%, var(--surplus) ${pSurplus}%, ` +
`var(--budget-accent) ${pSurplus}%, var(--budget-accent) ${pFillEnd}%, ` +
`var(--rule) ${pFillEnd}%, var(--rule) ${pTrackEnd}%, ` +
`var(--budget-rule) ${pFillEnd}%, var(--budget-rule) ${pTrackEnd}%, ` +
`var(--overspend) ${pTrackEnd}%, var(--overspend) 100%)`
);
}
@@ -64,20 +64,20 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b text-left text-muted-foreground">
<tr>
<th className="py-2">Post</th>
<th className="py-2 text-right">Carried in</th>
<th className="py-2 text-right">Allocated</th>
<th className="py-2 text-right">Spent</th>
<th className="py-2 text-right">Remaining</th>
<thead className="text-left">
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
<th className="py-2 font-medium">Post</th>
<th className="py-2 pl-6 text-right font-medium">Carried in</th>
<th className="py-2 pl-6 text-right font-medium">Allocated</th>
<th className="py-2 pl-6 text-right font-medium">Spent</th>
<th className="py-2 pl-6 text-right font-medium">Remaining</th>
</tr>
</thead>
<tbody>
{rows.map(({ post, figures, overridden }) => (
<Fragment key={post.id}>
<tr>
<td className="py-2">
<tr className="group transition-colors hover:bg-accent/60">
<td className="py-2.5">
<Link to={`/post/${post.id}/month/${monthId}`} className="hover:underline">
{post.name}
</Link>
@@ -111,7 +111,7 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
{onChangeRule && (
<button
type="button"
className="ml-2 text-xs text-muted-foreground underline decoration-dotted"
className="ml-2 rounded text-xs text-budget-ink-muted underline decoration-dotted underline-offset-2 opacity-0 transition-opacity hover:text-budget-accent focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => onChangeRule(post.id)}
>
change from here
@@ -119,7 +119,7 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
)}
</td>
<td
className={`font-money py-2 text-right ${
className={`font-money py-2.5 pl-6 text-right ${
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
@@ -129,14 +129,14 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
>
{formatSignedMoney(figures.carriedIn, baseCurrency)}
</td>
<td className="font-money py-2 text-right">
<td className="font-money py-2.5 pl-6 text-right">
{formatMoney(figures.allocation, baseCurrency)}
</td>
<td className="font-money py-2 text-right">
<td className="font-money py-2.5 pl-6 text-right">
{formatMoney(figures.charges, baseCurrency)}
</td>
<td
className={`font-money py-2 text-right font-medium ${
className={`font-money py-2.5 pl-6 text-right font-medium ${
figures.remaining < 0 ? "text-overspend" : ""
}`}
>
+24 -9
View File
@@ -27,9 +27,10 @@ import {
type BulkDraft,
} from "../bulkEntry.ts";
import { useDataset } from "../hooks/useDataset.ts";
import { parseMoneyInput } from "../moneyInput.ts";
import { useMutate } from "../hooks/useMutate.ts";
import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts";
import { CURRENCIES, type MonthId, type Purchase } from "../../domain/types.ts";
import type { MonthId, Purchase } from "../../domain/types.ts";
interface Props {
monthId: MonthId;
@@ -49,6 +50,11 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
const [draft, setDraft] = useState<PurchaseDraft>(() =>
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
// The amount field keeps its own text so a half-typed "30." or "30 " stays
// exactly as typed; `draft.amount` only moves when the text parses.
const [amountText, setAmountText] = useState(() =>
purchase ? String(purchase.total.amount) : "",
);
// "many" is only meaningful when adding — editing an existing purchase edits
// exactly one, so the toggle is hidden in that case.
const [mode, setMode] = useState<"one" | "many">("one");
@@ -179,7 +185,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
setBulk((b) => ({ ...b, currency }));
}}
>
{CURRENCIES.map((currency) => (
{dataset.currencies.map(({ code: currency }) => (
<option key={currency} value={currency}>
{currency}
</option>
@@ -247,15 +253,24 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
</div>
<div className="space-y-1">
<Label htmlFor="purchase-amount">Total</Label>
{/* type="text", not "number": a number input silently discards
anything non-numeric, so "30$" would never reach the parser.
Entry speed is the point — the currency is set from what was
typed rather than from a second control. */}
<Input
id="purchase-amount"
type="number"
step="0.01"
inputMode="decimal"
className="font-money"
value={draft.amount}
onChange={(event) =>
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 });
}
}}
/>
</div>
<div className="space-y-1">
@@ -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 }) => (
<option key={currency} value={currency}>
{currency}
</option>
+69
View File
@@ -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 (
<section className={`budget-card p-4 sm:p-5 ${className}`}>
{(title || action) && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
{title && <h2 className="text-base leading-tight">{title}</h2>}
{hint && (
<p className="mt-1 text-xs leading-relaxed text-budget-ink-muted">{hint}</p>
)}
</div>
{action && <div className="flex shrink-0 items-center gap-2">{action}</div>}
</div>
)}
{children}
</section>
);
}
/**
* 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 (
<div className="min-w-0" title={title}>
<dt className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
{label}
</dt>
<dd className={`font-money mt-0.5 text-lg leading-tight ${toneClass}`}>{children}</dd>
</div>
);
}
+3 -1
View File
@@ -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<SplitDraft>) {
+97
View File
@@ -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<ThemePreference, string> = {
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<ThemePreference>(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 (
<button
type="button"
onClick={cycle}
title={`${LABEL[preference]}. Click to change.`}
aria-label={`${LABEL[preference]}. Click to change.`}
className="grid size-8 place-items-center rounded-md text-budget-ink-muted transition-colors hover:bg-accent hover:text-accent-foreground"
>
{preference === "system" ? (
<MonitorGlyph />
) : resolved === "dark" ? (
<MoonGlyph />
) : (
<SunGlyph />
)}
</button>
);
}
/* 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 (
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none"
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<circle cx="8" cy="8" r="3.1" />
<path d="M8 1.4v1.5M8 13.1v1.5M1.4 8h1.5M13.1 8h1.5M3.3 3.3l1.1 1.1M11.6 11.6l1.1 1.1M12.7 3.3l-1.1 1.1M4.4 11.6l-1.1 1.1" />
</svg>
);
}
function MoonGlyph() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none"
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<path d="M13.2 10.1A5.6 5.6 0 0 1 5.9 2.8a5.6 5.6 0 1 0 7.3 7.3Z" />
</svg>
);
}
function MonitorGlyph() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none"
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<rect x="1.6" y="2.6" width="12.8" height="8.4" rx="1.3" />
<path d="M5.6 13.4h4.8" />
</svg>
);
}
+64 -53
View File
@@ -15,61 +15,72 @@ interface Props {
*/
export function YearMatrix({ view, mode }: Props) {
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[48rem] text-sm">
<thead className="border-b text-left text-muted-foreground">
<tr>
<th className="py-2">Post</th>
{view.months.map((month) => (
<th key={month} className="py-2 text-right font-normal">
{month.slice(5)}
</th>
))}
<th className="py-2 text-right">Total</th>
</tr>
</thead>
<tbody>
<tr className="border-b text-muted-foreground">
<td className="py-2">Income</td>
{view.incomeByMonth.map((income, i) => (
<td key={i} className="font-money py-2 text-right">
{formatAmount(income)}
</td>
))}
<td className="font-money py-2 text-right">{formatAmount(view.totalIncome)}</td>
</tr>
{view.rows.map((row) => (
<tr key={row.post.id} className="border-b last:border-0">
<td className="py-2">
<Link to={`/post/${row.post.id}/year/${view.year}`} className="hover:underline">
{row.post.name}
</Link>
</td>
{row.byMonth.map((figures, i) => {
const value = mode === "spend" ? figures.charges : figures.remaining;
return (
<td
key={i}
className={`font-money py-2 text-right ${
mode === "balance" && value < 0 ? "text-overspend" : ""
}`}
>
{formatAmount(value)}
</td>
);
})}
<td
className={`font-money py-2 text-right font-medium ${
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
}`}
>
{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.
<div className="relative">
<div className="overflow-x-auto">
<table className="w-full min-w-[84rem] text-sm">
<thead className="text-left">
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
<th className="py-2 font-medium">Post</th>
{view.months.map((month) => (
<th key={month} className="py-2 pl-4 text-right font-medium whitespace-nowrap">
{month.slice(5)}
</th>
))}
<th className="py-2 pl-4 text-right font-medium">Total</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-budget-rule text-budget-ink-muted">
<td className="py-2">Income</td>
{view.incomeByMonth.map((income, i) => (
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">
{formatAmount(income)}
</td>
))}
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">
{formatAmount(view.totalIncome)}
</td>
</tr>
))}
</tbody>
</table>
{view.rows.map((row) => (
<tr key={row.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
<td className="py-2">
<Link to={`/post/${row.post.id}/year/${view.year}`} className="hover:underline">
{row.post.name}
</Link>
</td>
{row.byMonth.map((figures, i) => {
const value = mode === "spend" ? figures.charges : figures.remaining;
return (
<td
key={i}
className={`font-money py-2 pl-4 text-right whitespace-nowrap ${
mode === "balance" && value < 0 ? "text-overspend" : ""
}`}
>
{formatAmount(value)}
</td>
);
})}
<td
className={`font-money py-2 pl-4 text-right font-medium whitespace-nowrap ${
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
}`}
>
{formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-budget-raised to-transparent"
/>
</div>
);
}
+38
View File
@@ -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([]);
});
+139
View File
@@ -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",
});
});
});
+143
View File
@@ -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;
}
+14 -13
View File
@@ -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 },
+15 -5
View File
@@ -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 {
+82
View File
@@ -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([]);
});
});
+90
View File
@@ -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<string, Purchase[]>();
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)!,
}));
}
+27 -2
View File
@@ -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(),
});
}
+156 -91
View File
@@ -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<string | null>(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 (
<section className="space-y-6">
<div className="space-y-5">
{error && (
<div className="flex items-center justify-between rounded border border-destructive p-3 text-sm">
<div
role="alert"
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm"
>
<span>Could not save: {error}</span>
<button onClick={clearError} className="underline">
<button onClick={clearError} className="underline underline-offset-2">
dismiss
</button>
</div>
)}
<header className="flex items-center gap-4">
<Link to={`/month/${addMonths(monthId, -1)}`} className="text-sm hover:underline">
&larr; {addMonths(monthId, -1)}
</Link>
<h1 className="text-2xl font-semibold">{monthId}</h1>
<Link to={`/month/${addMonths(monthId, 1)}`} className="text-sm hover:underline">
{addMonths(monthId, 1)} &rarr;
</Link>
{/* 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. */}
<header className="flex items-baseline gap-3">
<MonthStep to={`/month/${addMonths(monthId, -1)}`} label={addMonths(monthId, -1)} back />
<h1 className="text-2xl">{monthLabel(monthId)}</h1>
<MonthStep to={`/month/${addMonths(monthId, 1)}`} label={addMonths(monthId, 1)} />
</header>
<div className="flex flex-wrap items-end gap-6">
<div className="space-y-1">
<Label htmlFor="income">Income this month ({base})</Label>
<Input
id="income"
type="number"
step="0.01"
className="font-money w-40"
value={view.income}
onChange={(event) => {
// 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 }));
}}
/>
</div>
<dl className="flex gap-6 text-sm">
<div>
<dt className="text-muted-foreground">Allocated</dt>
<dd className="font-money">{formatMoney(view.totalAllocation, base)}</dd>
<Section>
<div className="flex flex-wrap items-end gap-x-10 gap-y-5">
<div className="space-y-1.5">
<Label htmlFor="income" className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
Income this month ({base})
</Label>
<Input
id="income"
type="number"
step="0.01"
className="font-money h-10 w-44 text-lg"
value={view.income}
onChange={(event) => {
// 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 }));
}}
/>
</div>
<div>
<dt className="text-muted-foreground">Spent</dt>
<dd className="font-money">{formatMoney(view.totalCharges, base)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Unallocated</dt>
<dd
className={`font-money ${view.unallocated < 0 ? "text-overspend" : ""}`}
<dl className="flex flex-wrap gap-x-10 gap-y-4">
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
<Stat
label="Unallocated"
tone={view.unallocated < 0 ? "overspend" : "default"}
title={
view.unallocated < 0
? "Allocations exceed this month's income. This is allowed."
@@ -84,17 +87,19 @@ export function MonthRoute() {
}
>
{formatSignedMoney(view.unallocated, base)}
</dd>
</div>
</dl>
</div>
</Stat>
</dl>
</div>
</Section>
<Section title="Posts" className="overflow-hidden">
<PostTable
monthId={monthId}
baseCurrency={base}
rows={view.rows}
onChangeRule={setChangingRuleFor}
/>
</Section>
{changingPost && (
<RuleFromMonth
@@ -109,52 +114,112 @@ export function MonthRoute() {
/>
)}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Purchases</h2>
<PurchaseDialog monthId={monthId} trigger={<Button>Add purchase</Button>} />
</div>
<ul className="divide-y text-sm">
{dataset.purchases
.filter((purchase) => sliceAmountForMonth(purchase, monthId) !== null)
.map((purchase) => {
const slice = sliceAmountForMonth(purchase, monthId)!;
return (
<li key={purchase.id} className="flex items-center gap-3 py-2">
<span className="flex-1">
{purchase.description}
{purchase.note && (
<span className="ml-2 text-xs text-muted-foreground">{purchase.note}</span>
)}
</span>
{purchase.schedule && (
<span className="text-xs text-muted-foreground">financed</span>
)}
<span className="font-money">
{formatMoney(slice.amount, slice.currency)}
</span>
<PurchaseDialog
monthId={monthId}
purchase={purchase}
trigger={
<Button size="sm" variant="ghost">
edit
</Button>
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => mutate((data) => deletePurchase(data, purchase.id))}
>
delete
</Button>
</li>
);
})}
</ul>
</section>
</section>
<Section
title="Purchases"
action={<PurchaseDialog monthId={monthId} trigger={<Button>Add purchase</Button>} />}
>
{groups.length === 0 ? (
<p className="py-1 text-sm text-budget-ink-muted">
Nothing recorded for this month yet.
</p>
) : (
<div className="space-y-3.5">
{groups.map((group) => (
<div key={group.key}>
<h3 className="mb-0.5 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
{group.label}
</h3>
<ul className="divide-y divide-budget-rule text-sm">
{group.purchases.map((purchase) => (
<PurchaseRow
key={purchase.id}
purchase={purchase}
monthId={monthId}
onDelete={() =>
mutate((data) => deletePurchase(data, purchase.id))
}
/>
))}
</ul>
</div>
))}
</div>
)}
</Section>
</div>
);
}
function PurchaseRow({
purchase,
monthId,
onDelete,
}: {
purchase: Purchase;
monthId: MonthId;
onDelete: () => void;
}) {
const slice = sliceAmountForMonth(purchase, monthId)!;
return (
<li className="group flex items-center gap-3 rounded-md px-1 py-1.5 transition-colors hover:bg-accent/60">
<span className="min-w-0 flex-1 truncate">
{purchase.description}
{purchase.note && (
<span className="ml-2 text-xs text-budget-ink-muted">{purchase.note}</span>
)}
</span>
{purchase.schedule && (
<span className="shrink-0 rounded-full border border-budget-rule px-1.5 py-px text-[0.625rem] uppercase tracking-wide text-budget-ink-muted">
financed
</span>
)}
<span className="font-money shrink-0 tabular-nums">
{formatMoney(slice.amount, slice.currency)}
</span>
{/* Held at a fixed width so revealing the actions on hover cannot shift
the amounts, which are the column being read down. */}
<span className="flex w-[7.5rem] shrink-0 justify-end gap-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<PurchaseDialog
monthId={monthId}
purchase={purchase}
trigger={
<Button size="sm" variant="ghost">
edit
</Button>
}
/>
<Button
size="sm"
variant="ghost"
className="text-overspend hover:text-overspend"
onClick={onDelete}
>
delete
</Button>
</span>
</li>
);
}
/** "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 (
<Link
to={to}
className="font-money rounded-md px-1.5 py-0.5 text-xs text-budget-ink-muted transition-colors hover:bg-accent hover:text-budget-ink"
>
{back ? `\u2190 ${label}` : `${label} \u2192`}
</Link>
);
}
+7 -1
View File
@@ -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 }] : [];
});
+8 -2
View File
@@ -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 (
<div className="max-w-4xl space-y-10">
<h1 className="text-2xl font-semibold">Settings</h1>
<div className="max-w-4xl space-y-5">
<h1 className="text-2xl">Settings</h1>
<CurrencySection />
<PostsSection />
<DataSection />
+42 -42
View File
@@ -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 (
<section className="space-y-6">
<h1 className="text-2xl font-semibold">Summary</h1>
<div className="space-y-5">
<h1 className="text-2xl">Summary</h1>
<Section>
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1">
<Label htmlFor="from">From</Label>
@@ -41,52 +43,49 @@ export function SummaryRoute() {
>
All time
</Button>
<div className="ml-auto flex gap-1">
{(["post", "month"] as const).map((option) => (
<Button
key={option}
size="sm"
variant={groupBy === option ? "default" : "outline"}
onClick={() => setGroupBy(option)}
>
by {option}
</Button>
))}
</div>
</div>
<dl className="flex gap-6 text-sm">
<div>
<dt className="text-muted-foreground">Income</dt>
<dd className="font-money">{formatMoney(view.totalIncome, base)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Spent</dt>
<dd className="font-money">{formatMoney(view.totalCharges, base)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Difference</dt>
<dd className={`font-money ${difference < 0 ? "text-overspend" : ""}`}>
{formatMoney(difference, base)}
</dd>
</div>
<dl className="mt-5 flex flex-wrap gap-x-10 gap-y-4 border-t border-budget-rule pt-5">
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
<Stat label="Difference" tone={difference < 0 ? "overspend" : "default"}>
{formatMoney(difference, base)}
</Stat>
</dl>
</Section>
<Section
title={groupBy === "post" ? "By post" : "By month"}
action={
<div className="flex gap-1">
{(["post", "month"] as const).map((option) => (
<Button
key={option}
size="sm"
variant={groupBy === option ? "default" : "outline"}
onClick={() => setGroupBy(option)}
>
by {option}
</Button>
))}
</div>
}
>
<table className="w-full text-sm">
<thead className="border-b text-left text-muted-foreground">
<tr>
<th className="py-2">{groupBy === "post" ? "Post" : "Month"}</th>
<th className="py-2 text-right">Spent</th>
<th className="py-2 text-right">Share</th>
<thead className="text-left">
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
<th className="py-2 font-medium">{groupBy === "post" ? "Post" : "Month"}</th>
<th className="py-2 pl-6 text-right font-medium">Spent</th>
<th className="py-2 pl-6 text-right font-medium">Share</th>
</tr>
</thead>
<tbody>
{groupBy === "post"
? view.byPost.map((entry) => (
<tr key={entry.post.id} className="border-b last:border-0">
<td className="py-2">{entry.post.name}</td>
<td className="py-2 text-right font-money">{formatAmount(entry.charges)}</td>
<td className="py-2 text-right font-money text-muted-foreground">
<tr key={entry.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
<td className="py-2.5">{entry.post.name}</td>
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
{view.totalCharges === 0
? "—"
: `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`}
@@ -94,14 +93,14 @@ export function SummaryRoute() {
</tr>
))
: view.byMonth.map((entry) => (
<tr key={entry.monthId} className="border-b last:border-0">
<tr key={entry.monthId} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
<td className="py-2">
<Link to={`/month/${entry.monthId}`} className="hover:underline">
{entry.monthId}
</Link>
</td>
<td className="py-2 text-right font-money">{formatAmount(entry.charges)}</td>
<td className="py-2 text-right font-money text-muted-foreground">
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
{view.totalCharges === 0
? "—"
: `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`}
@@ -110,6 +109,7 @@ export function SummaryRoute() {
))}
</tbody>
</table>
</section>
</Section>
</div>
);
}
+38 -23
View File
@@ -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 (
<section className="space-y-6">
<header className="flex items-center gap-4">
<Link to={`/year/${numericYear - 1}`} className="text-sm hover:underline">
<div className="space-y-5">
<header className="flex items-baseline gap-3">
<Link
to={`/year/${numericYear - 1}`}
className="font-money rounded-md px-1.5 py-0.5 text-xs text-budget-ink-muted transition-colors hover:bg-accent hover:text-budget-ink"
>
&larr; {numericYear - 1}
</Link>
<h1 className="text-2xl font-semibold">{year}</h1>
<Link to={`/year/${numericYear + 1}`} className="text-sm hover:underline">
<h1 className="text-2xl">{year}</h1>
<Link
to={`/year/${numericYear + 1}`}
className="font-money rounded-md px-1.5 py-0.5 text-xs text-budget-ink-muted transition-colors hover:bg-accent hover:text-budget-ink"
>
{numericYear + 1} &rarr;
</Link>
<div className="ml-auto flex gap-1">
{(["spend", "balance"] as const).map((option) => (
<Button
key={option}
size="sm"
variant={mode === option ? "default" : "outline"}
onClick={() => setMode(option)}
>
{option === "spend" ? "Spent" : "Closing balance"}
</Button>
))}
</div>
</header>
<p className="text-sm text-muted-foreground">
Income {formatMoney(view.totalIncome, base)} &middot; spent{" "}
{formatMoney(view.totalCharges, base)}
</p>
<Section>
<dl className="flex flex-wrap gap-x-10 gap-y-4">
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
</dl>
</Section>
<YearMatrix view={view} mode={mode} />
</section>
<Section
title="Posts by month"
action={
<div className="flex gap-1">
{(["spend", "balance"] as const).map((option) => (
<Button
key={option}
size="sm"
variant={mode === option ? "default" : "outline"}
onClick={() => setMode(option)}
>
{option === "spend" ? "Spent" : "Closing balance"}
</Button>
))}
</div>
}
>
<YearMatrix view={view} mode={mode} />
</Section>
</div>
);
}
+272 -73
View File
@@ -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<string | null>(null);
const targets = CURRENCIES.filter((c) => c !== base);
const [error, setError] = useState<string | null>(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 (
<section className="space-y-4">
<h2 className="text-lg font-medium">Currency</h2>
/** 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));
}
}
<div className="space-y-1">
<Label htmlFor="base-currency">Base currency (all totals use this)</Label>
return (
<Section
title="Currencies"
hint="Add any currency you spend in. The code is how it is identified, so it cannot be changed later — and everything is converted to the base currency for totals."
>
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
<div className="mb-5 space-y-1.5">
<Label
htmlFor="base-currency"
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
>
Base currency (all totals use this)
</Label>
<select
id="base-currency"
className="h-9 w-32 rounded border bg-background px-2 text-sm"
className="h-9 w-40 rounded-md border border-input bg-background px-2 text-sm"
value={base}
onChange={(event) => {
const currency = event.target.value as Currency;
const currency = event.target.value;
mutate((draft) => setBaseCurrency(draft, currency));
}}
>
{CURRENCIES.map((currency) => (
<option key={currency} value={currency}>
{currency}
{dataset.currencies.map(({ code }) => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
</div>
<table className="w-full max-w-md text-sm">
<thead className="border-b text-left text-muted-foreground">
<tr>
<th className="py-2">Currency</th>
<th className="py-2 text-right">1 unit = ? {base}</th>
<th className="py-2 text-right">Source</th>
</tr>
</thead>
<tbody>
{targets.map((currency) => {
const rate = dataset.fxRates.find((r) => r.currency === currency);
return (
<tr key={currency} className="border-b last:border-0">
<td className="py-2">{currency}</td>
<td className="py-2 text-right">
<Input
type="number"
step="0.000001"
className="font-money ml-auto h-8 w-32"
value={rate?.baseUnitsPerOne ?? ""}
onChange={(event) => {
// 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",
<div className="overflow-x-auto">
<table className="w-full min-w-[42rem] text-sm">
<thead className="text-left">
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
<th className="py-2 font-medium">Code</th>
<th className="py-2 pl-4 font-medium">Name</th>
<th className="py-2 pl-4 font-medium">Symbol</th>
<th className="py-2 pl-4 text-right font-medium">Decimals</th>
<th className="py-2 pl-4 text-right font-medium">1 unit = ? {base}</th>
<th className="py-2 pl-4 text-right font-medium">Source</th>
<th className="py-2 pl-4" />
</tr>
</thead>
<tbody>
{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 (
<tr
key={currency.code}
className="group border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60"
>
<td className="font-money py-2 font-medium">{currency.code}</td>
<td className="py-2 pl-4">
<Input
className="h-8 w-40"
aria-label={`Name for ${currency.code}`}
value={currency.name ?? ""}
onChange={(event) => {
const name = event.target.value;
mutate((draft) => updateCurrency(draft, currency.code, { name }));
}}
/>
</td>
<td className="py-2 pl-4">
<Input
className="font-money h-8 w-16"
aria-label={`Symbol for ${currency.code}`}
value={currency.symbol ?? ""}
onChange={(event) => {
const symbol = event.target.value;
mutate((draft) => updateCurrency(draft, currency.code, { symbol }));
}}
/>
</td>
<td className="py-2 pl-4 text-right">
<Input
type="number"
min="0"
max="4"
step="1"
className="font-money ml-auto h-8 w-16 text-right"
aria-label={`Decimal places for ${currency.code}`}
value={currency.digits}
onChange={(event) => {
const digits = Number(event.target.value);
if (!Number.isInteger(digits) || digits < 0 || digits > 4) return;
mutate((draft) => updateCurrency(draft, currency.code, { digits }));
}}
/>
</td>
<td className="py-2 pl-4 text-right">
{isBase ? (
<span className="font-money text-budget-ink-muted">1</span>
) : (
<Input
type="number"
step="0.000001"
className="font-money ml-auto h-8 w-32 text-right"
aria-label={`Rate for ${currency.code}`}
value={rate?.baseUnitsPerOne ?? ""}
onChange={(event) => {
// 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);
}
});
}}
/>
</td>
<td className="py-2 text-right text-xs text-muted-foreground">
{rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"}
</td>
</tr>
);
})}
</tbody>
</table>
}}
/>
)}
</td>
<td className="py-2 pl-4 text-right text-xs text-budget-ink-muted">
{isBase ? "—" : rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"}
</td>
<td className="py-2 pl-4 text-right">
<Button
size="sm"
variant="ghost"
className="text-overspend hover:text-overspend disabled:opacity-40"
disabled={used.length > 0}
title={
used.length > 0
? `In use by ${used.slice(0, 3).join(", ")}${used.length > 3 ? ` and ${used.length - 3} more` : ""}`
: `Remove ${currency.code}`
}
onClick={() =>
tryEdit(() => mutate((draft) => removeCurrency(draft, currency.code)))
}
>
remove
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="fx-url">Rate service URL</Label>
<AddCurrency onAdd={(def) => tryEdit(() => mutate((draft) => addCurrency(draft, def)))} />
<div className="mt-5 space-y-2 border-t border-budget-rule pt-5">
<div className="space-y-1.5">
<Label
htmlFor="fx-url"
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
>
Rate service URL
</Label>
<Input
id="fx-url"
className="max-w-xl"
@@ -123,16 +236,102 @@ export function CurrencySection() {
});
}}
/>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-budget-ink-muted">
{"{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.
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void refresh()}>
Fetch rates now
</Button>
{status && <p className="text-xs">{status}</p>}
{status && <p className="text-xs text-budget-ink-muted">{status}</p>}
</div>
</section>
</Section>
);
}
/**
* 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 (
<div className="mt-4 flex flex-wrap items-end gap-2">
<Input
className="font-money h-9 w-24 uppercase"
placeholder="JPY"
aria-label="New currency code"
value={code}
onChange={(event) => {
const next = event.target.value;
setCode(next);
}}
/>
<Input
className="h-9 w-44"
placeholder="Japanese yen"
aria-label="New currency name"
value={name}
onChange={(event) => {
const next = event.target.value;
setName(next);
}}
/>
<Input
className="font-money h-9 w-16"
placeholder="¥"
aria-label="New currency symbol"
value={symbol}
onChange={(event) => {
const next = event.target.value;
setSymbol(next);
}}
/>
<div className="space-y-1">
<Label
htmlFor="new-digits"
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
>
Decimals
</Label>
<Input
id="new-digits"
type="number"
min="0"
max="4"
step="1"
className="font-money h-9 w-16 text-right"
value={digits}
onChange={(event) => {
const next = event.target.value;
setDigits(next);
}}
/>
</div>
<Button variant="outline" disabled={!canAdd} onClick={add}>
Add currency
</Button>
</div>
);
}
+17 -8
View File
@@ -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 (
<section className="space-y-4">
<h2 className="text-lg font-medium">Your data</h2>
<p className="text-xs text-muted-foreground">
Everything lives in this browser. Export regularly — it is both your
backup and how you move your budget to another device.
</p>
<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}>
@@ -138,6 +147,6 @@ export function DataSection() {
</div>
</div>
)}
</section>
</Section>
);
}
+14 -18
View File
@@ -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 (
<section className="space-y-4">
<h2 className="text-lg font-medium">Posts</h2>
<p className="text-xs text-muted-foreground">
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%.
</p>
<Section
title="Posts"
hint="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. A single month can still override its own allocation from the month view, and percentages may total more than 100%."
>
<table className="w-full text-sm">
<thead className="border-b text-left text-muted-foreground">
<tr>
<th className="py-2">Name</th>
<th className="py-2">Display currency</th>
<th className="py-2">Rule</th>
<thead className="text-left">
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
<th className="py-2 font-medium">Name</th>
<th className="py-2 font-medium">Display currency</th>
<th className="py-2 font-medium">Rule</th>
<th className="py-2" />
</tr>
</thead>
@@ -76,7 +72,7 @@ export function PostsSection() {
mutate((draft) => updatePost(draft, post.id, { currency }));
}}
>
{CURRENCIES.map((currency) => (
{dataset.currencies.map(({ code: currency }) => (
<option key={currency} value={currency}>
{currency}
</option>
@@ -121,7 +117,7 @@ export function PostsSection() {
))}
</tbody>
</table>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-budget-ink-muted">
Archiving hides a post from new purchases while keeping its history. Posts
are never deleted, because purchases reference them.
</p>
@@ -144,6 +140,6 @@ export function PostsSection() {
Add post
</Button>
</div>
</section>
</Section>
);
}
+58
View File
@@ -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;
}
+100
View File
@@ -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);
});
});
+52
View File
@@ -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();
}
+90
View File
@@ -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");
});
+70
View File
@@ -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");
}