Lucas Winther 7a755f6406 fix(validation): review round 1 — close the store's source hole, fix ordering and messages
I1: a calendar charge's MISSING occurrenceDate no longer reports the literal
word "undefined" (String(undefined) printed it, and that text appears
nowhere in the file someone is searching). Missing and malformed are split
into two messages; the missing one names the cost, matching the anchored
side's message.

I2: addPurchase/updatePurchase accepted an arbitrary `source` unchecked —
only confirmOccurrence built a coherent one. A later task hand-builds a
source and passes it straight to addPurchase, so this closes it at the store
layer now rather than leaving that task to carry the validation. Both
enforce the same pairing the importer does: a calendar cost's source
requires occurrenceDate, an anchored cost's source forbids it, an unknown
cost throws. updatePurchase checks the resolved pair (incoming source if
given, else the stored one), the same style already used for anchoring.

M1: the "no recurring costs" fold test added last round was a duplicate of
an existing one and couldn't fail independently. Replaced with a test that
actually discriminates which charge an anchored cost's walk steps from —
a 10-day cycle matching the gap between three charges, where anchoring off
the earliest instead of the latest changes whether anything is still
expected in the same month. Verified by making that swap in
occurrences.ts, watching it fail, and reverting.

M2: updateRecurringCost now refuses a charged cost's anchoring change
before checking startDate granularity, so a doomed request fails on the
real reason first rather than making the owner fix an unrelated date
problem and resubmit into the same wall.

Also closed two pre-existing asymmetries with json.ts: requireRecurrence
now rejects an unknown recurrence kind, and both addRecurringCost and
updateRecurringCost reject an unknown anchoring — both previously relied
on TypeScript's unions alone, which a hand-edited object or an `as` cast
does not go through.
2026-09-05 22:58:20 +02:00

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.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.
S
Description
No description provided
Readme
1.7 MiB
Languages
TypeScript 98.3%
CSS 1.4%
HTML 0.2%