-
+ {/* The old bar read Total Spent · Categories · Top: X. "Categories: 18" is
+ a number no decision turns on, and the crown landed on Uncategorized
+ whenever it was the largest line — a trophy for a data-quality gap.
+ What a reader needs instead is how much of the total is unaccounted
+ for, and what the total is measured against. */}
+
+
+
Total spent · {rangeLabel}
+
{centsToDisplay(totalSpent)}
+ {totalSpent > 0 && (
+
+
+
+ Categorized
+ {centsToDisplay(categorized)}
+
+
+
+ Uncategorized
+ {centsToDisplay(uncategorized)}
+
+
+ )}
+
+
+
+
Compared with
+
+ {compLabel ? compLabel.replace(/^vs\s+/, "") : "Nothing — showing all time"}
+
+ {compLabel && (
+
the preceding period, same length
+ )}
+
+
+
+
Share of income
+
+ {shareOfIncome === null ? "—" : `${shareOfIncome.toFixed(1)}%`}
+
+
+ {totalIncome && totalIncome > 0
+ ? `of ${centsToDisplay(totalIncome)} received`
+ : "no income recorded in this range"}
+
+
+
Spending by Category
@@ -83,6 +129,7 @@ export function ReportSpending({
Category
Amount
+ % of total
{compLabel && Change}
@@ -106,13 +153,20 @@ export function ReportSpending({
@@ -126,6 +180,9 @@ export function ReportSpending({
{centsToDisplay(row.total)}
+
+ {totalSpent > 0 ? `${((row.total / totalSpent) * 100).toFixed(1)}%` : "—"}
+
{compLabel && (
{
+ test("a category absent from the baseline is new, not unchanged", () => {
+ // Both used to render as an empty cell, so "we have never seen this before"
+ // and "it did not move" looked identical in the Change column.
+ expect(comparisonState(5_000, null)).toEqual({ kind: "new" });
+ });
+
+ test("a baseline of zero is also new — there was nothing to grow from", () => {
+ expect(comparisonState(5_000, 0)).toEqual({ kind: "new" });
+ });
+
+ test("spending that went up", () => {
+ expect(comparisonState(150_00, 100_00)).toEqual({ kind: "up", percent: 50 });
+ });
+
+ test("spending that came down", () => {
+ expect(comparisonState(50_00, 100_00)).toEqual({ kind: "down", percent: -50 });
+ });
+
+ test("a move under half a percent reads as flat", () => {
+ expect(comparisonState(100_30, 100_00)).toEqual({ kind: "flat", percent: 0.3 });
+ expect(comparisonState(100_00, 100_00)).toEqual({ kind: "flat", percent: 0 });
+ });
+
+ test("half a percent is a move, not flat", () => {
+ expect(comparisonState(100_50, 100_00).kind).toBe("up");
+ });
+
+ test("a category that spent nothing this period against a real baseline", () => {
+ expect(comparisonState(0, 100_00)).toEqual({ kind: "down", percent: -100 });
+ });
+});
diff --git a/src/lib/comparison-state.ts b/src/lib/comparison-state.ts
new file mode 100644
index 0000000..64ccc57
--- /dev/null
+++ b/src/lib/comparison-state.ts
@@ -0,0 +1,24 @@
+export type ComparisonState =
+ | { kind: "new" }
+ | { kind: "flat"; percent: number }
+ | { kind: "up"; percent: number }
+ | { kind: "down"; percent: number };
+
+/** Below this, a move is noise rather than a trend. */
+const FLAT_THRESHOLD_PERCENT = 0.5;
+
+/**
+ * How this period compares with the baseline.
+ *
+ * A missing or zero baseline is reported as `new` rather than folded in with
+ * "no change": the Change column used to render an empty cell for both, so a
+ * category appearing for the first time was indistinguishable from one that had
+ * not moved.
+ */
+export function comparisonState(current: number, previous: number | null): ComparisonState {
+ if (previous === null || previous === 0) return { kind: "new" };
+
+ const percent = ((current - previous) / previous) * 100;
+ if (Math.abs(percent) < FLAT_THRESHOLD_PERCENT) return { kind: "flat", percent };
+ return { kind: percent > 0 ? "up" : "down", percent };
+}
diff --git a/src/lib/mcp/tools/reports.ts b/src/lib/mcp/tools/reports.ts
index 29adf50..ac30ed0 100644
--- a/src/lib/mcp/tools/reports.ts
+++ b/src/lib/mcp/tools/reports.ts
@@ -39,8 +39,11 @@ export function registerReportTools(server: McpServer, householdId: string) {
groupId: r.groupId,
totalCents: r.total,
totalDisplay: centsToDisplay(r.total),
+ // null means the category has no baseline row at all — new, rather
+ // than unchanged. Reporting it as 0 would let a consumer compute a
+ // change against a period the category was not in.
prevTotalCents: r.prevTotal,
- prevTotalDisplay: centsToDisplay(r.prevTotal),
+ prevTotalDisplay: r.prevTotal === null ? null : centsToDisplay(r.prevTotal),
})),
);
},
diff --git a/src/queries/reports.ts b/src/queries/reports.ts
index 8f46e29..f90771e 100644
--- a/src/queries/reports.ts
+++ b/src/queries/reports.ts
@@ -37,7 +37,12 @@ export interface SpendingRow {
groupId: string | null;
categoryIcon: string | null;
total: number;
- prevTotal: number;
+ /**
+ * The same category's spending in the comparison period, or `null` when the
+ * category has no baseline row — it is new, which is not the same as
+ * unchanged. `null` also when no comparison period was requested.
+ */
+ prevTotal: number | null;
}
export interface IncomeExpenseRow {
@@ -66,7 +71,7 @@ export async function getSpendingByCategory(
const currentSpending = await aggregateSpending(householdId, filters, db);
const enriched = await enrichSpendingMap(currentSpending, db);
- let prevMap = new Map();
+ let prevMap: Map | null = null;
if (comparisonPeriod) {
prevMap = await aggregateSpending(householdId, { ...filters, ...comparisonPeriod }, db);
}
@@ -78,7 +83,7 @@ export async function getSpendingByCategory(
groupId: row.groupId,
categoryIcon: row.categoryIcon,
total: row.value,
- prevTotal: prevMap.get(row.id ?? "uncategorized") ?? 0,
+ prevTotal: prevMap?.get(row.id ?? "uncategorized") ?? null,
}));
}
diff --git a/tests/integration/report-drill-down.test.ts b/tests/integration/report-drill-down.test.ts
index b28c53d..1b2023f 100644
--- a/tests/integration/report-drill-down.test.ts
+++ b/tests/integration/report-drill-down.test.ts
@@ -181,3 +181,32 @@ describe("an income drill-down explains its own figure", () => {
expect(drill.rows.map((r) => r.name).sort()).toEqual(["Bonus", "Salary"]);
});
});
+
+describe("a category's comparison against the baseline", () => {
+ test("a category absent from the baseline reports no previous figure, not zero", async () => {
+ // Food spends in both periods; Salary's category is irrelevant here — what
+ // matters is that a category with no baseline row is distinguishable from
+ // one that spent nothing, which `?? 0` made impossible.
+ await insertTransaction(db, householdId, accountId, { date: "2026-03-05", normalizedAmount: -5000, amount: 5000, categoryId: foodCatId, name: "Grocery" });
+ await insertTransaction(db, householdId, accountId, { date: "2026-02-10", normalizedAmount: -2000, amount: 2000, categoryId: foodCatId, name: "Grocery last period" });
+ await insertTransaction(db, householdId, accountId, { date: "2026-03-06", normalizedAmount: -3000, amount: 3000, categoryId: null, name: "First ever uncategorized" });
+ const { getSpendingByCategory } = await import("../../src/queries/reports");
+
+ const rows = await getSpendingByCategory(householdId, RANGE, db, {
+ dateFrom: "2026-02-01",
+ dateTo: "2026-02-28",
+ });
+
+ expect(rows.find((r) => r.categoryName === "Food")?.prevTotal).toBe(2000);
+ expect(rows.find((r) => r.categoryId === null)?.prevTotal).toBeNull();
+ });
+
+ test("without a comparison period nothing claims a previous figure", async () => {
+ await insertTransaction(db, householdId, accountId, { date: "2026-03-05", normalizedAmount: -5000, amount: 5000, categoryId: foodCatId, name: "Grocery" });
+ const { getSpendingByCategory } = await import("../../src/queries/reports");
+
+ const rows = await getSpendingByCategory(householdId, RANGE, db);
+
+ expect(rows.every((r) => r.prevTotal === null)).toBe(true);
+ });
+});