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. +

+ )} } /> @@ -79,10 +84,10 @@ export function AppRoutes() { ); } -export function App() { +export function App({ backupError }: { backupError?: string }) { return ( - + ); } diff --git a/src/ui/components/BudgetTree.tsx b/src/ui/components/BudgetTree.tsx index fad7cb2..2f39b39 100644 --- a/src/ui/components/BudgetTree.tsx +++ b/src/ui/components/BudgetTree.tsx @@ -34,7 +34,7 @@ export function BudgetTree({ monthId, rows, onChangeRule }: { monthId: MonthId; }; const billContent = (node: Extract) =>
{node.cost.name} {fmt.money(node.cost.amount.amount, node.cost.amount.currency)}Edit
-

{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"}

+

{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"}

; const purchaseContent = (node: Extract) =>
{node.purchase.description} {fmt.money(node.amount.amount, node.amount.currency)}{node.purchase.splits.length === 0 && }Edit} />
; const render = (nodes: BudgetNode[], mobile: boolean, depth = 0): ReactNode[] => nodes.flatMap(node => { diff --git a/src/ui/components/MonthPieShowcase.test.tsx b/src/ui/components/MonthPieShowcase.test.tsx new file mode 100644 index 0000000..33d9f59 --- /dev/null +++ b/src/ui/components/MonthPieShowcase.test.tsx @@ -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()); + 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); + } +}); diff --git a/src/ui/components/MonthPieShowcase.tsx b/src/ui/components/MonthPieShowcase.tsx new file mode 100644 index 0000000..0207ef6 --- /dev/null +++ b/src/ui/components/MonthPieShowcase.tsx @@ -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("spending"); + const fmt = useMoneyFormat(); + const chart = monthPieData(view, mode); + let angle = 0; + const label = mode === "spending" ? "spending" : "allocation"; + + return ( +
} + > +
+
+ + {chart.positiveTotal === 0 ? ( + + ) : chart.positive.length === 1 ? ( + + ) : chart.positive.map((item, index) => { + const start = angle; + angle += (item.amount / chart.positiveTotal) * 360; + return ( + + {`${item.label}: ${fmt.money(item.amount, baseCurrency)}`} + + ); + })} + +
+
+
+ {mode === "spending" ? "Net spent" : "Total allocated"} + {fmt.money(chart.netTotal, baseCurrency)} +
+ {chart.positive.length === 0 ? ( +

+ {mode === "spending" ? "No positive spending to chart." : "No positive allocations to chart."} +

+ ) : ( +
    + {chart.positive.map((item, index) => ( +
  1. +
  2. + ))} +
+ )} + {chart.adjustments.length > 0 && ( +
+

Refunds and negative adjustments are excluded from pie slices.

+
    + {chart.adjustments.map((item) => ( +
  • + {item.label} + {fmt.money(item.amount, baseCurrency)} +
  • + ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/ui/components/RecurringSplitEditor.test.tsx b/src/ui/components/RecurringSplitEditor.test.tsx index abfc8bc..bdbdc0a 100644 --- a/src/ui/components/RecurringSplitEditor.test.tsx +++ b/src/ui/components/RecurringSplitEditor.test.tsx @@ -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( { 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()); + } +}); diff --git a/src/ui/components/RecurringSplitEditor.tsx b/src/ui/components/RecurringSplitEditor.tsx index 7fd41c1..228c9a7 100644 --- a/src/ui/components/RecurringSplitEditor.tsx +++ b/src/ui/components/RecurringSplitEditor.tsx @@ -20,6 +20,19 @@ export function RecurringSplitEditor({ splitMode, splits, posts, onChange }: Pro return (
Destination posts + {splits.length > 1 && ( { const mode = event.target.value as RecurringCost["splitMode"]; @@ -54,7 +67,7 @@ export function RecurringSplitEditor({ splitMode, splits, posts, onChange }: Pro )} ))} - +
); } diff --git a/src/ui/monthPie.test.ts b/src/ui/monthPie.test.ts new file mode 100644 index 0000000..2c4bc84 --- /dev/null +++ b/src/ui/monthPie.test.ts @@ -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); +}); diff --git a/src/ui/monthPie.ts b/src/ui/monthPie.ts new file mode 100644 index 0000000..ca85909 --- /dev/null +++ b/src/ui/monthPie.ts @@ -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, + }; +} diff --git a/src/ui/recurringForm.test.ts b/src/ui/recurringForm.test.ts index 7e3ace2..d7cf6e3 100644 --- a/src/ui/recurringForm.test.ts +++ b/src/ui/recurringForm.test.ts @@ -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