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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Regular → Executable
+21
-4
@@ -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.
|
||||
@@ -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
Executable
+131
@@ -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.
|
||||
Regular → Executable
+9
-9
@@ -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**
|
||||
Regular → Executable
+1
-1
@@ -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
|
||||
|
||||
Regular → Executable
+3
-3
@@ -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
|
||||
Regular → Executable
+2
-2
@@ -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.
|
||||
|
||||
Regular → Executable
Executable
+177
@@ -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
Regular → Executable
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Executable
+118
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Regular → Executable
+1
-1
@@ -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
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(", ")})`;
|
||||
}
|
||||
@@ -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("-");
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user