feat: show budget folders on month page
This commit is contained in:
@@ -25,6 +25,12 @@ const d = createSeedDataset("2026-01", FALLBACK_FX_RATES);
|
||||
const [games, food, social] = d.posts as [typeof d.posts[0], typeof d.posts[0], typeof d.posts[0]];
|
||||
const rent = actions.addPost(d, "Rent", "DKK");
|
||||
const transport = actions.addPost(d, "Transport", "DKK");
|
||||
const housing = actions.addBudgetFolder(d, "Housing", null);
|
||||
const utilities = actions.addBudgetFolder(d, "Small bills", housing.id);
|
||||
rent.folderId = housing.id;
|
||||
const smallBills = actions.addPost(d, "Household bills", "DKK");
|
||||
smallBills.folderId = utilities.id;
|
||||
for (const name of ["Internet", "Phone"]) actions.addRecurringCost(d, { name, folderId: utilities.id, archived: false, amount: { amount: name === "Internet" ? 249 : 129, currency: "DKK" }, startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: smallBills.id, value: 100, absorbsRemainder: true }] });
|
||||
const savings = actions.addPost(d, "Savings", "DKK");
|
||||
|
||||
for (let m = 1; m <= 9; m++) {
|
||||
@@ -58,7 +64,7 @@ const wowSub = actions.addRecurringCost(d, {
|
||||
});
|
||||
actions.addRecurringCost(d, {
|
||||
name: "Rent",
|
||||
folderId: null,
|
||||
folderId: housing.id,
|
||||
archived: false,
|
||||
amount: { amount: 8200, currency: "DKK" },
|
||||
startDate: "2026-06",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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, addRecurringCost, confirmOccurrence } from "../../store/actions.ts";
|
||||
import { monthView } from "../../domain/views.ts";
|
||||
import { store } from "../../store/index.ts";
|
||||
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><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));
|
||||
@@ -0,0 +1,52 @@
|
||||
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 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 { DesktopPostBody, MobilePostRow } from "./PostTable.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 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 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.cost.id;
|
||||
if (node.kind === "post") return [mobile ? <MobilePostRow key={id} row={node.row} indent={indent} monthId={monthId} onChangeRule={onChangeRule} /> : <DesktopPostBody key={id} row={node.row} indent={indent} monthId={monthId} onChangeRule={onChangeRule} />];
|
||||
const name = node.kind === "folder" ? node.folder.name : node.cost.name;
|
||||
const content = node.kind === "folder" ? folderHeading(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 <>
|
||||
<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">{["Post / bill", "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)}
|
||||
</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)}</ul>
|
||||
</>;
|
||||
}
|
||||
@@ -173,7 +173,6 @@ function ChangeRuleButton({
|
||||
}
|
||||
|
||||
export function PostTable({ monthId, rows, onChangeRule }: Props) {
|
||||
const fmt = useMoneyFormat();
|
||||
return (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto sm:block">
|
||||
@@ -194,11 +193,32 @@ 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; }
|
||||
|
||||
export function DesktopPostBody({ row, monthId, onChangeRule, indent = 0 }: 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 }}>
|
||||
<PostName row={row} monthId={monthId} />
|
||||
{onChangeRule && (
|
||||
<ChangeRuleButton
|
||||
@@ -234,16 +254,13 @@ 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 }: 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">
|
||||
<PostName row={row} monthId={monthId} />
|
||||
@@ -281,9 +298,5 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import { ruleAt } from "../../domain/allocation.ts";
|
||||
import type { MonthId, Post, Purchase, Rule } from "../../domain/types.ts";
|
||||
import { sliceAmountForMonth } from "../../domain/charges.ts";
|
||||
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
|
||||
import { PostTable, PostTableLegend } from "../components/PostTable.tsx";
|
||||
import { PostTableLegend } from "../components/PostTable.tsx";
|
||||
import { BudgetTree } from "../components/BudgetTree.tsx";
|
||||
import { PurchaseDialog } from "../components/PurchaseDialog.tsx";
|
||||
import { ExpectedBand } from "../components/ExpectedBand.tsx";
|
||||
import { Meter } from "../components/Meter.tsx";
|
||||
@@ -128,11 +129,11 @@ export function MonthRoute() {
|
||||
<ExpectedBand monthId={monthId} />
|
||||
|
||||
<Section
|
||||
title="Posts"
|
||||
title="Budget"
|
||||
hint={<PostTableLegend baseCurrency={base} />}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<PostTable monthId={monthId} rows={view.rows} onChangeRule={setChangingRuleFor} />
|
||||
<BudgetTree monthId={monthId} rows={view.rows} onChangeRule={setChangingRuleFor} />
|
||||
</Section>
|
||||
|
||||
{changingPost && (
|
||||
|
||||
Reference in New Issue
Block a user