# 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 5. → [`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 for every currency the app shipped with, so a missed call site would pass every test and fail on the first zero-decimal currency. → [`AGENTS.md`](../AGENTS.md) §1, commit `cdece63` - **`Currency` is an open string code; `Dataset.currencies` is the authority.** 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) ## 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 ``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 `` 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