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