Files
budget2.0/docs/DECISIONS.md
T
Lucas WintherandClaude Opus 5 0fcfdd27cd fix(fx): the rate service moved, and a browser cannot follow its redirect
"Fetch rates now" failed on the deployed site with nothing but a CORS
error. api.frankfurter.app now answers every request with a 301 to
api.frankfurter.dev/v1, and a redirect response must itself carry
Access-Control-Allow-Origin for a browser to follow it cross-origin. That
301 carries no CORS headers at all, so the request died before the
redirect could be taken.

Measured rather than guessed, with an Origin header set:

  api.frankfurter.app  ->  301, no access-control-* headers
  api.frankfurter.dev  ->  200, access-control-allow-origin: *

The response shape is unchanged, so parseFxResponse needed no edit.

Nothing was wrong at build time, which is why this went unnoticed: a
server-side fetch follows the 301 freely, so the baked rates were always
correct. Only the browser path was broken.

Two changes, because a new default alone fixes only some datasets:

- DEFAULT_FX_API_URL points at .dev/v1, with a test that fails if the
  redirecting host ever comes back.
- A 3 -> 4 migration drops settings.fxApiUrl when it holds exactly the
  dead default, which is the case for anyone who edited that field. It is
  DROPPED rather than rewritten, so the dataset 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 is left strictly
  alone. No stored figure changes, so it cannot alter a number in the
  budget.

Verified by clicking the button in a real browser: "Updated 2 rate(s)",
USD 6.456195 and EUR 7.474959 both stored with source "api" — and the USD
figure differs from the baked constant, so it is a genuine live fetch.

Two migration tests asserted the chain ends at 3; they now assert
SCHEMA_VERSION, since what they mean is "all the way through".

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 22:20:43 +02:00

10 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. schemaVersion plus ordered migration functions applied on load, because IndexedDB holds the user's only copy of their financial data. Now at version 4. → 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
  • 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 §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
  • 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

Allocation rules

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
  • 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
  • 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-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
  • 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 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
  • 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
  • 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

  • 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.ts emits the precache list from Bun.build's outputs, keeping hashed filenames in sync automatically. → 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
  • 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
  • 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/catch accessor. Rejected a typeof process guard: 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 §3
  • No currency picker. Adding a currency means typing code, name, symbol and decimals by hand. → TODO.md §3
  • Symbol collisions resolve to the first currency defined with that symbol. Tested and documented rather than solved. → TODO.md §3