Commit Graph
4 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5 8d2b2ca881 feat(money): one decimals setting for the dataset, not one per currency
`Settings.digits` replaces `CurrencyDef.digits`. Every amount rounds to it,
whatever its currency, and `digitsFor` is gone: the sixteen call sites read the
setting, and the domain functions that took a currency table purely to look
digits up take a `digits: number` instead. Schema 6.

The owner asked for this with the cost in front of them, and the cost is real:
a dataset mixing minor units cannot be described any more. Yen at 0 beside
kroner at 2 has no correct global value — 2 gives ¥1234.56, an amount yen
cannot represent; 0 rounds 49.95 DKK to 50 and drifts the carry from the bank.
Nothing in the app warns. docs/specs/2026-09-02-global-decimals-design.md
argues it in full, including what re-adding a per-currency override would take,
and AGENTS.md §1 now says not to reintroduce one without revisiting the
decision.

The 5 -> 6 step takes the MAXIMUM of the per-currency digits, not the base
currency's. A dataset holding a three-decimal currency already has
three-decimal amounts in it, and adopting the base's 2 would truncate the next
edit of one; the maximum can only keep more precision, never less. On every
dataset this app has written all four currencies record 2, so nothing rounds
differently than before.

Changing the setting does not rewrite stored amounts. 49.95 stays 49.95 and
shows as 50; totals recompute at the new precision. Rewriting each Money is a
destructive whole-dataset write, which §1 owes a backup export first, and it is
lossy one way.

Display turned out to be the hole in the plan. src/ui/format.ts pinned
minimumFractionDigits: 2 in a module-level Intl.NumberFormat, so it never read
the dataset at all — equally broken under per-currency digits, where a JPY
rendered "3,333.00", so a pre-existing gap rather than a regression. Shipping a
Settings control that display ignores would look broken at 0 and be lossy at 3,
hiding precision the dataset holds. So format.ts takes digits like roundMoney
does, useMoneyFormat() binds them for the 64 call sites, and postRowCells takes
the formatter as an argument because it is a plain helper, not a component.

currencyDigits.test.ts is rewritten rather than deleted. Driving a dataset at
ZERO decimals through rounding, both FX directions, both split modes, plan
division, the fold and the month view still tells a real implementation from
one assuming 2 — it just cannot prove per-currency flow, because there is none.
It also asserts that raising the setting to 2 changes what the fold produces.

Verified in Chrome, not only in tests: a v5 dataset with digits 2/0/3 written
straight into IndexedDB migrates to a setting of 3, the table loses its
Decimals column, and editing the control persists schema 6 with digits stripped
from each currency and income left at 20000.55. The same month reads 600/206 at
zero decimals and 600.000/205.561 at three, so the figures recompute rather
than merely reformat.

Not verified here: the .ods in a real spreadsheet.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-03 03:12:14 +02:00
Lucas WintherandClaude Opus 5 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]>
2026-09-02 22:27:28 +02:00
Lucas WintherandClaude Opus 5 0fcfdd27cd fix(fx): the rate service moved, and a browser cannot follow its redirect
"Fetch rates now" failed on the deployed site with nothing but a CORS
error. api.frankfurter.app now answers every request with a 301 to
api.frankfurter.dev/v1, and a redirect response must itself carry
Access-Control-Allow-Origin for a browser to follow it cross-origin. That
301 carries no CORS headers at all, so the request died before the
redirect could be taken.

Measured rather than guessed, with an Origin header set:

  api.frankfurter.app  ->  301, no access-control-* headers
  api.frankfurter.dev  ->  200, access-control-allow-origin: *

The response shape is unchanged, so parseFxResponse needed no edit.

Nothing was wrong at build time, which is why this went unnoticed: a
server-side fetch follows the 301 freely, so the baked rates were always
correct. Only the browser path was broken.

Two changes, because a new default alone fixes only some datasets:

- DEFAULT_FX_API_URL points at .dev/v1, with a test that fails if the
  redirecting host ever comes back.
- A 3 -> 4 migration drops settings.fxApiUrl when it holds exactly the
  dead default, which is the case for anyone who edited that field. It is
  DROPPED rather than rewritten, so the dataset follows whatever the
  current default is and the next move of the service needs no second
  migration for the same user; a URL the owner chose is left strictly
  alone. No stored figure changes, so it cannot alter a number in the
  budget.

Verified by clicking the button in a real browser: "Updated 2 rate(s)",
USD 6.456195 and EUR 7.474959 both stored with source "api" — and the USD
figure differs from the baked constant, so it is a genuine live fetch.

Two migration tests asserted the chain ends at 3; they now assert
SCHEMA_VERSION, since what they mean is "all the way through".

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 22:20:43 +02:00
Lucas WintherandClaude Opus 5 21021952e7 docs: add DECISIONS.md, an index of what was chosen and over what
"Why is this the way it is" was answered by pointing at a directory of
five specs, which means reading five specs to find out whether the
question is even addressed. DECISIONS.md answers it in one line per
decision, grouped by area, each entry naming what the choice was made
*over* and linking into the spec that argues it.

Around forty entries. Where no spec exists — the currency model, the
baked rates, the reset — the commit message is cited as the record
instead of inventing one.

Written by reading the specs rather than from memory, which surfaced
history worth having in one place: dates were widened to accept "YYYY-MM"
rather than restructured into month plus optional day, specifically to
avoid running a migration in the browser against the user's only copy of
their financial data for an identical visible outcome.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 21:58:11 +02:00