Allow recurring costs and confirmations to remain unassigned when created from a purchase without a post. Add browser backup retention and restore controls, with coverage and documentation updates.
299 lines
16 KiB
Markdown
Executable File
299 lines
16 KiB
Markdown
Executable File
# 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`.
|
||
|
||
Two `src/domain/` modules worth naming individually: `days.ts` is day-ordinal
|
||
arithmetic with no `Date` at all, and `occurrences.ts` computes a recurring
|
||
cost's occurrences fresh on every fold rather than storing them, by one of two
|
||
walks chosen on `cost.anchoring`. A `calendar` cost (`gridOccurrences`) is a
|
||
fixed grid of slots stepped forward from `startDate`; nothing a payment does
|
||
moves it, so paying September AND October's rent correctly leaves nothing due
|
||
until November. A `lastCharge` cost (`anchoredOccurrences`) has no grid at
|
||
all — its next due date is one cycle after whichever charge is most recent, so
|
||
it can be charged any number of times in one cycle and each charge restarts
|
||
the clock, which is what a phone plan that recharges when its data runs out
|
||
actually does. The two walks share only `stepFrom`, which is also where
|
||
`everyNMonths` splits: month-granular (`addMonths`) under `calendar`, day-precise
|
||
and clamping (`addMonthsToDate`, `days.ts`) under `lastCharge`, because a plan
|
||
refreshed on the 20th is next due on the 20th, not at the start of the
|
||
following month.
|
||
|
||
`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 envelope figures over `foldBalances` / `figuresFor`. Overall Spent
|
||
also includes unassigned purchase slices, which have no envelope to fold. A
|
||
closing balance is read from `byMonth[11].remaining`, never re-summed.
|
||
|
||
A recurring cost adds a SECOND balance beside it, on its own carry:
|
||
|
||
```
|
||
projected = projectedCarriedIn + allocation − charges − expected
|
||
```
|
||
|
||
`remaining` never changes shape or value because of a recurring cost — it is
|
||
still the figure the owner reconciles against their bank statement. `projected`
|
||
runs beside it, additionally subtracting this month's unconfirmed occurrences,
|
||
so the month view shows both a `Remaining` and a `Projected` column per post
|
||
rather than only a separate list of upcoming bills. With no recurring costs the
|
||
two tracks are identical in every month; once every occurrence in a month is
|
||
confirmed they reconverge, because a confirmation is an ordinary purchase
|
||
counted in `charges` and skipped by `expected`. A `lastCharge` cost reconverges
|
||
the same way but by a different mechanism: it has no `confirmedBy` to skip, so
|
||
it reconverges because the anchored walk itself never re-projects a cycle that
|
||
has already been charged — it starts one cycle after the latest charge, full
|
||
stop. Whether a still-open cycle's next due date lands in this month or a
|
||
later one depends on the recurrence, same as it always has.
|
||
|
||
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 `useMutate` surfaces 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.** `useSyncExternalStore` detects change by reference identity;
|
||
mutating in place makes the UI silently stop re-rendering.
|
||
|
||
The two whole-dataset writes take the same queue and the same rules:
|
||
`store.replace()` behind JSON import, and `store.reset()` behind Settings'
|
||
"Reset everything". `reset()` calls the same `createSeedDataset` that `load()`
|
||
uses on a first run, so "reset" and "a brand-new browser" cannot drift apart.
|
||
Both are preceded in the UI by a JSON backup download that must succeed before
|
||
the destructive write is attempted.
|
||
|
||
## Session backups
|
||
|
||
On each page load, `src/frontend.tsx` waits for `store.load()`, then saves the
|
||
loaded dataset to a separate `backups` object store in the same IndexedDB
|
||
before rendering controls that can edit it. A failed backup does not prevent
|
||
access to the budget; the app shows an alert with the failure. The snapshot is
|
||
JSON, separate from the live `state` record, and is never read as live data.
|
||
|
||
Retention keeps the newest checkpoint from each of 7 UTC days, 8 Monday-based
|
||
UTC weeks and 12 UTC months. A checkpoint may cover multiple tiers. In
|
||
backup persistence, writing the new checkpoint and deleting checkpoints
|
||
outside that set share one IndexedDB transaction: a failed write cannot prune
|
||
older backups. Settings lists retained checkpoints for download and restore.
|
||
Restoring parses through the same JSON import boundary, downloads the current
|
||
active dataset first, then calls `store.replace()`; if the download fails, the
|
||
active data stays put. Reset replaces the active dataset but leaves session
|
||
backups available.
|
||
|
||
These backups use the same browser origin as the live dataset. They help with
|
||
accidental edits or resets, but do not survive loss of all browser storage.
|
||
Manual JSON export remains the portable backup.
|
||
|
||
## Session undo and redo
|
||
|
||
The snapshot store keeps up to 50 prior saved datasets in memory. A changed
|
||
`mutate` adds one step after its write succeeds; an unchanged mutation adds
|
||
none. Undo and redo run through the same queue and persist their target dataset
|
||
before changing the visible snapshot or either history stack. A failed write
|
||
therefore leaves both the data and the available undo/redo actions intact.
|
||
|
||
Import and reset clear the stacks only after their write succeeds. They are
|
||
whole-dataset replacement flows with backup-first protection, not ordinary
|
||
undoable edits. History is not in `Dataset` or IndexedDB and vanishes on reload.
|
||
The header reads a stable history capability snapshot from the store to enable
|
||
its controls; keyboard shortcuts skip editable fields so browser text undo
|
||
still works.
|
||
|
||
## Money
|
||
|
||
A float, deliberately, with two rules that make it safe:
|
||
|
||
1. **Round at every boundary** via `roundMoney` — after any division, after any
|
||
FX conversion, and at the `src/store/actions.ts` mutation boundary. The UI
|
||
adds no rounding of its own.
|
||
2. **Never round the last distributed part independently.** Compute it as
|
||
`total − sum(others)` so the parts sum exactly to the whole. `withRemainder`
|
||
in `money.ts` is 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.
|
||
|
||
**Decimal places are data, not a constant — one value for the whole dataset.**
|
||
`Settings.digits` is what every amount rounds to, whatever its currency;
|
||
`Dataset.currencies` records only a code, name and symbol. `roundMoney` takes a
|
||
digit count and every call site reads that setting, required rather than
|
||
defaulted, so a missed site is a compile error instead of something that works
|
||
while the setting is 2 and breaks the moment it is not.
|
||
`src/domain/currencyDigits.test.ts` drives a dataset at zero decimals through
|
||
the whole stack, which is the only test that can tell a real implementation
|
||
from one that assumes 2.
|
||
|
||
The cost is explicit: a dataset mixing minor units — yen at 0 beside kroner at
|
||
2 — cannot be described, and nothing in the app will warn about it. That was
|
||
the owner's call, argued in full in
|
||
[`specs/2026-09-02-global-decimals-design.md`](specs/2026-09-02-global-decimals-design.md),
|
||
which also says what re-adding a per-currency override would take.
|
||
|
||
## 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.
|
||
|
||
A purchase with no post has an empty `splits` array. Its dated amount counts in
|
||
monthly, yearly and summary Spent totals, but charges no envelope and changes no
|
||
rollover balance. A recurring bill may also have empty splits: its occurrences
|
||
still appear in Expected and Bills due, but they move no post's projected
|
||
balance. Confirming one creates an unassigned purchase, which counts in Spent.
|
||
JSON import and ODS export preserve these unassigned purchases. In the month
|
||
Budget tree, purchases appear in the permanent, derived Unassigned section for
|
||
each month they touch. Creating a post from one assigns its full amount to the
|
||
new post without changing the purchase total or schedule.
|
||
|
||
A post with no applicable allocation rule has no overspend status, even if its
|
||
balance is negative. The balance remains visible and carries normally. An
|
||
explicit zero rule, including a zero monthly override, does have an overspend
|
||
status when its balance is negative. The views use the presence of a rule rather
|
||
than infer it from the numeric allocation, since both cases fold to zero.
|
||
|
||
## 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
|
||
|
||
`AGENTS.md` §2 carries the enforceable one-line form of these, with the test
|
||
that fails when each reappears. What follows is the *mechanism* behind them,
|
||
which is the part worth reading once.
|
||
|
||
- **Never read `event.target.value` inside a `mutate()` callback.** `mutate`
|
||
defers 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.ts` fails the
|
||
build if it reappears.
|
||
- **Read `process.env.BUN_PUBLIC_*` through a `try/catch` accessor.** Bun only
|
||
inlines the literal when the variable is *set*; unset, the reference survives
|
||
into the browser bundle and throws `ReferenceError: process is not defined`
|
||
before the app boots. A `typeof process` guard does NOT work — after inlining
|
||
it still runs in the browser and discards the inlined value, silently breaking
|
||
subpath deploys. See `readBasePathEnv` in `src/ui/basePath.ts`.
|
||
- **Do not define a bare `--accent` CSS 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.js` must stay unhashed and be served `no-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.
|
||
|
||
**A browser IS available**, via `scripts/screenshot.ts` — headless Chrome
|
||
driven over CDP, because Chrome's own `--screenshot` fires on the load event
|
||
and captures an empty page before this app's async IndexedDB read resolves.
|
||
That script is how the month view, the carry meter and the rule editors were
|
||
first seen by anyone. It reports console errors too, which is the point: a page
|
||
that renders blank because of a thrown error looks identical to an empty one.
|
||
|
||
Its limits are real. The container's fontconfig maps every generic family to a
|
||
monospace face, so screenshots need a `FONTCONFIG_FILE` override to be
|
||
representative; Chrome starts with an empty profile each run; and no screenshot
|
||
can judge how a design feels. Real offline behaviour, the install and update
|
||
prompts, and the `.ods` opening in a spreadsheet remain unverifiable here.
|
||
Where a check could not be run, the honest substitute was used and recorded —
|
||
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.
|
||
|
||
## Budget placement
|
||
|
||
Folders organize posts and recurring bills in one sibling order. The pure
|
||
`budgetTree` selector combines month post rows with computed scheduled slots;
|
||
folder Bills due sums bill prices in the selected slot month, including
|
||
descendants, using confirmed purchase totals where available. Folder placement
|
||
is independent of bill split destinations. Only posts have allocations and
|
||
rollover balances. The month UI renders the same tree as a desktop table and a
|
||
mobile list, and keeps occurrence confirmation in the Expected band.
|
||
|
||
## Where the reasoning lives
|
||
|
||
- `AGENTS.md` — the rules, in enforceable one-line form, and which test fails
|
||
when one is broken. This file holds the reasoning; that one holds the rule.
|
||
- `docs/PRODUCT.md` — durable product truth: who it is for, what must never break.
|
||
- `docs/TODO.md` — what is *still* queued, each item carrying the reasoning already
|
||
settled. Finished work is not kept there; it is in the commit messages.
|
||
- `docs/DECISIONS.md` — an index of every significant decision and what it was
|
||
chosen over, pointing into the specs for the argument.
|
||
- `docs/specs/` — design decisions and the alternatives rejected, one document
|
||
per piece of work.
|
||
- `docs/plans/` — task-by-task implementation plans.
|