feat: add month breakdown and session backups
Continuous Integration / lint-and-test (push) Successful in 17s
Continuous Integration / docker-build-and-push (push) Successful in 35s

Allow recurring costs and confirmations to remain unassigned when created from a purchase without a post. Add browser backup retention and restore controls, with coverage and documentation updates.
This commit is contained in:
Lucas Winther committed 2026-10-01 04:01:49 +02:00
1 parent 019517e6e9
commit 7cccd7a41a
32 files changed
+640 -41

No files matched your search

+29 -5
View File
@@ -118,6 +118,28 @@ uses on a first run, so "reset" and "a brand-new browser" cannot drift apart.
Both are preceded in the UI by a JSON backup download that must succeed before
the destructive write is attempted.
## Session backups
On each page load, `src/frontend.tsx` waits for `store.load()`, then saves the
loaded dataset to a separate `backups` object store in the same IndexedDB
before rendering controls that can edit it. A failed backup does not prevent
access to the budget; the app shows an alert with the failure. The snapshot is
JSON, separate from the live `state` record, and is never read as live data.
Retention keeps the newest checkpoint from each of 7 UTC days, 8 Monday-based
UTC weeks and 12 UTC months. A checkpoint may cover multiple tiers. In
backup persistence, writing the new checkpoint and deleting checkpoints
outside that set share one IndexedDB transaction: a failed write cannot prune
older backups. Settings lists retained checkpoints for download and restore.
Restoring parses through the same JSON import boundary, downloads the current
active dataset first, then calls `store.replace()`; if the download fails, the
active data stays put. Reset replaces the active dataset but leaves session
backups available.
These backups use the same browser origin as the live dataset. They help with
accidental edits or resets, but do not survive loss of all browser storage.
Manual JSON export remains the portable backup.
## Session undo and redo
The snapshot store keeps up to 50 prior saved datasets in memory. A changed
@@ -175,11 +197,13 @@ test — and charges a fixed "2000 to Video Games" in full on every slice.
A purchase with no post has an empty `splits` array. Its dated amount counts in
monthly, yearly and summary Spent totals, but charges no envelope and changes no
rollover balance. Recurring bills still require a destination post. JSON import
and ODS export preserve these unassigned purchases. In the month Budget tree,
they appear in the permanent, derived Unassigned section for each month their
purchase or finance plan touches. Creating a post from one assigns its full
amount to the new post without changing the purchase total or schedule.
rollover balance. A recurring bill may also have empty splits: its occurrences
still appear in Expected and Bills due, but they move no post's projected
balance. Confirming one creates an unassigned purchase, which counts in Spent.
JSON import and ODS export preserve these unassigned purchases. In the month
Budget tree, purchases appear in the permanent, derived Unassigned section for
each month they touch. Creating a post from one assigns its full amount to the
new post without changing the purchase total or schedule.
A post with no applicable allocation rule has no overspend status, even if its
balance is negative. The balance remains visible and carries normally. An
+8
View File
@@ -20,6 +20,10 @@ record.
ordered migration functions applied on load, because IndexedDB holds the
user's only copy of their financial data. Now at version 11.
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
- **Session backups live in browser storage.** Each app start saves a checkpoint
before editing is available; 7 daily, 8 weekly and 12 monthly checkpoints
are retained. Chosen for automatic operation, with manual JSON export still
needed for browser-storage loss. → [`ARCHITECTURE.md`](ARCHITECTURE.md)
- **A destructive write downloads a backup first.** Import and reset both export
before replacing, and abort if the export fails. → commit `ac3d18a`
- **Reset restores first-run state, not an empty dataset.** The seed posts, the
@@ -85,6 +89,10 @@ record.
## Recurring costs
- **A recurring cost may have no post.** It still appears as due, while no
envelope projection moves; confirming it records an unassigned purchase.
This lets the purchase dialog turn an unassigned charge into a bill without
inventing a destination. → [`ARCHITECTURE.md`](ARCHITECTURE.md)
- **Two-stage confirmation, over projection-only or generation-only.** A
recurring cost projects expected occurrences that stay expected until the
owner confirms one into an ordinary `Purchase`. Projection-only would charge
+8 -2
View File
@@ -74,11 +74,17 @@ Confirmed and in place:
- Multi-currency (DKK/USD/EUR) against a user-maintained exchange-rate table,
with an optional on-demand fetch that fails soft and is never required.
- JSON export/import and ODS (spreadsheet) export, both generated in the
browser.
browser. On each app start, a separate browser-stored session backup is made
before editing is available; recent daily, weekly and monthly checkpoints can
be downloaded or restored from Settings. These checkpoints share the browser
origin with the live data, so JSON export is still needed for protection
against losing browser storage.
- Offline-capable installable PWA. Currently deploys as one static artifact to
both GitHub Pages (under a subpath) and a self-hosted Docker image.
- Recurring costs — a bill that repeats every N months, every N days, or
every N weeks on a weekday, split across posts like any purchase. A cost is
every N weeks on a weekday, optionally split across posts like any purchase.
A cost may have no post: it remains expected without moving an envelope's
projected balance, and confirming it records unassigned spending. A cost is
anchored one of two ways: `calendar` holds a fixed grid of due dates, so
paying two months at once still leaves nothing due until the one after; a
cost anchored to its **last charge** has no grid at all and can be charged
+4 -14
View File
@@ -9,9 +9,10 @@ each recording the alternatives that were rejected. Rules that outlived their
task moved to `AGENTS.md`.
**Current state:** the anchored model is merged with budget folders and
placement. 870 tests passing across 61 files. `bunx tsc --noEmit` is clean;
`bun run --bun build` succeeds using the committed seed rates when live-fetch
is unavailable. The runtime override bypasses a broken local Bun shim.
placement. The month pie showcase, browser-stored session backups, and
unassigned recurring costs are implemented. 881 tests pass across 64 files;
`bunx tsc --noEmit` is clean, and `SKIP_FX_FETCH=1 bun run build` succeeds
using the committed seed rates.
**Schema is at version 11.** v1 -> v2 made allocation rules a dated series;
v2 -> v3 moved currencies into the dataset; v3 -> v4 drops a stored rate-service
@@ -153,17 +154,6 @@ reintroduce the bug the anchored model exists to fix.
The anchored model was merged on 2026-09-30, after main had gained budget
folders, placement and nested bills. Everything below is known and unfinished.
### "+ New recurring cost…" is offered for a purchase with no post
`newCostOptionIsSelectable` in `src/ui/recurringForm.ts` checks only the date.
A bill needs at least one destination post (`requireRecurringSplitDestinations`
in `src/store/actions.ts`), so a purchase left unassigned (no splits) can pick
the option, and Save is then refused with "A bill needs a destination post".
The error surfaces and the dialog stays open, so nothing is lost — but it is a
doomed save the dialog could have prevented. Disable the option, with the
reason, while the draft has no splits, the same way a month-only date disables
the day-granular units.
### A bill created from the purchase dialog has no folder choice
Settings' new-bill form picks a folder; the dialog's "+ New recurring cost…"