19 KiB
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,PRODUCT.md - Single user, permanently. One person, one browser, one dataset. No roles,
no household mode. →
PRODUCT.md - Schema migrations are mandatory, not optional.
schemaVersionplus ordered migration functions applied on load, because IndexedDB holds the user's only copy of their financial data. Now at version 11. →specs/2026-09-01-budget-app-design.md - Session backups live in browser storage. Each app start saves a checkpoint
before editing is available; 7 daily, 8 weekly and 12 monthly checkpoints
are retained. Chosen for automatic operation, with manual JSON export still
needed for browser-storage loss. →
ARCHITECTURE.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
-
Actual available is this month's income minus actual spending. Unpaid bills, allocations and carry are separate figures. Purchases still count before the envelope fold's start. →
specs/2026-10-03-month-and-purchase-refinements.md -
JPY, AUD, CAD and MXN seed new budgets only. Existing currency tables remain under the owner's control; all currencies use the dataset's precision. →
specs/2026-10-03-month-and-purchase-refinements.md -
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 -
roundMoneytakes 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§1, commitcdece63 -
Decimal places are ONE dataset-wide setting, not per currency.
Settings.digits;CurrencyDef.digitsis 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 -
Currencyis an open string code;Dataset.currenciesis 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 —parseDatasetJsonrejects a code no currency defines. →src/domain/types.ts, commitcdece63 -
Envelope rollover has one left fold.
src/domain/fold.tsis 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 -
Dates widened rather than restructured.
Purchase.datemay be"YYYY-MM-DD"or"YYYY-MM". Rejected splitting it intomonthplus optionalday— 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
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 - 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 - The series lives on the post. Rejected keeping
standingRulealongside a map of later changes — it cannot express the history. →specs/2026-09-02-versioned-allocation-rules-design.md - Posts are archived, never deleted, because purchases reference them.
Archived posts still fold. →
ARCHITECTURE.md
Budget folders
- Folders contain posts and recurring bills in a mixed order. Folders carry
scheduled Bills due totals; allocation and rollover remain on posts. Bill
placement and split destinations are independent. Rejected nested posts as
folders because that would mix schedule prices and envelope balances.
→
specs/2026-09-29-budget-folders-and-recurring-cost-placement-design.md
Recurring costs
-
Creating a recurring cost is the default recurring action in Add purchase. Choosing an existing bill is enabled explicitly in Settings; ordinary purchases still start unlinked, and editing preserves an existing link. Chosen over showing existing bills on every new purchase. →
specs/2026-10-03-month-and-purchase-refinements.md -
A recurring cost may have no post. It still appears as due, while no envelope projection moves; confirming it records an unassigned purchase. This lets the purchase dialog turn an unassigned charge into a bill without inventing a destination. →
ARCHITECTURE.md -
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 -
A new
RecurringCostentity, over extendingPurchase.schedulewith a generative variant. Under two-stage confirmation a template's only job is to spawn purchases, so every consumer ofPurchasewould 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 -
lastChargeanchoring, over adataCapconcept 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 -
Day-less
everyNMonths, overmonthlyOnDay(day)with short-month clamping. The owner does not think of rent as landing on a date,IsoDatealready supports month-only values, and it keeps day arithmetic out of the most common case entirely. →specs/2026-09-03-recurring-costs-design.md -
A mutable
amount, over a dated series likePost.rules. Safe only because two-stage confirmation already holds every past amount in a confirmedPurchase; 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 -
Two projection models, one per anchoring, over a single unified model.
calendarwalks a fixed grid of slots;lastChargehas no grid and steps from whichever charge is most recent. Chosen over one model where a payment never claims a slot and acalendaroccurrence counts as settled by falling in its period (rejected: two rent payments in a month would settle one slot and leave the other floating), and over keeping slots but letting one hold several payments (rejected: it caps a phone plan at one extra charge per cycle rather than the "infinite number of times a month" the owner described). Deleted three guards that existed only to stop a confirmation landing behind the slot it claims —wouldAdvancePastandrebasedFrominsrc/domain/occurrences.ts, the "Coming up" offerability guard insrc/ui/components/ExpectedBand.tsx, andrequireConfirmationAdvancesinsrc/store/actions.ts— because with no slots on the anchored path the situation they guarded against cannot arise. →specs/2026-09-05-recurring-costs-anchored-model.md -
archivedMIRRORS "endedFromis set", maintained in one place (setRecurringCostEndedFrom) rather than two independent fields a caller could desynchronise.endedFromstops the projection;archivedonly dims the row, the same way an archived post stays listed rather than disappearing. Neither touches a past occurrence. One consequence is visible and undocumented until now: dating a cancellation in the future setsarchivedimmediately, so a bill still live for months still renders dimmed with a "restart" button while it keeps projecting rows right up toendedFrom. Defensible under the rule as chosen, not a bug. →specs/2026-09-03-recurring-costs-design.md,src/store/actions.ts
Design system
-
Monthly breakdown, Expected, Budget, and Purchases expand independently and persist per browser. Chosen over resetting on every visit or moving display choices with a JSON budget export, following the theme's storage boundary. →
specs/2026-10-03-month-and-purchase-refinements.md -
Spending chart selection changes the chart's denominator only. Each post remains available to check again; selecting one shows its purchase shares, using the existing monthly charge calculation. Refunds are separate from positive slices. Budget figures never change when a post is unchecked. →
specs/2026-10-03-month-and-purchase-refinements.md -
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 -
The brand teal is
--primary, never--accent. shadcn's--accentis 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 -
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 -
Fonts are self-hosted and injected at boot from
src/fontCss.ts. Rejected@font-facein 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 noexternaloption, 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 -
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 -
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 carryingrole="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 -
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.
formatMoneystill applies wherever the currency can vary. →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 -
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
Build, offline and deploy
-
Colophon shows a source ref and a separate UTC build date. A ref alone cannot tell when the artifact was built; the browser's current day cannot describe it. Source archives do not invent a Git ref. →
specs/2026-10-03-month-and-purchase-refinements.md -
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 -
The service worker and manifest are hand-written. No PWA plugin exists for Bun, so
build.tsemits the precache list fromBun.build's outputs, keeping hashed filenames in sync automatically. →specs/2026-09-01-budget-app-design.md -
A new build prompts, and calls
skipWaitingonly 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 -
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
MissingRateErrorrather than silently converting at a number the owner never chose. On a network failure the build falls back to committed constants markedsource: "manual", so Settings never claims a hardcoded number came from an API. → commitb4e1933 -
The rate service must be a host that answers directly, not one that redirects.
api.frankfurter.appbegan 301-ing toapi.frankfurter.dev/v1, and a redirect response has to carryAccess-Control-Allow-Originitself 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, andsrc/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 -
Base path is a build-time variable, read through a
try/catchaccessor. Rejected atypeof processguard: it survives Bun's inlining and discards the inlined value, silently breaking every subpath deploy. →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§3 - The income field and the split editor's fixed-amount fields do not parse a
currency; only the purchase total does. →
TODO.md§7 - No currency picker convenience. Adding a currency means typing its code,
name and symbol by hand. Precision is dataset-wide. →
TODO.md§7 - Symbol collisions resolve to the first currency defined with that symbol.
Tested and documented rather than solved. →
TODO.md§7