Files
budget2.0/AGENTS.md
T
Lucas Winther 0d2aa519b7 docs(recurring): record the invariants and what they cost
Two rules that only review catches, so they go in AGENTS.md §1: occurrences are
computed and never stored, and a confirmation's date and its slot are different
facts. Collapsing the second pair into one field would silently delete the
phone-bill behaviour, which is why it is written down rather than left to the
types.

§2 gets the termination guard. n >= 1 is rejected at both write boundaries
rather than clamped, and the walk throws rather than breaking, because a bill
silently dropped from a budget is worse than a visible error.

Demo data seeds a monthly rent and a 28-day subscription — the second is what
puts two occurrences in one month, which is the case worth looking at. It also
seeds a biweekly cost and a lastCharge-anchored one, so every recurrence kind
and both anchorings show up in Settings and the month view.

DECISIONS.md, PRODUCT.md, ARCHITECTURE.md and TODO.md are updated to match:
schema is at 7, the month view carries a second Projected balance beside
Remaining, and TODO.md records what a week of real use — not a test — will
have to answer.
2026-09-03 19:03:35 +02:00

15 KiB
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.
  • A recurring cost's occurrences are COMPUTED, never stored. Dataset holds the rule and the confirmations; occurrencesByMonth derives the rest on every fold. There is no horizon and no "generate ahead" step, and upToMonth comes from the caller so src/domain/ stays clock-free. Storing them would reintroduce the materialised snapshot the original design rejected.
  • On a confirmation, Purchase.date is TRUTH and source.occurrenceDate is IDENTITY. The slot is what a confirmation claims, so the projector stops emitting it; the date is when the money moved, so the fold counts it there. They differ whenever a bill is paid off schedule, and under lastCharge anchoring it is date that rebases the series. Do not "simplify" them into one field — that erases the phone-bill behaviour entirely.
  • 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.
  • A recurrence interval must be an integer >= 1, rejected rather than clamped, at BOTH write boundaries — requireRecurrence in src/store/actions.ts and in src/export/json.ts. The projection walk terminates only because every step strictly advances, and a clamped zero would hand the owner a schedule their data does not describe. The walk throws if a step fails to advance; do not soften that into a break. → src/domain/occurrences.test.ts, src/store/actions.test.ts, src/export/json.test.ts
  • 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:

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.