Files
Lucas Winther c250bbe476
Continuous Integration / lint-and-test (push) Successful in 29s
Continuous Integration / docker-build-and-push (push) Successful in 40s
feat: refine month totals and recurring purchase entry
2026-10-03 16:40:00 +02:00

17 KiB
Executable File
Raw Permalink Blame History

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.

The month header's Actual available is entered income minus all actual Spent for that month. It includes refunds, unassigned spending and finance-plan slices, but no allocation, carry or unpaid expectations. Before the rollover start, the month view reads the existing charge calculation directly because the fold has deliberately produced no envelope figures yet.

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

The default currency table seeds new datasets only. JPY, AUD, CAD and MXN join DKK, USD, EUR and GBP; existing currency tables are not rewritten. The build derives its FX targets from the seed table and carries complete committed fallback rates. These rates never replace one an owner has cleared.

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, which also says what re-adding a per-currency override would take.

Splits and finance plans compose

Purchase entry defaults to creating a new recurring cost when the owner chooses to make a purchase recur. Selecting an existing bill is enabled explicitly in Settings by the optional allowExistingRecurringCost boolean (absent is false), validated on JSON import. Existing purchase associations remain editable.

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.

Display preferences and build identity

Monthly chart visibility and Expected's expanded state persist per browser, alongside the theme preference. They never enter an exported dataset or change the month calculations. Storage errors leave the display controls usable.

Colophon receives a source ref and UTC build date separately from the build. The date describes the artifact, not the browser's current day. A source archive has no Git ref but still receives a unique service-worker cache version.

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.