diff --git a/CLAUDE.md b/CLAUDE.md index f01c9e0..aa36d43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,20 +192,36 @@ Adding a "missing" guard here is a regression: ## Verification limits **There IS a browser here.** `google-chrome` is on PATH, and -`bun scripts/screenshot.ts ` drives it over CDP: it waits past -the async IndexedDB read (which plain `--screenshot` does not), can seed data -with `--eval-file`, click with `--click`, and reports console errors and -uncaught exceptions. Read the PNG it writes — you can see the app. Use it -before claiming anything about layout. +`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. -Two things to know before trusting a screenshot: +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**, so the whole UI renders monospaced in a way no real user - sees. Point `FONTCONFIG_FILE` at an override that maps the generics to - installed sans/mono faces, or you will "fix" a font bug that does not exist. -- Chrome launches with a fresh profile each run, so IndexedDB starts empty and - the app re-seeds. Pass `--eval-file= --reload` for populated data. + 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 diff --git a/docs/superpowers/plans/2026-09-01-budget-app.md b/docs/superpowers/plans/2026-09-01-budget-app.md index ca3ed31..c25ff34 100644 --- a/docs/superpowers/plans/2026-09-01-budget-app.md +++ b/docs/superpowers/plans/2026-09-01-budget-app.md @@ -1,3 +1,10 @@ +> **Historical record, not current truth.** This plan was executed and is kept +> for its reasoning. Two things in it have since changed and the code is the +> authority: allocation rules are a dated series (`Post.rules`, schema v2), and +> currencies live in `Dataset.currencies` with `roundMoney` taking a digit +> count rather than a currency code (schema v3). Snippets below still show +> `CURRENCIES` / `CURRENCY_DIGITS`, which no longer exist. + # Budget 2.0 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. diff --git a/scripts/demo-data.ts b/scripts/demo-data.ts new file mode 100644 index 0000000..a422e71 --- /dev/null +++ b/scripts/demo-data.ts @@ -0,0 +1,110 @@ +/** + * Writes a browser script that seeds a synthetic dataset into IndexedDB, for + * screenshots and for poking at the app with realistic data. + * + * 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 + * + * `--reload` is not optional: the app reads its dataset once at boot, so it has + * to boot again to see what was written. Chrome also starts from a fresh + * 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 + * never leaves their browser, so it is never available as example material, and + * illustrative data must not pretend to be real. + */ +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 +// 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]]; +const rent = actions.addPost(d, "Rent", "DKK"); +const transport = actions.addPost(d, "Transport", "DKK"); +const savings = actions.addPost(d, "Savings", "DKK"); + +for (let m = 1; m <= 9; m++) { + const id = `2026-${String(m).padStart(2, "0")}`; + actions.setIncome(d, id, { amount: m === 7 ? 24500 : 28000, currency: "DKK" }); +} + +actions.setRuleFrom(d, rent.id, "2026-01", { kind: "fixed", amount: { amount: 8200, currency: "DKK" } }); +actions.setRuleFrom(d, food.id, "2026-01", { kind: "percentOfIncome", percent: 12 }); +actions.setRuleFrom(d, food.id, "2026-06", { kind: "percentOfIncome", percent: 15 }); +actions.setRuleFrom(d, games.id, "2026-01", { kind: "fixed", amount: { amount: 600, currency: "DKK" } }); +actions.setRuleFrom(d, social.id, "2026-01", { kind: "percentOfIncome", percent: 6 }); +actions.setRuleFrom(d, transport.id, "2026-01", { kind: "fixed", amount: { amount: 1100, currency: "DKK" } }); +actions.setRuleFrom(d, savings.id, "2026-03", { kind: "percentOfIncome", percent: 10 }); +actions.setRuleOverride(d, "2026-09", social.id, { kind: "fixed", amount: { amount: 2400, currency: "DKK" } }); + +const buy = (date: string, description: string, amount: number, currency: "DKK" | "EUR" | "USD", postId: string, note?: string) => + actions.addPurchase(d, { + date, description, note, + total: { amount, currency }, + splitMode: "percent", + splits: [{ postId, value: 100, absorbsRemainder: true }], + schedule: null, + }); + +for (let m = 1; m <= 9; m++) { + const mm = String(m).padStart(2, "0"); + buy(`2026-${mm}-01`, "Rent", 8200, "DKK", rent.id); + buy(`2026-${mm}-04`, "Groceries", 1180 + m * 37, "DKK", food.id); + buy(`2026-${mm}-18`, "Groceries", 1420 - m * 12, "DKK", food.id); + buy(`2026-${mm}-06`, "Commuter pass", 1100, "DKK", transport.id); +} +buy("2026-09-03", "Coffee with the team", 184, "DKK", social.id); +buy("2026-09-07", "Board game night", 465, "DKK", social.id, "split the taxi"); +buy("2026-09-12", "Indie bundle", 27.5, "EUR", games.id, "sale"); +buy("2026-09-15", "Birthday dinner", 1850, "DKK", social.id); +buy("2026-08-22", "Headphones", 89.99, "USD", games.id); + +// A split across posts, and a purchase spread over months. Both compose. +actions.addPurchase(d, { + date: "2026-09-09", description: "Weekend away", note: "food + social", + total: { amount: 2600, currency: "DKK" }, splitMode: "percent", + splits: [ + { postId: social.id, value: 65, absorbsRemainder: true }, + { postId: food.id, value: 35, absorbsRemainder: false }, + ], + schedule: null, +}); +actions.addPurchase(d, { + date: "2026-08-14", description: "Bike", note: "paid over 4 months", + total: { amount: 6400, currency: "DKK" }, splitMode: "percent", + splits: [{ postId: transport.id, value: 100, absorbsRemainder: true }], + schedule: { slices: [ + { month: "2026-08", amount: { amount: 1600, currency: "DKK" } }, + { month: "2026-09", amount: { amount: 1600, currency: "DKK" } }, + { month: "2026-10", amount: { amount: 1600, currency: "DKK" } }, + { month: "2026-11", amount: { amount: 1600, currency: "DKK" } }, + ] }, +}); + +await Bun.write( + process.argv[2]!, + `(async () => { + const data = ${JSON.stringify(d)}; + await new Promise((resolve, reject) => { + const open = indexedDB.open("budget2", 1); + open.onupgradeneeded = () => { + const db = open.result; + if (!db.objectStoreNames.contains("state")) db.createObjectStore("state"); + }; + open.onsuccess = () => { + const db = open.result; + const tx = db.transaction("state", "readwrite"); + tx.objectStore("state").put(data, "dataset"); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => reject(tx.error); + }; + open.onerror = () => reject(open.error); + }); + return "seeded " + data.posts.length + " posts, " + data.purchases.length + " purchases"; +})()`, +); +console.log("wrote eval script to", process.argv[2]); diff --git a/scripts/screenshot-fonts.conf b/scripts/screenshot-fonts.conf new file mode 100644 index 0000000..fde2bc9 --- /dev/null +++ b/scripts/screenshot-fonts.conf @@ -0,0 +1,42 @@ + + + + + /etc/fonts/fonts.conf + + + sans-serif + Inter + + + system-ui + Inter + + + ui-sans-serif + Inter + + + Segoe UI + Inter + + + Segoe UI Variable Display + Inter + + + monospace + JetBrains Mono + + + ui-monospace + JetBrains Mono + + + SF Mono + JetBrains Mono + + diff --git a/scripts/screenshot.ts b/scripts/screenshot.ts index d4d3260..d71293d 100644 --- a/scripts/screenshot.ts +++ b/scripts/screenshot.ts @@ -30,6 +30,19 @@ const width = Number(flag("w", "1280")); const height = Number(flag("h", "900")); const waitMs = Number(flag("wait", "1500")); +/** + * This container's fontconfig resolves EVERY generic family — sans-serif, + * system-ui, monospace — to Fira Code, a monospace font. Screenshots then show + * the whole UI monospaced in a way no real user sees, and the last agent to + * look nearly "fixed" a font bug that did not exist. Applied automatically so + * nobody has to know that; set FONTCONFIG_FILE yourself to override. + */ +const fontConfig = new URL("./screenshot-fonts.conf", import.meta.url).pathname; +const fontEnv = + process.env.FONTCONFIG_FILE || !(await Bun.file(fontConfig).exists()) + ? {} + : { FONTCONFIG_FILE: fontConfig }; + const chrome = Bun.spawn( [ process.env.CHROME ?? "google-chrome", @@ -49,7 +62,7 @@ const chrome = Bun.spawn( ...(has("dark") ? ["--force-dark-mode", "--enable-features=WebContentsForceDark"] : []), "about:blank", ], - { stdout: "ignore", stderr: "pipe" }, + { stdout: "ignore", stderr: "pipe", env: { ...process.env, ...fontEnv } }, ); /** Chrome announces its debugging endpoint on stderr, once, at startup. */