TODO.md carries the remaining work with the reasoning already settled, so the next agent does not re-derive decisions: tasks 3-6 of the versioned-rules plan, the designed-but-unbuilt baked FX rates, the queued visual redesign, the small residuals, and an explicit list of what only a human with a browser can check. ARCHITECTURE.md explains the layer rules, the single-implementation fold, the mutation pipeline's three deliberate properties, and the gotchas that each cost a real bug — reading a deferred event.target.value, an unset BUN_PUBLIC_* var reaching the browser, and overriding shadcn's --accent. CLAUDE.md gains those gotchas, the dated-rule model that replaced standingRule, the validation this project deliberately omits, and an explicit statement that visual and offline behaviour cannot be verified in the agent environment. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
7.1 KiB
Architecture
Orientation for anyone — human or agent — changing this codebase. It explains where things live and, more usefully, why several non-obvious choices are the way they are. Most of them were paid for with a bug.
Shape
A personal budgeting PWA. No backend. IndexedDB in the owner's browser is the only copy of their data. One static artifact deploys to both GitHub Pages (under a subpath) and a self-hosted Docker image.
Bun is the runtime, package manager, bundler and test runner. React 19, Tailwind 4, shadcn/ui. No Vite, no npm, no jest.
Layers
src/domain/ pure. no React, no IndexedDB, no clock. testable with no DOM.
src/store/ persistence, mutations, and the ONLY place a clock is read.
src/export/ JSON and ODS generation. pure.
src/ui/ React. reads through hooks, writes through store actions.
build.ts static build: publicPath, hashed names, SW precache, 404.html.
server.ts the Docker image's static file server.
The dependency rule is one-directional: ui -> store -> domain. domain imports
nothing from the others; store never imports from ui.
src/domain/smoke.test.ts asserts the domain is importable with no DOM. That is
why happy-dom is registered per test file rather than as a global bunfig
preload — a global DOM would make that assertion vacuously true and silently
retire the check.
The fold is the single source of every number
src/domain/fold.ts holds the one implementation of envelope rollover:
remaining = carriedIn + allocation − charges (carried forward, monthly)
Unspent carries; overspend carries as debt. Nothing else may recompute this.
The month view, year matrix, summary, per-post views and the ODS export all
aggregate over foldBalances / figuresFor. A closing balance is read from
byMonth[11].remaining, never re-summed.
If you find yourself writing carriedIn + allocation - charges outside
fold.ts, that is the invariant breaking. It matters because two screens
silently disagreeing about someone's money is the worst failure this app has.
Editing any past month recomputes everything after it. That is correct: the fold is a left fold, and history is causal.
Data flow for a single edit
onChange
└─ capture event.target.value into a const ← synchronously, see Gotchas
└─ store.mutate(draft => action(draft))
└─ serialization queue (one mutation at a time)
└─ structuredClone(snapshot) → apply → await persistence.write()
└─ snapshot = next; notify()
└─ useSyncExternalStore re-renders
Three deliberate properties in that chain:
- Write before the snapshot moves. If the write fails, the UI keeps showing
the last saved state, and
useMutatesurfaces the rejection. The user is never shown data that was not persisted. - Serialized. Two edits inside one write window would otherwise both clone the same base and the later commit would silently drop the earlier one.
- Cloned.
useSyncExternalStoredetects change by reference identity; mutating in place makes the UI silently stop re-rendering.
Money
A float, deliberately, with two rules that make it safe:
- Round at every boundary via
roundMoney— after any division, after any FX conversion, and at thesrc/store/actions.tsmutation boundary. The UI adds no rounding of its own. - Never round the last distributed part independently. Compute it as
total − sum(others)so the parts sum exactly to the whole.withRemainderinmoney.tsis the only place that logic lives.
FxRate.baseUnitsPerOne is never rounded. Rates are stored at six decimals
and are not money; quantizing one to cents is wrong in kind.
Splits and finance plans compose
A purchase can be split across posts and spread over months at once. The trick
is in charges.ts: first compute how the whole purchase divides across posts
(splitPartsOfTotal), then distribute each month's slice proportionally to
those parts. Both split modes collapse to one code path per slice.
Applying a split's raw value per-slice looks simpler and passes every percentage test — and charges a fixed "2000 to Video Games" in full on every slice.
Things the app deliberately does NOT validate
Adding a "missing" guard here is a regression, not a fix:
- going over budget, ever, 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).
Gotchas paid for with real bugs
- Never read
event.target.valueinside amutate()callback.mutatedefers behind the queue and a write; React resets a controlled input's DOM value long before the callback runs, so you read the old value and commit it. This froze the income field at 0.src/ui/eventCapture.test.tsfails the build if it reappears. - Read
process.env.BUN_PUBLIC_*through atry/catchaccessor. Bun only inlines the literal when the variable is set; unset, the reference survives into the browser bundle and throwsReferenceError: process is not definedbefore the app boots. Atypeof processguard does NOT work — after inlining it still runs in the browser and discards the inlined value, silently breaking subpath deploys. SeereadBasePathEnvinsrc/ui/basePath.ts. - Do not define a bare
--accentCSS variable. shadcn owns it as a near-white hover background paired with near-black text; overriding it drops hover contrast below WCAG AA app-wide. Project tokens are namespaced--budget-*. sw.jsmust stay unhashed and be servedno-cache. A cached or hashed service worker can never be replaced, pinning users to an old build forever.- A React error boundary does not catch throws from event handlers. Domain
functions that throw (
MissingRateError,remainderIndexOf,monthOf,equalSlices) must be guarded at the call site when called from a handler.
Testing, and its limits
Most logic is pure and tested directly with bun test. The domain carries the
bulk of the coverage; the UI is thin by design.
There is no browser in the agent environment. Nothing has ever verified
visual appearance, real offline behaviour, the install or update prompts,
keyboard focus traversal, or the .ods opening in a spreadsheet. Where a check
could not be run, the honest substitute was used and recorded — for example the
.ods is validated structurally by unzipping it and asserting mimetype is
first and stored, every XML part parses, and numeric cells carry office:value.
src/ui/eventCapture.test.ts is a source-level guard rather than a
behavioural one, because the bug it prevents depends on real browser render
timing that happy-dom does not reproduce.
Where the reasoning lives
PRODUCT.md— durable product truth: who it is for, what must never break.TODO.md— queued work, with the reasoning behind each decision already made.docs/superpowers/specs/— design decisions and the alternatives rejected.docs/superpowers/plans/— task-by-task implementation plans.