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]>
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]>
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]>
setRuleFrom replaces rather than appends for a month that already has a
version, keeping the effective rule unambiguous, and keeps the series sorted.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Previously only an empty posts array was tested for the "no standingRule"
path; a post that IS present but lacks the field was exercised only by an
ad-hoc script, not a committed test. Asserts rules === [] (never a version
wrapping undefined, which would later crash resolveRule/ruleAt) and that a
sibling post's rule is unaffected.
A pure shape change: standingRule becomes one version effective from the fold
start, which is where it already applied from. Asserted behaviour-preserving —
every post in every month resolves to the same allocation before and after, so
no balance moves.
Post.standingRule becomes Post.rules: RuleVersion[], resolved by ruleAt() as
the last version effective at or before a month. Per-month overrides still win
outright; a post with no applicable version allocates zero rather than
applying its earliest rule backwards.
allocationFor keeps its signature, so the fold, the views and the ODS export
are untouched. Settings gets a read-only rule summary for now; the history
editor follows.
Fixes the three findings from the final whole-branch review:
- SnapshotStore.mutate/replace now serialize behind a promise queue, so
concurrent mutate() calls (e.g. two independent onChange handlers firing
before either write resolves) each clone from the previous commit instead
of racing on a stale snapshot and silently losing an edit. Preserves
write-before-move-snapshot, caller-visible rejections, structuredClone
identity, and non-wedging on a failed write. Adds tests for concurrent
mutations and a failing write mid-queue.
- DataSection's exportJson/exportOds are wrapped in try/catch and report
failures through the existing import error state/rendering, since a throw
(e.g. MissingRateError from buildOds) inside a plain onClick is invisible
to the error boundary. The pre-import JSON backup now explicitly aborts
the destructive replace if the backup export fails, rather than relying on
an uncaught throw to prevent it.
- PlanEditor no longer calls monthOf()/withPlan() with an empty or partial
date from a native <input type="date">, and requires an integer month
count (with step={1}) before calling withPlan/equalSlices, preventing
both domain throws from firing inside plain event handlers.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Rate fetching is optional and fails soft: any network or parse error
leaves cached rates untouched and shows a message, never throwing into
render. A cleared or non-positive FX rate input now calls the new
removeFxRate action instead of storing 0, since toBase() silently
converts a stored 0 to a 0-value conversion while an absent row
correctly throws MissingRateError. Posts are archived, never deleted.
Import downloads a backup of the current data before replacing it and
requires confirmation showing before/after counts.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Every mutation is a pure function over a draft dataset, so the write
surface is unit-tested without React. Money amounts (income, purchase
totals, split values, schedule slices, fixed/percent rule amounts) are
rounded to their currency's minor unit before they land in the draft,
per CLAUDE.md's round-before-persisting rule -- actions.ts is the
single boundary every UI mutation passes through. FxRate.baseUnitsPerOne
is deliberately exempt: it is stored at six decimal places, not money.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Persistence is injected, so the store is testable with no browser. Writes
land before the snapshot moves, so the UI never shows unsaved data.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The dataset is one atomic record, so a write can never leave posts and
purchases inconsistent.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>