diff --git a/AGENTS.md b/AGENTS.md old mode 100644 new mode 100755 index d935c21..b70aa67 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` - 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 ` (never `node` or `ts-node`), `bun test` (never jest +or vitest), `bun install` (never npm/yarn/pnpm), `bun run - - -``` - -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

Hello, world!

; -} - -root.render(); -``` - -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=`, `--profile=` (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. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..40edcc5 --- /dev/null +++ b/GEMINI.md @@ -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. diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 93d2f19..380805c --- a/README.md +++ b/README.md @@ -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. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 9a5873a..0000000 --- a/TODO.md +++ /dev/null @@ -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 `` 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. diff --git a/docs/superpowers/plans/2026-09-01-budget-app.md b/docs/plans/2026-09-01-budget-app.md old mode 100644 new mode 100755 similarity index 99% rename from docs/superpowers/plans/2026-09-01-budget-app.md rename to docs/plans/2026-09-01-budget-app.md index c25ff34..fbf4882 --- a/docs/superpowers/plans/2026-09-01-budget-app.md +++ b/docs/plans/2026-09-01-budget-app.md @@ -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 = { 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** diff --git a/docs/superpowers/plans/2026-09-02-versioned-allocation-rules.md b/docs/plans/2026-09-02-versioned-allocation-rules.md old mode 100644 new mode 100755 similarity index 99% rename from docs/superpowers/plans/2026-09-02-versioned-allocation-rules.md rename to docs/plans/2026-09-02-versioned-allocation-rules.md index 09305d5..7803b55 --- a/docs/superpowers/plans/2026-09-02-versioned-allocation-rules.md +++ b/docs/plans/2026-09-02-versioned-allocation-rules.md @@ -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 diff --git a/docs/superpowers/specs/2026-09-01-budget-app-design.md b/docs/specs/2026-09-01-budget-app-design.md old mode 100644 new mode 100755 similarity index 98% rename from docs/superpowers/specs/2026-09-01-budget-app-design.md rename to docs/specs/2026-09-01-budget-app-design.md index 91f9eb8..c96a73c --- a/docs/superpowers/specs/2026-09-01-budget-app-design.md +++ b/docs/specs/2026-09-01-budget-app-design.md @@ -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 diff --git a/docs/superpowers/specs/2026-09-02-design-foundation-design.md b/docs/specs/2026-09-02-design-foundation-design.md old mode 100644 new mode 100755 similarity index 98% rename from docs/superpowers/specs/2026-09-02-design-foundation-design.md rename to docs/specs/2026-09-02-design-foundation-design.md index 5cb6f91..2722f05 --- a/docs/superpowers/specs/2026-09-02-design-foundation-design.md +++ b/docs/specs/2026-09-02-design-foundation-design.md @@ -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. diff --git a/docs/superpowers/specs/2026-09-02-fast-entry-refinements-design.md b/docs/specs/2026-09-02-fast-entry-refinements-design.md old mode 100644 new mode 100755 similarity index 100% rename from docs/superpowers/specs/2026-09-02-fast-entry-refinements-design.md rename to docs/specs/2026-09-02-fast-entry-refinements-design.md diff --git a/docs/specs/2026-09-02-month-view-design.md b/docs/specs/2026-09-02-month-view-design.md new file mode 100755 index 0000000..93cbe28 --- /dev/null +++ b/docs/specs/2026-09-02-month-view-design.md @@ -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 `
`. + +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 `` 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 `` per post, holding both the figures row and the meter row. +Multiple `` 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 +`` 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 `` 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 +`
` 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. diff --git a/docs/superpowers/specs/2026-09-02-versioned-allocation-rules-design.md b/docs/specs/2026-09-02-versioned-allocation-rules-design.md old mode 100644 new mode 100755 similarity index 100% rename from docs/superpowers/specs/2026-09-02-versioned-allocation-rules-design.md rename to docs/specs/2026-09-02-versioned-allocation-rules-design.md diff --git a/scripts/demo-data.ts b/scripts/demo-data.ts old mode 100644 new mode 100755 index a422e71..62d5b16 --- a/scripts/demo-data.ts +++ b/scripts/demo-data.ts @@ -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]]; diff --git a/scripts/fetch-fonts.ts b/scripts/fetch-fonts.ts old mode 100644 new mode 100755 index 772e2a8..ad6d6bc --- a/scripts/fetch-fonts.ts +++ b/scripts/fetch-fonts.ts @@ -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 diff --git a/scripts/screenshot.ts b/scripts/screenshot.ts old mode 100644 new mode 100755 index d71293d..775a643 --- a/scripts/screenshot.ts +++ b/scripts/screenshot.ts @@ -7,11 +7,12 @@ * [--eval=|--eval-file=] [--dark] [--reload] * [--profile=] [--eval-after-file=] * - * 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. diff --git a/src/cssPairs.test.ts b/src/cssPairs.test.ts old mode 100644 new mode 100755 index a075562..3dac705 --- a/src/cssPairs.test.ts +++ b/src/cssPairs.test.ts @@ -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 diff --git a/src/docsLinks.test.ts b/src/docsLinks.test.ts new file mode 100755 index 0000000..3f239f4 --- /dev/null +++ b/src/docsLinks.test.ts @@ -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(); + + 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 { + 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-.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); +}); diff --git a/src/domain/types.ts b/src/domain/types.ts old mode 100644 new mode 100755 index 8d0649e..e4b203e --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -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; diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index bab9337..b6b7691 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -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, diff --git a/src/domain/views.ts b/src/domain/views.ts index 9a13b71..6ebc7f4 100644 --- a/src/domain/views.ts +++ b/src/domain/views.ts @@ -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, }; } diff --git a/src/index.css b/src/index.css old mode 100644 new mode 100755 index 1cb5173..bb534b0 --- a/src/index.css +++ b/src/index.css @@ -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. diff --git a/src/store/actions.ts b/src/store/actions.ts old mode 100644 new mode 100755 index 6850ffc..fff5338 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -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). diff --git a/src/store/snapshot.test.ts b/src/store/snapshot.test.ts index c9869dc..598dd95 100644 --- a/src/store/snapshot.test.ts +++ b/src/store/snapshot.test.ts @@ -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"); diff --git a/src/store/snapshot.ts b/src/store/snapshot.ts index 94f6e84..256f330 100644 --- a/src/store/snapshot.ts +++ b/src/store/snapshot.ts @@ -13,6 +13,7 @@ export interface SnapshotStore { get(): Dataset; mutate(fn: (draft: Dataset) => void): Promise; replace(dataset: Dataset): Promise; + reset(): Promise; 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); diff --git a/src/ui/components/Meter.tsx b/src/ui/components/Meter.tsx new file mode 100644 index 0000000..405bd80 --- /dev/null +++ b/src/ui/components/Meter.tsx @@ -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 ( +
- - - - - - - - - - - {rows.map(({ post, figures, overridden }) => ( - - - - - - - - - - - - - ))} - -
PostCarried inAllocatedSpentRemaining
- - {post.name} - - {post.archived && ( - - archived - - )} - {overridden && ( - - overridden - - )} - {/* 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 && ( - - rule changes here - - )} - {onChangeRule && ( - - )} - 0 - ? "text-surplus" - : figures.carriedIn < 0 - ? "text-overspend" - : "" - }`} + <> + + {post.name} + + {post.archived && ( + + archived + + )} + {overridden && ( + + overridden + + )} + {/* 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 && ( + + rule changes here + + )} + + ); +} + +/** + * 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 ( + + ); +} + +export function PostTable({ monthId, rows, onChangeRule }: Props) { + return ( + <> +
+ + + + + + + + + + + {/* 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 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 ( + + + + + + + + + {/* The meter IS the row divider: tight under its own figures, + with the gap below it. */} + + + + + ); + })} +
PostCarried inAllocatedSpentRemaining
+ + {onChangeRule && ( + + )} + + {cells.carriedIn} + + {cells.allocation} + {cells.charges} + {cells.remaining} +
+ +
+
+ +
    + {rows.map((row) => { + const cells = postRowCells(row); + return ( +
  • +
    + + + + - {formatSignedMoney(figures.carriedIn, baseCurrency)} -
- {formatMoney(figures.allocation, baseCurrency)} - - {formatMoney(figures.charges, baseCurrency)} - - {formatSignedMoney(figures.remaining, baseCurrency)} -
- -
- + {cells.remaining} + + + +

+ + carried{" "} + {cells.carriedIn} + + + allocated {cells.allocation} + + + spent {cells.charges} + + {/* 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 && ( + + )} +

+ + ); + })} + + ); } diff --git a/src/ui/components/RuleHistory.tsx b/src/ui/components/RuleHistory.tsx old mode 100644 new mode 100755 index 2ed3116..e1eddf4 --- a/src/ui/components/RuleHistory.tsx +++ b/src/ui/components/RuleHistory.tsx @@ -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])$/; diff --git a/src/ui/format.test.ts b/src/ui/format.test.ts index 86e28df..658157b 100644 --- a/src/ui/format.test.ts +++ b/src/ui/format.test.ts @@ -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"); +}); diff --git a/src/ui/format.ts b/src/ui/format.ts index 5e184f5..dc29e77 100644 --- a/src/ui/format.ts +++ b/src/ui/format.ts @@ -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}`; } diff --git a/src/ui/meterSegments.test.ts b/src/ui/meterSegments.test.ts new file mode 100644 index 0000000..e189104 --- /dev/null +++ b/src/ui/meterSegments.test.ts @@ -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 { + 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); +}); diff --git a/src/ui/meterSegments.ts b/src/ui/meterSegments.ts new file mode 100644 index 0000000..11cd3ae --- /dev/null +++ b/src/ui/meterSegments.ts @@ -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(", ")})`; +} diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index 530f6d8..7c9fd4c 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -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() {
+ {/* 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. */}
-
+
-
- {formatMoney(view.totalAllocation, base)} +
+
+ {formatMoney(view.totalAllocation, base)} + + {formatSignedMoney(view.unallocated, base)} + +
+ +

{allocationCaption(view)}

+
+
+ +
+
{formatMoney(view.totalCharges, base)} - - {formatSignedMoney(view.unallocated, base)} -
+ {/* 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. */} +

0 ? "text-overspend" : "text-budget-ink-muted" + }`} + > + {overspentLabel(view.overspentCount)} +

-
- +
} + className="overflow-hidden" + > +
{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("-"); diff --git a/src/ui/routes/settings/DataSection.tsx b/src/ui/routes/settings/DataSection.tsx index 82a8ef3..df63ba0 100644 --- a/src/ui/routes/settings/DataSection.tsx +++ b/src/ui/routes/settings/DataSection.tsx @@ -17,6 +17,7 @@ export function DataSection() { const dataset = useDataset(); const fileInput = useRef(null); const [pending, setPending] = useState(null); + const [confirmingReset, setConfirmingReset] = useState(false); const [error, setError] = useState(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() { + - {error &&

{error}

} + {error &&

{error}

} + + {confirmingReset && ( +
+

This deletes everything in this browser.

+

+ Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases,{" "} + {dataset.currencies.length} currencies. +
+ 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. +

+

+ A backup of your current data will download first. +

+
+ + +
+
+ )} {pending && ( -
+

This replaces everything currently stored.

Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases.