Merge: month view redesign, reset to a fresh install, and a docs restructure

Three pieces of work, eight commits.

Month view (redesign sub-project 2 of 4). The header reads income ->
allocation meter -> spend, and answers "did I overspend" at the month level
instead of making you read down a column. The carry meter now belongs to its
own row rather than sitting equidistant between two. Phone width worked at all
for the first time: the five-column table became two-line blocks, so Spent and
Remaining are no longer off-screen behind a horizontal scroll.

Reset to a fresh install, in Settings. store.reset() is the same call load()
makes on a first run, so "reset" and "a brand-new browser" cannot drift apart.
It backs up before it destroys, and aborts if the backup fails.

Docs restructure. AGENTS.md is now the single agent guide, with CLAUDE.md and
GEMINI.md as pointers; the long-form docs live in docs/ without the superpowers
nesting; TODO.md holds only outstanding work; DECISIONS.md indexes what was
chosen and over what. Four claims that were no longer true are corrected, and a
guard now fails the build when documentation names a path that does not exist.

446 tests, tsc clean, build clean on both FX paths. Verified in real Chrome in
both themes at 1280px and 390px, and the reset was clicked through and read
back out of IndexedDB.

Still needs a human: how it all looks and feels, real offline behaviour, the
install prompt, and whether the pre-destructive backup lands as an openable
file.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-02 21:59:18 +02:00
co-authored by Claude Opus 5
37 changed files with 1722 additions and 614 deletions
Regular → Executable
+209 -20
View File
@@ -1,25 +1,214 @@
# Agent guide
# Budget 2.0 — agent guide
This repository's agent instructions live in **`CLAUDE.md`** — read it first.
It carries the project invariants, the gotchas that cost real bugs, and the
validation this codebase deliberately does not perform.
The instructions for changing this codebase, for any agent or any human. This
is the only copy: `CLAUDE.md` and `GEMINI.md` are pointers to this file, so it
cannot drift into three disagreeing versions.
Then, depending on what you are doing:
Most of the rules below are here because breaking one cost real time. This file
is the short, actionable form; the *reasoning* lives in `docs/ARCHITECTURE.md`
and `docs/specs/`, and is not repeated here.
- **Changing anything structural** → `ARCHITECTURE.md` (layers, data flow, and
why the non-obvious choices are what they are).
- **Picking up queued work** → `TODO.md` (ordered, each item carrying the
reasoning already settled).
- **Making a product decision** → `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/superpowers/specs/`
records the decisions *and the alternatives that were rejected*.
## 1. Invariants
Two rules worth knowing before your first edit:
- **`src/domain/` is pure.** No React, no IndexedDB, no `Date.now()`, no
`new Date()` without an explicit argument. It must be testable with no DOM
(`src/domain/smoke.test.ts` imports it without one).
- **Money is a float, rounded at every boundary.** Use `roundMoney` from
`src/domain/money.ts` after any division, after any FX conversion, and before
persisting.
- **`roundMoney(amount, digits)` takes a DIGIT COUNT, not a currency code.**
Decimal places live in `Dataset.currencies`, because the owner defines
currencies; resolve them with `digitsFor(dataset.currencies, code)`. The
argument is required on purpose — a default of 2 is right for every currency
the app shipped with, so a missed call site would pass every test and be
silently wrong for the first zero-decimal currency added.
- **When splitting an amount, never round the last part independently.** Compute
it as `total - sum(others)` so the parts sum exactly to the whole.
- **`Currency` is an open string code; `Dataset.currencies` is the authority.**
There is no global list of valid currencies. A code is IDENTITY — it keys the
FX table and every stored `Money` — so codes are normalised uppercase, are
never editable, and a currency still referenced cannot be removed
(`currencyUsage` names what refers to it). `parseDatasetJson` validates the
table and every `Money` against it; that boundary check is what replaced the
compile-time safety of the old closed union.
- **The baked FX rates seed a new dataset only.** They must NOT act as a
fallback when a rate is missing: clearing a rate removes the row so
`MissingRateError` surfaces, instead of silently converting money at a number
the owner never chose.
- **`MonthId` is the string `"YYYY-MM"`.** Stored data never contains `Date`
objects.
- **The envelope rollover fold has exactly one implementation**, in
`src/domain/fold.ts`. Year and summary views aggregate over it — they never
reimplement the math.
- **A post's allocation is a dated series**, `Post.rules: RuleVersion[]`,
resolved by `ruleAt(post, monthId)`. `Post.standingRule` no longer exists. A
post with no applicable version allocates zero; a per-month override still
wins outright.
- **Posts are archived, never deleted**, because purchases reference them.
Archived posts still fold.
- **An amount field parses its own currency:** "30$" is thirty dollars. Use
`parseMoneyInput` (`src/ui/moneyInput.ts`) and keep the input `type="text"` —
a number input discards the "$" before the parser ever sees it.
- **`sw.js` must stay unhashed and be served `no-cache`.** A cached or hashed
service worker can never be replaced, which pins users to an old build
forever.
- **A destructive whole-dataset write downloads a backup first.** Both JSON
import and "Reset everything" export the current dataset *before* replacing
it, and abort if that export fails. This is the owner's only copy.
1. **Bun only.** `bun test`, `bun run`, `bunx`. Never npm, jest, vitest or vite.
2. **There IS a browser here** — `bun scripts/screenshot.ts <url> <out.png>`
drives headless Chrome and hands you a PNG to look at. Read CLAUDE.md's
"Verification limits" first: the container's fonts lie, and offline, the
install prompt, the `.ods` in a spreadsheet, and how anything *feels* still
need a human. Say so rather than claiming it works.
## 2. Guards that fail the build
Each of these shipped as a bug once. The test named beside it is why it cannot
ship twice — re-introducing one is a regression, not a style choice.
- **Never read `event.target.value` inside a `mutate()` callback.** Capture it
into a `const` in the handler. `mutate` defers behind the write queue, and
React resets the input's DOM value first, so you commit the old value.
→ `src/ui/eventCapture.test.ts`, source-level because reproducing it needs
real browser render timing that happy-dom does not have.
- **Never define a bare `--accent`.** shadcn owns it as a hover BACKGROUND
paired with `--accent-foreground`; setting one without the other drops hover
contrast below WCAG AA app-wide. Redefine shadcn tokens only in pairs, and put
brand colour on `--primary`, never `--accent`. The app's own tokens are
`--budget-*`. → `src/cssPairs.test.ts`
- **A `var()` naming a token `src/index.css` does not define paints NOTHING,
silently.** An undefined custom property invalidates the whole declaration:
no warning, no throw, no fallback. Renaming `--rule` this way killed the carry
meter and no test noticed. → `src/ui/cssTokens.test.ts`, which matches source
*text* — so it flags a token named in a comment just as readily.
- **Every awaited `store.*` call in `src/ui/` must be guarded** by a `try/catch`
or a `.catch`. IndexedDB fails for reasons no code can prevent (full disk,
evicted storage, private-mode quota), and unguarded it is an unhandled
rejection: the button appears to do nothing and the user is told nothing.
`useMutate` is the guard for most call sites.
→ `src/ui/storeWriteErrors.test.ts`
- **`roundMoney` call sites must resolve real digits**, per §1.
→ `src/domain/currencyDigits.test.ts` is the only test that can tell a real
implementation from one that assumes 2: it drives a JPY-style zero-decimal
currency through rounding, both FX directions, both split modes, plan
division, the fold and the month view.
- **`process.env.BUN_PUBLIC_*` must be read via `try/catch`** — see
`readBasePathEnv`. Bun inlines the literal only when the variable is set;
unset, the bare reference reaches the browser and throws before the app boots.
A `typeof process` guard does *not* work: it survives inlining and discards
the inlined value, silently breaking subpath deploys.
→ `src/ui/basePath.test.ts` covers that accessor. Nothing stops a *new* bare
reference elsewhere, so this one still needs your attention.
- **The pre-paint theme script in `index.html` and `src/ui/theme.ts` must
agree.** The script cannot import the module — it has to run before any module
loads — so it repeats the storage key and the fallback logic.
→ `src/ui/theme.test.ts`
- **Anything derived from the base path is asserted for both a root and a
subpath deploy:** the manifest (`src/manifest.test.ts`), the font URLs
(`src/fontCss.test.ts`), the icons (`src/icons.test.ts`), and the service
worker's precache list (`src/swPrecache.test.ts`) — which must contain no
duplicate, because `cache.addAll` rejects on one, which is why the service
worker had never once installed in any environment.
- **Every repository path named in a navigable document must exist** — this
file, `README.md`, and `docs/*.md`. → `src/docsLinks.test.ts`, added when the
docs moved into `docs/`: a restructure that quietly leaves thirty references
pointing at nothing is the failure mode, and nothing else would catch it. It
excludes `docs/specs/` and `docs/plans/` on purpose — those are dated records
of a moment, and a plan naming scaffolding it later deleted is accurate
history, not a broken link.
Two rules with no test behind them. Only review catches these:
- **A React error boundary does not catch event-handler throws.** Domain
functions that throw must be guarded at the call site when called from one.
- **Do not "fix" a domain throw by making it return a fallback.** Silently wrong
numbers are worse than a visible error in a budgeting app.
## 3. Deliberately absent validation
Adding a "missing" guard here is a regression:
- going over budget, anywhere;
- allocation percentages summing past 100%;
- splits that do not sum to the total (the remainder-absorbing split reconciles
them by design);
- negative amounts — a refund is a normal line.
## 4. Environment
**Bun only.** `bun <file>` (never `node` or `ts-node`), `bun test` (never jest
or vitest), `bun install` (never npm/yarn/pnpm), `bun run <script>`, `bunx`,
`bun build <file.html|file.ts|file.css>` (never webpack, esbuild or vite). Bun
loads `.env` by itself — do not add dotenv.
**Serving and bundling.** `Bun.serve()` with HTML imports — `import index from
"./index.html"` in the `routes` map — plus WebSockets, HTTPS and
`development: { hmr: true, console: true }` if needed. No express. An HTML file
imports `.tsx`/`.jsx`/`.js` and `<link>`s stylesheets directly; Bun's bundler
transpiles and bundles both, Tailwind included. Dev server: `bun run dev`.
If a dependency ever calls for one: `bun:sqlite` not better-sqlite3,
`Bun.redis` not ioredis, `Bun.sql` not pg or postgres.js, the built-in
`WebSocket` not ws, `Bun.file` over `node:fs`'s readFile/writeFile, and
``Bun.$`ls` `` not execa. Full API docs are in
`node_modules/bun-types/docs/**.mdx`.
**Base path** comes from `BUN_PUBLIC_BASE_PATH`. Never hardcode a subpath, and
read the variable only through the `try/catch` accessor (§2).
**Where new documents go.** Design documents in `docs/specs/`, implementation
plans in `docs/plans/`, both named `YYYY-MM-DD-<topic>.md`. Note for tooling:
the brainstorming skill defaults to `docs/superpowers/specs/` — override it.
That directory was removed deliberately, because "superpowers" names the tool
that wrote the documents, not a kind of document.
**There IS a browser here.** `google-chrome` is on PATH, and
`scripts/screenshot.ts` drives it over CDP — it waits past the async IndexedDB
read (which Chrome's own `--screenshot` does not, which is why the docs used to
claim no browser existed), and it reports console errors and uncaught
exceptions. Read the PNG it writes. Use it before claiming anything about
layout.
The whole recipe, from a running `bun run dev`:
```sh
bun scripts/demo-data.ts /tmp/seed.js
bun scripts/screenshot.ts http://localhost:3000/month/2026-09 /tmp/shot.png \
--eval-file=/tmp/seed.js --reload
```
Then read `/tmp/shot.png`. Other flags: `--dark`, `--full`, `--w/--h`,
`--click=<selector>`, `--profile=<dir>` (keeps a service worker and IndexedDB
across runs — required for anything stateful), `--eval-after-file` (observe a
flow that ends in a navigation from the far side of it; it awaits a promise, so
it can click through a confirm and read IndexedDB back).
Two things that would otherwise waste your time:
- **`--reload` is not optional.** Chrome starts from a fresh profile each run,
so the app re-seeds itself empty; seeding IndexedDB only takes effect on the
next boot. Without it every screenshot shows an app full of zeros.
- **This container's fontconfig resolves every generic family to Fira Code, a
monospace font.** The UI then renders monospaced in a way no real user sees,
and one agent nearly "fixed" a font bug that did not exist.
`scripts/screenshot-fonts.conf` corrects it and the script applies it
automatically — do not undo that, and do not trust a screenshot taken with
`FONTCONFIG_FILE` set to something else.
Still **not** verifiable here, so do not claim it: real offline behaviour, the
install and update prompts, whether a downloaded file actually landed on disk,
opening the generated `.ods` in a spreadsheet, and anything about how the design
*feels*. A screenshot proves a layout exists and is not broken; it does not
prove it is good. Substitute a real check where one exists — structural
validation of the `.ods`, curl against the dev server, pure-function tests — and
state plainly what still needs a human.
## 5. Then, depending on what you are doing
- **Changing anything structural** → `docs/ARCHITECTURE.md`: layers, data flow,
and why the non-obvious choices are what they are. This file holds the rules;
that one holds the reasoning behind them.
- **Picking up queued work** → `docs/TODO.md`, ordered, each item carrying the
reasoning already settled. It lists only what is still outstanding — finished
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/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.
Regular → Executable
+5 -229
View File
@@ -1,231 +1,7 @@
# CLAUDE.md
Default to using Bun instead of Node.js.
The instructions for this repository live in **[`AGENTS.md`](AGENTS.md)**. Read
that file.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
## Project invariants (Budget 2.0)
- `src/domain/` is pure: no React, no IndexedDB, no `Date.now()`, no `new Date()`
without an explicit argument. It must be testable with no DOM.
- Money is a float. Always round with `roundMoney` from `src/domain/money.ts`
after any division, after any FX conversion, and before persisting.
- **`roundMoney(amount, digits)` takes a DIGIT COUNT, not a currency code.**
Decimal places live in `Dataset.currencies`, because the owner defines
currencies; resolve them with `digitsFor(dataset.currencies, code)`. The
argument is required on purpose — a default of 2 is right for every currency
the app shipped with, so a missed call site would pass every test and be
silently wrong for the first zero-decimal currency added.
- When splitting an amount, never round the last part independently. Compute it
as `total - sum(others)` so the parts sum exactly to the whole.
- `MonthId` is the string `"YYYY-MM"`. Stored data never contains `Date` objects.
- **`Currency` is an open string code; `Dataset.currencies` is the authority.**
There is no global list of valid currencies any more. A code is IDENTITY — it
keys the FX table and every stored `Money` — so codes are normalised
uppercase, are never editable, and a currency still referenced cannot be
removed (`currencyUsage` names what refers to it). `parseDatasetJson`
validates the table and every `Money` against it; that boundary check is
what replaced the compile-time safety of the old closed union.
- The envelope rollover fold has exactly one implementation, in
`src/domain/fold.ts`. Year and summary views aggregate over it — they never
reimplement the math.
- Over-budget is always allowed. Never add validation that blocks it.
- An amount field parses its own currency: "30$" is thirty dollars. Use
`parseMoneyInput` (`src/ui/moneyInput.ts`) and keep the input `type="text"` —
a number input discards the "$" before the parser ever sees it.
- Base path comes from `BUN_PUBLIC_BASE_PATH`; never hardcode a subpath, and
read it through a `try/catch` accessor — see the gotcha below.
- A post's allocation is a dated series, `Post.rules: RuleVersion[]`, resolved by
`ruleAt(post, monthId)`. `Post.standingRule` no longer exists. A post with no
applicable version allocates zero; a per-month override still wins outright.
- Posts are archived, never deleted, because purchases reference them. Archived
posts still fold.
## Start here
- `ARCHITECTURE.md` — layers, data flow, and why the non-obvious choices are
what they are. Read it before changing anything structural.
- `TODO.md` — queued work, each item carrying the reasoning already settled.
- `PRODUCT.md` — who this is for and what must never break.
- `docs/superpowers/specs/` — design decisions, including alternatives rejected.
## Gotchas that cost real bugs
Each of these shipped once. Re-introducing one is a regression, not a style
choice.
- **Never read `event.target.value` inside a `mutate()` callback.** `mutate`
defers behind the write queue; React resets the input's DOM value first, so
you commit the old value. Capture into a `const` in the handler.
`src/ui/eventCapture.test.ts` fails the build otherwise.
- **`process.env.BUN_PUBLIC_*` must be read via `try/catch`** (see
`readBasePathEnv`). Bun inlines the literal only when the variable is set;
unset, the bare reference reaches the browser and throws before the app boots.
A `typeof process` guard does not work — it survives inlining and discards the
inlined value, silently breaking subpath deploys.
- **Never define a bare `--accent` CSS variable.** shadcn owns it as a hover
BACKGROUND paired with `--accent-foreground`; setting one without the other
drops hover contrast below WCAG AA app-wide. Redefine shadcn tokens only in
pairs — `src/cssPairs.test.ts` enforces it — and put brand colour on
`--primary`, never `--accent`. Project tokens are `--budget-*`.
- **A `var(--token)` naming a token `src/index.css` does not define paints
NOTHING, silently.** An undefined custom property invalidates the whole
declaration: no warning, no throw, no fallback. Renaming `--rule` this way
killed the carry meter and no test noticed. `src/ui/cssTokens.test.ts` guards
it.
- **A React error boundary does not catch event-handler throws.** Domain
functions that throw must be guarded at the call site when called from one.
- **Do not "fix" a domain throw by making it return a fallback.** Silently wrong
numbers are worse than a visible error in a budgeting app.
## Deliberately absent validation
Adding a "missing" guard here is a regression:
- going over budget, anywhere;
- allocation percentages summing past 100%;
- splits that do not sum to the total (the remainder-absorbing split reconciles
them by design);
- negative amounts — a refund is a normal line.
## Verification limits
**There IS a browser here.** `google-chrome` is on PATH, and
`scripts/screenshot.ts` drives it over CDP — it waits past the async IndexedDB
read (which Chrome's own `--screenshot` does not, which is why the docs used to
claim no browser existed), and it reports console errors and uncaught
exceptions. Read the PNG it writes. Use it before claiming anything about
layout.
The whole recipe, from a running `bun run dev`:
```sh
bun scripts/demo-data.ts /tmp/seed.js
bun scripts/screenshot.ts http://localhost:3000/month/2026-09 /tmp/shot.png \
--eval-file=/tmp/seed.js --reload
```
Then read `/tmp/shot.png`. Other flags: `--dark`, `--full`, `--w/--h`,
`--click=<selector>`, `--profile=<dir>` (keeps a service worker and IndexedDB
across runs — required for anything stateful), `--eval-after-file` (observe a
flow that ends in a navigation from the far side of it).
Two things that would otherwise waste your time:
- **`--reload` is not optional.** Chrome starts from a fresh profile each run,
so the app re-seeds itself empty; seeding IndexedDB only takes effect on the
next boot. Without it every screenshot shows an app full of zeros.
- **This container's fontconfig resolves every generic family to Fira Code, a
monospace font.** The UI then renders monospaced in a way no real user sees,
and the last agent to look nearly "fixed" a font bug that did not exist.
`scripts/screenshot-fonts.conf` corrects it and the script applies it
automatically — do not undo that, and do not trust a screenshot taken with
`FONTCONFIG_FILE` set to something else.
Still **not** verifiable here, so do not claim it: real offline behaviour, the
install and update prompts, opening the generated `.ods` in a spreadsheet, and
anything about how the design *feels*. A screenshot proves a layout exists and
is not broken; it does not prove it is good. Substitute a real check where one
exists — structural validation of the `.ods`, curl against the dev server,
pure-function tests — and state plainly what still needs a human.
This is a pointer, not a copy. Three files of instructions would become three
files that disagree.
+7
View File
@@ -0,0 +1,7 @@
# GEMINI.md
The instructions for this repository live in **[`AGENTS.md`](AGENTS.md)**. Read
that file.
This is a pointer, not a copy. Three files of instructions would become three
files that disagree.
Regular → Executable
+23 -9
View File
@@ -8,7 +8,7 @@ your browser — there is no server and no account.
```bash
bun install
bun --hot src/index.ts # dev server with hot reload
bun run dev # dev server with hot reload, on :3000
bun test # run the test suite
bun run build # production build into dist/
```
@@ -37,8 +37,12 @@ the source in the repository settings.
Everything is stored in this browser's IndexedDB. **Export regularly** from
Settings → Your data: the JSON export is both your backup and how you move your
budget to another device. Import replaces everything and downloads a backup of
your current data first.
budget to another device.
Both destructive actions there — Import, and "Reset everything" — download a
backup of your current data before replacing it, and abort if that download
fails. Reset puts the app back to a brand-new install: the three starter posts,
the DKK/USD/EUR currency table, this month at zero income, no purchases.
## Known limitations
@@ -46,12 +50,22 @@ your current data first.
browser's site data, or move to a different browser or device, your budget is
gone unless you exported it first. The JSON export is not just a nice-to-have
backup — it is the only way to move a budget between devices.
- **The install prompt is incomplete.** `manifest.webmanifest` currently only
ships an SVG icon. Chrome requires PNG icons at 192×192 and 512×512 to offer
the "Install app" prompt; until those are added, the app works fine as a
regular tab but won't be installable on Chrome/Chromium.
- **Installing and offline use are unverified.** The manifest ships the SVG plus
192×192 and 512×512 PNG icons, and the service worker demonstrably installs,
activates and precaches every asset — but nobody has yet confirmed that Chrome
offers its "Install app" prompt, or loaded the app with the network actually
cut. It works as a regular tab regardless.
## Documentation
- Design: `docs/superpowers/specs/2026-09-01-budget-app-design.md`
- Implementation plan: `docs/superpowers/plans/2026-09-01-budget-app.md`
- `docs/ARCHITECTURE.md` — layers, data flow, and why the non-obvious choices are
what they are.
- `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.
- `docs/plans/` — task-by-task implementation plans.
-168
View File
@@ -1,168 +0,0 @@
# TODO
Work queued for whoever picks this up next, human or agent. Ordered. Each item
says enough to act on without re-deriving the reasoning.
**Current state:** merged to `main`. 419 tests passing, `bunx tsc --noEmit`
clean, `bun run build` succeeds on both the live-fetch and offline paths.
Sections 2 and 4 are DONE. Section 3 (redesign) is 1 of 4 sub-projects in.
Section 6 (currencies) is DONE. Every screen has now actually been looked at —
see section 5.
**Schema is at version 3.** v1 -> v2 made allocation rules a dated series;
v2 -> v3 moved currencies into the dataset. Both migrations are
behaviour-preserving by construction and tested as such.
---
## 1. ~~Time-versioned allocation rules~~ — DONE, all six tasks
**Spec:** `docs/superpowers/specs/2026-09-02-versioned-allocation-rules-design.md`
**Plan:** `docs/superpowers/plans/2026-09-02-versioned-allocation-rules.md`
**Ledger + extracted briefs:** `.superpowers/sdd/2026-09-02-versioned-allocation-rules/`
| Task | What | Commit |
|---|---|---|
| 1 | `Post.rules: RuleVersion[]` replaces `standingRule` | `68712e7` |
| 2 | v1 -> v2 migration | `15dcf93`, `1214d2b` |
| 3 | `setRuleFrom` / `removeRuleFrom` actions | `de128e3` |
| 4 | JSON import validation for the rule series | `1cb91d3` |
| 5 | Rule-history editor in Settings | `e3023d0` |
| 6 | "Change from here" control in the month view | `f94e458` |
Tasks 3-6 were executed by hand rather than dispatched. Every deviation from
the briefs is recorded in the ledger's task log with its reasoning; four of
them fixed real defects in the brief code (JSX that would not compile, a
component that pre-filled the previous post's rule, a number field that could
not be cleared, and missing validation that let a NaN allocation into the
fold). Worth a read before reviewing the diff.
Not yet done for this feature: nobody has independently reviewed tasks 3-6, and
no browser has seen the two new editing surfaces.
## 2. ~~Baked FX rates~~ — DONE (`b4e1933`)
So a fresh install can convert a EUR purchase on day one instead of erroring.
Built as designed, all three build paths verified (live fetch, unreachable
endpoint, `SKIP_FX_FETCH=1`), and confirmed in the real UI: Settings shows
USD 6.449532 / EUR 7.474959 as `manual · 2026-09-01` on a fresh dataset.
The reasoning below is kept because it is the reasoning, not a plan.
- `build.ts` fetches DKK/USD/EUR at build time and embeds them via `define`;
on any network failure it falls back to **committed constants** rather than
failing the build. Fetched rates get `source: "api"` with the build date;
the fallback constants get `source: "manual"`, so Settings never claims a
hardcoded number came from an API.
- They **seed a new dataset only**. They must NOT act as a fallback whenever a
rate is missing — that would undo a deliberate decision: clearing a rate
removes the row so `MissingRateError` surfaces, instead of silently
converting money at a number the user never chose.
- Domain purity forces the shape: `createSeedDataset` takes the rates as an
**argument**; the env read lives in a new `src/store/bakedRates.ts`, in the
store layer, using the `try/catch` accessor pattern (see CLAUDE.md gotchas).
## 3. Visual redesign — sub-project 1 of 4 DONE
The owner's words: *"right now it looks a bit boring, simple and ugly. We want
sleek, modern, and nice to use."* Then, on scoping: both themes with a toggle,
a full rethink of every screen, purchases compact and grouped by date, ship a
webfont — and *"override everything you want for this step."*
**Spec:** `docs/superpowers/specs/2026-09-02-design-foundation-design.md`
**The diagnosis, which should shape the rest:** a design system already existed
and was never wired up. `src/index.css` defined the paper/ink/teal palette with
a contract document behind it, while the app painted shadcn's default white and
near-black. The tokens reached the screen in two places only. Doing the
foundation last is how that happened; do not repeat it.
| # | Sub-project | State |
|---|---|---|
| 1 | Design foundation — two-theme tokens, theme toggle, self-hosted Inter + JetBrains Mono, one Section/Stat card, table furniture | DONE (`bb3c661`) |
| 2 | Month view | date-grouped purchases DONE (`a159189`); income/totals block and the carry meter's presentation still open |
| 3 | Year, Summary, Post detail | on the new foundation but not rethought. The 12-month matrix only fits by scrolling — decide whether compact number formatting replaces that |
| 4 | Dialogs and fast entry | untouched. Native `<select>` elements are still browser-default, and `up`/`down`/`archive` are bare text buttons |
**Watch for:** the brand teal is `--primary`, never `--accent` — shadcn's
`--accent` is a hover *background* whose paired foreground must move with it.
`src/cssPairs.test.ts` enforces the pairing, and `src/ui/cssTokens.test.ts`
fails if a component names a token `index.css` does not define. That second
guard exists because renaming `--rule` silently made the carry meter paint
nothing: an undefined custom property inside `linear-gradient()` invalidates
the whole declaration without warning.
## 4. ~~Smaller items~~ — ALL DONE
All six, `b9be882` / `71e8fc9` / `f50c65b`. Three were worse than recorded:
- `confirmImport` now guards its write, and a source-level check covers the
whole UI (`b9be882`).
- The PNG icons **were** generatable here — Chrome is on PATH and rasterizes
the SVG exactly. `scripts/make-icons.ts` produces them; they are committed,
precached, and pixel-verified against the source (`f50c65b`).
- Split values now round by what they MEAN — money in "fixed" mode, a
percentage in "percent" mode. Narrower than the note claimed: `round2` was
always right for `Rule.percent`, which is not money (`b9be882`).
- `EMPTY_FIGURES` is frozen (`b9be882`).
- The ODS export was **not** "fine at personal-budget scale": 30 posts over 10
years took 7983 ms synchronously on the main thread. Now 135 ms, with
byte-identical output (`71e8fc9`).
- The dev manifest did not merely hardcode `scope: "/"` — it was never served
at all; the catch-all answered with the HTML shell. One definition now feeds
the build and the dev server (`b9be882`).
## 6. ~~User-defined currencies, and amounts that carry their currency~~ — DONE
Both `cdece63`. `Currency` is an open string code and `Dataset.currencies` is
the authority on validity, symbols and decimal places; the purchase total
field reads "30$" as USD 30.
**The part worth knowing before touching money again:** `roundMoney(amount,
digits)` takes a digit count, and its ~29 call sites each resolve digits from
the dataset with `digitsFor`. The argument is required deliberately — every
currency the app shipped with uses two decimals, so a default of 2 would leave
a missed call site passing every test and silently wrong for the first
zero-decimal currency added. `src/domain/currencyDigits.test.ts` is the only
test that can tell a real implementation from one that assumes 2: it drives a
JPY-style currency through rounding, both FX directions, both split modes,
plan division, the fold and the month view.
Left undone, deliberately:
- **The income field and the split editor's fixed-amount fields do not parse a
currency yet** — only the purchase total does. Same helper
(`parseMoneyInput`), same three-line pattern; the income field is arguably
wrong to make multi-currency at all, since a month has one income.
- **No currency picker convenience.** Adding one means typing the code, name,
symbol and decimals by hand. A bundled ISO 4217 list would be nicer, and is
pure data.
- **Symbol collisions resolve to the first currency defined with that symbol.**
Fine for one "$" currency; if the owner adds two, typing the code
disambiguates and nothing warns them. Tested, documented, not solved.
## 5. Still needs a human
`scripts/screenshot.ts` drives headless Chrome, so the list here is much
shorter than it was. **Seen and confirmed working:** the carry meter (it
renders, and shows red where a post is overspent), the rule-history editor in
Settings, the "change from here" control, the baked FX rates arriving in a
fresh dataset, and every route at desktop and mobile widths.
Genuinely outstanding:
- **How it looks and feels.** A screenshot proves a layout is not broken. It
cannot tell you the design is good, and the container's fonts are not the
owner's fonts.
- **Offline behaviour** and the **install prompt**. The service worker itself
is no longer unverified: it demonstrably installs, activates, claims the
page, precaches all 11 assets, and applies an update cleanly (`e6da85e`) —
before that fix it had never installed once, in any environment, because
`cache.addAll` rejects on a duplicate request. What is still unverified is
a real offline load with the network actually cut, and Chrome's install
prompt appearing.
- the generated **`.ods` opening in a real spreadsheet** (validated
structurally: mimetype first and stored, well-formed XML, numeric cells).
- **keyboard flow in fast entry** — Tab traversal, Backspace-to-remove, and
focus placement after a row auto-appends. Drivable over CDP in principle;
not yet done.
- **GitHub Actions** has never been executed.
+21 -4
View File
@@ -74,6 +74,13 @@ Three deliberate properties in that chain:
- **Cloned.** `useSyncExternalStore` detects change by reference identity;
mutating in place makes the UI silently stop re-rendering.
The two whole-dataset writes take the same queue and the same rules:
`store.replace()` behind JSON import, and `store.reset()` behind Settings'
"Reset everything". `reset()` calls the same `createSeedDataset` that `load()`
uses on a first run, so "reset" and "a brand-new browser" cannot drift apart.
Both are preceded in the UI by a JSON backup download that must succeed before
the destructive write is attempted.
## Money
A float, deliberately, with two rules that make it safe:
@@ -119,6 +126,10 @@ Adding a "missing" guard here is a regression, not a fix:
## Gotchas paid for with real bugs
`AGENTS.md` §2 carries the enforceable one-line form of these, with the test
that fails when each reappears. What follows is the *mechanism* behind them,
which is the part worth reading once.
- **Never read `event.target.value` inside a `mutate()` callback.** `mutate`
defers behind the queue and a write; React resets a controlled input's DOM
value long before the callback runs, so you read the *old* value and commit
@@ -168,7 +179,13 @@ timing that happy-dom does not reproduce.
## Where the reasoning lives
- `PRODUCT.md` — durable product truth: who it is for, what must never break.
- `TODO.md` — queued work, with the reasoning behind each decision already made.
- `docs/superpowers/specs/` — design decisions and the alternatives rejected.
- `docs/superpowers/plans/` — task-by-task implementation plans.
- `AGENTS.md` — the rules, in enforceable one-line form, and which test fails
when one is broken. This file holds the reasoning; that one holds the rule.
- `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.
+151
View File
@@ -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 `<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`](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 `<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`](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
Regular → Executable
View File
Executable
+131
View File
@@ -0,0 +1,131 @@
# TODO
Work still queued, for whoever picks it up next, human or agent. Ordered. Each
item says enough to act on without re-deriving the reasoning.
Finished work is not listed here. It is in the commit messages, and the
decisions behind it are in `docs/specs/` — one spec per sub-project,
each recording the alternatives that were rejected. Rules that outlived their
task moved to `AGENTS.md`.
**Current state:** 443 tests passing, `bunx tsc --noEmit` clean, `bun run build`
succeeds on both the live-fetch and offline paths. The month view redesign and
the reset button are on branch `month-view-and-reset`, not yet merged to `main`.
**Schema is at version 3.** v1 -> v2 made allocation rules a dated series;
v2 -> v3 moved currencies into the dataset. Both migrations are
behaviour-preserving by construction and tested as such.
---
## 1. Visual redesign, sub-project 3 — Year, Summary, Post detail
The owner's brief for the whole redesign: *"right now it looks a bit boring,
simple and ugly. We want sleek, modern, and nice to use."* On scoping: both
themes with a toggle, a full rethink of every screen, purchases compact and
grouped by date, ship a webfont — and *"override everything you want for this
step."*
Sub-projects 1 (design foundation, `bb3c661`) and 2 (month view, `7b71811`) are
done, each with a spec in `docs/specs/`. These three screens are on
the new foundation but have not been rethought. The open question is the
12-month matrix: it only fits by scrolling, and compact number formatting may be
what replaces that rather than a scroll container.
**The diagnosis that shaped the decomposition, which still applies:** a design
system already existed and was never wired up. `src/index.css` defined the
paper/ink/teal palette with a contract document behind it, while the app painted
shadcn's default white and near-black; the tokens reached the screen in two
places only. Doing the foundation last is how that happened. Do not repeat it —
extend the foundation rather than styling a screen locally.
**Three things sub-project 2 settled that these screens should inherit rather
than re-decide** (spec: `2026-09-02-month-view-design.md`):
- **Meter geometry lives in `src/ui/meterSegments.ts`**, pure and tested, with
`<Meter>` as the only thing that paints it. A third meter belongs there too,
not inline in a component. Negatives clamp rather than invert, because a
segment of negative width puts the gradient's stops out of order and paints
something arbitrary instead of failing.
- **Two markups, not one reflowing grid.** The post table is a real `<table>`
from `sm:` up and a list of two-line blocks below it. At 390px the layout
genuinely stops being a table, and `role="table"` on it would describe rows
and columns that no longer exist on screen. The year matrix faces the same
question with more columns to lose.
- **State the currency once per table, not per cell.** Every figure in the post
table is base currency, so `formatSignedAmount` prints bare numbers and the
legend names DKK once. This is what stopped "+4,219.61 DKK" wrapping on a
phone. `formatMoney` is still right wherever an amount can be in some other
currency — every purchase row, for one.
## 2. Visual redesign, sub-project 4 — dialogs and fast entry
Untouched. Native `<select>` elements are still browser-default, and
`up` / `down` / `archive` in Settings are bare text buttons. Covers the purchase
dialog, the split editor, the plan editor and bulk entry.
There is already a spec for fast-entry *behaviour* —
`2026-09-02-fast-entry-refinements-design.md`. Read it before restyling, so the
visual work does not fight it.
## 3. Currency work deliberately left undone
All three were decided against at the time, not forgotten. Reopen only with a
reason.
- **The income field and the split editor's fixed-amount fields do not parse a
currency** — only the purchase total does. Same helper (`parseMoneyInput`),
same three-line pattern. The income field is arguably wrong to make
multi-currency at all, since a month has one income.
- **No currency picker convenience.** Adding a currency means typing the code,
name, symbol and decimals by hand. A bundled ISO 4217 list would be nicer, and
is pure data.
- **Symbol collisions resolve to the first currency defined with that symbol.**
Fine for one "$" currency; if the owner adds two, typing the code
disambiguates and nothing warns them. Tested, documented, not solved.
## 4. Nobody has independently reviewed the rule-history editing surfaces
Tasks 3-6 of the versioned-allocation-rules work — `setRuleFrom` /
`removeRuleFrom`, the JSON import validation, the Settings rule-history editor,
and the month view's "change from here" — were executed by hand rather than
dispatched, and no second pair of eyes has been over the diff.
Worth reading first: the task log in
`.superpowers/sdd/2026-09-02-versioned-allocation-rules/` records every deviation
from the original briefs with its reasoning. Four of them fixed real defects in
the brief code — JSX that would not compile, a component that pre-filled the
previous post's rule, a number field that could not be cleared, and missing
validation that let a NaN allocation into the fold.
Both surfaces have since been seen working in a browser. What is missing is
review, not verification.
## 5. Still needs a human
`scripts/screenshot.ts` drives headless Chrome, so this list is much shorter
than it was. Already seen and confirmed working: the carry meter (including red
where a post is overspent), the rule-history editor, "change from here", the
baked FX rates arriving in a fresh dataset, the reset writing a fresh dataset to
IndexedDB, and every route at desktop and mobile widths in both themes.
Genuinely outstanding:
- **How it looks and feels.** A screenshot proves a layout is not broken. It
cannot tell you the design is good, and the container's fonts are not the
owner's fonts.
- **Offline behaviour** and the **install prompt**. The service worker itself is
no longer unverified: it demonstrably installs, activates, claims the page,
precaches all 11 assets, and applies an update cleanly (`e6da85e`). What is
still unverified is a real offline load with the network actually cut, and
Chrome's install prompt appearing.
- **The pre-destructive backup actually landing as a file.** Import and reset
both download a JSON backup before replacing the dataset, and only proceed
when that reports success — but headless Chrome's download is not observable
here, so the backup is *issued*, never confirmed openable.
- **The generated `.ods` opening in a real spreadsheet.** Validated
structurally: mimetype first and stored, well-formed XML, numeric cells.
- **Keyboard flow in fast entry** — Tab traversal, Backspace-to-remove, and
focus placement after a row auto-appends. Drivable over CDP in principle; not
yet done.
- **GitHub Actions** has never been executed.
@@ -15,7 +15,7 @@
**Tech Stack:** Bun 1.3+ (runtime, package manager, bundler, test runner), React 19, Tailwind 4, shadcn/ui, `fflate` for ODS zip generation, hand-written service worker. No Vite. No Electron.
**Spec:** `docs/superpowers/specs/2026-09-01-budget-app-design.md`
**Spec:** `docs/specs/2026-09-01-budget-app-design.md`
## Global Constraints
@@ -72,7 +72,7 @@ Tests are co-located: `src/domain/money.test.ts` beside `src/domain/money.ts`.
**Files:**
- Create: `package.json`, `tsconfig.json`, `bunfig.toml`, `build.ts`, `src/index.ts`, `src/index.html`, `src/frontend.tsx`, `src/index.css` (all via `bun init`)
- Modify: `CLAUDE.md` (append project invariants), `.gitignore`
- Modify: `AGENTS.md` (append project invariants), `.gitignore`
- Delete: `src/APITester.tsx`, `src/logo.svg`, `src/react.svg`
- Test: `src/domain/smoke.test.ts`
@@ -158,9 +158,9 @@ Expected: PASS, 1 test.
Run: `bun --hot src/index.ts`
Expected: server starts, and the printed URL shows "Budget 2.0". Stop it with Ctrl-C.
- [ ] **Step 7: Append project invariants to CLAUDE.md**
- [ ] **Step 7: Append project invariants to AGENTS.md**
The scaffolded `CLAUDE.md` documents Bun idioms. Append the rules specific to this project:
The scaffolded `AGENTS.md` documents Bun idioms. Append the rules specific to this project:
```markdown
## Project invariants (Budget 2.0)
@@ -177,7 +177,7 @@ The scaffolded `CLAUDE.md` documents Bun idioms. Append the rules specific to th
reimplement the math.
- Over-budget is always allowed. Never add validation that blocks it.
- Base path comes from `BUN_PUBLIC_BASE_PATH`; never hardcode a subpath.
- Read the spec at `docs/superpowers/specs/2026-09-01-budget-app-design.md`.
- Read the spec at `docs/specs/2026-09-01-budget-app-design.md`.
```
- [ ] **Step 8: Ignore the IDE directory**
@@ -196,7 +196,7 @@ git add -A
git commit -m "chore: scaffold Bun + React + shadcn project
bun init --react=shadcn, with the demo API routes removed since this app
has no backend. Adds project invariants to CLAUDE.md and a smoke test
has no backend. Adds project invariants to AGENTS.md and a smoke test
proving the domain layer imports with no DOM."
```
@@ -253,7 +253,7 @@ export const CURRENCY_DIGITS: Record<Currency, number> = {
EUR: 2,
};
/** A float amount paired with its currency. See CLAUDE.md for rounding rules. */
/** A float amount paired with its currency. See AGENTS.md for rounding rules. */
export interface Money {
amount: number;
currency: Currency;
@@ -7357,8 +7357,8 @@ your current data first.
## Documentation
- Design: `docs/superpowers/specs/2026-09-01-budget-app-design.md`
- Implementation plan: `docs/superpowers/plans/2026-09-01-budget-app.md`
- Design: `docs/specs/2026-09-01-budget-app-design.md`
- Implementation plan: `docs/plans/2026-09-01-budget-app.md`
```
- [ ] **Step 7: Final verification**
@@ -8,7 +8,7 @@
**Tech Stack:** Bun (runtime, package manager, test runner), TypeScript strict, React 19, Tailwind 4, shadcn/ui. No new dependencies.
**Spec:** `docs/superpowers/specs/2026-09-02-versioned-allocation-rules-design.md`
**Spec:** `docs/specs/2026-09-02-versioned-allocation-rules-design.md`
## Global Constraints
@@ -310,10 +310,10 @@ migrations are mandatory rather than optional.
## Agent support files
- `CLAUDE.md` — the `bun init` scaffold provides Bun idioms ("use `bun test`",
- `AGENTS.md` — the `bun init` scaffold provides Bun idioms ("use `bun test`",
"don't use vite", HTML imports). This project extends it with its own
invariants: the domain layer stays free of React and IndexedDB, money
rounds at every boundary, the last distributed part is computed by
subtraction, and the rollover fold has exactly one implementation.
- `docs/superpowers/specs/` — this document
- `docs/superpowers/plans/` — the implementation plan, written next
- `docs/specs/` — this document
- `docs/plans/` — the implementation plan, written next
@@ -48,7 +48,7 @@ colours app components may name.
### The brand teal is `--primary`, never `--accent`
CLAUDE.md's hardest-won rule is "never define a bare `--accent`", paid for with
AGENTS.md's hardest-won rule is "never define a bare `--accent`", paid for with
an app-wide contrast bug. The mechanism matters: shadcn uses `--accent` as a
hover **background** with `--accent-foreground` as the text on it. Setting one
without the other repaints the background and leaves the text behind.
@@ -76,7 +76,7 @@ rather than on the arithmetic.
Inter for text, JetBrains Mono for figures, both variable, both self-hosted,
latin + latin-ext only. SIL OFL 1.1; licences committed beside the files.
Self-hosted rather than CDN-linked because PRODUCT.md's position is that the
Self-hosted rather than CDN-linked because docs/PRODUCT.md's position is that the
data never leaves the browser and the app needs no third party — a font request
to another origin on every cold load quietly contradicts that.
+177
View File
@@ -0,0 +1,177 @@
# Month view
Sub-project 2 of the visual redesign, decomposed in
`2026-09-02-design-foundation-design.md`: *"Month view — income and totals,
post rows and carry meter, purchases grouped by date."* Date-grouped purchases
shipped ahead of the rest (`a159189`); this spec covers what was left open.
The owner's standing instruction for the redesign still applies — *"override
everything you want for this step. This needs to look good."* — so the three
forks below were put to them as concrete alternatives rather than as open
questions. All three were answered with the recommended option.
## The diagnosis
Three problems, found by looking at the rendered page in Chrome at 1280px and
390px rather than by reading the code.
**The totals block was four peer figures with no relationship between them.**
Income, Allocated, Spent and Unallocated sat in one row at one weight. The
income *input* — the only editable thing on the screen — was in the same visual
register as three numbers derived from it. And nothing at the month level
answered the second of docs/PRODUCT.md's two questions, *did I overspend*: with
Transport at −3,200 DKK, you had to read down the Remaining column to find out.
**The carry meter belonged to the wrong row.** It is the design contract's
signature element, and it was rendering 21px below its own row and 22px above
the next one — visually a divider, not a property of a post. There was also no
legend anywhere, so its four segments were unreadable to anyone who had not
read `PostTable.tsx`.
**Phone width was broken, and nothing recorded it.** The five-column table
overflowed its container at 390px: *Spent* and *Remaining*, the two columns
that answer the app's two questions, were off-screen behind a horizontal
scroll, and every figure wrapped its currency onto a second line
("+4,219.61 / DKK"). This is the width at which a grocery trip actually gets
entered.
## The month header
Three zones in reading order, in the one `Section` card, on the paper ground
below the unchanged month `<header>`.
1. **Income** — the field. Unchanged in behaviour: still `type="number"`, still
base-currency only. Section 6 of docs/TODO.md records the reasoning — a month has
one income, so making the field parse a currency the way the purchase total
does is arguably wrong rather than merely undone. It keeps field affordance
so it stays visibly the one editable thing on the screen.
2. **Allocated and Unallocated**, with the allocation meter beneath them and
"N% of income allocated" beside it.
3. **Spent**, under a hairline, with the month-level overspend answer beside
it.
### The allocation meter scales to `max(income, allocated)`
Not to income. Over-budget is always allowed (AGENTS.md), and allocating past
income is the ordinary way that shows up here, so the meter has to render it
rather than cap at a full bar: the overflow past the income mark paints
`--overspend`, and the label reads past 100%. Capping would make
over-allocation look exactly like allocating income to the last krone.
Edge cases, all of them reachable:
- income ≤ 0 with allocation > 0 — no denominator, so the percentage is
omitted and the whole track is overflow.
- both zero — an empty track, no percentage.
- allocation ≤ 0 — an empty track; a fixed rule may be negative, and no
percentage can be below zero-length.
### "1 post overspent" is text, not a link
The Posts table is ~40px below this line. A control that scrolls the page by
less than a viewport is noise, so the line states the answer and stops.
It renders in both directions — *"no posts overspent"* in muted ink, *"1 post
overspent"* in `--overspend` — because a line that appears and disappears
shifts the layout of the block above it, and because "no" is a real answer to
the question rather than an absence of one.
The count is added to `MonthViewModel` as `overspentCount` rather than derived
in the component. It is a count over rows the fold already produced, and
putting it in the domain means it is tested where the rest of the month's
arithmetic is tested, with the UI left dumb.
## Post rows: two markups, one per structure
Below `sm:` each post becomes a two-line block — name and Remaining large on
the first line, the meter full width, then carried / allocated / spent as one
small caption line. Nothing is hidden and nothing scrolls sideways.
This is implemented as two sibling markups, `hidden sm:table` and `sm:hidden`,
not as one reflowing grid.
**Rejected: a single CSS grid carrying `role="table"` and friends.** It
deduplicates the markup, which is the whole of its appeal. But at 390px the
layout genuinely is not a table any more — it is a list of two-line blocks —
and leaving table roles on it would describe a grid of rows and columns to a
screen reader that no longer exists visually. Keeping a real `<table>` at the
width where it is a table, and a real list at the width where it is a list,
means each structure is honest about itself. The cost is that the figures
appear twice in the DOM at any one width; at six to thirty posts that is
nothing, and both markups read their cells from one shared `postRowCells`
helper, so the figures are still derived in exactly one place.
### The currency moves to the header
Every figure in this table is in the dataset's base currency, so repeating the
code on all thirty cells buys nothing and costs the line wrap that made the
phone layout unreadable. The `Section`'s hint carries "All figures in DKK"
once; the cells print `formatAmount`.
This is deliberately not a change to `formatMoney`, which is still right
everywhere an amount can be in some other currency — every purchase row, for
one, where a 27.50 EUR line sits among DKK.
## The carry meter becomes the row divider
One `<tbody>` per post, holding both the figures row and the meter row.
Multiple `<tbody>` elements in one table are valid, and this is what makes
`group-hover` tint a post's figures and its meter as one block; two loose
`<tr>` siblings cannot express that relationship at all.
The meter then sits tight under its own row with that row's hairline removed,
so the meter *is* the divider, and the whitespace moves below it. 3px to 4px.
The ambiguity was never about the meter's design — it was that the gaps above
and below it were within a pixel of each other.
A legend sits beside the hint, once for the table: four swatches, carried in /
spent / unspent / over. The meter itself stays `aria-hidden`; the four figures
beside it carry the same information as text, and a screen reader reading a
gradient twice is worse than not reading it.
Note what the meter still does not show: a *negative* carried-in balance. The
surplus segment is `max(carriedIn, 0)`, so Transport carrying −1,600 forward
shows a meter that starts at the allocation, and only the red Carried-in figure
says why the post is behind. That is existing, documented behaviour and this
sub-project does not change it.
## Where the code goes
The two meters are the same idea rendered at two scales, so the math comes out
of the component into `src/ui/meterSegments.ts` — pure, no DOM:
- `carryMeterSegments(figures)` — the four rollover segments.
- `allocationMeterSegments(income, allocated)` — the header's two or three.
- `gradientFor(segments)` — the hard-stopped `linear-gradient` string.
A thin `<Meter segments>` renders it. The token names stay in TypeScript
source, which is what keeps `src/ui/cssTokens.test.ts` guarding them — that
test exists because renaming `--rule` silently made this exact element paint
nothing, and moving the gradient into a `.css` file would have moved it out of
the guard's reach.
## Testing
- `src/ui/meterSegments.test.ts` (new): zero scale, negative carried-in,
charges past allocation, over-allocation, income 0 against allocation > 0,
and the two invariants that hold for every input — stops ascend, and every
stop lands within [0, 100].
- `src/domain/views.test.ts`: `overspentCount`, including that a post at
exactly zero is not counted.
- `src/cssPairs.test.ts` and `src/ui/cssTokens.test.ts` keep guarding the
tokens; no new pair is introduced.
- Both themes at 1280px and 390px through `scripts/screenshot.ts`.
## Not changed
No new validation anywhere: over-allocation still renders, negative figures
still render, and nothing here can block a write. `src/domain/` changes only by
gaining the count. The purchases list, the rule-from-month editor and the month
`<header>` are untouched.
## Verification limits
A screenshot proves the layout exists and is not broken. It cannot say the
month view now reads well, and this container's fonts are not the owner's
fonts. Whether the header answers the two questions faster than four peer
figures did is a judgement only the owner can make.
Regular → Executable
+2 -2
View File
@@ -11,7 +11,7 @@
* profile on every run unless you pass --profile, so without this every
* screenshot shows an app full of zeros.
*
* The data is visibly synthetic on purpose. PRODUCT.md: the owner's real budget
* The data is visibly synthetic on purpose. docs/PRODUCT.md: the owner's real budget
* never leaves their browser, so it is never available as example material, and
* illustrative data must not pretend to be real.
*/
@@ -19,7 +19,7 @@ import { createSeedDataset } from "../src/domain/seed.ts";
import * as actions from "../src/store/actions.ts";
import { FALLBACK_FX_RATES } from "../src/store/bakedRates.ts";
// Visibly synthetic, per PRODUCT.md: the owner's real budget must never be
// Visibly synthetic, per docs/PRODUCT.md: the owner's real budget must never be
// used as example material, and illustrative data must not look real.
const d = createSeedDataset("2026-01", FALLBACK_FX_RATES);
const [games, food, social] = d.posts as [typeof d.posts[0], typeof d.posts[0], typeof d.posts[0]];
Regular → Executable
+1 -1
View File
@@ -3,7 +3,7 @@
*
* bun scripts/fetch-fonts.ts
*
* Self-hosted, not linked from a CDN, because PRODUCT.md's position is that the
* Self-hosted, not linked from a CDN, because docs/PRODUCT.md's position is that the
* app works offline and needs no third party: a font request to another origin
* on every cold load contradicts that, and would leak the fact that the app was
* opened. The files are COMMITTED so neither the build nor a fresh checkout
Regular → Executable
+6 -5
View File
@@ -7,11 +7,12 @@
* [--eval=<js>|--eval-file=<path>] [--dark] [--reload]
* [--profile=<dir>] [--eval-after-file=<path>]
*
* Why this exists: ARCHITECTURE.md and TODO.md both say nothing here can run a
* browser, so appearance has never been verified. Chrome IS available, but its
* plain `--screenshot` flag fires on the load event — before this app's async
* IndexedDB read resolves, which captures an empty page. Driving CDP directly
* lets us wait, act, and read console errors.
* Why this exists: the docs used to say nothing here could run a browser, so
* appearance went unverified for a long time. Chrome IS available — but its
* plain `--screenshot` flag fires on the load event, before this app's async
* IndexedDB read resolves, so it captures an empty page, which is how that
* belief survived. Driving CDP directly lets us wait, act, and read console
* errors.
*
* This does NOT replace a human: it cannot judge feel, and it renders with one
* font stack on one platform. It does prove a layout exists and is not broken.
Regular → Executable
+1 -1
View File
@@ -1,7 +1,7 @@
import { test, expect, describe } from "bun:test";
/**
* CLAUDE.md's hardest-won rule, made mechanical.
* AGENTS.md's hardest-won rule, made mechanical.
*
* shadcn uses several tokens as a BACKGROUND with a paired FOREGROUND for the
* text on it. Redefining one without the other repaints the background and
+118
View File
@@ -0,0 +1,118 @@
import { test, expect } from "bun:test";
import { Glob } from "bun";
import { dirname, join, normalize } from "node:path";
/**
* Every repository path named in the documentation must exist.
*
* This exists because moving the docs into `docs/` rewrote about thirty
* cross-references at once. A reference left pointing at nothing does not fail
* a build, does not fail a test, and does not look wrong in review — it is only
* discovered by the next person who follows it and finds nothing there. The
* same silence is why `eventCapture.test.ts` and `cssTokens.test.ts` exist.
*
* Two kinds of reference are checked: markdown links, and backticked strings
* that look like paths. A candidate passes if it resolves either relative to
* the file naming it or from the repository root, because both conventions are
* in use and both are unambiguous to a reader.
*
* Scope is the docs a reader NAVIGATES — the entry points and `docs/*.md`. It
* deliberately excludes `docs/specs/` and `docs/plans/`, which are dated
* records of what was decided and built at a moment in time: a plan naming
* `src/App.tsx` is describing scaffolding it went on to delete, and holding
* history to today's tree would force either rewriting the past or deleting the
* guard.
*
* Also skipped: fenced code blocks (shell recipes, `/tmp` paths, URLs),
* absolute paths, and backticked strings starting with `./` or `../`, which are
* import specifiers relative to some source file rather than repository paths.
*/
/** Paths named deliberately BECAUSE they do not exist. */
const EXPECTED_ABSENT = new Map([
[
"docs/superpowers/specs/",
"AGENTS.md §4 names it as the tooling default to override; it was removed on purpose.",
],
]);
function stripCodeBlocks(markdown: string): string {
return markdown.replace(/^```[\s\S]*?^```/gm, "");
}
/** A candidate is a repo path only if it could not be prose or an identifier. */
function looksLikeRepoPath(candidate: string): boolean {
if (!candidate.includes("/")) return false; // `sw.js`, `Post.rules`
if (/[*<>{}|\s]/.test(candidate)) return false; // globs and placeholders
if (/^[a-z]+:/.test(candidate)) return false; // http:, mailto:
if (candidate.startsWith("#")) return false; // in-page anchor
if (candidate.startsWith("/")) return false; // /tmp/shot.png, a URL base path
return candidate.endsWith("/") || /\.[a-z0-9]+$/i.test(candidate);
}
function candidatesIn(source: string): string[] {
const body = stripCodeBlocks(source);
const found = new Set<string>();
for (const match of body.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
const target = match[1]!.split("#")[0]!.trim();
if (target && looksLikeRepoPath(target)) found.add(target);
}
for (const match of body.matchAll(/`([^`\n]+)`/g)) {
const inner = match[1]!.trim();
// `../domain/money.ts` in prose is an import specifier, not a repo path.
if (inner.startsWith("./") || inner.startsWith("../")) continue;
if (looksLikeRepoPath(inner)) found.add(inner);
}
return [...found];
}
async function exists(path: string): Promise<boolean> {
const clean = path.replace(/\/$/, "");
if (await Bun.file(clean).exists()) return true;
// Bun.file().exists() is false for a directory, so probe it as one.
try {
return [...new Glob("*").scanSync({ cwd: clean, onlyFiles: false })].length >= 0;
} catch {
return false;
}
}
test("every repository path named in the documentation exists", async () => {
const files = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "README.md"];
for await (const file of new Glob("docs/*.md").scan(".")) files.push(file);
const broken: string[] = [];
for (const file of files) {
const source = await Bun.file(file).text();
for (const candidate of candidatesIn(source)) {
if (EXPECTED_ABSENT.has(candidate)) continue;
const fromFile = normalize(join(dirname(file), candidate));
if ((await exists(fromFile)) || (await exists(candidate))) continue;
broken.push(`${file} names ${candidate}`);
}
}
expect(broken).toEqual([]);
});
test("the guard resolves both path conventions and rejects a dead one", async () => {
// Root-relative and file-relative both count, so the check cannot be passed
// by accident and cannot be tripped by a convention it does not like.
expect(await exists("docs/specs")).toBe(true);
expect(await exists("src/domain/fold.ts")).toBe(true);
expect(await exists("docs/superpowers/specs")).toBe(false);
});
test("prose, identifiers and non-repository paths are not mistaken for paths", () => {
expect(looksLikeRepoPath("/tmp/shot.png")).toBe(false);
expect(looksLikeRepoPath("/budget2.0/")).toBe(false);
expect(looksLikeRepoPath("Post.rules")).toBe(false);
expect(looksLikeRepoPath("sw.js")).toBe(false);
expect(looksLikeRepoPath("src/**/*.{ts,tsx}")).toBe(false);
expect(looksLikeRepoPath("docs/specs/YYYY-MM-DD-<topic>.md")).toBe(false);
expect(looksLikeRepoPath("http://localhost:3000/month/2026-09")).toBe(false);
expect(looksLikeRepoPath("src/domain/money.ts")).toBe(true);
expect(looksLikeRepoPath("docs/specs/")).toBe(true);
});
Regular → Executable
+1 -1
View File
@@ -33,7 +33,7 @@ export const SEED_CURRENCIES: readonly CurrencyDef[] = [
/** Used when a currency has no definition. Two places is right almost always. */
export const DEFAULT_CURRENCY_DIGITS = 2;
/** A float amount paired with its currency. See CLAUDE.md for rounding rules. */
/** A float amount paired with its currency. See AGENTS.md for rounding rules. */
export interface Money {
amount: number;
currency: Currency;
+23
View File
@@ -75,6 +75,29 @@ describe("monthView", () => {
expect(monthView(greedy, "2026-01").unallocated).toBe(-2000);
});
test("counts the posts that are overspent, so the month can say so at a glance", () => {
expect(monthView(data, "2026-01").overspentCount).toBe(0);
// food is allocated 2000 and spent 3000: one post behind.
const over: Dataset = {
...data,
purchases: [spend("a", "food", 3000, "2026-01-10")],
};
expect(monthView(over, "2026-01").overspentCount).toBe(1);
});
test("a post at exactly zero remaining is not overspent", () => {
// Rent-shaped: allocated 2000, spent 2000. Nothing left is not the same
// as owing something, and the header must not claim otherwise.
const exact: Dataset = {
...data,
purchases: [spend("a", "food", 2000, "2026-01-10")],
};
const view = monthView(exact, "2026-01");
expect(view.rows.find((r) => r.post.id === "food")!.figures.remaining).toBe(0);
expect(view.overspentCount).toBe(0);
});
test("archived posts appear only when they have activity", () => {
const withArchived: Dataset = {
...data,
+8
View File
@@ -18,6 +18,13 @@ export interface MonthViewModel {
totalCharges: number;
/** income - totalAllocation. Negative when allocations exceed income. */
unallocated: number;
/**
* How many rows are overspent — `remaining < 0`, so a post at exactly zero
* does not count. Computed here rather than in the component because it is
* the month-level answer to "did I overspend", and it belongs where the
* rest of the month's arithmetic is tested.
*/
overspentCount: number;
rows: MonthPostRow[];
}
@@ -61,6 +68,7 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
totalAllocation,
totalCharges,
unallocated: roundMoney(income - totalAllocation, baseDigits),
overspentCount: rows.filter((r) => r.figures.remaining < 0).length,
rows,
};
}
Regular → Executable
+1 -1
View File
@@ -25,7 +25,7 @@
* than fighting it means every primitive (dialog, select, badge, button)
* inherits both themes for free.
*
* WHY THE PAIRS MATTER. CLAUDE.md's rule is "never define a bare `--accent`",
* WHY THE PAIRS MATTER. AGENTS.md's rule is "never define a bare `--accent`",
* and it was paid for with an app-wide contrast bug: shadcn uses `--accent` as
* a hover BACKGROUND and `--accent-foreground` as the text on it, so setting
* one without the other repaints the background and leaves the text behind.
Regular → Executable
+1 -1
View File
@@ -25,7 +25,7 @@ import type {
* store.mutate(), which clones before applying and writes through afterwards.
*
* Every MONEY amount that flows through here is rounded to its currency's
* minor unit (via roundMoney) before it lands in the draft, per CLAUDE.md's
* minor unit (via roundMoney) before it lands in the draft, per AGENTS.md's
* "round before persisting" rule. FxRate.baseUnitsPerOne is deliberately
* exempted in setFxRate below — an exchange rate is not money, and rounding
* it to cents would be wrong in kind (see the comment there).
+81 -1
View File
@@ -1,7 +1,7 @@
import { test, expect, describe } from "bun:test";
import { createSnapshotStore, type Persistence } from "./snapshot.ts";
import { createSeedDataset } from "../domain/seed.ts";
import type { Dataset } from "../domain/types.ts";
import type { Dataset, FxRate } from "../domain/types.ts";
function fakePersistence(initial: Dataset | null = null) {
const writes: Dataset[] = [];
@@ -163,6 +163,86 @@ test("replace swaps the whole dataset, as JSON import needs", async () => {
expect(fake.stored!.settings.foldStartMonth).toBe("2020-01");
});
describe("reset", () => {
test("puts back exactly what a first run would have produced", async () => {
const fake = fakePersistence(null);
const rates: FxRate[] = [
{ currency: "USD", baseUnitsPerOne: 6.449532, updatedAt: "2026-09-01", source: "manual" },
];
const store = createSnapshotStore(fake.persistence, "2026-09", rates);
await store.load();
// Diverge from the seed in every direction a reset has to undo: an added
// currency, a renamed post, recorded income and a purchase.
await store.mutate((draft) => {
draft.currencies.push({ code: "JPY", digits: 0, symbol: "\u00a5", name: "Japanese yen" });
draft.posts[0]!.name = "Renamed";
draft.posts.push({
id: "extra",
name: "Extra",
order: 9,
archived: false,
currency: "DKK",
rules: [],
});
draft.months[0]!.income = { amount: 28000, currency: "DKK" };
draft.purchases.push({
id: "p1",
date: "2026-09-04",
description: "Groceries",
total: { amount: 100, currency: "DKK" },
splitMode: "percent",
splits: [{ postId: draft.posts[0]!.id, value: 100, absorbsRemainder: true }],
schedule: null,
});
});
let notifications = 0;
store.subscribe(() => notifications++);
await store.reset();
const after = store.get();
// Post IDs are freshly generated, so compare on everything else.
expect(after.posts.map((p) => p.name)).toEqual([
"Video Games",
"Food",
"Events and Social",
]);
expect(after.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]);
expect(after.fxRates).toEqual(rates);
expect(after.purchases).toEqual([]);
expect(after.months).toHaveLength(1);
expect(after.months[0]!.income.amount).toBe(0);
expect(notifications).toBe(1);
});
test("persists, so the reset survives a reload", async () => {
const fake = fakePersistence(null);
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
await store.mutate((draft) => {
draft.posts.length = 0;
});
await store.reset();
expect(fake.stored!.posts).toHaveLength(3);
});
test("leaves the snapshot untouched when the write fails", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
await store.mutate((draft) => {
draft.posts[0]!.name = "Renamed";
});
fake.persistence.write = async () => {
throw new Error("quota exceeded");
};
await expect(store.reset()).rejects.toThrow("quota exceeded");
expect(store.get().posts[0]!.name).toBe("Renamed");
});
});
test("unsubscribe stops notifications", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
+14
View File
@@ -13,6 +13,7 @@ export interface SnapshotStore {
get(): Dataset;
mutate(fn: (draft: Dataset) => void): Promise<void>;
replace(dataset: Dataset): Promise<void>;
reset(): Promise<void>;
subscribe(listener: Listener): () => void;
}
@@ -88,6 +89,19 @@ export function createSnapshotStore(
return enqueue(() => commit(dataset));
},
/**
* Back to first-run state: the seed posts, the seed currency table and the
* seed rates this store was built with. Seeding lives here rather than in
* the UI so "reset" and "a brand-new browser" cannot drift apart — they
* are the same call.
*
* Goes through the queue like every other write, so a reset issued while a
* mutation is still in flight cannot be overtaken by it.
*/
reset() {
return enqueue(() => commit(createSeedDataset(currentMonth, seedRates)));
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
+25
View File
@@ -0,0 +1,25 @@
import { gradientFor, type MeterSegment } from "../meterSegments.ts";
/**
* Draws a segment list as one hard-stopped gradient. Geometry and edge cases
* live in src/ui/meterSegments.ts; this is only the paint.
*
* `aria-hidden` because every meter in the app sits beside the same numbers
* as text. A screen reader announcing a gradient it cannot describe is worse
* than one skipping a decoration of figures it has already read.
*/
export function Meter({
segments,
className = "",
}: {
segments: MeterSegment[];
className?: string;
}) {
return (
<div
aria-hidden="true"
className={`w-full ${className}`}
style={{ background: gradientFor(segments) }}
/>
);
}
+235 -127
View File
@@ -1,157 +1,265 @@
import { Fragment } from "react";
import { Link } from "react-router";
import { Badge } from "@/components/ui/badge";
import { formatMoney, formatSignedMoney } from "../format.ts";
import { formatAmount, formatSignedAmount } from "../format.ts";
import { ruleAt } from "../../domain/allocation.ts";
import { carryMeterSegments, type MeterSegment } from "../meterSegments.ts";
import { Meter } from "./Meter.tsx";
import type { MonthPostRow } from "../../domain/views.ts";
import type { PostMonthFigures } from "../../domain/fold.ts";
import type { Currency, MonthId } from "../../domain/types.ts";
interface Props {
monthId: MonthId;
baseCurrency: Currency;
rows: MonthPostRow[];
/** Omitted on read-only mounts; only the month view offers rule editing. */
onChangeRule?: (postId: string) => void;
}
/**
* Carry meter: the design contract's signature element. A 3px rule beneath
* each post row, encoding the rollover fold's four numbers as a single
* hard-stopped gradient so envelope rollover is visible without reading
* digits.
* The month's posts, in two markups: a real table from `sm:` up, a list of
* two-line blocks below it.
*
* Segments, left to right, sized against a per-row scale of
* (carried-in surplus + max(allocation, charges)):
* - --surplus : carried-in surplus (omitted when carriedIn <= 0)
* - --budget-accent : spend within this month's allocation, filled from
* the track's left edge
* - --budget-rule : the unspent remainder of the allocation track
* - --overspend : spend beyond the allocation, past the track's end
* NOT one reflowing grid with `role="table"`. At 390px the layout genuinely
* stops being a table — it is a stack of two-line blocks — and leaving table
* roles on it would describe rows and columns to a screen reader that no
* longer exist on screen. Each structure is honest about itself instead. The
* figures appear twice in the DOM at any one width, which at six to thirty
* posts costs nothing, and both markups read their cells from `postRowCells`
* so the numbers are still derived in exactly one place.
*
* The currency is deliberately absent from every cell: it is stated once, in
* the legend, because every figure here is in the dataset's base currency.
* Repeating it bought nothing and wrapped "+4,219.61 DKK" onto two lines on a
* phone, which is the width a grocery trip is actually entered at.
*/
function carryMeterBackground(figures: PostMonthFigures): string {
const surplus = Math.max(figures.carriedIn, 0);
const scale = surplus + Math.max(figures.allocation, figures.charges);
interface RowCells {
carriedIn: string;
carriedInTone: string;
allocation: string;
charges: string;
remaining: string;
remainingTone: string;
segments: MeterSegment[];
}
if (scale <= 0) {
return "var(--budget-rule)";
}
const pSurplus = (surplus / scale) * 100;
const pFillEnd = pSurplus + (Math.min(figures.charges, figures.allocation) / scale) * 100;
const pTrackEnd = pSurplus + (figures.allocation / scale) * 100;
function postRowCells({ figures }: MonthPostRow): RowCells {
return {
carriedIn: formatSignedAmount(figures.carriedIn),
carriedInTone:
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
? "text-overspend"
: "",
allocation: formatAmount(figures.allocation),
charges: formatAmount(figures.charges),
remaining: formatSignedAmount(figures.remaining),
remainingTone: figures.remaining < 0 ? "text-overspend" : "",
segments: carryMeterSegments(figures),
};
}
/**
* The four colours of the carry meter, named once for the whole table. Sits in
* the Section's hint, so it renders inside a `<p>` — spans only, no divs.
*/
export function PostTableLegend({ baseCurrency }: { baseCurrency: Currency }) {
return (
`linear-gradient(to right, ` +
`var(--surplus) 0%, var(--surplus) ${pSurplus}%, ` +
`var(--budget-accent) ${pSurplus}%, var(--budget-accent) ${pFillEnd}%, ` +
`var(--budget-rule) ${pFillEnd}%, var(--budget-rule) ${pTrackEnd}%, ` +
`var(--overspend) ${pTrackEnd}%, var(--overspend) 100%)`
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span>All figures in {baseCurrency}.</span>
<Swatch token="--surplus" label="carried in" />
<Swatch token="--budget-accent" label="spent" />
<Swatch token="--budget-rule" label="unspent" />
<Swatch token="--overspend" label="over" />
</span>
);
}
function CarryMeter({ figures }: { figures: PostMonthFigures }) {
function Swatch({ token, label }: { token: string; label: string }) {
return (
<div
aria-hidden="true"
className="h-[3px] w-full"
style={{ background: carryMeterBackground(figures) }}
/>
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 rounded-sm"
style={{ background: `var(${token})` }}
/>
{label}
</span>
);
}
export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) {
function PostName({
row: { post, overridden },
monthId,
}: {
row: MonthPostRow;
monthId: MonthId;
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left">
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
<th className="py-2 font-medium">Post</th>
<th className="py-2 pl-6 text-right font-medium">Carried in</th>
<th className="py-2 pl-6 text-right font-medium">Allocated</th>
<th className="py-2 pl-6 text-right font-medium">Spent</th>
<th className="py-2 pl-6 text-right font-medium">Remaining</th>
</tr>
</thead>
<tbody>
{rows.map(({ post, figures, overridden }) => (
<Fragment key={post.id}>
<tr className="group transition-colors hover:bg-accent/60">
<td className="py-2.5">
<Link to={`/post/${post.id}/month/${monthId}`} className="hover:underline">
{post.name}
</Link>
{post.archived && (
<Badge variant="outline" className="ml-2">
archived
</Badge>
)}
{overridden && (
<Badge
variant="secondary"
className="ml-2"
title="This month overrides the rule otherwise in effect"
>
overridden
</Badge>
)}
{/* A month can carry BOTH badges: a version starting in a
month the user also overrode is unusual but legal, and
hiding either would obscure why the number is what it
is. The override still wins the allocation. */}
{ruleAt(post, monthId)?.from === monthId && (
<Badge
variant="outline"
className="ml-2"
title="This post's allocation rule changes from this month"
>
rule changes here
</Badge>
)}
{onChangeRule && (
<button
type="button"
className="ml-2 rounded text-xs text-budget-ink-muted underline decoration-dotted underline-offset-2 opacity-0 transition-opacity hover:text-budget-accent focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => onChangeRule(post.id)}
>
change from here
</button>
)}
</td>
<td
className={`font-money py-2.5 pl-6 text-right ${
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
? "text-overspend"
: ""
}`}
<>
<Link to={`/post/${post.id}/month/${monthId}`} className="hover:underline">
{post.name}
</Link>
{post.archived && (
<Badge variant="outline" className="ml-2">
archived
</Badge>
)}
{overridden && (
<Badge
variant="secondary"
className="ml-2"
title="This month overrides the rule otherwise in effect"
>
overridden
</Badge>
)}
{/* A month can carry BOTH badges: a version starting in a month the user
also overrode is unusual but legal, and hiding either would obscure
why the number is what it is. The override still wins. */}
{ruleAt(post, monthId)?.from === monthId && (
<Badge
variant="outline"
className="ml-2"
title="This post's allocation rule changes from this month"
>
rule changes here
</Badge>
)}
</>
);
}
/**
* Placed differently in each markup, which is why it is not part of PostName.
* In the table it is revealed on hover, beside the name. On a phone there is
* no hover, so it has to be permanently visible — and beside the name at that
* size it competes with the name itself, so it goes at the end of the quiet
* caption line instead.
*/
function ChangeRuleButton({
postId,
onChangeRule,
className = "",
}: {
postId: string;
onChangeRule: (postId: string) => void;
className?: string;
}) {
return (
<button
type="button"
className={`rounded text-budget-ink-muted underline decoration-dotted underline-offset-2 transition-opacity hover:text-budget-accent ${className}`}
onClick={() => onChangeRule(postId)}
>
change from here
</button>
);
}
export function PostTable({ monthId, rows, onChangeRule }: Props) {
return (
<>
<div className="hidden overflow-x-auto sm:block">
<table className="w-full text-sm">
<thead className="text-left">
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
<th className="py-2 font-medium">Post</th>
<th className="py-2 pl-6 text-right font-medium">Carried in</th>
<th className="py-2 pl-6 text-right font-medium">Allocated</th>
<th className="py-2 pl-6 text-right font-medium">Spent</th>
<th className="py-2 pl-6 text-right font-medium">Remaining</th>
</tr>
</thead>
{/* One tbody per post, holding the figures AND their meter. Multiple
tbodies are valid, and this is the only way to hover the pair as
one block — two loose sibling <tr>s cannot express that they
belong together, which is exactly how the meter came to look like
a divider between two rows rather than part of one. */}
{rows.map((row) => {
const cells = postRowCells(row);
return (
<tbody key={row.post.id} className="group">
<tr className="transition-colors group-hover:bg-accent/60">
<td className="pb-0 pt-3">
<PostName row={row} monthId={monthId} />
{onChangeRule && (
<ChangeRuleButton
postId={row.post.id}
onChangeRule={onChangeRule}
className="ml-2 text-xs opacity-0 focus-visible:opacity-100 group-hover:opacity-100"
/>
)}
</td>
<td
className={`font-money pb-0 pl-6 pt-3 text-right ${cells.carriedInTone}`}
>
{cells.carriedIn}
</td>
<td className="font-money pb-0 pl-6 pt-3 text-right">
{cells.allocation}
</td>
<td className="font-money pb-0 pl-6 pt-3 text-right">{cells.charges}</td>
<td
className={`font-money pb-0 pl-6 pt-3 text-right font-medium ${cells.remainingTone}`}
>
{cells.remaining}
</td>
</tr>
{/* The meter IS the row divider: tight under its own figures,
with the gap below it. */}
<tr className="transition-colors group-hover:bg-accent/60">
<td colSpan={5} className="px-0 pb-4 pt-1.5">
<Meter segments={cells.segments} className="h-1" />
</td>
</tr>
</tbody>
);
})}
</table>
</div>
<ul className="space-y-4 sm:hidden">
{rows.map((row) => {
const cells = postRowCells(row);
return (
<li key={row.post.id} className="space-y-1.5">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="min-w-0">
<PostName row={row} monthId={monthId} />
</span>
<span
className={`font-money shrink-0 text-base font-medium ${cells.remainingTone}`}
>
{formatSignedMoney(figures.carriedIn, baseCurrency)}
</td>
<td className="font-money py-2.5 pl-6 text-right">
{formatMoney(figures.allocation, baseCurrency)}
</td>
<td className="font-money py-2.5 pl-6 text-right">
{formatMoney(figures.charges, baseCurrency)}
</td>
<td
className={`font-money py-2.5 pl-6 text-right font-medium ${
figures.remaining < 0 ? "text-overspend" : ""
}`}
>
{formatSignedMoney(figures.remaining, baseCurrency)}
</td>
</tr>
<tr>
<td colSpan={5} className="p-0">
<CarryMeter figures={figures} />
</td>
</tr>
</Fragment>
))}
</tbody>
</table>
</div>
{cells.remaining}
</span>
</div>
<Meter segments={cells.segments} className="h-1" />
<p className="flex flex-wrap gap-x-3 text-[0.6875rem] text-budget-ink-muted">
<span>
carried{" "}
<span className={`font-money ${cells.carriedInTone}`}>{cells.carriedIn}</span>
</span>
<span>
allocated <span className="font-money">{cells.allocation}</span>
</span>
<span>
spent <span className="font-money">{cells.charges}</span>
</span>
{/* Pushed to the right end of the line it wraps onto, so six
of these down a phone screen read as a quiet affordance
rather than as each row's primary action. */}
{onChangeRule && (
<ChangeRuleButton
postId={row.post.id}
onChangeRule={onChangeRule}
className="ml-auto"
/>
)}
</p>
</li>
);
})}
</ul>
</>
);
}
+1 -1
View File
@@ -14,7 +14,7 @@ interface Props {
* Shape AND range, matching the import validator. `setRuleFrom` sorts the
* series with `compareMonths`, which THROWS on a malformed MonthId; useMutate
* would catch it and show an error, but a disabled button is the better
* answer, and CLAUDE.md requires guarding a throwing domain call at the site
* answer, and AGENTS.md requires guarding a throwing domain call at the site
* that invokes it from a handler.
*/
const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/;
+12 -1
View File
@@ -1,5 +1,10 @@
import { test, expect } from "bun:test";
import { formatAmount, formatMoney, formatSignedMoney } from "./format.ts";
import {
formatAmount,
formatMoney,
formatSignedAmount,
formatSignedMoney,
} from "./format.ts";
test("formatAmount groups thousands and always shows two decimals", () => {
expect(formatAmount(1234.5)).toBe("1,234.50");
@@ -16,3 +21,9 @@ test("formatSignedMoney marks positive balances with a plus", () => {
expect(formatSignedMoney(-200, "DKK")).toBe("-200.00 DKK");
expect(formatSignedMoney(0, "DKK")).toBe("0.00 DKK");
});
test("formatSignedAmount signs the number without naming a currency", () => {
expect(formatSignedAmount(200)).toBe("+200.00");
expect(formatSignedAmount(-200)).toBe("-200.00");
expect(formatSignedAmount(0)).toBe("0.00");
});
+11 -2
View File
@@ -14,7 +14,16 @@ export function formatMoney(amount: number, currency: Currency): string {
return `${formatAmount(amount)} ${currency}`;
}
/**
* A signed amount with no currency code. For columns whose currency is stated
* once in the header — the month view's post table, where every figure is in
* the dataset's base currency, and repeating the code on all thirty cells only
* bought a line wrap at phone width.
*/
export function formatSignedAmount(amount: number): string {
return `${amount > 0 ? "+" : ""}${formatAmount(amount)}`;
}
export function formatSignedMoney(amount: number, currency: Currency): string {
const sign = amount > 0 ? "+" : "";
return `${sign}${formatMoney(amount, currency)}`;
return `${formatSignedAmount(amount)} ${currency}`;
}
+190
View File
@@ -0,0 +1,190 @@
import { test, expect } from "bun:test";
import {
allocatedPercentOfIncome,
allocationMeterSegments,
carryMeterSegments,
gradientFor,
type MeterSegment,
} from "./meterSegments.ts";
import type { PostMonthFigures } from "../domain/fold.ts";
function figures(partial: Partial<PostMonthFigures>): PostMonthFigures {
const carriedIn = partial.carriedIn ?? 0;
const allocation = partial.allocation ?? 0;
const charges = partial.charges ?? 0;
return {
carriedIn,
allocation,
charges,
remaining: partial.remaining ?? carriedIn + allocation - charges,
};
}
/** The shape assertions read better as a list of "token at width" pairs. */
function widths(segments: MeterSegment[]): [string, number][] {
return segments.map((s) => [s.token, Number(s.percent.toFixed(4))]);
}
test("a post with no activity is an empty track", () => {
expect(widths(carryMeterSegments(figures({})))).toEqual([["--budget-rule", 100]]);
});
test("an untouched allocation is all unspent track", () => {
expect(widths(carryMeterSegments(figures({ allocation: 600 })))).toEqual([
["--budget-rule", 100],
]);
});
test("spending half the allocation fills half the track", () => {
expect(widths(carryMeterSegments(figures({ allocation: 600, charges: 300 })))).toEqual([
["--budget-accent", 50],
["--budget-rule", 50],
]);
});
test("a carried-in surplus takes its share of the track before the allocation", () => {
// 1000 carried in + 1000 allocated, half of the allocation spent: the
// surplus is half the scale, the spend a quarter, the remainder a quarter.
const segments = carryMeterSegments(
figures({ carriedIn: 1000, allocation: 1000, charges: 500 }),
);
expect(widths(segments)).toEqual([
["--surplus", 50],
["--budget-accent", 25],
["--budget-rule", 25],
]);
});
test("overspending paints past the end of the allocation and leaves no unspent track", () => {
// Allocation 1000, spend 1500: the scale is the spend, so the allocation is
// the first two thirds and the overspend the last third.
const segments = carryMeterSegments(figures({ allocation: 1000, charges: 1500 }));
expect(widths(segments)).toEqual([
["--budget-accent", 66.6667],
["--overspend", 33.3333],
]);
});
test("a negative carried-in balance contributes no surplus segment", () => {
// Transport in the demo data: behind by 1600, allocated 1100, spent 2700.
// The debt is not on the meter — only the red Carried-in figure says why the
// post is behind. Documented behaviour, asserted so it cannot drift.
const segments = carryMeterSegments(
figures({ carriedIn: -1600, allocation: 1100, charges: 2700 }),
);
expect(segments.some((s) => s.token === "--surplus")).toBe(false);
expect(widths(segments)).toEqual([
["--budget-accent", 40.7407],
["--overspend", 59.2593],
]);
});
test("a net refund clamps to an empty spend rather than a negative width", () => {
// Refunds are a normal line, so charges can come out below zero.
const segments = carryMeterSegments(figures({ allocation: 500, charges: -200 }));
expect(widths(segments)).toEqual([["--budget-rule", 100]]);
});
test("a negative allocation is an empty track, not an inverted one", () => {
const segments = carryMeterSegments(figures({ allocation: -500 }));
expect(widths(segments)).toEqual([["--budget-rule", 100]]);
});
test("allocating under income leaves unallocated track", () => {
expect(widths(allocationMeterSegments(28000, 21000))).toEqual([
["--budget-accent", 75],
["--budget-rule", 25],
]);
});
test("allocating past income overflows instead of capping at a full bar", () => {
// Over-budget is always allowed, so this is an ordinary state and the meter
// has to be able to draw it. Income 1000, allocated 2000: half the track is
// the income, half is the overflow.
expect(widths(allocationMeterSegments(1000, 2000))).toEqual([
["--budget-accent", 50],
["--overspend", 50],
]);
});
test("allocating against no income is entirely overflow", () => {
expect(widths(allocationMeterSegments(0, 2000))).toEqual([["--overspend", 100]]);
});
test("no income and no allocation is an empty track", () => {
expect(widths(allocationMeterSegments(0, 0))).toEqual([["--budget-rule", 100]]);
});
test("a negative income is treated as none", () => {
expect(widths(allocationMeterSegments(-500, 100))).toEqual([["--overspend", 100]]);
});
/**
* The two properties that must hold for every input, because a segment list
* that breaks either produces a gradient with stops out of order — which
* paints something arbitrary rather than failing.
*/
test("every segment list is non-negative and sums to exactly 100", () => {
const cases: PostMonthFigures[] = [
figures({}),
figures({ allocation: 1 }),
figures({ charges: 1 }),
figures({ carriedIn: -1, charges: 1 }),
figures({ carriedIn: 0.005, allocation: 0.01, charges: 0.005 }),
figures({ carriedIn: 1e6, allocation: 1, charges: 1e7 }),
figures({ carriedIn: -1e6, allocation: -1, charges: -1 }),
figures({ allocation: 8200, charges: 8200 }),
];
for (const f of cases) {
const segments = carryMeterSegments(f);
const total = segments.reduce((sum, s) => sum + s.percent, 0);
expect(segments.every((s) => s.percent > 0)).toBe(true);
expect(total).toBeCloseTo(100, 8);
}
for (const [income, allocated] of [
[0, 0],
[1, 0],
[0, 1],
[-1, -1],
[28000, 19300],
[1, 1e9],
] as const) {
const segments = allocationMeterSegments(income, allocated);
const total = segments.reduce((sum, s) => sum + s.percent, 0);
expect(segments.every((s) => s.percent > 0)).toBe(true);
expect(total).toBeCloseTo(100, 8);
}
});
test("the gradient hard-stops each segment and ends at 100%", () => {
const css = gradientFor([
{ token: "--surplus", percent: 25 },
{ token: "--budget-accent", percent: 75 },
]);
expect(css).toBe(
"linear-gradient(to right, " +
"var(--surplus) 0%, var(--surplus) 25%, " +
"var(--budget-accent) 25%, var(--budget-accent) 100%)",
);
});
test("gradient stops never run backwards, whatever the segment widths", () => {
const css = gradientFor(carryMeterSegments(figures({ carriedIn: 1, allocation: 3, charges: 7 })));
const stops = [...css.matchAll(/ ([\d.]+)%/g)].map((m) => Number(m[1]));
const sorted = [...stops].sort((a, b) => a - b);
expect(stops).toEqual(sorted);
expect(stops.at(-1)).toBe(100);
});
test("the allocated percentage is null when there is no income to be a share of", () => {
expect(allocatedPercentOfIncome(0, 500)).toBeNull();
expect(allocatedPercentOfIncome(-1, 500)).toBeNull();
});
test("the allocated percentage rounds to whole percent and can exceed 100", () => {
expect(allocatedPercentOfIncome(28000, 19300)).toBe(69);
expect(allocatedPercentOfIncome(1000, 2000)).toBe(200);
expect(allocatedPercentOfIncome(1000, 0)).toBe(0);
});
+122
View File
@@ -0,0 +1,122 @@
import type { PostMonthFigures } from "../domain/fold.ts";
/**
* Meter geometry, kept out of the components that draw it.
*
* Two meters in the app encode money as a hard-stopped gradient: the carry
* meter under every post row, and the allocation meter in the month header.
* They are the same idea at two scales, so the arithmetic lives here — pure,
* no DOM — where every edge case it has to survive can be asserted directly.
*
* The token names stay in TypeScript on purpose. `src/ui/cssTokens.test.ts`
* fails if a token referenced here is not one `src/index.css` defines, and
* that guard exists because renaming `--rule` silently made this exact element
* paint nothing: an undefined custom property inside `linear-gradient()`
* invalidates the whole declaration without warning. (That test reads source
* text, so it will flag a token named in a comment too — as this one was.)
*/
export interface MeterSegment {
/** A custom property defined in src/index.css, e.g. "--surplus". */
token: string;
/** Width as a percentage of the track. Always > 0; the list sums to 100. */
percent: number;
}
/** An empty track: the same hairline colour as an unspent remainder. */
const EMPTY_TRACK: MeterSegment[] = [{ token: "--budget-rule", percent: 100 }];
function scaled(parts: [string, number][], scale: number): MeterSegment[] {
const segments: MeterSegment[] = [];
for (const [token, size] of parts) {
if (size <= 0) continue;
segments.push({ token, percent: (size / scale) * 100 });
}
return segments.length > 0 ? segments : EMPTY_TRACK;
}
/**
* The rollover fold's four numbers as one track, left to right:
*
* --surplus carried-in surplus
* --budget-accent spend within this month's allocation
* --budget-rule the unspent remainder of the allocation
* --overspend spend beyond the allocation
*
* Negatives are clamped rather than inverted. A carried-in *debt* therefore
* does not appear here at all — only the red Carried-in figure beside the
* meter says why the post is behind — and a net refund reads as no spend.
* Both are deliberate: a segment of negative width would put the gradient's
* stops out of order, which paints something arbitrary instead of failing.
*/
export function carryMeterSegments(figures: PostMonthFigures): MeterSegment[] {
const surplus = Math.max(figures.carriedIn, 0);
const allocation = Math.max(figures.allocation, 0);
const charges = Math.max(figures.charges, 0);
const scale = surplus + Math.max(allocation, charges);
if (scale <= 0) return EMPTY_TRACK;
return scaled(
[
["--surplus", surplus],
["--budget-accent", Math.min(charges, allocation)],
["--budget-rule", allocation - Math.min(charges, allocation)],
["--overspend", charges - Math.min(charges, allocation)],
],
scale,
);
}
/**
* The month header's meter: how much of this month's income is allocated.
*
* Scaled to `max(income, allocated)`, NOT to income. Over-budget is always
* allowed, and allocating past income is the ordinary way that shows up here,
* so the overflow has to be drawable: capping at a full bar would make
* over-allocation look identical to allocating income to the last krone.
*/
export function allocationMeterSegments(income: number, allocated: number): MeterSegment[] {
const earned = Math.max(income, 0);
const spent = Math.max(allocated, 0);
const scale = Math.max(earned, spent);
if (scale <= 0) return EMPTY_TRACK;
return scaled(
[
["--budget-accent", Math.min(spent, earned)],
["--budget-rule", earned - Math.min(spent, earned)],
["--overspend", spent - Math.min(spent, earned)],
],
scale,
);
}
/** Null when there is no income for the allocation to be a share of. */
export function allocatedPercentOfIncome(income: number, allocated: number): number | null {
if (income <= 0) return null;
return Math.round((allocated / income) * 100);
}
/** Trimmed so a stop reads `25%` rather than `25.0000%`. */
function stop(percent: number): string {
return `${Number(percent.toFixed(4))}%`;
}
/**
* Hard stops, so the track reads as discrete quantities rather than a blend:
* each segment ends and the next begins at the same position.
*/
export function gradientFor(segments: MeterSegment[]): string {
const stops: string[] = [];
let at = 0;
segments.forEach((segment, index) => {
const from = at;
at += segment.percent;
// The last segment closes on a literal 100% so accumulated float error
// can never leave a hairline of unpainted track at the right edge.
const to = index === segments.length - 1 ? 100 : at;
stops.push(`var(${segment.token}) ${stop(from)}`, `var(${segment.token}) ${stop(to)}`);
});
return `linear-gradient(to right, ${stops.join(", ")})`;
}
+67 -23
View File
@@ -5,16 +5,18 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useDataset } from "../hooks/useDataset.ts";
import { useMutate } from "../hooks/useMutate.ts";
import { monthView } from "../../domain/views.ts";
import { monthView, type MonthViewModel } from "../../domain/views.ts";
import { addMonths } from "../../domain/months.ts";
import { setIncome, deletePurchase, setRuleFrom } from "../../store/actions.ts";
import { ruleAt } from "../../domain/allocation.ts";
import type { MonthId, Post, Purchase, Rule } from "../../domain/types.ts";
import { sliceAmountForMonth } from "../../domain/charges.ts";
import { formatMoney, formatSignedMoney } from "../format.ts";
import { PostTable } from "../components/PostTable.tsx";
import { PostTable, PostTableLegend } from "../components/PostTable.tsx";
import { PurchaseDialog } from "../components/PurchaseDialog.tsx";
import { Meter } from "../components/Meter.tsx";
import { Section, Stat } from "../components/Section.tsx";
import { allocatedPercentOfIncome, allocationMeterSegments } from "../meterSegments.ts";
import { groupPurchasesByDate } from "../purchaseGroups.ts";
export function MonthRoute() {
@@ -54,8 +56,14 @@ export function MonthRoute() {
<MonthStep to={`/month/${addMonths(monthId, 1)}`} label={addMonths(monthId, 1)} />
</header>
{/* Income is the origin of every figure below it, so the block reads
left to right as one statement: what came in, how much of it is
committed, what is left to commit — then, under a rule, what has
actually been spent and whether any post is behind. The four peer
figures this replaced stated the same numbers at the same weight and
answered neither question. */}
<Section>
<div className="flex flex-wrap items-end gap-x-10 gap-y-5">
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:gap-10">
<div className="space-y-1.5">
<Label htmlFor="income" className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
Income this month ({base})
@@ -74,31 +82,52 @@ export function MonthRoute() {
}}
/>
</div>
<dl className="flex flex-wrap gap-x-10 gap-y-4">
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
<div className="min-w-0 flex-1 space-y-2">
<dl className="flex flex-wrap gap-x-10 gap-y-4">
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
<Stat
label="Unallocated"
tone={view.unallocated < 0 ? "overspend" : "default"}
title={
view.unallocated < 0
? "Allocations exceed this month's income. This is allowed."
: undefined
}
>
{formatSignedMoney(view.unallocated, base)}
</Stat>
</dl>
<Meter
segments={allocationMeterSegments(view.income, view.totalAllocation)}
className="h-1.5 rounded-full"
/>
<p className="text-xs text-budget-ink-muted">{allocationCaption(view)}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap items-end justify-between gap-x-10 gap-y-2 border-t border-budget-rule pt-3">
<dl>
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
<Stat
label="Unallocated"
tone={view.unallocated < 0 ? "overspend" : "default"}
title={
view.unallocated < 0
? "Allocations exceed this month's income. This is allowed."
: undefined
}
>
{formatSignedMoney(view.unallocated, base)}
</Stat>
</dl>
{/* Rendered in both directions. "No posts overspent" is a real answer
to the app's second question, and a line that only appears when
something is wrong moves everything above it when it arrives. */}
<p
className={`text-sm ${
view.overspentCount > 0 ? "text-overspend" : "text-budget-ink-muted"
}`}
>
{overspentLabel(view.overspentCount)}
</p>
</div>
</Section>
<Section title="Posts" className="overflow-hidden">
<PostTable
monthId={monthId}
baseCurrency={base}
rows={view.rows}
onChangeRule={setChangingRuleFor}
/>
<Section
title="Posts"
hint={<PostTableLegend baseCurrency={base} />}
className="overflow-hidden"
>
<PostTable monthId={monthId} rows={view.rows} onChangeRule={setChangingRuleFor} />
</Section>
{changingPost && (
@@ -201,6 +230,21 @@ function PurchaseRow({
);
}
function allocationCaption(view: MonthViewModel): string {
const percent = allocatedPercentOfIncome(view.income, view.totalAllocation);
if (percent === null) {
return view.totalAllocation > 0
? "Allocated against no recorded income."
: "No income recorded for this month yet.";
}
return `${percent}% of income allocated`;
}
function overspentLabel(count: number): string {
if (count === 0) return "No posts overspent";
return `${count} post${count === 1 ? "" : "s"} overspent`;
}
/** "2026-09" reads as a key; "September 2026" reads as a month. */
function monthLabel(monthId: MonthId): string {
const [year, month] = monthId.split("-");
+63 -2
View File
@@ -17,6 +17,7 @@ export function DataSection() {
const dataset = useDataset();
const fileInput = useRef<HTMLInputElement>(null);
const [pending, setPending] = useState<Dataset | null>(null);
const [confirmingReset, setConfirmingReset] = useState(false);
const [error, setError] = useState<string | null>(null);
// Shared by every export button and by the pre-import backup below.
@@ -61,6 +62,7 @@ export function DataSection() {
async function chooseFile(file: File) {
setError(null);
setConfirmingReset(false);
try {
setPending(parseDatasetJson(await file.text()));
} catch (cause) {
@@ -91,6 +93,28 @@ export function DataSection() {
setPending(null);
}
/**
* Back to a brand-new browser: the seed posts, the seed currency table and
* the baked rates. `store.reset()` is the same call `load()` makes on a
* first run, so the two cannot drift apart.
*
* Order of operations is the import flow's, for the import flow's reason:
* this is the user's only copy, so the backup must download BEFORE the
* destructive write, and a backup that fails aborts the reset rather than
* destroying data no one has a copy of.
*/
async function confirmReset() {
if (!runExport(buildJsonExport)) return;
try {
await store.reset();
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
setError(`Could not reset: ${message}. Your data has not been changed.`);
return;
}
setConfirmingReset(false);
}
const counts = describeDataset(dataset);
return (
@@ -109,6 +133,17 @@ export function DataSection() {
<Button variant="outline" onClick={() => fileInput.current?.click()}>
Import JSON…
</Button>
<Button
variant="outline"
className="text-destructive hover:text-destructive"
onClick={() => {
setError(null);
setPending(null);
setConfirmingReset(true);
}}
>
Reset everything…
</Button>
<input
ref={fileInput}
type="file"
@@ -122,10 +157,36 @@ export function DataSection() {
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
{confirmingReset && (
<div className="mt-3 space-y-2 rounded border border-destructive p-3 text-sm">
<p className="font-medium">This deletes everything in this browser.</p>
<p>
Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases,{" "}
{dataset.currencies.length} currencies.
<br />
After reset: the three starter posts (Video Games, Food, Events and Social), the
DKK / USD / EUR currency table with the built-in exchange rates, this month at
zero income, and no purchases. Your own posts, currencies, income and allocation
history are all replaced.
</p>
<p className="text-xs text-muted-foreground">
A backup of your current data will download first.
</p>
<div className="flex gap-2">
<Button variant="destructive" size="sm" onClick={() => void confirmReset()}>
Reset my data
</Button>
<Button variant="outline" size="sm" onClick={() => setConfirmingReset(false)}>
Cancel
</Button>
</div>
</div>
)}
{pending && (
<div className="space-y-2 rounded border border-destructive p-3 text-sm">
<div className="mt-3 space-y-2 rounded border border-destructive p-3 text-sm">
<p className="font-medium">This replaces everything currently stored.</p>
<p>
Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases.