feat: filter spending posts and collapse month sections
This commit is contained in:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user