feat: refine month totals and recurring purchase entry
This commit is contained in:
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");
|
||||
}
|
||||
});
|
||||
@@ -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" } } }];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user