feat: filter spending posts and collapse month sections
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 34s

This commit is contained in:
Lucas Winther
2026-10-03 17:01:53 +02:00
parent 5c6cee0f0b
commit 2bd5c754e8
12 changed files with 294 additions and 36 deletions
+4 -2
View File
@@ -72,8 +72,10 @@ no purchases. Decimal places are one setting for the whole budget.
Import and reset clear session undo history after they succeed.
The month page shows Actual available beside Spent: entered income minus actual
spending for that month. Show chart and Expected's Expand/Collapse controls
remember their state in this browser. Add purchase can create a new recurring
spending for that month. Tick posts in Monthly breakdown to filter spending;
select one post to see its purchases. Monthly breakdown, Expected, Budget, and
Purchases have Expand/Collapse controls that remember their state in this
browser. Add purchase can create a new recurring
cost; enable choosing existing bills under Settings → Recurring costs.
## Known limitations
+8 -1
View File
@@ -164,11 +164,18 @@ record.
## Design system
- **Chart visibility and Expected's expanded state persist per browser.**
- **Monthly breakdown, Expected, Budget, and Purchases expand independently
and persist per browser.**
Chosen over resetting on every visit or moving display choices with a JSON
budget export, following the theme's storage boundary.
→ [`specs/2026-10-03-month-and-purchase-refinements.md`](specs/2026-10-03-month-and-purchase-refinements.md)
- **Spending chart selection changes the chart's denominator only.** Each
post remains available to check again; selecting one shows its purchase
shares, using the existing monthly charge calculation. Refunds are separate
from positive slices. Budget figures never change when a post is unchecked.
→ [`specs/2026-10-03-month-and-purchase-refinements.md`](specs/2026-10-03-month-and-purchase-refinements.md)
- **Two token layers.** `--budget-*` is the app's own vocabulary; shadcn's
entire variable set points at it, so every primitive inherits both themes.
→ [`specs/2026-09-02-design-foundation-design.md`](specs/2026-09-02-design-foundation-design.md)
+3 -1
View File
@@ -10,7 +10,9 @@ task moved to `AGENTS.md`.
**Current state:** the month and purchase refinements landed in `c250bbe`,
building on the merged anchored model, budget folders and session backups.
911 tests pass across 66 files;
The spending chart now supports post selection and purchase detail, and Monthly
breakdown, Expected, Budget, and Purchases have remembered collapse controls.
915 tests pass across 67 files;
`bunx tsc --noEmit` is clean, and `SKIP_FX_FETCH=1 bun run build` succeeds
using the committed seed rates.
@@ -94,3 +94,30 @@ Desktop and mobile captures cover light and dark themes. Screenshots establish
layout and console behavior; visual feel still requires the owner.
The complete suite passes 911 tests across 66 files, TypeScript is clean, and
production builds succeed at both root and subpath base URLs with seed rates.
## Follow-up: post selection and section collapse
The owner clarified that chart visibility should also operate on individual
posts, so a large post can be excluded to inspect smaller spending. Every post
with actual charge activity, including Unassigned and a zero net total made
from purchases and refunds, starts selected. Unchecked posts remain available;
Select all and Clear recover from any selection. Net spent and percentages
reflect the selected posts, leaving all budget figures unchanged. With exactly
one selected post, the chart shows that post's individual purchase shares for
the month. The existing charge and slice functions provide split amounts,
instalments, FX conversion, and dataset precision. Refunds remain separate from
positive slices.
The Show chart checkbox is replaced by Expected's Expand/Collapse button style.
Budget and Purchases also receive independent remembered controls. All four
sections start expanded, retaining the previous chart visibility preference.
The post selection lasts for the current month-page visit and does not enter
the exported dataset. Budget and Purchases hide mounted content to preserve
editing drafts; Add purchase remains reachable while Purchases is collapsed.
Regression checks cover filtering, single-post purchase details, instalment
shares, FX at zero decimals, refunds cancelling to zero, no selection, restoring
all posts, independent collapse state, mounted drafts and reachable actions.
The complete suite now passes 915 tests across 67 files, TypeScript and the
production build pass, and Chrome verifies filtering, drilldown and collapse
controls on mobile without horizontal overflow or uncaught exceptions.
+2 -6
View File
@@ -1,5 +1,4 @@
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,7 +9,7 @@ import { confirmOccurrence } from "../../store/actions.ts";
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 { Section, SectionToggle } from "./Section.tsx";
import { useDisplayPreference } from "../hooks/useDisplayPreference.ts";
import type { Currency, Dataset, IsoDate, MonthId } from "../../domain/types.ts";
@@ -328,10 +327,7 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
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>}
action={<SectionToggle title="Expected" expanded={expanded} contentId={contentId} onToggle={() => setExpanded(!expanded)} />}
>
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not confirm: {error}</p>}
<div id={contentId}>
+38 -4
View File
@@ -21,16 +21,16 @@ test("hiding the breakdown stays remembered after remount and leaves its control
const render = () => root.render(<MonthPieShowcase view={monthView(data, "2026-09")} baseCurrency="DKK" />);
try {
act(render);
const toggle = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
const toggle = container.querySelector('button[aria-controls]') as HTMLButtonElement;
expect(toggle).not.toBeNull();
expect(toggle.checked).toBe(true);
expect(toggle.getAttribute("aria-expanded")).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);
const remembered = container.querySelector('button[aria-controls]') as HTMLButtonElement;
expect(remembered.getAttribute("aria-expanded")).toBe("false");
act(() => remembered.click());
expect(container.querySelector('svg[role="img"]')).not.toBeNull();
} finally {
@@ -63,3 +63,37 @@ test("pie showcase switches between spending and allocations", () => {
Object.assign(store, original);
}
});
test("post checkboxes filter spending, reveal purchase detail, and stay reachable with none selected", () => {
localStorage.removeItem("budget2:show-month-breakdown");
const data = createSeedDataset("2026-09");
data.purchases = [
{ id: "small", date: "2026-09", description: "Small game", total: { amount: 50, currency: "DKK" }, splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }], schedule: null },
{ id: "large", date: "2026-09", description: "Large shop", total: { amount: 900, currency: "DKK" }, splitMode: "percent", splits: [{ postId: data.posts[1]!.id, value: 100, absorbsRemainder: true }], schedule: null },
];
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div");
const root = createRoot(container);
try {
act(() => root.render(<MonthPieShowcase view={monthView(data, "2026-09")} baseCurrency="DKK" />));
const food = container.querySelector('input[aria-label="Include Food"]') as HTMLInputElement;
act(() => food.click());
expect(container.querySelector('svg title')?.textContent).toContain("Small game");
expect(container.textContent).toContain("Purchases in Video Games");
const games = container.querySelector('input[aria-label="Include Video Games"]') as HTMLInputElement;
act(() => games.click());
expect(container.textContent).toContain("No posts selected");
expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2);
const all = [...container.querySelectorAll("button")].find(button => button.textContent === "Select all")!;
act(() => all.click());
expect(food.checked).toBe(true);
expect(games.checked).toBe(true);
expect(container.textContent).toContain("950.00 DKK");
} finally {
act(() => root.unmount());
Object.assign(store, original);
localStorage.removeItem("budget2:show-month-breakdown");
}
});
+50 -15
View File
@@ -1,10 +1,12 @@
import { useState } from "react";
import { useId, useState } from "react";
import type { MonthViewModel } from "../../domain/views.ts";
import { monthPieData, type MonthPieMode } from "../monthPie.ts";
import { monthPieData, spendingPieData, type MonthPieMode } from "../monthPie.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { Section } from "./Section.tsx";
import { Section, SectionToggle } from "./Section.tsx";
import { Segmented } from "./Segmented.tsx";
import { useDisplayPreference } from "../hooks/useDisplayPreference.ts";
import { useDataset } from "../hooks/useDataset.ts";
import { Button } from "@/components/ui/button";
const COLORS = ["#0d6b60", "#cc8050", "#537ba8", "#9a6bb1", "#9b8b39", "#ca6574", "#4a9b93", "#816eaf"];
const OPTIONS = [
@@ -26,30 +28,34 @@ 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 [excluded, setExcluded] = useState<ReadonlySet<string>>(() => new Set());
const contentId = useId();
const dataset = useDataset();
const fmt = useMoneyFormat();
const chart = monthPieData(view, mode);
const spending = spendingPieData(dataset, view.monthId, excluded);
const chart = mode === "spending" ? spending.chart : monthPieData(view, mode);
let angle = 0;
const label = mode === "spending" ? "spending" : "allocation";
return (
<Section
title="Monthly breakdown"
hint={visible ? `Share of this month's ${label} by post${mode === "spending" ? ", including unassigned purchases" : ""}.` : undefined}
hint={visible ? mode === "spending" ? "Choose posts to compare. Select one to see its purchases. Unchecking a post changes this chart only." : "Share of this month's allocations by post." : 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" />}
<SectionToggle title="Monthly breakdown" expanded={visible} contentId={contentId} onToggle={() => setVisible(!visible)} />
</>}
>
{visible && <div className="grid items-center gap-5 sm:grid-cols-[minmax(12rem,16rem)_1fr] sm:gap-8">
<div id={contentId}>
{visible && <div className="grid items-start 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 ? (
<circle cx="100" cy="100" r="88" fill="var(--budget-tint)" />
) : chart.positive.length === 1 ? (
<circle cx="100" cy="100" r="88" fill={COLORS[0]} />
<circle cx="100" cy="100" r="88" fill={COLORS[0]}>
<title>{`${chart.positive[0]!.label}: ${fmt.money(chart.positive[0]!.amount, baseCurrency)}`}</title>
</circle>
) : chart.positive.map((item, index) => {
const start = angle;
angle += (item.amount / chart.positiveTotal) * 360;
@@ -69,14 +75,42 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
</div>
<div className="min-w-0">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-x-5 gap-y-1 border-b border-budget-rule pb-3">
<span className="text-sm text-budget-ink-muted">{mode === "spending" ? "Net spent" : "Total allocated"}</span>
<span className="text-sm text-budget-ink-muted">{mode === "spending" ? spending.selectedCount === spending.posts.length ? "Net spent" : "Selected net spent" : "Total allocated"}</span>
<strong className="font-money text-xl font-medium tabular-nums">{fmt.money(chart.netTotal, baseCurrency)}</strong>
</div>
{mode === "spending" && spending.posts.length > 0 && <fieldset className="mb-4">
<legend className="sr-only">Posts included in spending chart</legend>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs text-budget-ink-muted">{spending.selectedCount} of {spending.posts.length} selected</span>
<div className="flex gap-1">
<Button size="xs" variant="ghost" onClick={() => setExcluded(new Set())}>Select all</Button>
<Button size="xs" variant="ghost" onClick={() => setExcluded(new Set(spending.posts.map(post => post.id)))}>Clear</Button>
</div>
</div>
<div className="space-y-1">
{spending.posts.map(post => <label key={post.id} className="flex min-h-8 cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" aria-label={`Include ${post.label}`} checked={!excluded.has(post.id)} className="size-4 shrink-0 accent-primary" onChange={event => {
const checked = event.target.checked;
setExcluded(previous => {
const next = new Set(previous);
if (checked) next.delete(post.id); else next.add(post.id);
return next;
});
}} />
{!spending.focusLabel && !excluded.has(post.id) && post.amount > 0 && <span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: COLORS[chart.positive.findIndex(item => item.id === post.id) % COLORS.length] }} aria-hidden="true" />}
<span className="min-w-0 flex-1 break-words">{post.label}</span>
<span className="font-money shrink-0 tabular-nums">{fmt.money(post.amount, baseCurrency)}
{!spending.focusLabel && !excluded.has(post.id) && post.amount > 0 && <span className="ml-1 text-xs text-budget-ink-muted">{Math.round(post.amount / chart.positiveTotal * 100)}%</span>}
</span>
</label>)}
</div>
</fieldset>}
{mode === "spending" && spending.focusLabel && <h3 className="mb-2 border-t border-budget-rule pt-3 text-sm">Purchases in {spending.focusLabel}</h3>}
{chart.positive.length === 0 ? (
<p className="py-5 text-sm text-budget-ink-muted">
{mode === "spending" ? "No positive spending to chart." : "No positive allocations to chart."}
{mode === "spending" ? spending.posts.length > 0 && spending.selectedCount === 0 ? "No posts selected. Tick a post or select all to show spending." : "No positive spending to chart." : "No positive allocations to chart."}
</p>
) : (
) : mode === "allocation" || spending.focusLabel ? (
<ol className="space-y-2.5">
{chart.positive.map((item, index) => (
<li key={item.id} className="grid grid-cols-[1rem_minmax(0,1fr)_auto] items-baseline gap-x-2.5 text-sm">
@@ -86,7 +120,7 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
</li>
))}
</ol>
)}
) : null}
{chart.adjustments.length > 0 && (
<div className="mt-4 border-t border-budget-rule pt-3">
<p className="mb-2 text-xs text-budget-ink-muted">Refunds and negative adjustments are excluded from pie slices.</p>
@@ -102,6 +136,7 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
)}
</div>
</div>}
</div>
</Section>
);
}
+44
View File
@@ -0,0 +1,44 @@
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 { CollapsibleSection } from "./Section.tsx";
test("sections collapse independently, preserve drafts and actions, and remember their state", () => {
const budgetKey = "budget2:test-expand-budget";
const purchasesKey = "budget2:test-expand-purchases";
localStorage.removeItem(budgetKey);
localStorage.removeItem(purchasesKey);
const container = document.createElement("div");
let root = createRoot(container);
const render = () => root.render(<>
<CollapsibleSection title="Budget" preferenceKey={budgetKey}><input aria-label="Budget draft" defaultValue="" /></CollapsibleSection>
<CollapsibleSection title="Purchases" preferenceKey={purchasesKey} action={<button>Add purchase</button>}><p>Purchase rows</p></CollapsibleSection>
</>);
try {
act(render);
const input = container.querySelector("input")!;
input.value = "Unsaved draft";
const collapse = container.querySelector('button[aria-label="Collapse Budget"]') as HTMLButtonElement;
act(() => collapse.click());
expect(input.closest("[hidden]")).not.toBeNull();
expect(container.querySelector('button[aria-label="Collapse Purchases"]')).not.toBeNull();
act(() => (container.querySelector('button[aria-label="Expand Budget"]') as HTMLButtonElement).click());
expect(input.value).toBe("Unsaved draft");
expect(input.closest("[hidden]")).toBeNull();
act(() => (container.querySelector('button[aria-label="Collapse Purchases"]') as HTMLButtonElement).click());
const add = [...container.querySelectorAll("button")].find(button => button.textContent === "Add purchase")!;
expect(add.closest("[hidden]")).toBeNull();
act(() => root.unmount());
root = createRoot(container);
act(render);
expect(container.querySelector('button[aria-label="Collapse Budget"]')).not.toBeNull();
expect(container.querySelector('button[aria-label="Expand Purchases"]')).not.toBeNull();
} finally {
act(() => root.unmount());
localStorage.removeItem(budgetKey);
localStorage.removeItem(purchasesKey);
}
});
+28 -1
View File
@@ -1,4 +1,7 @@
import type { ReactNode } from "react";
import { useId, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useDisplayPreference } from "../hooks/useDisplayPreference.ts";
interface Props {
/** Omitted when the card's contents are self-explanatory (a toolbar). */
@@ -46,6 +49,30 @@ export function Section({ title, action, hint, children, className = "" }: Props
);
}
export function SectionToggle({ title, expanded, contentId, onToggle }: {
title: string;
expanded: boolean;
contentId: string;
onToggle: () => void;
}) {
return <Button variant="ghost" size="sm" aria-label={`${expanded ? "Collapse" : "Expand"} ${title}`} aria-expanded={expanded} aria-controls={contentId} onClick={onToggle}>
{expanded ? "Collapse" : "Expand"}
<ChevronDown aria-hidden="true" className={expanded ? "rotate-180" : ""} />
</Button>;
}
/** Hide rather than unmount editors so collapsing cannot discard a draft. */
export function CollapsibleSection({ preferenceKey, title, hint, action, children, ...props }: Props & { title: string; preferenceKey: string }) {
const [expanded, setExpanded] = useDisplayPreference(preferenceKey, true);
const contentId = useId();
return <Section {...props} title={title} hint={expanded ? hint : undefined} action={<>
{action}
<SectionToggle title={title} expanded={expanded} contentId={contentId} onToggle={() => setExpanded(!expanded)} />
</>}>
<div id={contentId} hidden={!expanded}>{children}</div>
</Section>;
}
/**
* A single figure with its label. Used for the month's totals, where the
* numbers matter more than the words: the label is small and quiet, the figure
+36 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test";
import { createSeedDataset } from "../domain/seed.ts";
import { monthView } from "../domain/views.ts";
import { monthPieData } from "./monthPie.ts";
import { monthPieData, spendingPieData } from "./monthPie.ts";
test("spending pie separates positive slices from refunds and includes unassigned charges", () => {
const data = createSeedDataset("2026-09");
@@ -28,3 +28,38 @@ test("allocation pie reflects the month view and has an honest empty state", ()
expect(chart.positive.map(({ label, amount }) => [label, amount])).toEqual([["Video Games", 250]]);
expect(chart.netTotal).toBe(250);
});
test("spending selection excludes large posts and drills into monthly purchase shares", () => {
const data = createSeedDataset("2026-09");
const [games, food] = data.posts;
data.purchases = [
{ id: "split", date: "2026-08", description: "Shared order", total: { amount: 200, currency: "DKK" }, splitMode: "percent", splits: [{ postId: games!.id, value: 25, absorbsRemainder: false }, { postId: food!.id, value: 75, absorbsRemainder: true }], schedule: { slices: [{ month: "2026-08", amount: { amount: 100, currency: "DKK" } }, { month: "2026-09", amount: { amount: 100, currency: "DKK" } }] } },
{ id: "refund", date: "2026-09", description: "Returned game", total: { amount: -5, currency: "DKK" }, splitMode: "percent", splits: [{ postId: games!.id, value: 100, absorbsRemainder: true }], schedule: null },
{ id: "big", date: "2026-09", description: "Big shop", total: { amount: 900, currency: "DKK" }, splitMode: "percent", splits: [{ postId: food!.id, value: 100, absorbsRemainder: true }], schedule: null },
{ id: "loose", date: "2026-09", description: "Loose", total: { amount: 10, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null },
];
const all = spendingPieData(data, "2026-09", new Set());
expect(all.chart.netTotal).toBe(1005);
const filtered = spendingPieData(data, "2026-09", new Set([`post:${food!.id}`, "unassigned"]));
expect(filtered.focusLabel).toBe(games!.name);
expect(filtered.chart.positive.map(item => [item.label, item.amount])).toEqual([["Shared order", 25]]);
expect(filtered.chart.adjustments.map(item => [item.label, item.amount])).toEqual([["Returned game", -5]]);
expect(filtered.chart.netTotal).toBe(20);
expect(filtered.posts).toHaveLength(3);
const empty = spendingPieData(data, "2026-09", new Set(all.posts.map(item => item.id)));
expect(empty.chart.netTotal).toBe(0);
expect(empty.chart.positive).toEqual([]);
});
test("purchase detail keeps offsetting refunds visible and uses converted dataset precision", () => {
const data = createSeedDataset("2026-09");
data.settings.digits = 0;
const postId = data.posts[0]!.id;
data.fxRates = [{ currency: "USD", baseUnitsPerOne: 7.3, updatedAt: "2026-09-01", source: "manual" }];
data.purchases = [10, -10].map((amount, index) => ({ id: String(index), date: "2026-09", description: index ? "Refund" : "Bought", total: { amount, currency: "USD" }, splitMode: "percent", splits: [{ postId, value: 100, absorbsRemainder: true }], schedule: null }));
const result = spendingPieData(data, "2026-09", new Set());
expect(result.posts).toHaveLength(1);
expect(result.chart.netTotal).toBe(0);
expect(result.chart.positive[0]!.amount).toBe(73);
expect(result.chart.adjustments[0]!.amount).toBe(-73);
});
+47
View File
@@ -1,4 +1,8 @@
import type { MonthViewModel } from "../domain/views.ts";
import type { Dataset, MonthId } from "../domain/types.ts";
import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../domain/charges.ts";
import { toBase } from "../domain/fx.ts";
import { roundMoney } from "../domain/money.ts";
export type MonthPieMode = "spending" | "allocation";
export interface MonthPieSlice {
@@ -7,6 +11,49 @@ export interface MonthPieSlice {
amount: number;
}
/** Read-only chart selection; shares reuse the same charge boundaries as the fold. */
export function spendingPieData(dataset: Dataset, monthId: MonthId, excluded: ReadonlySet<string>) {
const groups = new Map<string, { post: MonthPieSlice; purchases: MonthPieSlice[] }>();
const { baseCurrency, digits } = dataset.settings;
const labels = new Map(dataset.posts.map(post => [post.id, post.name]));
for (const purchase of dataset.purchases) {
const slice = sliceAmountForMonth(purchase, monthId);
if (!slice) continue;
const charges = purchase.splits.length === 0
? [{ id: "unassigned", label: "Unassigned", amount: toBase(slice, baseCurrency, dataset.fxRates, digits) }]
: chargesForPurchaseInMonth(purchase, monthId, baseCurrency, dataset.fxRates, digits)
.map(charge => ({ id: `post:${charge.postId}`, label: labels.get(charge.postId) ?? charge.postId, amount: charge.amount }));
for (const charge of charges) {
if (charge.amount === 0) continue;
let group = groups.get(charge.id);
if (!group) {
group = { post: { ...charge, amount: 0 }, purchases: [] };
groups.set(charge.id, group);
}
group.post.amount = roundMoney(group.post.amount + charge.amount, digits);
const existing = group.purchases.find(item => item.id === purchase.id);
if (existing) existing.amount = roundMoney(existing.amount + charge.amount, digits);
else group.purchases.push({ id: purchase.id, label: purchase.description || "Untitled purchase", amount: charge.amount });
}
}
const posts = [...groups.values()].map(group => group.post)
.sort((a, b) => b.amount - a.amount || a.label.localeCompare(b.label));
const selected = posts.filter(post => !excluded.has(post.id));
const focus = selected.length === 1 ? groups.get(selected[0]!.id)! : null;
const items = focus ? focus.purchases : selected;
return {
posts,
selectedCount: selected.length,
focusLabel: focus?.post.label ?? null,
chart: {
positive: items.filter(item => item.amount > 0).sort((a, b) => b.amount - a.amount || a.label.localeCompare(b.label)),
adjustments: items.filter(item => item.amount < 0).sort((a, b) => a.amount - b.amount),
positiveTotal: roundMoney(items.reduce((sum, item) => sum + Math.max(0, item.amount), 0), digits),
netTotal: roundMoney(selected.reduce((sum, item) => sum + item.amount, 0), digits),
},
};
}
/** Chart data comes from the same folded figures as the month table. */
export function monthPieData(view: MonthViewModel, mode: MonthPieMode) {
const items: MonthPieSlice[] = view.rows.map(({ post, figures }) => ({
+7 -5
View File
@@ -19,7 +19,7 @@ import { MonthPieShowcase } from "../components/MonthPieShowcase.tsx";
import { PurchaseDialog } from "../components/PurchaseDialog.tsx";
import { ExpectedBand } from "../components/ExpectedBand.tsx";
import { Meter } from "../components/Meter.tsx";
import { Section, Stat } from "../components/Section.tsx";
import { Section, CollapsibleSection, Stat } from "../components/Section.tsx";
import { allocatedPercentOfIncome, allocationMeterSegments } from "../meterSegments.ts";
import { groupPurchasesByDate } from "../purchaseGroups.ts";
@@ -141,13 +141,14 @@ export function MonthRoute() {
<ExpectedBand monthId={monthId} />
<Section
<CollapsibleSection
title="Budget"
preferenceKey="budget2:expand-budget"
hint={<PostTableLegend baseCurrency={base} />}
className="overflow-hidden"
>
<BudgetTree monthId={monthId} rows={view.rows} onChangeRule={setChangingRuleFor} />
</Section>
</CollapsibleSection>
{changingPost && (
<RuleFromMonth
@@ -162,8 +163,9 @@ export function MonthRoute() {
/>
)}
<Section
<CollapsibleSection
title="Purchases"
preferenceKey="budget2:expand-purchases"
action={<PurchaseDialog monthId={monthId} trigger={<Button>Add purchase</Button>} />}
>
{groups.length === 0 ? (
@@ -193,7 +195,7 @@ export function MonthRoute() {
))}
</div>
)}
</Section>
</CollapsibleSection>
</div>
);
}