From 21021952e7dd8a8d5c9d4cbc32f9f5554f1cc2b6 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 21:58:11 +0200 Subject: [PATCH] docs: add DECISIONS.md, an index of what was chosen and over what MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Why is this the way it is" was answered by pointing at a directory of five specs, which means reading five specs to find out whether the question is even addressed. DECISIONS.md answers it in one line per decision, grouped by area, each entry naming what the choice was made *over* and linking into the spec that argues it. Around forty entries. Where no spec exists — the currency model, the baked rates, the reset — the commit message is cited as the record instead of inventing one. Written by reading the specs rather than from memory, which surfaced history worth having in one place: dates were widened to accept "YYYY-MM" rather than restructured into month plus optional day, specifically to avoid running a migration in the browser against the user's only copy of their financial data for an identical visible outcome. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 5 +- README.md | 2 + docs/ARCHITECTURE.md | 2 + docs/DECISIONS.md | 151 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 docs/DECISIONS.md diff --git a/AGENTS.md b/AGENTS.md index c239fa3..4bb49b4 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,6 +201,7 @@ state plainly what still needs a human. work lives in the commit messages and in `docs/specs/`. - **Making a product decision** → `docs/PRODUCT.md`: who this is for, what must never break, and what must not be invented. -- **Wondering why something is the way it is** → `docs/specs/` records the - decisions *and the alternatives that were rejected*; `docs/plans/` has the +- **Wondering why something is the way it is** → `docs/DECISIONS.md`, an index + of every significant choice and what it was chosen *over*, each entry pointing + into `docs/specs/` for the argument in full. `docs/plans/` has the task-by-task implementation plans. diff --git a/README.md b/README.md index 10f95fc..380805c 100755 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ the DKK/USD/EUR currency table, this month at zero income, no purchases. - `docs/PRODUCT.md` — who this is for and what must never break. - `AGENTS.md` — the rules for changing this codebase, and the test that catches each one when broken. `CLAUDE.md` and `GEMINI.md` point at it. +- `docs/DECISIONS.md` — an index of the choices that shaped the app, and what + each was chosen over. - `docs/TODO.md` — what is still queued. - `docs/specs/` — one design document per piece of work, each recording the alternatives that were rejected. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 784e266..eace194 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -184,6 +184,8 @@ timing that happy-dom does not reproduce. - `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. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..d2c82d7 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,151 @@ + # 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 3. + → [`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` +- **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