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
2026-10-01 04:01:49 +02:00
parent 019517e6e9
commit 7cccd7a41a
32 changed files with 640 additions and 41 deletions
+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…"
+1 -1
View File
@@ -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");
+7
View File
@@ -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")!);
+3
View File
@@ -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(
+1 -1
View File
@@ -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[];
}
+20 -2
View File
@@ -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/);
});
+1 -1
View File
@@ -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) {
+11 -1
View File
@@ -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(<App />),
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(<App backupError={backupError} />);
},
(error: unknown) => {
container.textContent = `Could not open your budget data: ${
error instanceof Error ? error.message : String(error)
+13
View File
@@ -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);
-1
View File
@@ -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`);
+30
View File
@@ -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");
});
+44
View File
@@ -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<string, RetainedBackup>();
for (const [tier, limit] of LIMITS) {
const buckets = new Set<string>();
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));
}
+75
View File
@@ -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<T>(operation: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
operation.onsuccess = () => resolve(operation.result);
operation.onerror = () => reject(operation.error);
});
}
function transactionDone(tx: IDBTransaction): Promise<void> {
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<void> {
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<RetainedBackup[]> {
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<BackupRecord> {
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();
}
}
+5 -1
View File
@@ -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<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
@@ -23,6 +24,9 @@ export function openDb(): Promise<IDBDatabase> {
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);
+8 -3
View File
@@ -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 (
<div className="min-h-screen bg-background text-foreground">
@@ -62,6 +62,11 @@ export function AppRoutes() {
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
{backupError && (
<p role="alert" className="mb-5 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
Could not create a session backup: {backupError}. Export your data from Settings.
</p>
)}
<RouteErrorBoundaryReset>
<Routes>
<Route path="/month/:monthId" element={<MonthRoute />} />
@@ -79,10 +84,10 @@ export function AppRoutes() {
);
}
export function App() {
export function App({ backupError }: { backupError?: string }) {
return (
<BrowserRouter basename={ROUTER_BASENAME}>
<AppRoutes />
<AppRoutes backupError={backupError} />
</BrowserRouter>
);
}
+1 -1
View File
@@ -34,7 +34,7 @@ export function BudgetTree({ monthId, rows, onChangeRule }: { monthId: MonthId;
};
const billContent = (node: Extract<BudgetNode, { kind: "bill" }>) => <div className="space-y-1 py-3 text-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2"><span className="font-medium">{node.cost.name} <span className="ml-2 font-money font-normal">{fmt.money(node.cost.amount.amount, node.cost.amount.currency)}</span></span><Link className="text-xs underline underline-offset-2 hover:text-budget-accent" to={`/settings#bill-${node.cost.id}`}>Edit</Link></div>
<p className="text-xs text-budget-ink-muted">{cadence(node.cost.recurrence)} · Assigned to {node.cost.splits.map(split => dataset.posts.find(post => post.id === split.postId)?.name).join(", ")} · {node.nextOccurrence ? `Next ${node.nextOccurrence.date}` : "Ended"}</p>
<p className="text-xs text-budget-ink-muted">{cadence(node.cost.recurrence)} · {node.cost.splits.length === 0 ? "Unassigned" : `Assigned to ${node.cost.splits.map(split => dataset.posts.find(post => post.id === split.postId)?.name).join(", ")}`} · {node.nextOccurrence ? `Next ${node.nextOccurrence.date}` : "Ended"}</p>
</div>;
const purchaseContent = (node: Extract<BudgetNode, { kind: "purchase" }>) => <div className="flex flex-wrap items-center justify-between gap-2 py-3 text-sm"><span>{node.purchase.description} <span className="ml-2 font-money">{fmt.money(node.amount.amount, node.amount.currency)}</span></span><span className="flex items-center gap-3">{node.purchase.splits.length === 0 && <button type="button" aria-label={`Create post for ${node.purchase.description}`} className="text-xs underline underline-offset-2 hover:text-budget-accent" onClick={() => void mutate(draft => createPostForPurchase(draft, node.purchase.id))}>Create post</button>}<PurchaseDialog monthId={monthId} purchase={node.purchase} trigger={<button type="button" className="text-xs underline underline-offset-2 hover:text-budget-accent">Edit</button>} /></span></div>;
const render = (nodes: BudgetNode[], mobile: boolean, depth = 0): ReactNode[] => nodes.flatMap(node => {
@@ -0,0 +1,35 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { monthView } from "../../domain/views.ts";
import { store } from "../../store/index.ts";
import { MonthPieShowcase } from "./MonthPieShowcase.tsx";
test("pie showcase switches between spending and allocations", () => {
const data = createSeedDataset("2026-09");
data.posts[0]!.rules = [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 250, currency: "DKK" } } }];
data.months[0]!.income = { amount: 1000, currency: "DKK" };
data.purchases.push({ id: "p", date: "2026-09", description: "Games", total: { amount: 50, currency: "DKK" }, splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }], schedule: null });
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
try {
act(() => root.render(<MonthPieShowcase view={monthView(data, "2026-09")} baseCurrency="DKK" />));
expect(container.textContent).toContain("50.00 DKK");
const allocation = [...container.querySelectorAll("button")].find((button) => button.textContent === "Allocated")!;
act(() => allocation.click());
expect(allocation.getAttribute("aria-pressed")).toBe("true");
expect(container.textContent).toContain("250.00 DKK");
expect(container.querySelector("svg[role='img']")).not.toBeNull();
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
+99
View File
@@ -0,0 +1,99 @@
import { useState } from "react";
import type { MonthViewModel } from "../../domain/views.ts";
import { monthPieData, type MonthPieMode } from "../monthPie.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { Section } from "./Section.tsx";
import { Segmented } from "./Segmented.tsx";
const COLORS = ["#0d6b60", "#cc8050", "#537ba8", "#9a6bb1", "#9b8b39", "#ca6574", "#4a9b93", "#816eaf"];
const OPTIONS = [
{ value: "spending", label: "Spent" },
{ value: "allocation", label: "Allocated" },
] as const;
function point(degrees: number): [number, number] {
const radians = ((degrees - 90) * Math.PI) / 180;
return [100 + 88 * Math.cos(radians), 100 + 88 * Math.sin(radians)];
}
function wedge(start: number, end: number): string {
const [startX, startY] = point(start);
const [endX, endY] = point(end);
return `M 100 100 L ${startX} ${startY} A 88 88 0 ${end - start > 180 ? 1 : 0} 1 ${endX} ${endY} Z`;
}
export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel; baseCurrency: string }) {
const [mode, setMode] = useState<MonthPieMode>("spending");
const fmt = useMoneyFormat();
const chart = monthPieData(view, mode);
let angle = 0;
const label = mode === "spending" ? "spending" : "allocation";
return (
<Section
title="Monthly breakdown"
hint={`Share of this month's ${label} by post${mode === "spending" ? ", including unassigned purchases" : ""}.`}
action={<Segmented value={mode} options={OPTIONS} onChange={setMode} label="Pie chart view" />}
>
<div className="grid items-center gap-5 sm:grid-cols-[minmax(12rem,16rem)_1fr] sm:gap-8">
<div className="mx-auto w-full max-w-56">
<svg viewBox="0 0 200 200" role="img" aria-label={`${label} pie chart`} className="aspect-square w-full">
{chart.positiveTotal === 0 ? (
<circle cx="100" cy="100" r="88" fill="var(--budget-tint)" />
) : chart.positive.length === 1 ? (
<circle cx="100" cy="100" r="88" fill={COLORS[0]} />
) : chart.positive.map((item, index) => {
const start = angle;
angle += (item.amount / chart.positiveTotal) * 360;
return (
<path
key={item.id}
d={wedge(start, index === chart.positive.length - 1 ? 360 : angle)}
fill={COLORS[index % COLORS.length]}
stroke="var(--budget-raised)"
strokeWidth="2"
>
<title>{`${item.label}: ${fmt.money(item.amount, baseCurrency)}`}</title>
</path>
);
})}
</svg>
</div>
<div className="min-w-0">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-x-5 gap-y-1 border-b border-budget-rule pb-3">
<span className="text-sm text-budget-ink-muted">{mode === "spending" ? "Net spent" : "Total allocated"}</span>
<strong className="font-money text-xl font-medium tabular-nums">{fmt.money(chart.netTotal, baseCurrency)}</strong>
</div>
{chart.positive.length === 0 ? (
<p className="py-5 text-sm text-budget-ink-muted">
{mode === "spending" ? "No positive spending to chart." : "No positive allocations to chart."}
</p>
) : (
<ol className="space-y-2.5">
{chart.positive.map((item, index) => (
<li key={item.id} className="grid grid-cols-[1rem_minmax(0,1fr)_auto] items-baseline gap-x-2.5 text-sm">
<span className="inline-block size-2.5 self-center rounded-full" style={{ backgroundColor: COLORS[index % COLORS.length] }} aria-hidden="true" />
<span className="min-w-0 truncate" title={item.label}>{item.label}</span>
<span className="font-money text-right tabular-nums whitespace-nowrap">{fmt.money(item.amount, baseCurrency)} <span className="ml-1 text-xs text-budget-ink-muted">{Math.round((item.amount / chart.positiveTotal) * 100)}%</span></span>
</li>
))}
</ol>
)}
{chart.adjustments.length > 0 && (
<div className="mt-4 border-t border-budget-rule pt-3">
<p className="mb-2 text-xs text-budget-ink-muted">Refunds and negative adjustments are excluded from pie slices.</p>
<ul className="space-y-1.5 text-sm">
{chart.adjustments.map((item) => (
<li key={item.id} className="flex justify-between gap-3">
<span className="min-w-0 truncate" title={item.label}>{item.label}</span>
<span className="font-money shrink-0 tabular-nums">{fmt.money(item.amount, baseCurrency)}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</Section>
);
}
@@ -28,3 +28,19 @@ test("bill can split across active posts", () => {
expect(selection.splits.map(split => split.absorbsRemainder)).toEqual([false, true]);
act(() => root.unmount());
});
test("an unassigned bill can gain a destination with a rounding post", () => {
const data = createSeedDataset("2026-09");
let selection: RecurringSplitSelection = { splitMode: "percent", splits: [] };
const container = document.createElement("div");
const root = createRoot(container);
const render = () => root.render(<RecurringSplitEditor {...selection} posts={data.posts} onChange={next => { selection = next; render(); }} />);
try {
act(render);
expect(container.textContent).toContain("No post");
act(() => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Add another post"))!).click(); });
expect(selection.splits).toEqual([{ postId: "", value: 100, absorbsRemainder: true }]);
} finally {
act(() => root.unmount());
}
});
+14 -1
View File
@@ -20,6 +20,19 @@ export function RecurringSplitEditor({ splitMode, splits, posts, onChange }: Pro
return (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Destination posts</legend>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={splits.length === 0}
onChange={(event) => onChange({
splitMode,
splits: event.target.checked
? []
: [{ postId: posts[0]?.id ?? "", value: 100, absorbsRemainder: true }],
})}
/>
No post — keep this recurring cost unassigned
</label>
{splits.length > 1 && (
<NativeSelect aria-label="Split by" value={splitMode} onChange={event => {
const mode = event.target.value as RecurringCost["splitMode"];
@@ -54,7 +67,7 @@ export function RecurringSplitEditor({ splitMode, splits, posts, onChange }: Pro
)}
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => onChange({ splitMode, splits: [...splits, { postId: "", value: 0, absorbsRemainder: false }] })}>Add another post</Button>
<Button type="button" size="sm" variant="outline" onClick={() => onChange({ splitMode, splits: [...splits, { postId: "", value: splits.length === 0 ? 100 : 0, absorbsRemainder: splits.length === 0 }] })}>Add another post</Button>
</fieldset>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from "bun:test";
import { createSeedDataset } from "../domain/seed.ts";
import { monthView } from "../domain/views.ts";
import { monthPieData } from "./monthPie.ts";
test("spending pie separates positive slices from refunds and includes unassigned charges", () => {
const data = createSeedDataset("2026-09");
const [games, food] = data.posts;
data.purchases = [
{ id: "food", date: "2026-09", description: "Food", total: { amount: 90, currency: "DKK" }, splitMode: "percent", splits: [{ postId: food!.id, value: 100, absorbsRemainder: true }], schedule: null },
{ id: "refund", date: "2026-09", description: "Refund", total: { amount: -20, currency: "DKK" }, splitMode: "percent", splits: [{ postId: games!.id, value: 100, absorbsRemainder: true }], schedule: null },
{ id: "loose", date: "2026-09", description: "Loose", total: { amount: 30, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null },
];
const chart = monthPieData(monthView(data, "2026-09"), "spending");
expect(chart.positive.map(({ label, amount }) => [label, amount])).toEqual([["Food", 90], ["Unassigned", 30]]);
expect(chart.adjustments.map(({ label, amount }) => [label, amount])).toEqual([["Video Games", -20]]);
expect(chart.positiveTotal).toBe(120);
expect(chart.netTotal).toBe(100);
});
test("allocation pie reflects the month view and has an honest empty state", () => {
const data = createSeedDataset("2026-09");
const empty = monthPieData(monthView(data, "2026-09"), "allocation");
expect(empty.positive).toEqual([]);
data.posts[0]!.rules = [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 250, currency: "DKK" } } }];
data.months[0]!.income = { amount: 1000, currency: "DKK" };
const chart = monthPieData(monthView(data, "2026-09"), "allocation");
expect(chart.positive.map(({ label, amount }) => [label, amount])).toEqual([["Video Games", 250]]);
expect(chart.netTotal).toBe(250);
});
+29
View File
@@ -0,0 +1,29 @@
import type { MonthViewModel } from "../domain/views.ts";
export type MonthPieMode = "spending" | "allocation";
export interface MonthPieSlice {
id: string;
label: string;
amount: number;
}
/** Chart data comes from the same folded figures as the month table. */
export function monthPieData(view: MonthViewModel, mode: MonthPieMode) {
const items: MonthPieSlice[] = view.rows.map(({ post, figures }) => ({
id: post.id,
label: post.name,
amount: mode === "spending" ? figures.charges : figures.allocation,
}));
if (mode === "spending" && view.looseCharges !== 0) {
items.push({ id: "unassigned", label: "Unassigned", amount: view.looseCharges });
}
const byAmount = (a: MonthPieSlice, b: MonthPieSlice) => b.amount - a.amount || a.label.localeCompare(b.label);
const positive = items.filter((item) => item.amount > 0).sort(byAmount);
const adjustments = items.filter((item) => item.amount < 0).sort((a, b) => a.amount - b.amount);
return {
positive,
adjustments,
positiveTotal: positive.reduce((sum, item) => sum + item.amount, 0),
netTotal: mode === "spending" ? view.totalCharges : view.totalAllocation,
};
}
+4
View File
@@ -183,6 +183,10 @@ describe("sourceFor", () => {
// offered; it is tested directly, since a DOM test cannot exercise a
// predicate the way it can a rendered <option disabled>.
describe("newCostOptionIsSelectable", () => {
test("an unassigned purchase can create a new bill", () => {
expect(newCostOptionIsSelectable("everyNMonths", "calendar", "2026-09")).toBe(true);
});
test("everyNMonths + calendar is selectable at either granularity — rent occurs IN a month", () => {
expect(newCostOptionIsSelectable("everyNMonths", "calendar", "2026-09")).toBe(true);
expect(newCostOptionIsSelectable("everyNMonths", "calendar", "2026-09-20")).toBe(true);
+3
View File
@@ -15,6 +15,7 @@ import { sliceAmountForMonth } from "../../domain/charges.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { PostTableLegend } from "../components/PostTable.tsx";
import { BudgetTree } from "../components/BudgetTree.tsx";
import { MonthPieShowcase } from "../components/MonthPieShowcase.tsx";
import { PurchaseDialog } from "../components/PurchaseDialog.tsx";
import { ExpectedBand } from "../components/ExpectedBand.tsx";
import { Meter } from "../components/Meter.tsx";
@@ -129,6 +130,8 @@ export function MonthRoute() {
</div>
</Section>
<MonthPieShowcase view={view} baseCurrency={base} />
<ExpectedBand monthId={monthId} />
<Section
+2
View File
@@ -5,6 +5,7 @@ import { BudgetFoldersSection } from "./settings/BudgetFoldersSection.tsx";
import { PostsSection } from "./settings/PostsSection.tsx";
import { RecurringSection } from "./settings/RecurringSection.tsx";
import { DataSection } from "./settings/DataSection.tsx";
import { BackupSection } from "./settings/BackupSection.tsx";
import { ColophonSection } from "./settings/ColophonSection.tsx";
/**
@@ -39,6 +40,7 @@ export function SettingsRoute() {
<BudgetFoldersSection />
<PostsSection />
<RecurringSection />
<BackupSection />
<DataSection />
<ColophonSection />
</div>
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { parseDatasetJson, exportDatasetJson } from "../../../export/json.ts";
import { listSessionBackups, readSessionBackup } from "../../../store/backups.ts";
import type { RetainedBackup } from "../../../store/backupRetention.ts";
import { store } from "../../../store/index.ts";
import { downloadBlob } from "../../download.ts";
import { Section } from "../../components/Section.tsx";
function filename(backup: RetainedBackup): string {
return `budget-backup-${backup.createdAt.replaceAll(":", "-")}.json`;
}
function describeError(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
}
export function BackupSection() {
const [backups, setBackups] = useState<RetainedBackup[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [restoring, setRestoring] = useState<RetainedBackup | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let active = true;
listSessionBackups().then(
(items) => { if (active) setBackups(items); },
(cause) => { if (active) setError(`Could not read backups: ${describeError(cause)}`); },
);
return () => { active = false; };
}, []);
async function download(backup: RetainedBackup) {
try {
setError(null);
const record = await readSessionBackup(backup.id);
downloadBlob(filename(backup), new Blob([record.json], { type: "application/json" }));
} catch (cause) {
setError(`Could not download backup: ${describeError(cause)}`);
}
}
async function confirmRestore() {
if (!restoring || busy) return;
setBusy(true);
setError(null);
try {
const record = await readSessionBackup(restoring.id);
const restored = parseDatasetJson(record.json);
// Same rule as JSON import and Reset: download the active dataset before
// a whole-dataset write. If this fails, leave the active data intact.
downloadBlob(
`budget-before-restore-${new Date().toISOString().replaceAll(":", "-")}.json`,
new Blob([exportDatasetJson(store.get())], { type: "application/json" }),
);
await store.replace(restored);
setRestoring(null);
} catch (cause) {
setError(`Could not restore backup: ${describeError(cause)}. Your active data was not changed.`);
} finally {
setBusy(false);
}
}
return (
<Section
title="Session backups"
hint="Saved in this browser when Budget opens. The newest backup from each of 7 days, 8 weeks and 12 months is kept. Export JSON too: browser storage can be lost together."
>
{error && <p role="alert" className="mb-3 text-sm text-destructive">{error}</p>}
{backups === null ? (
<p className="text-sm text-budget-ink-muted">Loading backups…</p>
) : backups.length === 0 ? (
<p className="text-sm text-budget-ink-muted">No session backups yet.</p>
) : (
<ul className="divide-y divide-budget-rule">
{backups.map((backup) => (
<li key={backup.id} className="flex flex-wrap items-center justify-between gap-2 py-2 text-sm">
<span>
<span className="font-medium">{new Date(backup.createdAt).toLocaleString()}</span>
<span className="ml-2 text-xs capitalize text-budget-ink-muted">{backup.tier}</span>
</span>
<span className="flex gap-1">
<Button size="xs" variant="ghost" onClick={() => void download(backup)}>Download</Button>
<Button size="xs" variant="outline" onClick={() => { setError(null); setRestoring(backup); }}>Restore…</Button>
</span>
</li>
))}
</ul>
)}
{restoring && (
<div className="mt-4 space-y-3 rounded-md border border-destructive/60 bg-destructive/5 p-4 text-sm">
<p>Restore the backup from {new Date(restoring.createdAt).toLocaleString()}? It will replace your active budget.</p>
<p className="text-xs text-budget-ink-muted">A JSON copy of your current budget will download first.</p>
<div className="flex gap-2">
<Button size="sm" variant="destructive" disabled={busy} onClick={() => void confirmRestore()}>Restore backup</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => setRestoring(null)}>Cancel</Button>
</div>
</div>
)}
</Section>
);
}
+1 -1
View File
@@ -160,7 +160,7 @@ export function DataSection() {
{confirmingReset && (
<div className="mt-4 space-y-3 rounded-md border border-destructive/60 bg-destructive/5 p-4 text-sm">
<p className="font-medium">This deletes everything in this browser.</p>
<p className="font-medium">This replaces your active budget. Session backups remain available in this browser.</p>
<p>
Now: {counts.posts} posts, {counts.months} months, {counts.purchases} purchases,{" "}
{dataset.currencies.length} currencies.
@@ -83,6 +83,33 @@ test("new bill defaults to a same-name post and accepts schedule controls", asyn
}
});
test("Settings can create a recurring cost with no post", async () => {
const data = createSeedDataset("2026-09");
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
store.get = () => data;
store.subscribe = () => () => {};
store.mutate = async change => { change(data); };
const container = document.createElement("div");
const root = createRoot(container);
document.body.append(container);
try {
act(() => root.render(<RecurringSection />));
const name = container.querySelector('input[placeholder="New cost name"]') as HTMLInputElement;
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(name, "Phone"); name.dispatchEvent(new InputEvent("input", { bubbles: true })); });
const amount = container.querySelector('input[aria-label="New bill amount"]') as HTMLInputElement;
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(amount, "30 DKK"); amount.dispatchEvent(new InputEvent("input", { bubbles: true })); });
const destination = container.querySelector('select[aria-label="Destination type"]') as HTMLSelectElement;
act(() => { destination.value = "unassigned"; destination.dispatchEvent(new Event("change", { bubbles: true })); });
const add = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Add recurring cost")!;
expect(add.disabled).toBe(false);
await act(async () => { add.click(); });
expect(data.recurring[0]!.splits).toEqual([]);
expect(data.recurring[0]!.parentPostId).toBeUndefined();
} finally {
act(() => root.unmount()); container.remove(); Object.assign(store, original);
}
});
test("failed destination edit keeps its editor and error visible", async () => {
const data = createSeedDataset("2026-09");
const cost = addRecurringCost(data, {
+8 -6
View File
@@ -120,7 +120,7 @@ export function RecurringSection() {
const { mutate, error } = useMutate();
const [newName, setNewName] = useState("");
const [newAmount, setNewAmount] = useState(`0 ${dataset.settings.baseCurrency}`);
const [destinationKind, setDestinationKind] = useState<"existing" | "dedicated">("dedicated");
const [destinationKind, setDestinationKind] = useState<"existing" | "dedicated" | "unassigned">("dedicated");
const [newPostName, setNewPostName] = useState("");
const [newInterval, setNewInterval] = useState("1");
const [newUnit, setNewUnit] = useState<Recurrence["kind"]>("everyNMonths");
@@ -476,15 +476,16 @@ export function RecurringSection() {
<option value="">Unfiled</option>
{dataset.folders.map(folder => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
</NativeSelect>
<NativeSelect aria-label="Destination type" value={destinationKind} onChange={event => setDestinationKind(event.target.value as "existing" | "dedicated")}>
<NativeSelect aria-label="Destination type" value={destinationKind} onChange={event => setDestinationKind(event.target.value as "existing" | "dedicated" | "unassigned")}>
<option value="dedicated">Create dedicated post</option>
<option value="existing">Use existing post</option>
<option value="unassigned">No post (unassigned)</option>
</NativeSelect>
{destinationKind === "dedicated" ? (
<Input aria-label="Dedicated post name" placeholder={newName.trim() || "Same name as bill"} value={newPostName} onChange={event => setNewPostName(event.target.value)} />
) : (
) : destinationKind === "existing" ? (
<RecurringSplitEditor {...newSelection} posts={activePosts} onChange={setNewSelection} />
)}
) : null}
<Button
variant="outline"
disabled={newName.trim() === "" || !parsedNewAmount || !validNewInterval || !validNewStart || (destinationKind === "existing" && !validNewSplits)}
@@ -513,8 +514,9 @@ export function RecurringSection() {
};
if (selectedKind === "dedicated") addRecurringCostWithPost(draft, input, dedicatedName);
else {
const bill = addRecurringCost(draft, { ...input, splitMode: selectedMode, splits: selectedSplits });
if (selectedFolder === null && selectedSplits.length === 1) placeRecurringUnderPost(draft, bill.id, selectedSplits[0]!.postId);
const splits = selectedKind === "unassigned" ? [] : selectedSplits;
const bill = addRecurringCost(draft, { ...input, splitMode: selectedMode, splits });
if (selectedFolder === null && splits.length === 1) placeRecurringUnderPost(draft, bill.id, splits[0]!.postId);
}
},
).then((saved) => {