ebef56eef48bb06cb3f63e8d5159fa1f56fbc529
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f4223573e4 |
feat(currencies): ship sterling, and migrate it into existing datasets
GBP joins the currencies the app comes with: £, two decimals, and a committed fallback rate of 8.704735 DKK to the pound. build.ts now fetches it too, so a build with a network bakes a live GBP rate and one without still covers it — the coverage check derives from the seed table, so it demanded GBP as soon as the table listed it. The seed table alone would not have delivered this. Baked currencies and rates seed a NEW dataset and nothing else, so an existing dataset would have been left typing the code, name, symbol and decimals by hand. A 4 -> 5 migration adds sterling to a dataset that lacks it, with its rate. Supplying a rate is the one thing the "rates never backstop a cleared rate" rule permits here: it forbids re-supplying a number the owner deleted, not introducing one for a currency they have never had. A GBP the owner already defined, or a GBP rate they already hold, is left exactly as it is — asserted, including the case where their definition is "wrong" (zero decimals), because it is theirs. This exposed a real latent bug. The 2 -> 3 step seeded the live seed-currency table BY REFERENCE, so adding sterling there would have retroactively changed what an old dataset received from a step it had already been through — silently, and only for whoever had not migrated yet. Every step now carries its own frozen copy, and a source-level guard fails the build if a live default is imported again. Scoping that guard took two goes: matching the whole file flagged the comment explaining the rule, and then flagged the frozen local copy that is the fix. Two test fixtures used GBP as their example of an UNSUPPORTED currency and now use CHF, which no seed table lists. Verified in a browser by writing a schemaVersion 4 dataset into IndexedDB and reloading: the currency table gains "British pound / £ / 2" with "manual · 2026-09-02", the post currency selector offers GBP, and the income and post survive untouched. Note for the next person — a migration applies in memory on load and is written back on the next mutation, so reading IndexedDB straight after a reload still shows the OLD version. That is recorded in docs/TODO.md, because it made this verification look like a failure at first. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
61e7093c36 |
feat(settings): add a colophon, with a link to the source
Production notes at the foot of Settings: where this came from, what it was built with, what it is set in, and what it stores where. The schema version is real, read from the dataset rather than written into the copy, because it is the number that matters when someone opens a backup and wonders what will read it. The build stamp is new plumbing, and small: build.ts already computed a `version` for the service worker, so the app bundle now gets the same value. Read through a try/catch accessor, never a `typeof process` guard (AGENTS.md §2) — and genuinely absent in development, which is the normal case and reads as "development" rather than as an error. A caller's own BUILD_VERSION is printed as given; only build.ts's default timestamp is translated, because "1788379707862" tells a reader nothing and "2026-09-02" tells them what they wanted. ISO, not a locale format: this string ends up in bug reports and should not vary by who reads it. rel="noreferrer" on the link is deliberate rather than boilerplate. An app whose whole position is that the data never leaves the browser should not hand this page's URL to another origin on the way out. No licence is claimed for the app: there is no LICENSE file in the repository, and asserting one in the UI would be worse than saying nothing. The font licences are committed beside the files, so those are named. Verified: both build paths inline the value and leave no bare process.env reference in the bundle (BUILD_VERSION=v1.4.0-test appears literally, the default appears as a timestamp), and the section was read in both themes. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
cdece63c68 |
feat: user-defined currencies, and amounts that carry their own currency
Two requests. The second is small; the first changed the money layer.
TYPING THE CURRENCY. The purchase total field now reads "30$" as thirty
dollars, "€25,50" as EUR 25.50, "250 kr" as DKK, "1.234,56 usd" as USD
1234.56, and sets the currency selector from what was typed. Entry speed is a
stated product feature and a second control per amount works against it.
The field is type="text", not type="number": a number input silently discards
anything non-numeric, so "30$" would never reach the parser. It keeps its own
text so a half-typed "30." stays as typed and only commits when it parses.
Separator handling is where parseMoneyInput earns its 26 tests. With both "."
and "," present the LAST is the decimal point, which covers "1.234,56" and
"1,234.56" without knowing a locale. With one, 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. Spaces group thousands only in
three-digit groups, because "30 40" is two amounts typed by mistake and
committing it as 3040 would be worse than rejecting it. An unrecognised
currency returns null rather than falling back: reading "30 GBP" as 30 DKK
would book money at the wrong currency.
USER-DEFINED CURRENCIES. `Currency` was a closed union and decimal places were
a module constant, so "add a currency" was a code change. Both now live in the
dataset: `Dataset.currencies` is the authority on which codes are valid, what
they are called, their symbol, and how many decimals they round to. Settings
grows a table to add, edit and remove them.
That forced the money layer open. `roundMoney` takes a DIGIT COUNT, not a
currency code, and every one of its ~29 call sites resolves digits from the
dataset via `digitsFor`. Making digits a required number rather than an
optional one with a default of 2 is the whole safety argument: every site
became a compile error the type checker enumerated for me, where a default
would have left each one silently correct for DKK/USD/EUR and silently wrong
for the first zero-decimal currency the owner adds.
Because every existing currency uses two decimals, a broken version of this
refactor would still pass all 356 previous tests. src/domain/currencyDigits.test.ts
is the oracle that cannot lie: a JPY-style zero-decimal currency driven through
roundMoney, both FX directions, both split modes, finance-plan division, the
fold, and the month view, asserting no figure has a fractional yen in it.
Guards that come with it, all tested:
- Codes are identity — they key the FX table and every stored Money — so they
are normalised (" jpy " -> "JPY") and cannot be edited, only added/removed.
- A currency in use cannot be removed, and the refusal names what refers to
it. The same reasoning as posts being archived rather than deleted: stored
Money carries the code, and dangling references would make the import
validator refuse the owner's own exported file. Removing an unused one takes
its exchange rate with it, leaving no orphan row.
- The import validator now checks the currency table itself — codes, duplicate
codes, integer decimals 0-4 — and validates every Money against it. That
boundary check replaces the compile-time safety the closed union provided.
- parseFxResponse takes the accepted codes instead of a global constant, so a
rate service returning dozens cannot put unreachable rows in the table.
Migration v2 -> v3 seeds the currency table. 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 kept rather than overwritten,
since a hand-edited file could already carry currencies the owner added.
Verified in the browser, not just in tests: JPY added through the real Settings
form with 0 decimals, and the five inputs above typed into the real purchase
field, each setting the right currency.
Two obsolete test helpers went away. plans.test.ts and actions.test.ts used to
monkey-patch the CURRENCY_DIGITS constant to prove the table was consulted;
digits are an argument now, so those tests just ask for zero decimals.
types.test.ts no longer asserts "the supported currencies are exactly DKK,
USD, EUR" — that stopped being a property of the code.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
||
|
|
bb3c661ef6 |
feat(ui): design foundation — two themes, shipped type, one card
Sub-project 1 of the redesign. The diagnosis that shaped it: a design system already existed and was never wired up. src/index.css defined --paper, --ink and a muted teal with a contract document behind them, while the app painted itself with shadcn's default white and near-black. Those tokens reached the screen in exactly two places — the carry meter and the +/- number colours. TOKENS. Two layers: --budget-* is the app's own vocabulary, defined once per theme, and shadcn's whole variable set points at it, so every primitive inherits both themes rather than being fought. Light is cool paper with ink navy; dark is deep slate, never pure black, because a black ground makes the hairlines and the carry meter float. The brand teal is --primary, NOT --accent. That is precisely what the old bug got wrong: shadcn's --accent is a subtle hover BACKGROUND paired with --accent-foreground, so a saturated colour there turns every hover into a coloured slab with unreadable text. src/cssPairs.test.ts now makes the rule mechanical — redefining either half of a shadcn pair without the other fails the build, in any block. CONTRAST was computed, not eyeballed: the weakest text pair is 5.50:1 against a 4.5:1 requirement, and field borders clear the 3:1 non-text requirement on both of their backgrounds. Verified afterwards by reading the tokens back out of the live browser in both themes rather than trusting the arithmetic. TYPE. Inter and JetBrains Mono, self-hosted (SIL OFL, licences committed alongside), latin + latin-ext only. Not in index.css: Bun's CSS bundler resolves every url() from disk and inlines woff2 as base64 — relative or absolute, and a "file" loader override does not stop it — which made the stylesheet 303 KB of render-blocking bytes and defeated the unicode-range subsetting entirely. They are injected at boot from one definition instead, which is the only arrangement identical in dev and in a subpath build. THEME TOGGLE cycles system / light / dark, remembers the choice in localStorage (a per-device display preference does not belong in the exported budget), keeps following the OS while set to "system", and is applied by a tiny inline script before first paint so a dark-theme user never sees a white flash. Every storage access is wrapped: localStorage throws outright in some configurations, and a theme preference must never stop the app booting. A REGRESSION I CAUSED AND CAUGHT: renaming --rule to --budget-rule silently killed the carry meter. An undefined custom property inside linear-gradient() does not warn, does not throw and does not fall back — the declaration is invalid, so the element paints nothing. It looked like a missing feature. Only pixel-sampling a screenshot found it. src/ui/cssTokens.test.ts now fails if any TypeScript file references a token src/index.css does not define. Also: one Section/Stat card treatment applied across month, year, summary and settings; quiet uppercase table headers; row hover; the rule-editing and purchase-row actions revealed on hover instead of six always-on links; a scroll fade on the year matrix so it reads as "more to come" rather than clipped; and "September 2026" instead of "2026-09" as the month heading. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
b4e1933761 |
feat: bake FX rates in at build time so a fresh install can convert
TODO section 2, designed and approved, now built. A brand-new dataset arrives with DKK/USD/EUR rates instead of erroring on the first EUR purchase. build.ts fetches at build time and embeds the result via `define`. A network failure does NOT fail the build — it bakes committed constants instead, and an incomplete response is treated as a failure too, since one unconvertible currency is the problem this solves. Fetched rates carry source "api" and the BUILD date, which is why parseFxResponse now takes an explicit updatedAt; the constants carry source "manual" and their own date, so Settings never claims a hardcoded number came from a rate service. SKIP_FX_FETCH=1 and FX_API_URL are there for offline, CI and reproducible builds. They seed a NEW dataset and nothing else. They are NOT a fallback for a missing rate: clearing a rate removes its row so MissingRateError surfaces, rather than silently converting money at a number the user never chose. A test asserts a cleared rate still throws, and loading an existing dataset never touches its rates. Domain purity forced the shape. createSeedDataset takes the rates as an argument (and copies the rows, so shared constants cannot be edited out from under the caller); the env read lives in the new src/store/bakedRates.ts behind the try/catch accessor, because an unset BUN_PUBLIC_* leaves a bare process.env in the browser bundle; and createSnapshotStore takes seed rates as a parameter so it stays free of the ambient environment, with src/store/index.ts wiring it. Verified all three build paths: live fetch bakes api-sourced rates with today's date, an unreachable endpoint and SKIP_FX_FETCH both bake manual constants and still build, and the payload is present in the bundle in each case. Verified at runtime that a fresh install converts EUR 100 -> 747.50 DKK and USD 100 -> 644.95 DKK with the variable both set and unset. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
f50c65bd77 |
feat: ship the 192 and 512 PNG icons Chrome's install prompt wants
TODO recorded these as needing a human because binary assets cannot be generated in the agent environment. That turned out to be wrong: Chrome is on PATH here and rasterizes the SVG exactly, so scripts/make-icons.ts generates them and the output is committed. Verified pixel-accurate against src/icon.svg before trusting it (background #0f172a, bars #38bdf8, underline #e2e8f0, transparent rounded corners). The generator uses an explicit Bun.spawn with a timeout rather than a shell template, because Chrome did not always exit on its own here. Both sizes now appear in the manifest, get copied into dist, are served by the dev server, and are listed in the service worker precache — along with icon.svg and manifest.webmanifest, which were also absent from it because the list is derived from bundler outputs and these are written separately. Without that an installed app could not render its own icon offline. src/icons.test.ts checks the committed files are genuine 8-bit RGBA images of the right dimensions by inflating IDAT and asserting the exact pixel-data length, so a truncated or stub file cannot pass. It cannot detect staleness if src/icon.svg changes — re-run the generator. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
b9be882c05 |
fix: four latent defects from TODO section 4
Each of these was recorded with its reasoning already settled; each is fixed test-first, and two turned out to be worse than the note claimed. EMPTY_FIGURES is frozen. figuresFor returns it by shared reference for every (post, month) the fold has no entry for, so one consumer mutating its result would have moved every other unknown row with it. Split values now round by what they MEAN. A split's value is a percentage in "percent" mode but MONEY in the purchase's currency in "fixed" mode; both were rounded to a hardcoded 2 places. sliceTotal likewise rounded to DKK's minor unit whatever currency the slices carried. Note this is narrower than TODO claimed: round2 is still correct for Rule.percent, which is not money at all. Neither bug is observable while every supported currency uses hundredths, so the tests force CURRENCY_DIGITS for their duration. confirmImport guards its write. A failed import was an unhandled rejection with nothing shown; the dialog now reports it and keeps `pending` so the user can retry. A source-level guard covers the whole UI, in the spirit of eventCapture.test.ts. The manifest has one definition. The dev placeholder did not merely hardcode scope "/" — it was never served at all: the "/*" catch-all answered /manifest.webmanifest with the HTML shell, as text/html, and /icon.svg too. buildManifest moves to src/manifest.ts, the dev server generates both from the base path, and the source file stays as a valueless resolution stub because Bun.serve has no `external` option to skip the <link> href. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
4478c46a43 |
fix(pwa): make manifest/icon hrefs absolute for multi-segment SPA routes
Relative hrefs in the built index.html ("manifest.webmanifest", "icon.svg")
resolve against the *document* URL, not the site root. Since this is an
SPA where every route serves the same shell, a route with more path
segments than the base path (e.g. /budget2.0/month/2026-09, which is
where the app's own catch-all route redirects on first load) resolved
these to a wrong, 404ing path — breaking the manifest and favicon, and
with them installability, on the app's default landing URL.
build.ts now rewrites both hrefs to be absolute (prefixed with basePath)
as part of the existing dist/index.html -> dist/404.html step, keeping
the two files byte-identical. src/index.html itself keeps the plain
relative hrefs unchanged, since the substitution only happens in the
built dist/ output.
Also fixes a pre-existing, previously-uncaught bug surfaced while
verifying the dev server: bun --hot src/index.ts 500'd on every request
because Bun's dev-server HTML bundler (unlike Bun.build) has no
equivalent of the `external` build option and always tries to resolve
<link> hrefs as module imports — manifest.webmanifest never existed as a
real file under src/, only as build.ts's generated dist/ output. Added
src/manifest.webmanifest as a small static placeholder so the dev server
can resolve it; the production build still ignores this file entirely
(external: ["*.webmanifest"] in build.ts) and writes its own generated
manifest, unaffected by this addition.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
||
|
|
c069ed3a94 |
feat(pwa): add service worker, manifest, and subpath-aware build
The precache list is generated from Bun.build's outputs, so hashed asset names cannot drift. A waiting worker prompts to reload rather than pinning the user to a stale build. Two fixes beyond the brief: naming.entry changed from "[dir]/[name].[ext]" to "[name].[ext]" because [dir] resolved to "src", putting index.html at dist/src/index.html instead of the dist root; and external: ["*.webmanifest", "*.svg"] was added because Bun's HTML bundler otherwise tries to resolve <link> hrefs as module imports, which fails outright for the not-yet-generated manifest.webmanifest and would hash icon.svg, breaking the fixed paths the manifest and service worker depend on. happy-dom is registered per-file in src/ui/registerSw.test.ts only, not as a global test preload, so src/domain/smoke.test.ts still guards the domain layer's DOM-free invariant. PNG icons (192px/512px) are not included — binary image generation isn't available here; a human needs to add them for full Chrome installability. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
26752aa225 |
chore: scaffold Bun + React + shadcn project
bun init --react=shadcn, with the demo API routes removed since this app has no backend. Adds project invariants to CLAUDE.md and a smoke test proving the domain layer imports with no DOM. |