Files
budget2.0/docs/DECISIONS.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

217 lines
14 KiB
Markdown

# Decisions
An index of the choices that shaped this codebase, and what each one was chosen
*over*. One or two lines each — enough to know whether a decision is the one you
are looking for.
This file does not argue them. The spec named beside each entry does, including
the alternatives in full; where an entry has no spec, the commit message is the
record.
## Product and storage
- **IndexedDB is the only store. No server, no account, no sync.** A deliberate
product position, not a missing backend: the data never leaves the browser.
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md),
[`PRODUCT.md`](PRODUCT.md)
- **Single user, permanently.** One person, one browser, one dataset. No roles,
no household mode. → [`PRODUCT.md`](PRODUCT.md)
- **Schema migrations are mandatory, not optional.** `schemaVersion` plus
ordered migration functions applied on load, because IndexedDB holds the
user's only copy of their financial data. Now at version 7.
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
- **A destructive write downloads a backup first.** Import and reset both export
before replacing, and abort if the export fails. → commit `ac3d18a`
- **Reset restores first-run state, not an empty dataset.** The seed posts, the
seed currency table and the baked rates come back, because "reset" and "a
brand-new browser" should be the same call. → commit `ac3d18a`
## Money and the data model
- **Money is a float, not an integer of minor units.** Made safe by two rules
rather than by the type: round at every boundary, and compute a distributed
last part as `total - sum(others)`.
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
- **`roundMoney` takes a digit count, and the argument is required.** Rejected a
default of 2: it is correct while the decimals setting is 2, so a missed call
site would pass every test and fail the moment it moves.
→ [`AGENTS.md`](../AGENTS.md) §1, commit `cdece63`
- **Decimal places are ONE dataset-wide setting, not per currency.**
`Settings.digits`; `CurrencyDef.digits` is gone. Chosen over a per-currency
default and over display-only rounding, and knowingly over correctness for
mixed minor units: a dataset holding both yen and kroner can no longer be
described, and the app does not warn. The owner asked for the simpler model
with that trade-off in front of them.
→ [`specs/2026-09-02-global-decimals-design.md`](specs/2026-09-02-global-decimals-design.md)
- **`Currency` is an open string code; `Dataset.currencies` is the authority**
on which codes exist, their names and their symbols — but no longer on their
decimals. Rejected a closed union, which would have made "add a currency" a
code change. The compile-time safety is replaced by validation at the
boundaries — `parseDatasetJson` rejects a code no currency defines.
→ `src/domain/types.ts`, commit `cdece63`
- **Every derived number comes from one left fold.** `src/domain/fold.ts` is the
only implementation; year and summary views aggregate over it rather than
recomputing. Two screens disagreeing about someone's money is the worst
failure this app has. → [`ARCHITECTURE.md`](ARCHITECTURE.md)
- **Dates widened rather than restructured.** `Purchase.date` may be
`"YYYY-MM-DD"` or `"YYYY-MM"`. Rejected splitting it into `month` plus
optional `day` — the more honest model, and the right call greenfield, but it
changes a type every layer depends on and needs a migration run in the browser
against the user's only copy, for an identical user-visible outcome.
→ [`specs/2026-09-02-fast-entry-refinements-design.md`](specs/2026-09-02-fast-entry-refinements-design.md)
## Allocation rules
- **A post's allocation is a dated series, and per-month overrides still
exist.** Both survive because they answer different questions: "from now on"
versus "just this month". A month override wins outright.
→ [`specs/2026-09-02-versioned-allocation-rules-design.md`](specs/2026-09-02-versioned-allocation-rules-design.md)
- **No rule yet means an allocation of zero.** Rejected applying the earliest
rule backwards, which invents budget for months the user never budgeted.
→ [`specs/2026-09-02-versioned-allocation-rules-design.md`](specs/2026-09-02-versioned-allocation-rules-design.md)
- **The series lives on the post.** Rejected keeping `standingRule` alongside a
map of later changes — it cannot express the history.
→ [`specs/2026-09-02-versioned-allocation-rules-design.md`](specs/2026-09-02-versioned-allocation-rules-design.md)
- **Posts are archived, never deleted**, because purchases reference them.
Archived posts still fold. → [`ARCHITECTURE.md`](ARCHITECTURE.md)
## Recurring costs
- **Two-stage confirmation, over projection-only or generation-only.** A
recurring cost projects expected occurrences that stay expected until the
owner confirms one into an ordinary `Purchase`. Projection-only would charge
automatically with no reconciliation and would also force a dated amount
series, since nothing else would hold what was actually paid; generation-only
needs a horizon and a notion of "today" the domain is built to avoid.
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
- **A new `RecurringCost` entity, over extending `Purchase.schedule` with a
generative variant.** Under two-stage confirmation a template's only job is
to spawn purchases, so every consumer of `Purchase` would have to learn to
skip templates — a different case from splits and finance plans composing on
one entity.
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
- **`lastCharge` anchoring, over a `dataCap` concept for the phone-bill case.**
Rebasing the series on the confirmation date reproduces the behaviour exactly,
with no usage tracking and no third recurrence kind.
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
- **Day-less `everyNMonths`, over `monthlyOnDay(day)` with short-month
clamping.** The owner does not think of rent as landing on a date, `IsoDate`
already supports month-only values, and it keeps day arithmetic out of the
most common case entirely.
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
- **A mutable `amount`, over a dated series like `Post.rules`.** Safe only
because two-stage confirmation already holds every past amount in a confirmed
`Purchase`; the rule describes what is expected next, the ledger holds what
happened. Under projection-only this would not have held, and a dated series
would have been required.
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
## Design system
- **Two token layers.** `--budget-*` is the app's own vocabulary; shadcn's
entire variable set points at it, so every primitive inherits both themes.
→ [`specs/2026-09-02-design-foundation-design.md`](specs/2026-09-02-design-foundation-design.md)
- **The brand teal is `--primary`, never `--accent`.** shadcn's `--accent` is a
hover *background* whose paired foreground must move with it; a saturated
colour there turns every hover into a slab with the wrong text on it.
→ [`specs/2026-09-02-design-foundation-design.md`](specs/2026-09-02-design-foundation-design.md)
- **Contrast was computed and read back out of the browser**, not chosen by eye.
Two candidate field borders were rejected at 2.17:1 and 2.55:1.
→ [`specs/2026-09-02-design-foundation-design.md`](specs/2026-09-02-design-foundation-design.md)
- **Fonts are self-hosted and injected at boot from `src/fontCss.ts`.** Rejected
`@font-face` in the bundled stylesheet (Bun inlines woff2 as base64 either
way: 70 KB became 303 KB of render-blocking bytes and defeated the subsetting)
and rejected a separate `<link>`ed stylesheet (works in a build, but the dev
server has no `external` option, so dev would lie about typography). A CDN was
rejected on product grounds: a font request to another origin contradicts
"the data never leaves the browser".
→ [`specs/2026-09-02-design-foundation-design.md`](specs/2026-09-02-design-foundation-design.md)
- **Theme preference lives in `localStorage`, not in the dataset.** The dataset
is exported and imported, and restoring a desktop backup must not flip a phone
to dark. Cycles system → light → dark, because "follow the system" is a real
answer and there must be a way back to it.
→ [`specs/2026-09-02-design-foundation-design.md`](specs/2026-09-02-design-foundation-design.md)
- **Two markups per responsive table, not one reflowing grid.** A real `<table>`
where it is a table, a real list where it is not. Rejected one CSS grid
carrying `role="table"`: it deduplicates the markup but describes rows and
columns to a screen reader that no longer exist on screen.
→ [`specs/2026-09-02-month-view-design.md`](specs/2026-09-02-month-view-design.md)
- **A table states its currency once, not per cell.** Every figure in the post
table is base currency; repeating the code only wrapped figures onto two lines
on a phone. `formatMoney` still applies wherever the currency can vary.
→ [`specs/2026-09-02-month-view-design.md`](specs/2026-09-02-month-view-design.md)
- **Meter geometry is pure and clamped.** All meter arithmetic lives in
`src/ui/meterSegments.ts`; negatives clamp rather than invert, because a
segment of negative width puts a gradient's stops out of order and paints
something arbitrary instead of failing.
→ [`specs/2026-09-02-month-view-design.md`](specs/2026-09-02-month-view-design.md)
- **The allocation meter scales to `max(income, allocated)`.** Over-budget is
always allowed, so over-allocation has to be drawable; capping at a full bar
would make it look identical to allocating income exactly.
→ [`specs/2026-09-02-month-view-design.md`](specs/2026-09-02-month-view-design.md)
## Build, offline and deploy
- **Bun is the runtime, package manager, bundler and test runner.** shadcn/ui
components are vendored into the repo and owned by this project.
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
- **The service worker and manifest are hand-written.** No PWA plugin exists for
Bun, so `build.ts` emits the precache list from `Bun.build`'s outputs, keeping
hashed filenames in sync automatically.
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
- **A new build prompts, and calls `skipWaiting` only on user action.** Without
it a stale service worker pins the user to an old version indefinitely.
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
- **FX rates are baked at build time, and seed a new dataset only.** They are
never a fallback for a missing rate: clearing a rate must surface
`MissingRateError` rather than silently converting at a number the owner never
chose. On a network failure the build falls back to committed constants marked
`source: "manual"`, so Settings never claims a hardcoded number came from an
API. → commit `b4e1933`
- **The rate service must be a host that answers directly, not one that
redirects.** `api.frankfurter.app` began 301-ing to `api.frankfurter.dev/v1`,
and a redirect response has to carry `Access-Control-Allow-Origin` itself for
a browser to follow it cross-origin — that 301 carries none, so "Fetch rates
now" died with only a CORS error to go on. The build-time fetch never noticed,
because a server-side fetch follows redirects freely. → commit history, and
`src/store/fxApi.ts`
- **A migration must never read today's defaults.** The 2 -> 3 step seeded the
live seed-currency table by reference, so adding sterling to that table would
have retroactively changed what an old dataset got from a step it had already
been through — silently, and only for whoever had not migrated yet. Each step
now carries its own frozen copy, and a source-level guard fails the build if a
live default is imported again. → `src/store/migrations.ts`
- **Sterling ships with the app, and is migrated into datasets that already
exist.** Baked currencies and rates seed a NEW dataset and nothing else, so
the seed table alone would have left every existing user typing the code, name
symbol and decimals by hand. The 4 -> 5 step supplies the rate as well, which
is the one thing the "rates never backstop a cleared rate" rule permits here:
it forbids re-supplying a number the owner deleted, not introducing one for a
currency they have never had. A GBP the owner already defined is left exactly
as it is. → `src/store/migrations.ts`
- **The v3 -> v4 migration DROPS the stale URL rather than rewriting it** to the
new endpoint, so a dataset that stored it follows whatever the current default
is and the next move of the service needs no second migration for the same
user. A URL the owner chose themselves is left strictly alone.
→ `src/store/migrations.ts`
- **ODS export is generated client-side with `fflate`.** No server exists to
generate it. → [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
- **Base path is a build-time variable, read through a `try/catch` accessor.**
Rejected a `typeof process` guard: it survives Bun's inlining and discards the
inlined value, silently breaking every subpath deploy.
→ [`AGENTS.md`](../AGENTS.md) §2, `src/ui/basePath.ts`
## Things deliberately left undone
Not oversights. Each was decided against, and reopening one needs a reason.
- Over-budget, percentages past 100%, splits that do not sum, and negative
amounts are all **allowed**. Adding validation is a regression.
→ [`AGENTS.md`](../AGENTS.md) §3
- The income field and the split editor's fixed-amount fields **do not parse a
currency**; only the purchase total does. → [`TODO.md`](TODO.md) §3
- **No currency picker.** Adding a currency means typing code, name, symbol and
decimals by hand. → [`TODO.md`](TODO.md) §3
- **Symbol collisions resolve to the first currency defined with that symbol.**
Tested and documented rather than solved. → [`TODO.md`](TODO.md) §3