fix(ui): harden summary and settings workflows
This commit is contained in:
1 parent
c93ff9908c
commit
aed2313167
10 files changed
+159
-36
No files matched your search
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user