fix(ui): harden summary and settings workflows

This commit is contained in:
Lucas Winther committed 2026-09-29 19:41:58 +02:00
1 parent c93ff9908c
commit aed2313167
10 files changed
+159 -36

No files matched your search

+9
View File
@@ -181,6 +181,15 @@ describe("yearView", () => {
});
describe("summaryView", () => {
test("difference follows the dataset's decimal setting", () => {
const precise: Dataset = {
...data,
settings: { ...data.settings, digits: 3 },
months: [{ id: "2026-01", income: { amount: 1.234, currency: "DKK" }, ruleOverrides: {} }],
purchases: [spend("small", "food", 0.111, "2026-01-10")],
};
expect(summaryView(precise, "2026-01", "2026-01").difference).toBe(1.123);
});
test("totals charges by post and by month across a range", () => {
const view = summaryView(data, "2026-01", "2026-02");
expect(view.totalCharges).toBe(800);
+13 -8
View File
@@ -149,6 +149,7 @@ export interface SummaryViewModel {
byMonth: { monthId: MonthId; charges: number }[];
totalCharges: number;
totalIncome: number;
difference: number;
}
export function summaryView(
@@ -181,19 +182,23 @@ export function summaryView(
),
}));
const totalCharges = roundMoney(
byMonth.reduce((sum, m) => sum + m.charges, 0),
baseDigits,
);
const totalIncome = roundMoney(
months.reduce((sum, m) => sum + incomeFor(dataset, m), 0),
baseDigits,
);
return {
from,
to,
byPost,
byMonth,
totalCharges: roundMoney(
byMonth.reduce((sum, m) => sum + m.charges, 0),
baseDigits,
),
totalIncome: roundMoney(
months.reduce((sum, m) => sum + incomeFor(dataset, m), 0),
baseDigits,
),
totalCharges,
totalIncome,
difference: roundMoney(totalIncome - totalCharges, baseDigits),
};
}
+5 -3
View File
@@ -174,7 +174,7 @@ export function expectedGroups(
*/
export function ExpectedBand({ monthId }: { monthId: MonthId }) {
const dataset = useDataset();
const { mutate } = useMutate();
const { mutate, error } = useMutate();
const [amounts, setAmounts] = useState<Record<string, string>>({});
const { pending, overdue, comingUp } = useMemo(
@@ -239,7 +239,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
@@ -247,12 +247,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];
@@ -272,6 +273,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)))}
+4 -1
View File
@@ -18,7 +18,10 @@ export function useMutate() {
const mutate = useCallback((fn: (draft: Dataset) => void): Promise<boolean> => {
return store.mutate(fn).then(
() => true,
() => {
setError(null);
return true;
},
(cause: unknown) => {
setError(cause instanceof Error ? cause.message : String(cause));
return false;
+10
View File
@@ -0,0 +1,10 @@
import { expect, test } from "bun:test";
import { isMonthId } from "./SummaryRoute.tsx";
test("Summary range accepts real months and rejects partial or impossible input", () => {
expect(isMonthId("2026-09")).toBe(true);
expect(isMonthId("")).toBe(false);
expect(isMonthId("2026-0")).toBe(false);
expect(isMonthId("2026-13")).toBe(false);
expect(isMonthId("2026-09-01")).toBe(false);
});
+27 -13
View File
@@ -5,10 +5,19 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useDataset } from "../hooks/useDataset.ts";
import { datasetMonthSpan, summaryView } from "../../domain/views.ts";
import { monthOf } from "../../domain/months.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { Section, Stat } from "../components/Section.tsx";
import { Segmented } from "../components/Segmented.tsx";
export function isMonthId(value: string): boolean {
try {
return monthOf(value) === value;
} catch {
return false;
}
}
export function SummaryRoute() {
const fmt = useMoneyFormat();
const dataset = useDataset();
@@ -17,9 +26,10 @@ export function SummaryRoute() {
const [to, setTo] = useState(span.to);
const [groupBy, setGroupBy] = useState<"post" | "month">("post");
const view = summaryView(dataset, from, to);
const validFrom = isMonthId(from);
const validTo = isMonthId(to);
const view = validFrom && validTo ? summaryView(dataset, from, to) : null;
const base = dataset.settings.baseCurrency;
const difference = Number((view.totalIncome - view.totalCharges).toFixed(2));
return (
<div className="space-y-5">
@@ -29,11 +39,11 @@ export function SummaryRoute() {
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1">
<Label htmlFor="from">From</Label>
<Input id="from" className="w-32" value={from} onChange={(e) => setFrom(e.target.value)} />
<Input id="from" className="w-32" value={from} aria-invalid={!validFrom} onChange={(e) => setFrom(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="to">To</Label>
<Input id="to" className="w-32" value={to} onChange={(e) => setTo(e.target.value)} />
<Input id="to" className="w-32" value={to} aria-invalid={!validTo} onChange={(e) => setTo(e.target.value)} />
</div>
<Button
variant="outline"
@@ -46,16 +56,20 @@ export function SummaryRoute() {
</Button>
</div>
<dl className="mt-5 flex flex-wrap gap-x-10 gap-y-4 border-t border-budget-rule pt-5">
<Stat label="Income">{fmt.money(view.totalIncome, base)}</Stat>
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
<Stat label="Difference" tone={difference < 0 ? "overspend" : "default"}>
{fmt.money(difference, base)}
</Stat>
</dl>
{view ? (
<dl className="mt-5 flex flex-wrap gap-x-10 gap-y-4 border-t border-budget-rule pt-5">
<Stat label="Income">{fmt.money(view.totalIncome, base)}</Stat>
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
<Stat label="Difference" tone={view.difference < 0 ? "overspend" : "default"}>
{fmt.money(view.difference, base)}
</Stat>
</dl>
) : (
<p role="alert" className="mt-4 text-sm text-destructive">Enter a month as YYYY-MM in each field.</p>
)}
</Section>
<Section
{view && <Section
title={groupBy === "post" ? "By post" : "By month"}
action={
<Segmented
@@ -107,7 +121,7 @@ export function SummaryRoute() {
))}
</tbody>
</table>
</Section>
</Section>}
</div>
);
}
+11 -3
View File
@@ -21,7 +21,7 @@ import type { Currency } from "../../../domain/types.ts";
export function CurrencySection() {
const dataset = useDataset();
const { mutate } = useMutate();
const { mutate, error: writeError } = useMutate();
const base = dataset.settings.baseCurrency;
const apiUrl = dataset.settings.fxApiUrl ?? DEFAULT_FX_API_URL;
const [status, setStatus] = useState<string | null>(null);
@@ -32,9 +32,13 @@ export function CurrencySection() {
setStatus("Fetching…");
try {
const rates = await fetchFxRates(apiUrl, base, targets);
mutate((draft) => {
const saved = await mutate((draft) => {
for (const rate of rates) setFxRate(draft, rate);
});
if (!saved) {
setStatus(null);
return;
}
const missing = targets.filter((t) => !rates.some((r) => r.currency === t));
setStatus(
`Updated ${rates.length} rate(s).` +
@@ -67,7 +71,11 @@ export function CurrencySection() {
title="Currencies"
hint="Add any currency you spend in. The code is how it is identified, so it cannot be changed later — and everything is converted to the base currency for totals."
>
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
{(error || writeError) && (
<p role="alert" className="mb-3 text-sm text-destructive">
{error ?? `Could not save: ${writeError}`}
</p>
)}
<div className="mb-5 flex flex-wrap items-end gap-x-8 gap-y-4">
<div className="space-y-1.5">
@@ -0,0 +1,65 @@
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 { PostsSection } from "./PostsSection.tsx";
const originalGet = store.get;
const originalMutate = store.mutate;
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
store.get = originalGet;
store.mutate = originalMutate;
if (root) act(() => root!.unmount());
root = null;
container?.remove();
container = null;
});
test("a failed post archive explains why the post stayed active", async () => {
const dataset = createSeedDataset("2026-01");
store.get = () => dataset;
store.mutate = async () => { throw new Error("Disk full"); };
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root!.render(<PostsSection />));
await act(async () => {
Array.from(container!.querySelectorAll("button"))
.find((button) => button.textContent === "archive")!.click();
});
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
expect(dataset.posts[0]!.archived).toBe(false);
});
test("a successful retry clears the previous save error", async () => {
const dataset = createSeedDataset("2026-01");
store.get = () => dataset;
let fail = true;
store.mutate = async (change) => {
if (fail) throw new Error("Disk full");
change(dataset);
};
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root!.render(<PostsSection />));
const archive = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent === "archive")!;
await act(async () => { archive.click(); });
expect(container.querySelector('[role="alert"]')).not.toBeNull();
fail = false;
await act(async () => { archive.click(); });
expect(container.querySelector('[role="alert"]')).toBeNull();
});
+9 -4
View File
@@ -13,7 +13,7 @@ import { Section } from "../../components/Section.tsx";
export function PostsSection() {
const dataset = useDataset();
const { mutate } = useMutate();
const { mutate, error } = useMutate();
const [newName, setNewName] = useState("");
const [openHistory, setOpenHistory] = useState<string | null>(null);
const ordered = [...dataset.posts].sort((a, b) => a.order - b.order);
@@ -40,7 +40,9 @@ export function PostsSection() {
title="Posts"
hint="A post's allocation is a dated series: each rule applies from its own month onward until the next one takes over, so changing it never rewrites what earlier months got. Click a rule to see and edit that history. A single month can still override its own allocation from the month view, and percentages may total more than 100%."
>
<table className="w-full text-sm">
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] text-sm">
<thead className="text-left">
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
<th className="py-2 font-medium">Name</th>
@@ -130,6 +132,7 @@ export function PostsSection() {
))}
</tbody>
</table>
</div>
<p className="mt-3 max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
Archiving hides a post from new purchases while keeping its history. Posts
are never deleted, because purchases reference them.
@@ -146,8 +149,10 @@ export function PostsSection() {
variant="outline"
disabled={newName.trim() === ""}
onClick={() => {
mutate((draft) => addPost(draft, newName.trim(), base));
setNewName("");
const name = newName.trim();
void mutate((draft) => addPost(draft, name, base)).then((saved) => {
if (saved) setNewName("");
});
}}
>
Add post
+6 -4
View File
@@ -95,7 +95,7 @@ export function normalizedStartDateFor(kind: Recurrence["kind"], startDate: IsoD
export function RecurringSection() {
const dataset = useDataset();
const { mutate } = useMutate();
const { mutate, error } = useMutate();
const [newName, setNewName] = useState("");
const ordered = [...dataset.recurring].sort((a, b) => a.order - b.order);
const base = dataset.settings.baseCurrency;
@@ -202,6 +202,7 @@ export function RecurringSection() {
title="Recurring costs"
hint="Bills that repeat. Each one projects forward as EXPECTED spending and is confirmed from the month view when the money actually moves — expected never touches a post's real balance. Anchoring decides where the next charge is measured from: the calendar, or the date the last one actually charged. Use the second for a bill that can charge early, like a phone plan that recharges when its data runs out."
>
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
{ordered.length === 0 ? (
<p className="text-sm text-budget-ink-muted">
No recurring costs yet. Add rent, a subscription, or a phone bill below.
@@ -369,7 +370,7 @@ export function RecurringSection() {
disabled={newName.trim() === "" || !firstPost}
onClick={() => {
const name = newName.trim();
mutate((draft) =>
void mutate((draft) =>
addRecurringCost(draft, {
name,
archived: false,
@@ -380,8 +381,9 @@ export function RecurringSection() {
splitMode: "percent",
splits: [{ postId: firstPost!.id, value: 100, absorbsRemainder: true }],
}),
);
setNewName("");
).then((saved) => {
if (saved) setNewName("");
});
}}
>
Add recurring cost