diff --git a/scripts/demo-data.ts b/scripts/demo-data.ts index 995557d..0f9c42c 100755 --- a/scripts/demo-data.ts +++ b/scripts/demo-data.ts @@ -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", diff --git a/src/ui/components/BudgetTree.test.tsx b/src/ui/components/BudgetTree.test.tsx new file mode 100644 index 0000000..df54080 --- /dev/null +++ b/src/ui/components/BudgetTree.test.tsx @@ -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()); 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)); diff --git a/src/ui/components/BudgetTree.tsx b/src/ui/components/BudgetTree.tsx new file mode 100644 index 0000000..0a0144b --- /dev/null +++ b/src/ui/components/BudgetTree.tsx @@ -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>(() => 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) => { + const open = expanded.has(node.folder.id); + const Icon = open ? ChevronDown : ChevronRight; + return
+ + Bills due {fmt.money(node.billsDue, dataset.settings.baseCurrency)} +
; + }; + const billContent = (node: Extract) =>
+
{node.cost.name} {fmt.money(node.cost.amount.amount, node.cost.amount.currency)}Edit
+

{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"}

+
; + 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 ? : ]; + 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 ?
  • {content}
  • : {content}; + return [item, ...(node.kind === "folder" && expanded.has(id) ? render(node.children, mobile, depth + 1) : [])]; + }); + return <> +
    {["Post / bill", "Carried in", "Allocated", "Spent", "Remaining", "Projected"].map((label, index) => )} + {render(tree.roots, false)} + {tree.unfiled.length > 0 && }{render(tree.unfiled, false)} +
    {label}
    Unfiled
    +
      {render(tree.roots, true)}{tree.unfiled.length > 0 &&
    • Unfiled
    • }{render(tree.unfiled, true)}
    + ; +} diff --git a/src/ui/components/PostTable.tsx b/src/ui/components/PostTable.tsx index 0690c38..43a5ece 100644 --- a/src/ui/components/PostTable.tsx +++ b/src/ui/components/PostTable.tsx @@ -173,7 +173,6 @@ function ChangeRuleButton({ } export function PostTable({ monthId, rows, onChangeRule }: Props) { - const fmt = useMoneyFormat(); return ( <>
    @@ -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 ( - + + ); + })} + +
    + +
      + {rows.map((row) => { + return ( + + ); + })} +
    + + ); +} + +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 ( + - + {onChangeRule && ( - ); - })} - - + ); +} -
      - {rows.map((row) => { - const cells = postRowCells(row, fmt); - return ( -
    • +export function MobilePostRow({ row, monthId, onChangeRule, indent = 0 }: PostRowProps) { + const cells = postRowCells(row, useMoneyFormat()); + return ( +
    • @@ -281,9 +298,5 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) { )}

    • - ); - })} -
    - ); } diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index 16a7e4d..b3b8a5d 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -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() {
    } className="overflow-hidden" > - +
    {changingPost && (