A lastCharge cost's next due date is one cycle after the latest actual charge. So it can be charged any number of times per cycle, each charge restarts the clock, and the reported bug is gone: two payments in September leave the next due in October, not November. calendar is untouched and keeps its grid. Paying September AND October's rent really does mean nothing is due until November — the bug was only ever a bug for the anchored case. occurrencesOf's second parameter changes from a confirmations Map to the raw purchase list: the anchored walk needs chargesFor's view (every Purchase.date for the cost, latest wins), the grid walk needs confirmationsFor's view (purchases keyed by the slot they settle), and only occurrencesOf itself knows which to build. occurrencesByMonth now passes dataset.purchases straight through instead of pre-building a map. stepFrom's `anchoring === "lastCharge" && date !== monthOf(date)` guard (Task 2 scaffolding, for the old month-only-cursor walk) is deleted. The anchored walk's cursor is always day-granular now (startDate or a real charge date), so the condition was dead; if a month-only date ever reaches that branch, addMonthsToDate throwing is correct, not a bug to paper over. confirmationsFor's `if (!purchase.source.occurrenceDate) continue` scaffolding guard (also Task 2) is now a thrown error instead: the function is calendar- only, a lastCharge charge should never reach it, and continuing silently would hide a programming error as a slot that merely looks unpaid. DELETED: rebasedFrom, wouldAdvancePast, requireConfirmationAdvances, and the tests that existed only to exercise them (6 in occurrences.test.ts describing the old Map-keyed lastCharge slot-rebasing and its termination throw, 3 in ExpectedBand.test.ts exercising the band's X1 "would this pairing advance the series" suppression, 5 in actions.test.ts exercising requireConfirmationAdvances's refusal and its pass-throughs — see each file's CONTROLLER RULING comment for the line-by-line reasoning). All three existed to stop a confirmation landing behind the slot it claims; with no slots on the anchored path and no rebasing on the grid path, that situation cannot arise any more. The anchored walk's own termination throw stays, as a defensive assert against n < 1 — never softened into a break. ExpectedBand.tsx and actions.ts also had their wouldAdvancePast-dependent code removed (the X1 condition and requireConfirmationAdvances respectively) so this task's suite would compile and run; Task 6/7 own reintroducing whatever anchored-model-correct guard, if any, replaces them. This removes more code than it adds.
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.