Finding I3 (Critical): PurchaseDialog calls offerableCosts(dataset, draft.date)
unconditionally in its render body. The native <input type="date"> lets the
owner clear it, setting draft.date to "" — monthOf("") throws, uncaught,
during render. Unlike an event-handler throw an error boundary can react to,
this crashes the dialog with no interaction to undo.
monthOf itself stays strict per AGENTS.md (no softening a domain throw into a
fallback): offerableCosts now wraps it in a UI-layer isParseableMonth check
and returns no costs for an empty (or otherwise unparseable) date, rather than
reporting an error for a form the owner simply hasn't finished.
Checked the other recurringForm.ts helpers that take a date for the same
hole:
- newCostOptionIsSelectable had it too — "everyNMonths"+"calendar" read as
selectable for ANY date length, including empty, though seedCostFromDraft
still hands that combo's date to monthOf. Fixed the same way, so every
"+ New recurring cost..." option is disabled while the date is empty.
- sourceFor and seedCostFromDraft are only ever called at save time, inside
the guarded store.mutate, never from render — left strict, since a bad
date there already surfaces as a caught save-time error exactly like any
other bad-date save (ensureMonth's monthOf in addPurchase). Documented and
tested that determination rather than inventing a second rule for it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Budget 2.0
A personal budgeting PWA. Envelope-style monthly budgets with rollover, split purchases, finance plans, multi-currency, and JSON/ODS export. All data lives in your browser — there is no server and no account.
Development
bun install
bun run dev # dev server with hot reload, on :3000
bun test # run the test suite
bun run build # production build into dist/
Self-hosting with Docker
docker build -t budget2 .
docker run --rm -p 3000:3000 budget2
To serve from a subpath, build and run with BUN_PUBLIC_BASE_PATH:
docker build -t budget2 --build-arg BUN_PUBLIC_BASE_PATH=/budget/ .
docker run --rm -p 3000:3000 -e BUN_PUBLIC_BASE_PATH=/budget/ budget2
GitHub Pages
Pushing to main builds and deploys to Pages automatically, using
/<repository-name>/ as the base path. Enable Pages with "GitHub Actions" as
the source in the repository settings.
Your data
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.
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
- Data is per-browser. There is no account and no sync. If you clear this 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.
- 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
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.mdandGEMINI.mdpoint 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.