feat: add month breakdown and session backups
Allow recurring costs and confirmations to remain unassigned when created from a purchase without a post. Add browser backup retention and restore controls, with coverage and documentation updates.
This commit is contained in:
@@ -34,7 +34,7 @@ export function BudgetTree({ monthId, rows, onChangeRule }: { monthId: MonthId;
|
||||
};
|
||||
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>
|
||||
<p className="text-xs text-budget-ink-muted">{cadence(node.cost.recurrence)} · {node.cost.splits.length === 0 ? "Unassigned" : `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 purchaseContent = (node: Extract<BudgetNode, { kind: "purchase" }>) => <div className="flex flex-wrap items-center justify-between gap-2 py-3 text-sm"><span>{node.purchase.description} <span className="ml-2 font-money">{fmt.money(node.amount.amount, node.amount.currency)}</span></span><span className="flex items-center gap-3">{node.purchase.splits.length === 0 && <button type="button" aria-label={`Create post for ${node.purchase.description}`} className="text-xs underline underline-offset-2 hover:text-budget-accent" onClick={() => void mutate(draft => createPostForPurchase(draft, node.purchase.id))}>Create post</button>}<PurchaseDialog monthId={monthId} purchase={node.purchase} trigger={<button type="button" className="text-xs underline underline-offset-2 hover:text-budget-accent">Edit</button>} /></span></div>;
|
||||
const render = (nodes: BudgetNode[], mobile: boolean, depth = 0): ReactNode[] => nodes.flatMap(node => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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 { monthView } from "../../domain/views.ts";
|
||||
import { store } from "../../store/index.ts";
|
||||
import { MonthPieShowcase } from "./MonthPieShowcase.tsx";
|
||||
|
||||
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" } } }];
|
||||
data.months[0]!.income = { amount: 1000, currency: "DKK" };
|
||||
data.purchases.push({ id: "p", date: "2026-09", description: "Games", total: { amount: 50, currency: "DKK" }, splitMode: "percent", splits: [{ postId: data.posts[0]!.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" />));
|
||||
expect(container.textContent).toContain("50.00 DKK");
|
||||
const allocation = [...container.querySelectorAll("button")].find((button) => button.textContent === "Allocated")!;
|
||||
act(() => allocation.click());
|
||||
expect(allocation.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(container.textContent).toContain("250.00 DKK");
|
||||
expect(container.querySelector("svg[role='img']")).not.toBeNull();
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
Object.assign(store, original);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import type { MonthViewModel } from "../../domain/views.ts";
|
||||
import { monthPieData, type MonthPieMode } from "../monthPie.ts";
|
||||
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
|
||||
import { Section } from "./Section.tsx";
|
||||
import { Segmented } from "./Segmented.tsx";
|
||||
|
||||
const COLORS = ["#0d6b60", "#cc8050", "#537ba8", "#9a6bb1", "#9b8b39", "#ca6574", "#4a9b93", "#816eaf"];
|
||||
const OPTIONS = [
|
||||
{ value: "spending", label: "Spent" },
|
||||
{ value: "allocation", label: "Allocated" },
|
||||
] as const;
|
||||
|
||||
function point(degrees: number): [number, number] {
|
||||
const radians = ((degrees - 90) * Math.PI) / 180;
|
||||
return [100 + 88 * Math.cos(radians), 100 + 88 * Math.sin(radians)];
|
||||
}
|
||||
|
||||
function wedge(start: number, end: number): string {
|
||||
const [startX, startY] = point(start);
|
||||
const [endX, endY] = point(end);
|
||||
return `M 100 100 L ${startX} ${startY} A 88 88 0 ${end - start > 180 ? 1 : 0} 1 ${endX} ${endY} Z`;
|
||||
}
|
||||
|
||||
export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel; baseCurrency: string }) {
|
||||
const [mode, setMode] = useState<MonthPieMode>("spending");
|
||||
const fmt = useMoneyFormat();
|
||||
const chart = monthPieData(view, mode);
|
||||
let angle = 0;
|
||||
const label = mode === "spending" ? "spending" : "allocation";
|
||||
|
||||
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" />}
|
||||
>
|
||||
<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 ? (
|
||||
<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]} />
|
||||
) : chart.positive.map((item, index) => {
|
||||
const start = angle;
|
||||
angle += (item.amount / chart.positiveTotal) * 360;
|
||||
return (
|
||||
<path
|
||||
key={item.id}
|
||||
d={wedge(start, index === chart.positive.length - 1 ? 360 : angle)}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
stroke="var(--budget-raised)"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<title>{`${item.label}: ${fmt.money(item.amount, baseCurrency)}`}</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</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>
|
||||
<strong className="font-money text-xl font-medium tabular-nums">{fmt.money(chart.netTotal, baseCurrency)}</strong>
|
||||
</div>
|
||||
{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."}
|
||||
</p>
|
||||
) : (
|
||||
<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">
|
||||
<span className="inline-block size-2.5 self-center rounded-full" style={{ backgroundColor: COLORS[index % COLORS.length] }} aria-hidden="true" />
|
||||
<span className="min-w-0 truncate" title={item.label}>{item.label}</span>
|
||||
<span className="font-money text-right tabular-nums whitespace-nowrap">{fmt.money(item.amount, baseCurrency)} <span className="ml-1 text-xs text-budget-ink-muted">{Math.round((item.amount / chart.positiveTotal) * 100)}%</span></span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{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>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{chart.adjustments.map((item) => (
|
||||
<li key={item.id} className="flex justify-between gap-3">
|
||||
<span className="min-w-0 truncate" title={item.label}>{item.label}</span>
|
||||
<span className="font-money shrink-0 tabular-nums">{fmt.money(item.amount, baseCurrency)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -28,3 +28,19 @@ test("bill can split across active posts", () => {
|
||||
expect(selection.splits.map(split => split.absorbsRemainder)).toEqual([false, true]);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
test("an unassigned bill can gain a destination with a rounding post", () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
let selection: RecurringSplitSelection = { splitMode: "percent", splits: [] };
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
const render = () => root.render(<RecurringSplitEditor {...selection} posts={data.posts} onChange={next => { selection = next; render(); }} />);
|
||||
try {
|
||||
act(render);
|
||||
expect(container.textContent).toContain("No post");
|
||||
act(() => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Add another post"))!).click(); });
|
||||
expect(selection.splits).toEqual([{ postId: "", value: 100, absorbsRemainder: true }]);
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,6 +20,19 @@ export function RecurringSplitEditor({ splitMode, splits, posts, onChange }: Pro
|
||||
return (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">Destination posts</legend>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={splits.length === 0}
|
||||
onChange={(event) => onChange({
|
||||
splitMode,
|
||||
splits: event.target.checked
|
||||
? []
|
||||
: [{ postId: posts[0]?.id ?? "", value: 100, absorbsRemainder: true }],
|
||||
})}
|
||||
/>
|
||||
No post — keep this recurring cost unassigned
|
||||
</label>
|
||||
{splits.length > 1 && (
|
||||
<NativeSelect aria-label="Split by" value={splitMode} onChange={event => {
|
||||
const mode = event.target.value as RecurringCost["splitMode"];
|
||||
@@ -54,7 +67,7 @@ export function RecurringSplitEditor({ splitMode, splits, posts, onChange }: Pro
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => onChange({ splitMode, splits: [...splits, { postId: "", value: 0, absorbsRemainder: false }] })}>Add another post</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => onChange({ splitMode, splits: [...splits, { postId: "", value: splits.length === 0 ? 100 : 0, absorbsRemainder: splits.length === 0 }] })}>Add another post</Button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user