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

+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) => {