feat: refine month totals and recurring purchase entry
Continuous Integration / lint-and-test (push) Successful in 29s
Continuous Integration / docker-build-and-push (push) Successful in 40s

This commit is contained in:
Lucas Winther committed 2026-10-03 16:40:00 +02:00
1 parent 7cccd7a41a
commit c250bbe476
30 files changed
+634 -67

No files matched your search

@@ -0,0 +1,54 @@
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 { addRecurringCost } from "../../store/actions.ts";
import { store, today } from "../../store/index.ts";
import { ExpectedBand } from "./ExpectedBand.tsx";
test("Expected collapses, remembers the choice and restores a typed confirmation amount", () => {
localStorage.removeItem("budget2:expand-expected");
const monthId = today.slice(0, 7);
const data = createSeedDataset(monthId);
addRecurringCost(data, { name: "Phone", folderId: null, archived: false,
amount: { amount: 30, currency: "DKK" }, startDate: monthId,
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [] });
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div");
document.body.append(container);
let root = createRoot(container);
const render = () => root.render(<ExpectedBand monthId={monthId} />);
try {
act(render);
const input = container.querySelector("input")!;
act(() => {
input.focus();
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "35");
input.dispatchEvent(new InputEvent("input", { bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "5" }));
});
const toggle = container.querySelector<HTMLButtonElement>('button[aria-expanded]');
expect(toggle !== null).toBe(true);
act(() => toggle!.click());
expect(toggle!.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelector("input") === null).toBe(true);
expect(container.textContent).toContain("1 expected");
act(() => toggle!.click());
expect(container.querySelector("input")!.value).toBe("35");
act(() => toggle!.click());
act(() => root.unmount());
root = createRoot(container);
act(render);
expect(container.querySelector('button[aria-expanded]')!.getAttribute("aria-expanded")).toBe("false");
expect(data.purchases).toHaveLength(0);
} finally {
act(() => root.unmount()); container.remove();
Object.assign(store, original);
localStorage.removeItem("budget2:expand-expected");
}
});
+45 -29
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useDataset } from "../hooks/useDataset.ts";
@@ -10,6 +11,7 @@ import { occurrencesByMonth, type Occurrence } from "../../domain/occurrences.ts
import { addMonths, compareMonths } from "../../domain/months.ts";
import { parseMoneyInput, type CurrencyOption, type ParsedMoney } from "../moneyInput.ts";
import { Section } from "./Section.tsx";
import { useDisplayPreference } from "../hooks/useDisplayPreference.ts";
import type { Currency, Dataset, IsoDate, MonthId } from "../../domain/types.ts";
/**
@@ -197,6 +199,8 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
const { mutate, error } = useMutate();
const fmt = useMoneyFormat();
const [amounts, setAmounts] = useState<Record<string, string>>({});
const [expanded, setExpanded] = useDisplayPreference("budget2:expand-expected", true);
const contentId = useId();
const { pending, overdue, comingUp } = useMemo(
() => expectedGroups(dataset, monthId, today),
@@ -309,7 +313,9 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
{fmt.money(occurrence.amount.amount, occurrence.amount.currency)}
</span>
<span className="text-xs text-budget-ink-muted">
Add a purchase and tag it to this cost to record a charge early.
{dataset.settings.allowExistingRecurringCost
? "Add a purchase and choose this cost to record a charge early."
: "Enable existing bills in Settings → Recurring costs to record an early charge from Add purchase."}
</span>
</div>
</li>
@@ -319,37 +325,47 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
return (
<Section
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."
hint={expanded
? "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."
: `${pending.length} expected · ${comingUp.length} coming up · ${overdue} overdue`}
action={<Button variant="ghost" size="sm" aria-expanded={expanded} aria-controls={contentId} onClick={() => setExpanded(!expanded)}>
{expanded ? "Collapse" : "Expand"}
<ChevronDown aria-hidden="true" className={expanded ? "rotate-180" : ""} />
</Button>}
>
{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)))}
</ul>
)}
<div id={contentId}>
{expanded && <>
{pending.length > 0 && (
<ul className="divide-y divide-budget-rule">
{pending.map((occurrence) => row(occurrence, recordedDateFor("pending", today)))}
</ul>
)}
{comingUp.length > 0 && (
<div className={pending.length > 0 ? "mt-4" : ""}>
<h3 className="mb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
Coming up
</h3>
<ul className="divide-y divide-budget-rule">
{comingUp.map((occurrence) =>
occurrence.confirmable
? row(occurrence, recordedDateFor("comingUp", today))
: informationalRow(occurrence),
)}
</ul>
</div>
)}
{comingUp.length > 0 && (
<div className={pending.length > 0 ? "mt-4" : ""}>
<h3 className="mb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
Coming up
</h3>
<ul className="divide-y divide-budget-rule">
{comingUp.map((occurrence) =>
occurrence.confirmable
? row(occurrence, recordedDateFor("comingUp", today))
: informationalRow(occurrence),
)}
</ul>
</div>
)}
{overdue > 0 && (
<p className="mt-3 text-xs leading-relaxed text-budget-ink-muted">
{overdue} earlier {overdue === 1 ? "occurrence has" : "occurrences have"} never
been confirmed. Until {overdue === 1 ? "it is" : "they are"}, the projected
balance stays lower than the real one by that amount.
</p>
)}
{overdue > 0 && (
<p className="mt-3 text-xs leading-relaxed text-budget-ink-muted">
{overdue} earlier {overdue === 1 ? "occurrence has" : "occurrences have"} never
been confirmed. Until {overdue === 1 ? "it is" : "they are"}, the projected
balance stays lower than the real one by that amount.
</p>
)}
</>}
</div>
</Section>
);
}
@@ -10,6 +10,36 @@ import { monthView } from "../../domain/views.ts";
import { store } from "../../store/index.ts";
import { MonthPieShowcase } from "./MonthPieShowcase.tsx";
test("hiding the breakdown stays remembered after remount and leaves its control reachable", () => {
localStorage.removeItem("budget2:show-month-breakdown");
const data = createSeedDataset("2026-09");
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div");
let root = createRoot(container);
const render = () => root.render(<MonthPieShowcase view={monthView(data, "2026-09")} baseCurrency="DKK" />);
try {
act(render);
const toggle = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(toggle).not.toBeNull();
expect(toggle.checked).toBe(true);
act(() => toggle.click());
expect(container.querySelector('svg[role="img"]')).toBeNull();
act(() => root.unmount());
root = createRoot(container);
act(render);
const remembered = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(remembered.checked).toBe(false);
act(() => remembered.click());
expect(container.querySelector('svg[role="img"]')).not.toBeNull();
} finally {
act(() => root.unmount());
Object.assign(store, original);
localStorage.removeItem("budget2:show-month-breakdown");
}
});
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" } } }];
+12 -4
View File
@@ -4,6 +4,7 @@ import { monthPieData, type MonthPieMode } from "../monthPie.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { Section } from "./Section.tsx";
import { Segmented } from "./Segmented.tsx";
import { useDisplayPreference } from "../hooks/useDisplayPreference.ts";
const COLORS = ["#0d6b60", "#cc8050", "#537ba8", "#9a6bb1", "#9b8b39", "#ca6574", "#4a9b93", "#816eaf"];
const OPTIONS = [
@@ -24,6 +25,7 @@ function wedge(start: number, end: number): string {
export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel; baseCurrency: string }) {
const [mode, setMode] = useState<MonthPieMode>("spending");
const [visible, setVisible] = useDisplayPreference("budget2:show-month-breakdown", true);
const fmt = useMoneyFormat();
const chart = monthPieData(view, mode);
let angle = 0;
@@ -32,10 +34,16 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
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" />}
hint={visible ? `Share of this month's ${label} by post${mode === "spending" ? ", including unassigned purchases" : ""}.` : undefined}
action={<>
<label className="flex min-h-8 cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" checked={visible} onChange={event => setVisible(event.target.checked)} className="size-4 accent-primary" />
Show chart
</label>
{visible && <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">
{visible && <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 ? (
@@ -93,7 +101,7 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
</div>
)}
</div>
</div>
</div>}
</Section>
);
}
+111
View File
@@ -0,0 +1,111 @@
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 type { Root } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { addRecurringCost } from "../../store/actions.ts";
import { store } from "../../store/index.ts";
import type { Dataset, Purchase } from "../../domain/types.ts";
const { createRoot } = await import("react-dom/client");
const { PurchaseDialog } = await import("./PurchaseDialog.tsx");
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
let root: Root;
let container: HTMLDivElement;
afterEach(() => {
if (root) act(() => root.unmount());
container?.remove();
Object.assign(store, original);
});
function mount(data: Dataset, purchase?: Purchase) {
store.get = () => data;
store.subscribe = () => () => {};
store.mutate = async change => { change(data); };
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root.render(<PurchaseDialog monthId="2026-09" purchase={purchase} trigger={<button>Add</button>} />));
act(() => container.querySelector("button")!.click());
}
function type(id: string, value: string) {
const input = document.getElementById(id) as HTMLInputElement;
act(() => {
input.focus();
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" }));
input.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: value.slice(-1) }));
});
}
function selection() { return document.getElementById("purchase-recurring") as HTMLSelectElement; }
function select(value: string) {
act(() => { selection().value = value; selection().dispatchEvent(new Event("change", { bubbles: true })); });
}
async function save() {
const button = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find(b => b.textContent === "Save")!;
expect(button.disabled).toBe(false);
await act(async () => { button.click(); });
}
function withBill() {
const data = createSeedDataset("2026-09");
const bill = addRecurringCost(data, { name: "Phone", folderId: null, archived: false,
amount: { amount: 30, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [] });
return { data, bill };
}
test("saving and reopening a new purchase clears the visible total", async () => {
const data = createSeedDataset("2026-09");
mount(data);
type("purchase-description", "Groceries");
type("purchase-amount", "123.45");
await save();
expect(data.purchases[0]!.total.amount).toBe(123.45);
act(() => container.querySelector("button")!.click());
expect((document.getElementById("purchase-amount") as HTMLInputElement).value).toBe("");
expect((document.getElementById("purchase-description") as HTMLInputElement).value).toBe("");
});
test("new purchases offer creation instead of existing bills by default", async () => {
const { data, bill } = withBill();
mount(data);
expect(selection().value).toBe("none");
expect(selection().querySelector(`option[value="${bill.id}"]`) === null).toBe(true);
type("purchase-description", "Subscription");
type("purchase-amount", "50");
select("__new__");
await save();
expect(data.recurring).toHaveLength(2);
expect(data.recurring[1]!.name).toBe("Subscription");
expect(data.purchases[0]!.source?.recurringId).toBe(data.recurring[1]!.id);
});
test("enabling existing bills lets a new purchase settle the selected bill", async () => {
const { data, bill } = withBill();
data.settings.allowExistingRecurringCost = true;
mount(data);
select(bill.id);
type("purchase-description", "Phone payment");
type("purchase-amount", "30");
await save();
expect(data.recurring).toHaveLength(1);
expect(data.purchases[0]!.source).toEqual({ recurringId: bill.id, occurrenceDate: "2026-09" });
});
test("editing keeps its saved total and bill even when existing bills are hidden", async () => {
const { data, bill } = withBill();
const purchase: Purchase = { id: "p", description: "Phone payment", date: "2026-09",
total: { amount: 30, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null,
source: { recurringId: bill.id, occurrenceDate: "2026-09" } };
data.purchases.push(purchase);
mount(data, purchase);
expect((document.getElementById("purchase-amount") as HTMLInputElement).value).toBe("30");
expect(selection().value).toBe(bill.id);
await save();
expect(data.purchases[0]!.source?.recurringId).toBe(bill.id);
});
+11 -7
View File
@@ -111,8 +111,12 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
selected && !offerable.some((cost) => cost.id === selected.id)
? [...offerable, selected]
: offerable;
const visibleCosts = dataset.settings.allowExistingRecurringCost
? selectableCosts
: selectableCosts.filter(cost => cost.id === purchase?.source?.recurringId);
function reset() {
setAmountText(purchase ? String(purchase.total.amount) : "");
setPlacement(purchase?.placement ? purchase.placement.folderId ?? "__unfiled" : "__ledger");
setDraft(
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
@@ -199,7 +203,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
}}
>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogContent className="max-h-[calc(100dvh-2rem)] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>
{purchase ? "Edit purchase" : bulkMode ? "Add many purchases" : "Add purchase"}
@@ -416,17 +420,17 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
}}
>
<option value="none">None</option>
{selectableCosts.map((cost) => (
{visibleCosts.map((cost) => (
<option key={cost.id} value={cost.id}>
{cost.name}
</option>
))}
<option value={NEW_COST}>+ New recurring cost…</option>
<option value={NEW_COST}>Create a recurring cost for this purchase…</option>
</NativeSelect>
<p className="text-xs text-budget-ink-muted">
Tag this purchase as a charge for one of your bills: for one
measured from the last charge, that also restarts its cycle
from today.
{dataset.settings.allowExistingRecurringCost
? "Create a new bill or record a charge for an existing one. A bill measured from its last charge restarts from this purchase's date."
: "Create a bill that repeats from this purchase. To choose an existing bill, enable that option in Settings → Recurring costs."}
</p>
{/* An anchored cost stays SELECTED when the date drops to
month-only (the selection is never taken away out from under
@@ -563,7 +567,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
)}
<p className="text-xs text-budget-ink-muted">
This purchase becomes its first charge. Anchored to the last
charge, its cycle restarts from today rather than from a fixed
charge, its cycle restarts from this purchase's date rather than from a fixed
grid.
</p>
</fieldset>