feat: add month breakdown and session backups
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:
+29
-5
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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…"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")!);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user