diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 7ebd42c..b684a29 100755
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -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
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index 4f3e04e..157f0d6 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -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
diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md
index c0395e5..b62759f 100755
--- a/docs/PRODUCT.md
+++ b/docs/PRODUCT.md
@@ -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
diff --git a/docs/TODO.md b/docs/TODO.md
index 651162e..e70cfec 100755
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -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…"
diff --git a/scripts/demo-data.ts b/scripts/demo-data.ts
index 8d36541..fe75f46 100755
--- a/scripts/demo-data.ts
+++ b/scripts/demo-data.ts
@@ -167,7 +167,7 @@ await Bun.write(
`(async () => {
const data = ${JSON.stringify(d)};
await new Promise((resolve, reject) => {
- const open = indexedDB.open("budget2", 1);
+ const open = indexedDB.open("budget2");
open.onupgradeneeded = () => {
const db = open.result;
if (!db.objectStoreNames.contains("state")) db.createObjectStore("state");
diff --git a/src/domain/charges.test.ts b/src/domain/charges.test.ts
index b3eba82..6dac0f9 100644
--- a/src/domain/charges.test.ts
+++ b/src/domain/charges.test.ts
@@ -275,6 +275,13 @@ describe("expectedForMonth", () => {
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
};
+ test("an unassigned occurrence has no envelope projection", () => {
+ const data = dataset({ ...rent, splits: [] });
+ const occurrences = occurrencesByMonth(data, "2026-01").get("2026-01")!;
+ expect(occurrences).toHaveLength(1);
+ expect(expectedForMonth(data, occurrences).size).toBe(0);
+ });
+
test("an unconfirmed occurrence charges its post", () => {
const data = dataset(rent);
const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!);
diff --git a/src/domain/charges.ts b/src/domain/charges.ts
index e230ba7..1028dd9 100644
--- a/src/domain/charges.ts
+++ b/src/domain/charges.ts
@@ -202,6 +202,9 @@ export function expectedForMonth(
if (occurrence.confirmedBy) continue;
const cost = byId.get(occurrence.recurringId);
if (!cost) continue;
+ // A bill can be unassigned, like a purchase. It still appears in Expected,
+ // but without a post there is no envelope projection to move.
+ if (cost.splits.length === 0) continue;
const index = remainderIndexIn(cost.splits, `Recurring cost "${cost.name}"`);
const weights = splitParts(
diff --git a/src/domain/types.ts b/src/domain/types.ts
index ff06ffc..9cd5810 100755
--- a/src/domain/types.ts
+++ b/src/domain/types.ts
@@ -182,7 +182,7 @@ export interface RecurringCost {
endedFrom?: IsoDate;
recurrence: Recurrence;
anchoring: Anchoring;
- /** Bills require exactly one remainder-absorbing destination. */
+ /** Empty splits keep the bill unassigned; otherwise one absorbs rounding. */
splitMode: "percent" | "fixed";
splits: Split[];
}
diff --git a/src/export/json.test.ts b/src/export/json.test.ts
index 114571c..0c2fccf 100644
--- a/src/export/json.test.ts
+++ b/src/export/json.test.ts
@@ -54,6 +54,24 @@ describe("round trip", () => {
});
describe("validation", () => {
+ test("accepts an unassigned recurring cost and its unassigned charge", () => {
+ const data = populated();
+ const cost = addRecurringCost(data, {
+ name: "Phone", folderId: null, archived: false,
+ amount: { amount: 30, currency: "DKK" }, startDate: "2026-01",
+ recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
+ splitMode: "percent", splits: [],
+ });
+ data.purchases.push({
+ id: "phone-charge", date: "2026-01", description: "Phone",
+ total: { amount: 30, currency: "DKK" }, splitMode: "percent", splits: [],
+ schedule: null, source: { recurringId: cost.id, occurrenceDate: "2026-01" },
+ });
+ const restored = parseDatasetJson(exportDatasetJson(data));
+ expect(restored.recurring[0]!.splits).toEqual([]);
+ expect(restored.purchases.at(-1)!.splits).toEqual([]);
+ expect(() => monthView(restored, "2026-01")).not.toThrow();
+ });
test("accepts a purchase with no post and preserves it through export", () => {
const data = populated();
data.purchases[0]!.splits = [];
@@ -553,8 +571,8 @@ describe("recurring cost validation", () => {
).toThrow(/unknown post/);
});
- test("no splits at all is refused", () => {
- expect(() => parseDatasetJson(withRecurring([{ ...valid, splits: [] }])))
+ test("a missing splits array is refused", () => {
+ expect(() => parseDatasetJson(withRecurring([{ ...valid, splits: null }])))
.toThrow(/no splits/);
});
diff --git a/src/export/json.ts b/src/export/json.ts
index c8d27f1..db6f5af 100644
--- a/src/export/json.ts
+++ b/src/export/json.ts
@@ -501,7 +501,7 @@ export function parseDatasetJson(text: string): Dataset {
}
}
- requireSplits(cost.splits, cost.splitMode, label, postIds);
+ requireSplits(cost.splits, cost.splitMode, label, postIds, true);
}
for (const month of dataset.months) {
diff --git a/src/frontend.tsx b/src/frontend.tsx
index eedffda..6125e76 100644
--- a/src/frontend.tsx
+++ b/src/frontend.tsx
@@ -13,6 +13,7 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import { App } from "./ui/App.tsx";
import { store } from "./store/index.ts";
+import { captureSessionBackup } from "./store/backups.ts";
import { buildFontCss } from "./fontCss.ts";
import { BASE_PATH } from "./ui/basePath.ts";
@@ -25,7 +26,16 @@ document.head.appendChild(fontStyle);
const container = document.getElementById("root") ?? document.body;
store.load().then(
- () => createRoot(container).render(),
+ async () => {
+ let backupError: string | undefined;
+ try {
+ // Complete the session checkpoint before controls can mutate the dataset.
+ await captureSessionBackup(store.get());
+ } catch (cause) {
+ backupError = cause instanceof Error ? cause.message : String(cause);
+ }
+ createRoot(container).render();
+ },
(error: unknown) => {
container.textContent = `Could not open your budget data: ${
error instanceof Error ? error.message : String(error)
diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts
index ac03435..a71c65c 100644
--- a/src/store/actions.test.ts
+++ b/src/store/actions.test.ts
@@ -635,6 +635,19 @@ describe("recurring costs", () => {
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
};
+ test("an unassigned bill projects without charging a post and confirms as unassigned spending", () => {
+ const data = draft();
+ const bill = actions.addRecurringCost(data, { ...rentInput, amount: { amount: 30, currency: "DKK" }, splits: [] });
+ expect(monthView(data, "2026-01").rows.every(row => row.figures.expected === 0)).toBe(true);
+ const purchase = actions.confirmOccurrence(data, bill.id, "2026-01");
+ expect(purchase.splits).toEqual([]);
+ expect(monthView(data, "2026-01").looseCharges).toBe(30);
+ expect(monthView(data, "2026-01").totalCharges).toBe(30);
+ actions.updateRecurringCost(data, bill.id, { splits: [{ postId: "housing", value: 100, absorbsRemainder: true }] });
+ actions.updateRecurringCost(data, bill.id, { splits: [] });
+ expect(bill.splits).toEqual([]);
+ });
+
test("dedicated post and bill save together", () => {
const data = draft();
const folder = actions.addBudgetFolder(data, "Housing", null);
diff --git a/src/store/actions.ts b/src/store/actions.ts
index 71d4a31..e9d4d13 100755
--- a/src/store/actions.ts
+++ b/src/store/actions.ts
@@ -233,7 +233,6 @@ function roundSplits(
}
function requireRecurringSplitDestinations(draft: Dataset, splits: Split[]): void {
- if (splits.length === 0) throw new Error("A bill needs a destination post");
for (const split of splits) {
const post = requirePost(draft, split.postId);
if (post.archived) throw new Error(`Post ${post.name} is archived`);
diff --git a/src/store/backupRetention.test.ts b/src/store/backupRetention.test.ts
new file mode 100644
index 0000000..ed34c1e
--- /dev/null
+++ b/src/store/backupRetention.test.ts
@@ -0,0 +1,30 @@
+import { expect, test } from "bun:test";
+import { retainedBackups, type BackupRecord } from "./backupRetention.ts";
+
+function record(day: string, suffix = "a"): BackupRecord {
+ return { id: `${day}-${suffix}`, createdAt: `${day}T12:00:00.000Z`, json: "{}" };
+}
+
+test("keeps the newest session from each recent day, week, and month", () => {
+ const records: BackupRecord[] = [];
+ for (let day = 1; day <= 31; day++) records.push(record(`2026-10-${String(day).padStart(2, "0")}`));
+ records.push({ ...record("2026-10-31", "b"), createdAt: "2026-10-31T14:00:00.000Z" });
+ for (let month = 1; month <= 14; month++) records.push(record(`2025-${String(month <= 12 ? month : month - 12).padStart(2, "0")}-15`, `older-${month}`));
+ const kept = retainedBackups(records);
+ expect(kept[0]?.id).toBe("2026-10-31-b");
+ expect(kept.some((item) => item.id === "2026-10-31-a")).toBe(false);
+ expect(kept.filter((item) => item.tier === "daily")).toHaveLength(7);
+ expect(new Set(kept.map((item) => item.id)).size).toBe(kept.length);
+ expect(kept.some((item) => item.tier === "weekly")).toBe(true);
+ expect(kept.some((item) => item.tier === "monthly")).toBe(true);
+});
+
+test("weekly checkpoints use Monday boundaries across New Year", () => {
+ const records: BackupRecord[] = [];
+ for (let day = 22; day <= 31; day++) records.push(record(`2025-12-${day}`));
+ for (let day = 1; day <= 5; day++) records.push(record(`2026-01-0${day}`));
+ const kept = retainedBackups(records);
+ expect(kept.find((item) => item.id === "2025-12-28-a")?.tier).toBe("weekly");
+ expect(kept.some((item) => item.id === "2025-12-29-a")).toBe(false);
+ expect(kept.find((item) => item.id === "2026-01-05-a")?.tier).toBe("daily");
+});
diff --git a/src/store/backupRetention.ts b/src/store/backupRetention.ts
new file mode 100644
index 0000000..8a5307b
--- /dev/null
+++ b/src/store/backupRetention.ts
@@ -0,0 +1,44 @@
+export interface BackupRecord {
+ id: string;
+ /** UTC timestamp, used to make retention independent of the browser's locale. */
+ createdAt: string;
+ json: string;
+}
+export type BackupTier = "daily" | "weekly" | "monthly";
+export interface RetainedBackup extends BackupRecord {
+ tier: BackupTier;
+}
+
+const LIMITS: ReadonlyArray<[BackupTier, number]> = [
+ ["daily", 7],
+ ["weekly", 8],
+ ["monthly", 12],
+];
+
+function weekOf(timestamp: string): string {
+ const date = new Date(`${timestamp.slice(0, 10)}T00:00:00.000Z`);
+ const weekdayFromMonday = (date.getUTCDay() + 6) % 7;
+ date.setUTCDate(date.getUTCDate() - weekdayFromMonday);
+ return date.toISOString().slice(0, 10);
+}
+
+/** Newest snapshot per calendar bucket. A record can cover more than one tier. */
+export function retainedBackups(records: readonly BackupRecord[]): RetainedBackup[] {
+ const newest = [...records].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
+ const selected = new Map();
+ for (const [tier, limit] of LIMITS) {
+ const buckets = new Set();
+ for (const record of newest) {
+ const key = tier === "daily"
+ ? record.createdAt.slice(0, 10)
+ : tier === "weekly"
+ ? weekOf(record.createdAt)
+ : record.createdAt.slice(0, 7);
+ if (buckets.has(key)) continue;
+ if (buckets.size >= limit) break;
+ buckets.add(key);
+ if (!selected.has(record.id)) selected.set(record.id, { ...record, tier });
+ }
+ }
+ return [...selected.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
+}
diff --git a/src/store/backups.ts b/src/store/backups.ts
new file mode 100644
index 0000000..44a1cf7
--- /dev/null
+++ b/src/store/backups.ts
@@ -0,0 +1,75 @@
+import { exportDatasetJson } from "../export/json.ts";
+import type { Dataset } from "../domain/types.ts";
+import { BACKUP_STORE_NAME, openDb } from "./db.ts";
+import { retainedBackups, type BackupRecord, type RetainedBackup } from "./backupRetention.ts";
+
+function request(operation: IDBRequest): Promise {
+ return new Promise((resolve, reject) => {
+ operation.onsuccess = () => resolve(operation.result);
+ operation.onerror = () => reject(operation.error);
+ });
+}
+
+function transactionDone(tx: IDBTransaction): Promise {
+ return new Promise((resolve, reject) => {
+ tx.oncomplete = () => resolve();
+ tx.onabort = () => reject(tx.error ?? new Error("Backup transaction was aborted"));
+ tx.onerror = () => reject(tx.error ?? new Error("Backup transaction failed"));
+ });
+}
+
+/** Writes the new checkpoint and prunes old ones in one transaction. */
+export async function captureSessionBackup(dataset: Dataset, now = new Date()): Promise {
+ const record: BackupRecord = {
+ id: `${now.toISOString()}-${crypto.randomUUID()}`,
+ createdAt: now.toISOString(),
+ json: exportDatasetJson(dataset),
+ };
+ const db = await openDb();
+ try {
+ const tx = db.transaction(BACKUP_STORE_NAME, "readwrite");
+ const done = transactionDone(tx);
+ const objectStore = tx.objectStore(BACKUP_STORE_NAME);
+ try {
+ const existing = await request(objectStore.getAll()) as BackupRecord[];
+ const kept = retainedBackups([...existing, record]);
+ const keepIds = new Set(kept.map((backup) => backup.id));
+ objectStore.put(record);
+ for (const old of existing) {
+ if (!keepIds.has(old.id)) objectStore.delete(old.id);
+ }
+ await done;
+ } catch (cause) {
+ // If the read fails, wait for the abort too, so it cannot become an
+ // unhandled rejection while the startup error is being reported.
+ try { tx.abort(); } catch { /* Already committed or aborted. */ }
+ await done.catch(() => undefined);
+ throw cause;
+ }
+ } finally {
+ db.close();
+ }
+}
+
+export async function listSessionBackups(): Promise {
+ const db = await openDb();
+ try {
+ const tx = db.transaction(BACKUP_STORE_NAME, "readonly");
+ const all = await request(tx.objectStore(BACKUP_STORE_NAME).getAll()) as BackupRecord[];
+ return retainedBackups(all);
+ } finally {
+ db.close();
+ }
+}
+
+export async function readSessionBackup(id: string): Promise {
+ const db = await openDb();
+ try {
+ const tx = db.transaction(BACKUP_STORE_NAME, "readonly");
+ const found = await request(tx.objectStore(BACKUP_STORE_NAME).get(id)) as BackupRecord | undefined;
+ if (!found) throw new Error("This backup is no longer available. Refresh the list and try again.");
+ return found;
+ } finally {
+ db.close();
+ }
+}
diff --git a/src/store/db.ts b/src/store/db.ts
index 1dcff23..e02f188 100644
--- a/src/store/db.ts
+++ b/src/store/db.ts
@@ -4,9 +4,10 @@ import { migrate } from "./migrations.ts";
export const DB_NAME = "budget2";
export const STORE_NAME = "state";
export const RECORD_KEY = "dataset";
+export const BACKUP_STORE_NAME = "backups";
/** IndexedDB's own version. Bumped only when the object stores change. */
-const IDB_VERSION = 1;
+const IDB_VERSION = 2;
function request(req: IDBRequest): Promise {
return new Promise((resolve, reject) => {
@@ -23,6 +24,9 @@ export function openDb(): Promise {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
+ if (!db.objectStoreNames.contains(BACKUP_STORE_NAME)) {
+ db.createObjectStore(BACKUP_STORE_NAME, { keyPath: "id" });
+ }
};
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 8c41b25..afe696d 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -37,7 +37,7 @@ function NavItem({ to, children }: { to: string; children: React.ReactNode }) {
);
}
-export function AppRoutes() {
+export function AppRoutes({ backupError }: { backupError?: string }) {
const year = currentMonth.slice(0, 4);
return (
@@ -62,6 +62,11 @@ export function AppRoutes() {
+ {backupError && (
+
+ Could not create a session backup: {backupError}. Export your data from Settings.
+