`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]>
243 lines
14 KiB
Markdown
Executable File
243 lines
14 KiB
Markdown
Executable File
# Budget 2.0 — agent guide
|
|
|
|
The instructions for changing this codebase, for any agent or any human. This
|
|
is the only copy: `CLAUDE.md` and `GEMINI.md` are pointers to this file, so it
|
|
cannot drift into three disagreeing versions.
|
|
|
|
Most of the rules below are here because breaking one cost real time. This file
|
|
is the short, actionable form; the *reasoning* lives in `docs/ARCHITECTURE.md`
|
|
and `docs/specs/`, and is not repeated here.
|
|
|
|
## 1. Invariants
|
|
|
|
- **`src/domain/` is pure.** No React, no IndexedDB, no `Date.now()`, no
|
|
`new Date()` without an explicit argument. It must be testable with no DOM
|
|
(`src/domain/smoke.test.ts` imports it without one).
|
|
- **Money is a float, rounded at every boundary.** Use `roundMoney` from
|
|
`src/domain/money.ts` after any division, after any FX conversion, and before
|
|
persisting. DISPLAY is a boundary too: `src/ui/format.ts` takes digits like
|
|
everything else, and `useMoneyFormat()` is how a component gets them bound to
|
|
the dataset. A pinned 2 lived in that file once and made the decimals setting
|
|
invisible at 0 and lossy at 3.
|
|
- **`roundMoney(amount, digits)` takes a DIGIT COUNT, not a currency code.**
|
|
Decimal places are ONE setting for the whole dataset — `settings.digits` —
|
|
and every currency rounds to it. Read that field; there is no `digitsFor`,
|
|
and `CurrencyDef` has no `digits`. The argument is required on purpose: a
|
|
default of 2 is right while the setting is 2, so a missed call site would
|
|
pass every test and go silently wrong the moment the owner moves it.
|
|
A dataset mixing minor units (yen at 0 beside kroner at 2) CANNOT be
|
|
described, and the app does not warn — the owner chose that, and
|
|
`docs/specs/2026-09-02-global-decimals-design.md` records the argument and
|
|
what re-adding a per-currency override would cost. Do not "fix" it by
|
|
reintroducing per-currency digits without that decision being revisited.
|
|
- **When splitting an amount, never round the last part independently.** Compute
|
|
it as `total - sum(others)` so the parts sum exactly to the whole.
|
|
- **`Currency` is an open string code; `Dataset.currencies` is the authority**
|
|
on which codes exist and on their names and symbols — NOT on their decimals,
|
|
which are dataset-wide per the rule above.
|
|
There is no global list of valid currencies. 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 baked FX rates seed a new dataset only.** They must NOT act as a
|
|
fallback when a rate is missing: clearing a rate removes the row so
|
|
`MissingRateError` surfaces, instead of silently converting money at a number
|
|
the owner never chose.
|
|
- **`MonthId` is the string `"YYYY-MM"`.** Stored data never contains `Date`
|
|
objects.
|
|
- **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.
|
|
- **A post's allocation is a dated series**, `Post.rules: RuleVersion[]`,
|
|
resolved by `ruleAt(post, monthId)`. `Post.standingRule` no longer exists. A
|
|
post with no applicable version allocates zero; a per-month override still
|
|
wins outright.
|
|
- **Posts are archived, never deleted**, because purchases reference them.
|
|
Archived posts still fold.
|
|
- **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.
|
|
- **`sw.js` must stay unhashed and be served `no-cache`.** A cached or hashed
|
|
service worker can never be replaced, which pins users to an old build
|
|
forever.
|
|
- **A destructive whole-dataset write downloads a backup first.** Both JSON
|
|
import and "Reset everything" export the current dataset *before* replacing
|
|
it, and abort if that export fails. This is the owner's only copy.
|
|
|
|
## 2. Guards that fail the build
|
|
|
|
Each of these shipped as a bug once. The test named beside it is why it cannot
|
|
ship twice — re-introducing one is a regression, not a style choice.
|
|
|
|
- **Never read `event.target.value` inside a `mutate()` callback.** Capture it
|
|
into a `const` in the handler. `mutate` defers behind the write queue, and
|
|
React resets the input's DOM value first, so you commit the old value.
|
|
→ `src/ui/eventCapture.test.ts`, source-level because reproducing it needs
|
|
real browser render timing that happy-dom does not have.
|
|
- **Never define a bare `--accent`.** 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, and put
|
|
brand colour on `--primary`, never `--accent`. The app's own tokens are
|
|
`--budget-*`. → `src/cssPairs.test.ts`
|
|
- **A `var()` 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`, which matches source
|
|
*text* — so it flags a token named in a comment just as readily.
|
|
- **Every awaited `store.*` call in `src/ui/` must be guarded** by a `try/catch`
|
|
or a `.catch`. IndexedDB fails for reasons no code can prevent (full disk,
|
|
evicted storage, private-mode quota), and unguarded it is an unhandled
|
|
rejection: the button appears to do nothing and the user is told nothing.
|
|
`useMutate` is the guard for most call sites.
|
|
→ `src/ui/storeWriteErrors.test.ts`
|
|
- **`roundMoney` call sites must read `settings.digits`**, per §1 — never a
|
|
literal, and never a per-currency lookup.
|
|
→ `src/domain/currencyDigits.test.ts` is the only test that can tell a real
|
|
implementation from one that assumes 2: it drives a dataset at ZERO decimals
|
|
through rounding, both FX directions, both split modes, plan division, the
|
|
fold and the month view, and asserts that raising the setting to 2 changes
|
|
what the fold produces. It cannot prove digits vary per currency, because
|
|
they no longer do.
|
|
- **`process.env.BUN_PUBLIC_*` must be read via `try/catch`** — see
|
|
`readBasePathEnv`. 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 process` guard does *not* work: it survives inlining and discards
|
|
the inlined value, silently breaking subpath deploys.
|
|
→ `src/ui/basePath.test.ts` covers that accessor. Nothing stops a *new* bare
|
|
reference elsewhere, so this one still needs your attention.
|
|
- **The pre-paint theme script in `index.html` and `src/ui/theme.ts` must
|
|
agree.** The script cannot import the module — it has to run before any module
|
|
loads — so it repeats the storage key and the fallback logic. Both set two
|
|
things, not one: the `.dark` class and `<meta name="color-scheme">`, which
|
|
must also match the `color-scheme` declarations in `src/index.css`. The meta
|
|
tag is what Dark Reader reads to decide the app already has a dark theme;
|
|
without it, it samples the painted colours and inverts our dark palette a
|
|
second time. → `src/ui/theme.test.ts`, which runs the real script out of the
|
|
HTML rather than matching its text.
|
|
- **Anything derived from the base path is asserted for both a root and a
|
|
subpath deploy:** the manifest (`src/manifest.test.ts`), the font URLs
|
|
(`src/fontCss.test.ts`), the icons (`src/icons.test.ts`), and the service
|
|
worker's precache list (`src/swPrecache.test.ts`) — which must contain no
|
|
duplicate, because `cache.addAll` rejects on one, which is why the service
|
|
worker had never once installed in any environment.
|
|
- **A migration step must not import a live default** — no seed table, no baked
|
|
rates. Each step carries its own frozen copy of whatever it writes, because a
|
|
step's output must not change when today's defaults do: it would silently
|
|
rewrite what an old dataset receives, and only for whoever had not migrated
|
|
yet. → `src/store/migrations.test.ts`, scoped to import bindings (matching the
|
|
whole file flagged both the comment explaining the rule and the frozen local
|
|
copy that is the fix).
|
|
- **Every repository path named in a navigable document must be git-TRACKED** —
|
|
this file, `README.md`, and `docs/*.md`. → `src/docsLinks.test.ts`, added when
|
|
the docs moved into `docs/`: a restructure that quietly leaves thirty
|
|
references pointing at nothing is the failure mode, and nothing else would
|
|
catch it. Tracked rather than merely present, because the first version
|
|
checked the disk, passed locally and failed CI — it was pointing readers at a
|
|
tool ledger that had never been committed. A path only you can follow is the
|
|
reference a reader cannot. It excludes `docs/specs/` and `docs/plans/` on
|
|
purpose: those are dated records of a moment, and a plan naming scaffolding it
|
|
later deleted is accurate history, not a broken link.
|
|
|
|
Two rules with no test behind them. Only review catches these:
|
|
|
|
- **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
|
|
numbers are worse than a visible error in a budgeting app.
|
|
|
|
## 3. Deliberately absent validation
|
|
|
|
Adding a "missing" guard here is a regression:
|
|
|
|
- going over budget, anywhere;
|
|
- allocation percentages summing past 100%;
|
|
- splits that do not sum to the total (the remainder-absorbing split reconciles
|
|
them by design);
|
|
- negative amounts — a refund is a normal line.
|
|
|
|
## 4. Environment
|
|
|
|
**Bun only.** `bun <file>` (never `node` or `ts-node`), `bun test` (never jest
|
|
or vitest), `bun install` (never npm/yarn/pnpm), `bun run <script>`, `bunx`,
|
|
`bun build <file.html|file.ts|file.css>` (never webpack, esbuild or vite). Bun
|
|
loads `.env` by itself — do not add dotenv.
|
|
|
|
**Serving and bundling.** `Bun.serve()` with HTML imports — `import index from
|
|
"./index.html"` in the `routes` map — plus WebSockets, HTTPS and
|
|
`development: { hmr: true, console: true }` if needed. No express. An HTML file
|
|
imports `.tsx`/`.jsx`/`.js` and `<link>`s stylesheets directly; Bun's bundler
|
|
transpiles and bundles both, Tailwind included. Dev server: `bun run dev`.
|
|
|
|
If a dependency ever calls for one: `bun:sqlite` not better-sqlite3,
|
|
`Bun.redis` not ioredis, `Bun.sql` not pg or postgres.js, the built-in
|
|
`WebSocket` not ws, `Bun.file` over `node:fs`'s readFile/writeFile, and
|
|
``Bun.$`ls` `` not execa. Full API docs are in
|
|
`node_modules/bun-types/docs/**.mdx`.
|
|
|
|
**Base path** comes from `BUN_PUBLIC_BASE_PATH`. Never hardcode a subpath, and
|
|
read the variable only through the `try/catch` accessor (§2).
|
|
|
|
**Where new documents go.** Design documents in `docs/specs/`, implementation
|
|
plans in `docs/plans/`, both named `YYYY-MM-DD-<topic>.md`. Note for tooling:
|
|
the brainstorming skill defaults to `docs/superpowers/specs/` — override it.
|
|
That directory was removed deliberately, because "superpowers" names the tool
|
|
that wrote the documents, not a kind of document.
|
|
|
|
**There IS a browser here.** `google-chrome` is on PATH, and
|
|
`scripts/screenshot.ts` drives it over CDP — it waits past the async IndexedDB
|
|
read (which Chrome's own `--screenshot` does not, which is why the docs used to
|
|
claim no browser existed), and it reports console errors and uncaught
|
|
exceptions. Read the PNG it writes. Use it before claiming anything about
|
|
layout.
|
|
|
|
The whole recipe, from a running `bun run dev`:
|
|
|
|
```sh
|
|
bun scripts/demo-data.ts /tmp/seed.js
|
|
bun scripts/screenshot.ts http://localhost:3000/month/2026-09 /tmp/shot.png \
|
|
--eval-file=/tmp/seed.js --reload
|
|
```
|
|
|
|
Then read `/tmp/shot.png`. Other flags: `--dark`, `--full`, `--w/--h`,
|
|
`--click=<selector>`, `--profile=<dir>` (keeps a service worker and IndexedDB
|
|
across runs — required for anything stateful), `--eval-after-file` (observe a
|
|
flow that ends in a navigation from the far side of it; it awaits a promise, so
|
|
it can click through a confirm and read IndexedDB back).
|
|
|
|
Two things that would otherwise waste your time:
|
|
|
|
- **`--reload` is not optional.** Chrome starts from a fresh profile each run,
|
|
so the app re-seeds itself empty; seeding IndexedDB only takes effect on the
|
|
next boot. Without it every screenshot shows an app full of zeros.
|
|
- **This container's fontconfig resolves every generic family to Fira Code, a
|
|
monospace font.** The UI then renders monospaced in a way no real user sees,
|
|
and one agent nearly "fixed" a font bug that did not exist.
|
|
`scripts/screenshot-fonts.conf` corrects it and the script applies it
|
|
automatically — do not undo that, and do not trust a screenshot taken with
|
|
`FONTCONFIG_FILE` set to something else.
|
|
|
|
Still **not** verifiable here, so do not claim it: real offline behaviour, the
|
|
install and update prompts, whether a downloaded file actually landed on disk,
|
|
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.
|
|
|
|
## 5. Then, depending on what you are doing
|
|
|
|
- **Changing anything structural** → `docs/ARCHITECTURE.md`: layers, data flow,
|
|
and why the non-obvious choices are what they are. This file holds the rules;
|
|
that one holds the reasoning behind them.
|
|
- **Picking up queued work** → `docs/TODO.md`, ordered, each item carrying the
|
|
reasoning already settled. It lists only what is still outstanding — finished
|
|
work lives in the commit messages and in `docs/specs/`.
|
|
- **Making a product decision** → `docs/PRODUCT.md`: who this is for, what must
|
|
never break, and what must not be invented.
|
|
- **Wondering why something is the way it is** → `docs/DECISIONS.md`, an index
|
|
of every significant choice and what it was chosen *over*, each entry pointing
|
|
into `docs/specs/` for the argument in full. `docs/plans/` has the
|
|
task-by-task implementation plans.
|