Merge main into the anchored model

Main moved on by 27 commits since this branch forked, including three schema
steps of its own (budget folders, placement, nested bills). Resolutions worth
knowing:

- The anchored-model migration becomes 10 -> 11; main's 7 -> 10 run first.
- Main's nextUnconfirmedOccurrenceOnOrAfter walked the calendar grid for
  every cost, which would have shown the slot-model "next due" for an
  anchored bill in the budget tree. Both walks are now generators and the
  lookup dispatches on anchoring, like occurrencesOf.
- A folder's "Bills due" summed only occurrences, and an anchored bill's
  charges are never occurrences, so a charged phone plan read as 0 due.
  budgetTree now adds an anchored bill's charges in the month.
- Main's RecurringSection (with RecurringSplitEditor) supersedes this
  branch's split editor; the anchoring rules are ported onto it: start-date
  validation via startDateNeedsDay, month precision hidden for lastCharge,
  the anchoring control locked once a cost has charges.
- PurchaseDialog keeps main's placement control. The "Recurring cost" select
  replaces main's "Unlink from bill" checkbox, since "None" does the same.
  A bill created from the dialog nests under its post like one from Settings.
- Dropped main's "bounded projection ignores invalid confirmations" test: it
  exercised slot confirmations on a lastCharge cost, which no longer exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-09-30 16:51:17 +02:00
commit 933027cb1b
89 files changed
+4023 -651

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
import { expect, test } from "bun:test";
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 { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router";
import { createSeedDataset } from "../../domain/seed.ts";
import { addBudgetFolder, addPurchase, addRecurringCost, confirmOccurrence, placeRecurringUnderPost } from "../../store/actions.ts";
import { monthView } from "../../domain/views.ts";
import { store } from "../../store/index.ts";
import { PostTableLegend } from "./PostTable.tsx";
import { BudgetTree } from "./BudgetTree.tsx";
function setup(run: (container: HTMLDivElement, billId: string) => void, confirm = false) {
const data = createSeedDataset("2026-09");
const housing = addBudgetFolder(data, "Housing", null);
const utilities = addBudgetFolder(data, "Utilities", housing.id); utilities.order = 0;
const post = data.posts[0]!; post.name = "Rent post"; post.folderId = housing.id; post.order = 1;
const bill = addRecurringCost(data, { name: "Rent bill", folderId: housing.id, archived: false, amount: { amount: 8000, currency: "DKK" }, startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 18 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: post.id, value: 100, absorbsRemainder: true }] }); bill.order = 2;
addRecurringCost(data, { ...bill, name: "Ended bill", folderId: null, endedFrom: "2026-08" });
if (confirm) confirmOccurrence(data, bill.id, "2026-09", { date: "2026-09-01" });
const original = { get: store.get, subscribe: store.subscribe }; store.get = () => data; store.subscribe = () => () => {};
const container = document.createElement("div"); document.body.append(container); const root = createRoot(container);
try { act(() => root.render(<MemoryRouter><PostTableLegend baseCurrency="DKK" /><BudgetTree monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>)); run(container, bill.id); }
finally { act(() => root.unmount()); container.remove(); Object.assign(store, original); }
}
function housing(container: HTMLDivElement) { return container.querySelector('button[aria-label="Housing"]') as HTMLButtonElement; }
test("collapsed folder keeps Bills due", () => setup(container => {
expect(housing(container).getAttribute("aria-expanded")).toBe("false");
expect(housing(container).parentElement!.textContent).toContain("8,000");
expect(housing(container).parentElement!.textContent).toContain("Bills due");
expect(container.querySelector('[data-budget-name="Rent post"]')).toBeNull();
}));
test("expanded tree preserves mixed order", () => setup(container => {
act(() => housing(container).click());
for (const scope of [container.querySelector("table")!, container.querySelector("ul")!]) {
const names = Array.from(scope.querySelectorAll("[data-budget-item]")).map(item => item.getAttribute("data-budget-name"));
expect(names.slice(0, 4)).toEqual(["Housing", "Utilities", "Rent post", "Rent bill"]);
}
expect((container.querySelector('table [data-budget-name="Rent post"] td') as HTMLElement).style.paddingInlineStart).toBe("16px");
expect((container.querySelector('ul [data-budget-name="Rent post"]') as HTMLElement).style.paddingInlineStart).toBe("16px");
expect(container.textContent).toContain("Unfiled"); expect(container.textContent).toContain("Social");
expect(container.querySelector("table > div")).toBeNull();
}));
test("bill edit reaches its settings row", () => setup((container, billId) => {
act(() => housing(container).click());
expect(container.querySelector(`[data-budget-item="${billId}"] a`)!.getAttribute("href")).toBe(`/settings#bill-${billId}`);
expect(container.textContent).toContain("Ended");
expect(container.textContent).toContain("Next 2028-03");
}, true));
test("legend scopes base currency to post figures and identifies bill currencies", () => setup(container => {
const legend = container.querySelector("span.flex.flex-wrap")!;
expect(legend.textContent).toContain("Post figures in DKK.");
expect(legend.textContent).toContain("Bill prices show their own currency.");
expect(legend.textContent).toContain("Projected subtracts bills not yet confirmed.");
expect(legend.textContent).not.toContain("All figures");
}));
test("bills beneath a post are hidden until its post is expanded", () => {
const data = createSeedDataset("2026-09");
const games = data.posts[0]!;
const bill = addRecurringCost(data, { name: "Star Rail Pass", folderId: null, archived: false, amount: { amount: 35, currency: "DKK" }, startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: games.id, value: 100, absorbsRemainder: true }] });
placeRecurringUnderPost(data, bill.id, games.id);
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div"); document.body.append(container);
const root = createRoot(container);
try {
act(() => root.render(<MemoryRouter><BudgetTree monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>));
const controls = Array.from(container.querySelectorAll(`button[aria-label="Bills under ${games.name}"]`)) as HTMLButtonElement[];
expect(controls).toHaveLength(2);
expect(controls[0]!.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelector(`[data-budget-item="${bill.id}"]`)).toBeNull();
act(() => controls[0]!.click());
expect(controls[0]!.getAttribute("aria-expanded")).toBe("true");
expect(container.querySelectorAll(`[data-budget-item="${bill.id}"]`)).toHaveLength(2);
} finally {
act(() => root.unmount()); container.remove(); Object.assign(store, original);
}
});
test("Unassigned is a permanent Budget section with a create-post action", () => {
const data = createSeedDataset("2026-09");
const purchase = addPurchase(data, { date: "2026-09", description: "Train ticket", total: { amount: 50, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null });
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div"); document.body.append(container);
const root = createRoot(container);
try {
act(() => root.render(<MemoryRouter><BudgetTree monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>));
for (const scope of [container.querySelector("table")!, container.querySelector("ul")!]) {
expect(scope.textContent).toContain("Unassigned");
const row = scope.querySelector(`[data-budget-item="${purchase.id}"]`)!;
expect(row.textContent).toContain("Train ticket");
expect(row.querySelector("button[aria-label='Create post for Train ticket']")).not.toBeNull();
}
} finally {
act(() => root.unmount()); container.remove(); Object.assign(store, original);
}
});
+64
View File
@@ -0,0 +1,64 @@
import { useState, type ReactNode } from "react";
import { Link } from "react-router";
import { ChevronDown, ChevronRight } from "lucide-react";
import { budgetTree, type BudgetNode } from "../../domain/budgetTree.ts";
import { createPostForPurchase } from "../../store/actions.ts";
import type { MonthId, Recurrence } from "../../domain/types.ts";
import type { MonthPostRow } from "../../domain/views.ts";
import { useDataset } from "../hooks/useDataset.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { useMutate } from "../hooks/useMutate.ts";
import { DesktopPostBody, MobilePostRow } from "./PostTable.tsx";
import { PurchaseDialog } from "./PurchaseDialog.tsx";
function cadence(rule: Recurrence) {
const unit = rule.kind === "everyNMonths" ? "month" : rule.kind === "everyNWeeks" ? "week" : "day";
return `Every ${rule.n} ${unit}${rule.n === 1 ? "" : "s"}`;
}
/** A mixed placement tree; only post rows carry envelope balances. */
export function BudgetTree({ monthId, rows, onChangeRule }: { monthId: MonthId; rows: MonthPostRow[]; onChangeRule?: (postId: string) => void }) {
const dataset = useDataset();
const fmt = useMoneyFormat();
const { mutate, error } = useMutate();
const tree = budgetTree(dataset, monthId, rows);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const toggle = (id: string) => setExpanded(previous => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; });
const folderHeading = (node: Extract<BudgetNode, { kind: "folder" }>) => {
const open = expanded.has(node.folder.id);
const Icon = open ? ChevronDown : ChevronRight;
return <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
<button type="button" aria-label={node.folder.name} aria-expanded={open} onClick={() => toggle(node.folder.id)} className="flex items-center gap-2 rounded-sm py-2 font-medium hover:text-budget-accent focus-visible:outline-2 focus-visible:outline-ring"><Icon size={16} aria-hidden="true" />{node.folder.name}</button>
<span className="text-xs text-budget-ink-muted">Bills due <span className="font-money ml-2">{fmt.money(node.billsDue, dataset.settings.baseCurrency)}</span></span>
</div>;
};
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>
</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 => {
const indent = Math.min(depth, 4) * 16;
const id = node.kind === "folder" ? node.folder.id : node.kind === "post" ? node.row.post.id : node.kind === "purchase" ? node.purchase.id : node.cost.id;
if (node.kind === "post") {
const rowProps = { row: node.row, indent, monthId, onChangeRule, ...(node.children.length ? { childrenExpanded: expanded.has(id), onToggleChildren: () => toggle(id) } : {}) };
return [mobile ? <MobilePostRow key={id} {...rowProps} /> : <DesktopPostBody key={id} {...rowProps} />, ...(expanded.has(id) ? render(node.children, mobile, depth + 1) : [])];
}
const name = node.kind === "folder" ? node.folder.name : node.kind === "purchase" ? node.purchase.description : node.cost.name;
const content = node.kind === "folder" ? folderHeading(node) : node.kind === "purchase" ? purchaseContent(node) : billContent(node);
const style = { paddingInlineStart: `${indent}px` };
const item = mobile ? <li key={id} data-budget-item={id} data-budget-name={name} style={style}>{content}</li> : <tbody key={id} data-budget-item={id} data-budget-name={name}><tr><td colSpan={6} className={node.kind === "folder" ? "border-b border-budget-rule pt-3" : "border-b border-budget-rule"} style={style}>{content}</td></tr></tbody>;
return [item, ...(node.kind === "folder" && expanded.has(id) ? render(node.children, mobile, depth + 1) : [])];
});
return <>
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
<div className="hidden overflow-x-auto sm:block"><table className="w-full text-sm"><thead className="text-left"><tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">{["Budget item", "Carried in", "Allocated", "Spent", "Remaining", "Projected"].map((label, index) => <th key={label} className={`py-2 font-medium ${index ? "pl-6 text-right" : ""}`}>{label}</th>)}</tr></thead>
{render(tree.roots, false)}
{tree.unfiled.length > 0 && <tbody><tr><th colSpan={6} className="border-b border-budget-rule pb-2 pt-5 text-left font-medium">Unfiled</th></tr></tbody>}{render(tree.unfiled, false)}
<tbody><tr><th colSpan={6} className="border-b border-budget-rule pb-2 pt-5 text-left font-medium">Unassigned</th></tr></tbody>
{render(tree.unassigned, false, 1)}
{tree.unassigned.length === 0 && <tbody><tr><td colSpan={6} className="py-3 text-sm text-budget-ink-muted">No unassigned purchases this month.</td></tr></tbody>}
</table></div>
<ul className="space-y-3 sm:hidden">{render(tree.roots, true)}{tree.unfiled.length > 0 && <li className="border-b border-budget-rule pb-2 pt-4 font-medium">Unfiled</li>}{render(tree.unfiled, true)}<li className="border-b border-budget-rule pb-2 pt-4 font-medium">Unassigned</li>{render(tree.unassigned, true, 1)}{tree.unassigned.length === 0 && <li className="py-2 text-sm text-budget-ink-muted">No unassigned purchases this month.</li>}</ul>
</>;
}
+1
View File
@@ -68,6 +68,7 @@ function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
id: "r1",
name: "Rent",
order: 0,
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
+5 -3
View File
@@ -194,7 +194,7 @@ export function expectedGroups(
*/
export function ExpectedBand({ monthId }: { monthId: MonthId }) {
const dataset = useDataset();
const { mutate } = useMutate();
const { mutate, error } = useMutate();
const fmt = useMoneyFormat();
const [amounts, setAmounts] = useState<Record<string, string>>({});
@@ -262,7 +262,7 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
size="sm"
variant="outline"
disabled={invalid}
onClick={() => {
onClick={async () => {
// Captured before mutate, per AGENTS.md: mutate defers behind
// the write queue and React resets the input first, so reading
// in the callback commits stale text. `invalid` already guards
@@ -270,12 +270,13 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
// `parsed` is either a real amount or the field was never
// touched.
if (invalid) return;
mutate((draft) =>
const saved = await mutate((draft) =>
confirmOccurrence(draft, occurrence.recurringId, occurrence.date, {
date: recordedDate ?? undefined,
amount: parsed ?? undefined,
}),
);
if (!saved) return;
setAmounts((current) => {
const next = { ...current };
delete next[key];
@@ -320,6 +321,7 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
title="Expected"
hint="Committed but not yet spent. These do not touch a post's balance until you confirm one, which records it as an ordinary purchase you can edit or delete afterwards."
>
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not confirm: {error}</p>}
{pending.length > 0 && (
<ul className="divide-y divide-budget-rule">
{pending.map((occurrence) => row(occurrence, recordedDateFor("pending", today)))}
@@ -0,0 +1,94 @@
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 { afterEach, expect, test } from "bun:test";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { createSnapshotStore } from "../../store/snapshot.ts";
import { store } from "../../store/index.ts";
import { HistoryControls } from "./HistoryControls.tsx";
const original = { getHistory: store.getHistory, subscribe: store.subscribe, undo: store.undo, redo: store.redo };
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) act(() => root!.unmount());
root = null;
container?.remove();
container = null;
Object.assign(store, original);
});
async function setup() {
let saved = createSeedDataset("2026-09");
let fail = false;
const history = createSnapshotStore({
async read() { return saved; },
async write(next) {
if (fail) throw new Error("Disk full");
saved = next;
},
}, "2026-09");
await history.load();
Object.assign(store, {
getHistory: history.getHistory,
subscribe: history.subscribe,
undo: history.undo,
redo: history.redo,
});
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root!.render(<HistoryControls />));
return { history, failWrites(value: boolean) { fail = value; } };
}
test("buttons follow saved history and restore data", async () => {
const { history } = await setup();
const undo = container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!;
const redo = container!.querySelector<HTMLButtonElement>('button[aria-label="Redo"]')!;
expect(undo.disabled).toBe(true);
expect(redo.disabled).toBe(true);
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
expect(undo.disabled).toBe(false);
await act(async () => { undo.click(); });
expect(history.get().posts[0]!.name).toBe("Video Games");
expect(redo.disabled).toBe(false);
await act(async () => { redo.click(); });
expect(history.get().posts[0]!.name).toBe("Changed");
});
test("a failed undo shows an error and permits retry", async () => {
const { history, failWrites } = await setup();
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
failWrites(true);
await act(async () => { container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!.click(); });
expect(container!.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
expect(history.get().posts[0]!.name).toBe("Changed");
failWrites(false);
await act(async () => { container!.querySelector<HTMLButtonElement>('button[aria-label="Undo"]')!.click(); });
expect(container!.querySelector('[role="alert"]')).toBeNull();
});
test("keyboard shortcut reverses saved edits outside editable controls", async () => {
const { history } = await setup();
await act(async () => { await history.mutate(draft => { draft.posts[0]!.name = "Changed"; }); });
const field = document.createElement("input");
container!.append(field);
await act(async () => {
field.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true }));
});
expect(history.get().posts[0]!.name).toBe("Changed");
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true }));
});
expect(history.get().posts[0]!.name).toBe("Video Games");
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true }));
});
expect(history.get().posts[0]!.name).toBe("Changed");
});
+59
View File
@@ -0,0 +1,59 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { Redo2, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { store } from "../../store/index.ts";
/** Session history controls. Dataset writes remain serialized by the store. */
export function HistoryControls() {
const { canUndo, canRedo } = useSyncExternalStore(store.subscribe, store.getHistory);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const busyRef = useRef(false);
const run = useCallback(async (direction: "undo" | "redo") => {
if (busyRef.current) return;
busyRef.current = true;
setBusy(true);
try {
await store[direction]();
setError(null);
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
setError(`Could not ${direction}: ${message}. Your data has not changed.`);
} finally {
busyRef.current = false;
setBusy(false);
}
}, []);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.altKey || !(event.ctrlKey || event.metaKey)) return;
const target = event.target;
if (target instanceof Element && target.closest("input, textarea, select, [contenteditable]")) return;
const key = event.key.toLowerCase();
const direction = key === "z"
? (event.shiftKey ? "redo" : "undo")
: key === "y" && !event.shiftKey ? "redo" : null;
if (!direction) return;
event.preventDefault();
void run(direction);
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [run]);
return (
<div className="flex items-center gap-0.5 border-r border-budget-rule pr-1.5 mr-1.5">
<Button type="button" variant="ghost" size="icon-sm" aria-label="Undo"
title="Undo (Ctrl/Cmd+Z)" disabled={busy || !canUndo} onClick={() => void run("undo")}>
<Undo2 aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Redo"
title="Redo (Ctrl/Cmd+Shift+Z)" disabled={busy || !canRedo} onClick={() => void run("redo")}>
<Redo2 aria-hidden="true" />
</Button>
{error && <p role="alert" className="absolute right-4 top-full max-w-[calc(100vw-2rem)] rounded-b-md border border-t-0 border-destructive/40 bg-background px-3 py-2 text-sm text-destructive shadow-sm sm:right-6">{error}</p>}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
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 { store } from "../../store/index.ts";
import { emptyDraft, type PurchaseDraft } from "../purchaseForm.ts";
import { PlanEditor } from "./PlanEditor.tsx";
test("a purchase dated by month can start a finance plan in that month", () => {
const original = { get: store.get, subscribe: store.subscribe };
const dataset = createSeedDataset("2026-09");
store.get = () => dataset;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
let draft: PurchaseDraft = { ...emptyDraft("2026-09", "food"), amount: 90 };
const render = () => root.render(<PlanEditor draft={draft} onChange={(next) => { draft = next; render(); }} />);
try {
act(render);
const button = container.querySelector("button")!;
expect(button.disabled).toBe(false);
act(() => button.click());
expect(draft.plan?.slices.map((slice) => slice.month)).toEqual(["2026-09", "2026-10", "2026-11"]);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
test("an incomplete purchase date cannot start a finance plan", () => {
const original = { get: store.get, subscribe: store.subscribe };
const dataset = createSeedDataset("2026-09");
store.get = () => dataset;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
try {
act(() => root.render(<PlanEditor draft={{ ...emptyDraft("2026-09", "food"), date: "" }} onChange={() => {}} />));
expect(container.querySelector("button")!.disabled).toBe(true);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
+7 -9
View File
@@ -17,15 +17,13 @@ interface Props {
onChange: (next: PurchaseDraft) => void;
}
// A native <input type="date"> reports "" until the user has filled in every
// field, and monthOf() throws on anything that isn't a well-formed
// YYYY-MM-DD. This is UI-level input validation, not a domain concern — the
// domain is right to throw on malformed input; the caller just must not call
// it with malformed input.
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function hasValidDate(draft: PurchaseDraft): boolean {
return ISO_DATE_PATTERN.test(draft.date);
try {
monthOf(draft.date);
return true;
} catch {
return false;
}
}
export function PlanEditor({ draft, onChange }: Props) {
@@ -53,7 +51,7 @@ export function PlanEditor({ draft, onChange }: Props) {
</Button>
{!dateReady && (
<p className="text-xs text-muted-foreground">
Pick a date for this purchase first.
Pick a month or date for this purchase first.
</p>
)}
</div>
+43
View File
@@ -0,0 +1,43 @@
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 { MemoryRouter } from "react-router";
import { createSeedDataset } from "../../domain/seed.ts";
import { monthView } from "../../domain/views.ts";
import { store } from "../../store/index.ts";
import { PostTable } from "./PostTable.tsx";
test("an unbudgeted post shows no allocation or overspend status, unlike an explicit zero rule", () => {
const data = createSeedDataset("2026-09");
const [unbudgeted, zero] = data.posts;
unbudgeted!.name = "Unbudgeted";
unbudgeted!.rules = [];
zero!.name = "Zero rule";
zero!.rules = [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 0, currency: "DKK" } } }];
data.purchases = [unbudgeted!, zero!].map((post, i) => ({
id: `p${i}`, date: "2026-09", description: post.name,
total: { amount: 25, currency: "DKK" }, splitMode: "percent" as const,
splits: [{ postId: post.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(<MemoryRouter><PostTable monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>));
const noneRow = container.querySelector('tbody[data-budget-name="Unbudgeted"]')!;
const zeroRow = container.querySelector('tbody[data-budget-name="Zero rule"]')!;
expect(noneRow.querySelectorAll("td")[2]!.textContent).toBe("—");
expect(noneRow.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(false);
expect(zeroRow.querySelectorAll("td")[2]!.textContent).toContain("0");
expect(zeroRow.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(true);
} finally {
act(() => root.unmount());
Object.assign(store, original);
}
});
+44 -23
View File
@@ -1,4 +1,5 @@
import { Link } from "react-router";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useMoneyFormat, type MoneyFormat } from "../hooks/useMoneyFormat.ts";
import { ruleAt } from "../../domain/allocation.ts";
@@ -50,21 +51,21 @@ interface RowCells {
* plain function, not a component, and both table markups call it from inside
* a map.
*/
function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells {
function postRowCells({ figures, hasAllocationRule }: MonthPostRow, fmt: MoneyFormat): RowCells {
return {
carriedIn: fmt.signedAmount(figures.carriedIn),
carriedInTone:
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
: hasAllocationRule && figures.carriedIn < 0
? "text-overspend"
: "",
allocation: fmt.amount(figures.allocation),
allocation: hasAllocationRule ? fmt.amount(figures.allocation) : "—",
charges: fmt.amount(figures.charges),
remaining: fmt.signedAmount(figures.remaining),
remainingTone: figures.remaining < 0 ? "text-overspend" : "",
remainingTone: hasAllocationRule && figures.remaining < 0 ? "text-overspend" : "",
projected: fmt.signedAmount(figures.projected),
segments: carryMeterSegments(figures),
segments: carryMeterSegments(figures, hasAllocationRule),
};
}
@@ -76,7 +77,7 @@ export function PostTableLegend({ baseCurrency }: { baseCurrency: Currency }) {
return (
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span>
All figures in {baseCurrency}. Projected subtracts bills not yet confirmed.
Post figures in {baseCurrency}. Bill prices show their own currency. Projected subtracts bills not yet confirmed.
</span>
<Swatch token="--surplus" label="carried in" />
<Swatch token="--budget-accent" label="spent" />
@@ -173,7 +174,6 @@ function ChangeRuleButton({
}
export function PostTable({ monthId, rows, onChangeRule }: Props) {
const fmt = useMoneyFormat();
return (
<>
<div className="hidden overflow-x-auto sm:block">
@@ -194,11 +194,38 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
belong together, which is exactly how the meter came to look like
a divider between two rows rather than part of one. */}
{rows.map((row) => {
const cells = postRowCells(row, fmt);
return (
<tbody key={row.post.id} className="group">
<DesktopPostBody key={row.post.id} row={row} monthId={monthId} onChangeRule={onChangeRule} />
);
})}
</table>
</div>
<ul className="space-y-4 sm:hidden">
{rows.map((row) => {
return (
<MobilePostRow key={row.post.id} row={row} monthId={monthId} onChangeRule={onChangeRule} />
);
})}
</ul>
</>
);
}
interface PostRowProps { row: MonthPostRow; monthId: MonthId; onChangeRule?: (postId: string) => void; indent?: number; childrenExpanded?: boolean; onToggleChildren?: () => void; }
function PostChildrenToggle({ postName, expanded, onToggle }: { postName: string; expanded: boolean; onToggle: () => void }) {
const Icon = expanded ? ChevronDown : ChevronRight;
return <button type="button" aria-label={`Bills under ${postName}`} aria-expanded={expanded} onClick={onToggle} className="mr-1 inline-flex size-6 align-middle items-center justify-center rounded-sm text-budget-ink-muted hover:text-budget-accent focus-visible:outline-2 focus-visible:outline-ring"><Icon size={16} aria-hidden="true" /></button>;
}
export function DesktopPostBody({ row, monthId, onChangeRule, indent = 0, childrenExpanded, onToggleChildren }: PostRowProps) {
const cells = postRowCells(row, useMoneyFormat());
return (
<tbody data-budget-item={row.post.id} data-budget-name={row.post.name} className="group">
<tr className="transition-colors group-hover:bg-accent/60">
<td className="pb-0 pt-3">
<td className="pb-0 pt-3" style={{ paddingInlineStart: indent }}>
{onToggleChildren && <PostChildrenToggle postName={row.post.name} expanded={!!childrenExpanded} onToggle={onToggleChildren} />}
<PostName row={row} monthId={monthId} />
{onChangeRule && (
<ChangeRuleButton
@@ -234,18 +261,16 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
</td>
</tr>
</tbody>
);
})}
</table>
</div>
);
}
<ul className="space-y-4 sm:hidden">
{rows.map((row) => {
const cells = postRowCells(row, fmt);
return (
<li key={row.post.id} className="space-y-1.5">
export function MobilePostRow({ row, monthId, onChangeRule, indent = 0, childrenExpanded, onToggleChildren }: PostRowProps) {
const cells = postRowCells(row, useMoneyFormat());
return (
<li data-budget-item={row.post.id} data-budget-name={row.post.name} className="space-y-1.5" style={{ paddingInlineStart: indent }}>
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="min-w-0">
{onToggleChildren && <PostChildrenToggle postName={row.post.name} expanded={!!childrenExpanded} onToggle={onToggleChildren} />}
<PostName row={row} monthId={monthId} />
</span>
<span
@@ -281,9 +306,5 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
)}
</p>
</li>
);
})}
</ul>
</>
);
}
+25 -2
View File
@@ -35,6 +35,8 @@ import {
addPurchase,
addRecurringCost,
cancelScheduleFrom,
placePurchase,
placeRecurringUnderPost,
updatePurchase,
} from "../../store/actions.ts";
import {
@@ -62,8 +64,12 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
const activePosts = dataset.posts
.filter((p) => !p.archived)
.sort((a, b) => a.order - b.order);
const editablePosts = dataset.posts
.filter((p) => !p.archived || purchase?.splits.some(split => split.postId === p.id))
.sort((a, b) => a.order - b.order);
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState(() => purchase?.placement ? purchase.placement.folderId ?? "__unfiled" : "__ledger");
const [draft, setDraft] = useState<PurchaseDraft>(() =>
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
@@ -107,6 +113,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
: offerable;
function reset() {
setPlacement(purchase?.placement ? purchase.placement.folderId ?? "__unfiled" : "__ledger");
setDraft(
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
);
@@ -142,6 +149,9 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
data,
seedCostFromDraft(draft, newRecurrence, newAnchoring),
);
// Nested under its post when it has exactly one, the same default a
// bill created in Settings gets.
if (seeded.splits.length === 1) placeRecurringUnderPost(data, seeded.id, seeded.splits[0]!.postId);
source = sourceFor(data, seeded, draft.date);
} else if (recurringSelection !== "none") {
const cost = data.recurring.find((c) => c.id === recurringSelection);
@@ -165,6 +175,8 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
// when the owner picks "None" would leave a stored association
// untouched instead of clearing it.
updatePurchase(data, purchase.id, { ...toPurchase(draft), source });
const destination = draft.splits.length === 0 || placement === "__ledger" ? undefined : placement === "__unfiled" ? null : placement;
if (destination !== purchase.placement?.folderId || (destination === undefined) !== (purchase.placement === undefined)) placePurchase(data, purchase.id, destination);
} else {
addPurchase(data, { ...toPurchase(draft), ...(source ? { source } : {}) });
}
@@ -246,7 +258,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
setBulk((b) => ({ ...b, postId }));
}}
>
<option value="">Choose a post…</option>
<option value="">No post (unassigned)</option>
{activePosts.map((post) => (
<option key={post.id} value={post.id}>
{post.name}
@@ -557,7 +569,18 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
</fieldset>
)}
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
<SplitEditor draft={draft} posts={purchase ? editablePosts : activePosts} onChange={setDraft} />
{purchase && draft.splits.length === 0 && <p className="text-xs text-budget-ink-muted">This purchase appears under Unassigned in Budget.</p>}
{purchase && draft.splits.length > 0 && <div className="space-y-1">
<Label htmlFor="purchase-placement">Budget listing</Label>
<NativeSelect id="purchase-placement" value={placement} onChange={event => setPlacement(event.target.value)}>
<option value="__ledger">Monthly purchases only</option>
<option value="__unfiled">Own row in Unfiled</option>
{dataset.folders.map(folder => <option key={folder.id} value={folder.id}>Own row in {folder.name}</option>)}
</NativeSelect>
<p className="text-xs text-budget-ink-muted">Post splits above determine which balances this purchase charges.</p>
</div>}
<PlanEditor draft={draft} onChange={setDraft} />
</>
@@ -0,0 +1,30 @@
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 { RecurringSplitEditor, type RecurringSplitSelection } from "./RecurringSplitEditor.tsx";
test("bill can split across active posts", () => {
const data = createSeedDataset("2026-09");
data.posts[0]!.archived = true;
const active = data.posts.filter(post => !post.archived);
let selection: RecurringSplitSelection = { splitMode: "percent", splits: [{ postId: active[0]!.id, value: 100, absorbsRemainder: true }] };
const container = document.createElement("div");
const root = createRoot(container);
const render = () => root.render(<RecurringSplitEditor {...selection} posts={active} onChange={next => { selection = next; render(); }} />);
act(render);
expect(container.querySelector(`option[value="${data.posts[0]!.id}"]`)).toBeNull();
act(() => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Add another post"))!).click(); });
expect(selection.splits).toHaveLength(2);
const selects = container.querySelectorAll("select");
act(() => { selects[2]!.value = active[1]!.id; selects[2]!.dispatchEvent(new Event("change", { bubbles: true })); });
const radios = container.querySelectorAll('input[type="radio"]');
act(() => { (radios[1] as HTMLInputElement).click(); });
expect(selection.splits.map(split => split.postId)).toEqual([active[0]!.id, active[1]!.id]);
expect(selection.splits.map(split => split.absorbsRemainder)).toEqual([false, true]);
act(() => root.unmount());
});
@@ -0,0 +1,60 @@
import { Button } from "@/components/ui/button";
import { useId } from "react";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import type { Post, RecurringCost, Split } from "../../domain/types.ts";
export type RecurringSplitSelection = Pick<RecurringCost, "splitMode" | "splits">;
interface Props extends RecurringSplitSelection {
posts: Post[];
onChange: (next: RecurringSplitSelection) => void;
}
export function RecurringSplitEditor({ splitMode, splits, posts, onChange }: Props) {
const absorberGroup = useId();
function changeSplit(index: number, changes: Partial<Split>) {
onChange({ splitMode, splits: splits.map((split, i) => i === index ? { ...split, ...changes } : split) });
}
return (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Destination posts</legend>
{splits.length > 1 && (
<NativeSelect aria-label="Split by" value={splitMode} onChange={event => {
const mode = event.target.value as RecurringCost["splitMode"];
onChange({ splitMode: mode, splits: splits.map((split, index) => ({ ...split, value: mode === "percent" && index === 0 ? 100 : 0 })) });
}}>
<option value="percent">Percentages</option>
<option value="fixed">Amounts</option>
</NativeSelect>
)}
{splits.map((split, index) => (
<div key={index} className="flex flex-wrap items-center gap-2">
<NativeSelect
aria-label={index === 0 ? "Destination post" : `Destination post ${index + 1}`}
className="min-w-36 flex-1"
value={split.postId}
onChange={event => changeSplit(index, { postId: event.target.value })}
>
<option value="">Choose a post…</option>
{posts.map(post => <option key={post.id} value={post.id}>{post.name}</option>)}
</NativeSelect>
{splits.length > 1 && (
<>
<Input aria-label={`Split value ${index + 1}`} type="number" step="0.01" className="w-24" value={split.value} onChange={event => changeSplit(index, { value: Number(event.target.value) || 0 })} />
<span className="text-xs text-budget-ink-muted">{splitMode === "percent" ? "%" : "amount"}</span>
<label className="flex items-center gap-1 text-xs"><input type="radio" name={absorberGroup} checked={split.absorbsRemainder} onChange={() => onChange({ splitMode, splits: splits.map((item, i) => ({ ...item, absorbsRemainder: i === index })) })} />rounding</label>
<Button type="button" size="xs" variant="ghost" onClick={() => {
const remaining = splits.filter((_, i) => i !== index);
if (!remaining.some(item => item.absorbsRemainder)) remaining[0] = { ...remaining[0]!, absorbsRemainder: true };
onChange({ splitMode, splits: remaining });
}}>remove</Button>
</>
)}
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => onChange({ splitMode, splits: [...splits, { postId: "", value: 0, absorbsRemainder: false }] })}>Add another post</Button>
</fieldset>
);
}
+101
View File
@@ -0,0 +1,101 @@
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 { afterEach, expect, test } from "bun:test";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { store } from "../../store/index.ts";
import { RuleHistory } from "./RuleHistory.tsx";
import { RuleFromMonth } from "../routes/MonthRoute.tsx";
const originalMutate = store.mutate;
let root: Root | null = null;
let container: HTMLDivElement | null = null;
function mount(child: React.ReactNode): HTMLDivElement {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root!.render(child));
return container;
}
afterEach(() => {
store.mutate = originalMutate;
if (root) act(() => root!.unmount());
root = null;
container?.remove();
container = null;
});
test("change from here preserves the currency of a prefilled fixed rule", async () => {
const data = createSeedDataset("2026-01");
const post = data.posts[0]!;
const initial = { kind: "fixed" as const, amount: { amount: 100, currency: "USD" } };
let saved = false;
store.mutate = async (change) => {
change(data);
saved = true;
};
const el = mount(
<RuleFromMonth post={post} monthId="2026-01" initial={initial} onDone={() => {}} />,
);
expect(el.querySelector('input[aria-label="Amount in USD"]')).not.toBeNull();
await act(async () => {
(Array.from(el.querySelectorAll("button")).find((button) => button.textContent === "Apply")!).click();
});
expect(saved).toBe(true);
expect(post.rules[0]!.rule).toEqual(initial);
});
test("change from here keeps the editor open and explains a failed write", async () => {
const data = createSeedDataset("2026-01");
let closed = false;
store.mutate = async () => {
throw new Error("Disk full");
};
const el = mount(
<RuleFromMonth
post={data.posts[0]!}
monthId="2026-01"
initial={{ kind: "percentOfIncome", percent: 10 }}
onDone={() => { closed = true; }}
/>,
);
await act(async () => {
(Array.from(el.querySelectorAll("button")).find((button) => button.textContent === "Apply")!).click();
});
expect(closed).toBe(false);
expect(el.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
});
test("rule history explains a failed write", async () => {
const data = createSeedDataset("2026-01");
const post = data.posts[0]!;
post.rules = [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } }];
store.mutate = async () => {
throw new Error("Disk full");
};
const el = mount(<RuleHistory post={post} />);
await act(async () => {
(Array.from(el.querySelectorAll("button")).find((button) => button.textContent === "remove")!).click();
});
expect(el.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
});
test("rule history shows imported versions in chronological order", () => {
const data = createSeedDataset("2026-01");
const post = data.posts[0]!;
post.rules = [
{ from: "2026-07", rule: { kind: "percentOfIncome", percent: 15 } },
{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } },
];
const el = mount(<RuleHistory post={post} />);
const months = Array.from(el.querySelectorAll("tbody tr td:first-child"), (cell) => cell.textContent);
expect(months).toEqual(["2026-01", "2026-07"]);
});
+12 -8
View File
@@ -27,12 +27,13 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/;
* so rather than letting it look like an append.
*/
export function RuleHistory({ post }: Props) {
const { mutate } = useMutate();
const { mutate, error, clearError } = useMutate();
const [from, setFrom] = useState<string>(currentMonth);
const [kind, setKind] = useState<Rule["kind"]>("percentOfIncome");
// Held as text, not a number, so the field can be cleared and can hold a
// half-typed "12." mid-entry. Parsed once, on submit.
const [value, setValue] = useState<string>("");
const versions = [...post.rules].sort((a, b) => a.from.localeCompare(b.from));
const replaces = post.rules.some((v) => v.from === from);
const monthOk = MONTH_ID.test(from);
@@ -44,15 +45,16 @@ export function RuleHistory({ post }: Props) {
kind === "fixed"
? { kind: "fixed", amount: { amount, currency: post.currency } }
: { kind: "percentOfIncome", percent: amount };
mutate((draft) => setRuleFrom(draft, post.id, from, rule));
clearError();
void mutate((draft) => setRuleFrom(draft, post.id, from, rule));
}
return (
<div className="space-y-3 rounded-md border border-budget-rule p-3">
{post.rules.length === 0 ? (
<p className="text-xs text-muted-foreground">
Not budgeted. Until a rule starts, this post is allocated nothing —
spending still records, and shows as overspend.
Not budgeted. Until a rule starts, this post is allocated nothing.
Spending still records without an overspend status.
</p>
) : (
<table className="w-full text-xs">
@@ -64,7 +66,7 @@ export function RuleHistory({ post }: Props) {
</tr>
</thead>
<tbody>
{post.rules.map((version) => (
{versions.map((version) => (
<tr key={version.from} className="border-t">
<td className="py-1 font-money">{version.from}</td>
<td className="py-1">
@@ -76,9 +78,10 @@ export function RuleHistory({ post }: Props) {
<Button
size="sm"
variant="ghost"
onClick={() =>
mutate((draft) => removeRuleFrom(draft, post.id, version.from))
}
onClick={() => {
clearError();
void mutate((draft) => removeRuleFrom(draft, post.id, version.from));
}}
>
remove
</Button>
@@ -137,6 +140,7 @@ export function RuleHistory({ post }: Props) {
</span>
)}
</div>
{error && <p role="alert" className="text-sm text-destructive">Could not save: {error}</p>}
</div>
);
}
+22
View File
@@ -39,6 +39,26 @@ export function SplitEditor<T extends SplitTarget>({ draft, posts, onChange }: P
return (
<fieldset className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={draft.splits.length === 0}
onChange={(event) =>
onChange({
...draft,
splits: event.target.checked
? []
: posts[0]
? [{ postId: posts[0].id, value: 100, absorbsRemainder: true }]
: [],
})
}
disabled={posts.length === 0 && draft.splits.length === 0}
/>
No post — leave this purchase unassigned
</label>
{draft.splits.length > 0 && (
<>
<div className="flex items-center justify-between">
<Label>Split across posts</Label>
<Segmented
@@ -136,6 +156,8 @@ export function SplitEditor<T extends SplitTarget>({ draft, posts, onChange }: P
: `${fmt.amount(balance)} ${unit} unassigned — the rounding post absorbs it.`}
</span>
</div>
</>
)}
</fieldset>
);
}
+11 -2
View File
@@ -59,7 +59,7 @@ export function YearMatrix({ view, mode }: Props) {
<td
key={i}
className={`font-money py-2 pl-4 text-right whitespace-nowrap ${
mode === "balance" && value < 0 ? "text-overspend" : ""
mode === "balance" && row.hasAllocationRuleByMonth[i] && value < 0 ? "text-overspend" : ""
}`}
>
{fmt.amount(value)}
@@ -68,13 +68,22 @@ export function YearMatrix({ view, mode }: Props) {
})}
<td
className={`font-money py-2 pl-4 text-right font-medium whitespace-nowrap ${
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
mode === "balance" && row.hasAllocationRuleByMonth[11] && row.closingBalance < 0 ? "text-overspend" : ""
}`}
>
{fmt.amount(mode === "spend" ? row.totalCharges : row.closingBalance)}
</td>
</tr>
))}
{mode === "spend" && view.totalLooseCharges !== 0 && (
<tr className="border-b border-budget-rule text-budget-ink-muted">
<td className="py-2">Unassigned</td>
{view.looseByMonth.map((amount, i) => (
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">{fmt.amount(amount)}</td>
))}
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">{fmt.amount(view.totalLooseCharges)}</td>
</tr>
)}
</tbody>
</table>
</div>