feat(reports): getCategoryOverTime -> id-keyed trends tree (sidecar) + tree-based results

Adds an id-keyed hierarchical `tree: OverTimeRow[]` sidecar to
getCategoryOverTime, built via the generic buildLeafDrivenTree (#263) as
buildOverTimeTree — leaf-driven, income-first, no top-N / no "Other" bucket.
The name-keyed pivot (data/categories/colors/types/categoryIds) is left
BYTE-IDENTICAL, so the Trends chart and the dashboard render unchanged.

computeOverTimeResults now consumes the id-keyed tree LEAVES (never the
`is_parent` subtotals) grouped by each leaf's category_id-resolved type, so
the section subtotals and the Result-before/net lines are exact: two homonym
categories of different types no longer collide, and a non-top-N income or
transfer category is no longer lumped into "Other" as an expense.

CategoryOverTimeTable renders the tree leaves (every category, id-safe)
instead of the top-N pivot; the chart keeps reading the top-N-capped pivot so
its stacked series stay bounded. OverTimeRow mirrors CategoryDelta's snake_case
hierarchy block (parent_id/is_parent/depth/category_type) so it composes with
collapsibleRows / useCollapsibleGroups for the #265 hierarchy work.

Tests: rewrote overTimeResults.test.ts onto the tree (homonym regression +
leaves-only-not-parents); added buildOverTimeTree suite (nesting, income-first,
grand-total invariance, orphan, A->B->A cycle depth guard) and a
getCategoryOverTime tree-wiring test proving the pivot stays top-N+Other while
the tree carries every category. 720 vitest green, build + tsc green.

Resolves #264

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-07-07 19:30:50 -04:00
parent 5a1ce42034
commit 19cdad2718
12 changed files with 493 additions and 127 deletions

View file

@ -6,6 +6,10 @@
- Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254).
### Corrigé
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat couvre désormais **toutes** les catégories au lieu des 50 plus grosses, et ses lignes de Résultat sont exactes. Deux catégories différentes portant le même nom ne sont plus fusionnées, et un petit revenu ou transfert n'est plus comptabilisé comme une dépense — les revenus et le résultat net s'additionnent donc correctement (#264).
## [0.12.0] - 2026-07-05
### Modifié

View file

@ -6,6 +6,10 @@
- Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254).
### Fixed
- Reports → Trends → by category (table view): the income-statement table now covers **every** category instead of only the 50 largest, and its Result lines are exact. Two different categories that happen to share a name are no longer merged together, and a smaller income or transfer category is no longer miscounted as an expense — so the revenues and the net result add up correctly (#264).
## [0.12.0] - 2026-07-05
### Changed

View file

@ -59,6 +59,9 @@ export default function CategoryOverTimeChart({
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
// Reads the name-keyed pivot (`data.categories`), which stays top-N-capped
// (Issue #264 sidecar) so the stacked series never explode to 80+ layers. The
// full, uncapped hierarchy lives in `data.tree` and drives the table instead.
const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
const categoryEntries = visibleCategories.map((name, index) => ({
name,

View file

@ -33,10 +33,9 @@ const SECTION_TOTAL_KEY: Record<OverTimeType, string> = {
interface CategoryOverTimeTableProps {
data: CategoryOverTimeData;
hiddenCategories?: Set<string>;
}
export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) {
export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) {
const { t } = useTranslation();
if (data.data.length === 0) {
@ -47,11 +46,9 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
);
}
const visibleCategories = hiddenCategories?.size
? data.categories.filter((name) => !hiddenCategories.has(name))
: data.categories;
const { months, sections, hasTransfers, net, beforeTransfers } = computeOverTimeResults(data, visibleCategories);
// Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and
// the result lines are exact, and homonym categories never collide.
const { months, sections, hasTransfers, net, beforeTransfers } = computeOverTimeResults(data);
const colSpan = months.length + 2;
return (
@ -85,26 +82,23 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
{t(SECTION_LABEL_KEY[section.type])}
</td>
</tr>
{/* Category rows — neutral levels, not deltas */}
{section.categories.map((category) => {
const rowTotal = data.data.reduce(
(sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0),
0,
);
return (
<tr key={category} className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40">
{/* Category rows — neutral leaf magnitudes (not deltas) */}
{section.rows.map((row, rowIdx) => (
<tr
key={`${row.categoryId ?? "u"}-${rowIdx}`}
className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: data.colors[category] }}
style={{ backgroundColor: row.categoryColor }}
/>
{category}
{row.categoryName}
</span>
</td>
{months.map((month) => {
const monthData = data.data.find((d) => d.month === month);
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
{months.map((month, monthIdx) => {
const value = row.monthly[monthIdx] ?? 0;
return (
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
{value ? cadFormatter(value) : "—"}
@ -112,11 +106,10 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
);
})}
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
{cadFormatter(rowTotal)}
{cadFormatter(row.total)}
</td>
</tr>
);
})}
))}
{/* Section subtotal */}
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold">
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">

View file

@ -1,12 +1,41 @@
import { describe, it, expect } from "vitest";
import { computeOverTimeResults, OVER_TIME_TYPE_ORDER } from "./overTimeResults";
import type { CategoryOverTimeData } from "../../shared/types";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
function makeData(
data: CategoryOverTimeData["data"],
types: Record<string, "income" | "expense" | "transfer">,
): CategoryOverTimeData {
return { categories: Object.keys(types), data, colors: {}, categoryIds: {}, types };
/** Builds an id-keyed leaf row; `monthly` is index-aligned to the months list. */
function leaf(
categoryId: number | null,
categoryName: string,
monthly: number[],
category_type: "income" | "expense" | "transfer",
extra: Partial<OverTimeRow> = {},
): OverTimeRow {
return {
categoryId,
categoryName,
categoryColor: "#000",
monthly,
total: monthly.reduce((s, v) => s + v, 0),
parent_id: null,
is_parent: false,
depth: 0,
category_type,
...extra,
};
}
/** Wraps a month list + id-keyed tree into a CategoryOverTimeData. The pivot
* fields are irrelevant here computeOverTimeResults reads `data.tree`, using
* `data.data` only for the (ordered) month list. */
function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData {
return {
categories: [],
data: months.map((month) => ({ month })),
colors: {},
categoryIds: {},
types: {},
tree,
};
}
describe("OVER_TIME_TYPE_ORDER", () => {
@ -16,19 +45,21 @@ describe("OVER_TIME_TYPE_ORDER", () => {
});
describe("computeOverTimeResults", () => {
it("groups categories into ordered sections with per-month + total subtotals", () => {
it("groups leaves into ordered sections with per-month + total subtotals", () => {
const data = makeData(
["2025-01", "2025-02"],
[
{ month: "2025-01", Salary: 3000, Rent: 1000, Groceries: 500, Savings: 200 },
{ month: "2025-02", Salary: 3000, Rent: 1000, Groceries: 700, Savings: 200 },
leaf(2, "Rent", [1000, 1000], "expense"),
leaf(4, "Savings", [200, 200], "transfer"),
leaf(1, "Salary", [3000, 3000], "income"),
leaf(3, "Groceries", [500, 700], "expense"),
],
{ Salary: "income", Rent: "expense", Groceries: "expense", Savings: "transfer" },
);
const r = computeOverTimeResults(data, ["Salary", "Rent", "Groceries", "Savings"]);
const r = computeOverTimeResults(data);
expect(r.months).toEqual(["2025-01", "2025-02"]);
// Income-first ordering, regardless of the input category order.
// Income-first ordering, regardless of the input row order.
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense", "transfer"]);
const income = r.sections.find((s) => s.type === "income")!;
@ -37,7 +68,7 @@ describe("computeOverTimeResults", () => {
expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3000 });
expect(income.total).toBe(6000);
expect(expense.categories).toEqual(["Rent", "Groceries"]);
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Rent", "Groceries"]);
expect(expense.monthly).toEqual({ "2025-01": 1500, "2025-02": 1700 });
expect(expense.total).toBe(3200);
expect(transfer.monthly).toEqual({ "2025-01": 200, "2025-02": 200 });
@ -46,14 +77,16 @@ describe("computeOverTimeResults", () => {
it("computes net (income expense + transfer) and before-transfers per month", () => {
const data = makeData(
["2025-01", "2025-02"],
[
{ month: "2025-01", Salary: 3000, Rent: 1000, Groceries: 500, Savings: 200 },
{ month: "2025-02", Salary: 3000, Rent: 1000, Groceries: 700, Savings: 200 },
leaf(1, "Salary", [3000, 3000], "income"),
leaf(2, "Rent", [1000, 1000], "expense"),
leaf(3, "Groceries", [500, 700], "expense"),
leaf(4, "Savings", [200, 200], "transfer"),
],
{ Salary: "income", Rent: "expense", Groceries: "expense", Savings: "transfer" },
);
const r = computeOverTimeResults(data, ["Salary", "Rent", "Groceries", "Savings"]);
const r = computeOverTimeResults(data);
expect(r.hasTransfers).toBe(true);
// before = income expense
@ -66,11 +99,11 @@ describe("computeOverTimeResults", () => {
it("collapses net to before-transfers when there are no transfers", () => {
const data = makeData(
[{ month: "2025-01", Salary: 2000, Rent: 2500 }],
{ Salary: "income", Rent: "expense" },
["2025-01"],
[leaf(1, "Salary", [2000], "income"), leaf(2, "Rent", [2500], "expense")],
);
const r = computeOverTimeResults(data, ["Salary", "Rent"]);
const r = computeOverTimeResults(data);
expect(r.hasTransfers).toBe(false);
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense"]);
@ -80,24 +113,27 @@ describe("computeOverTimeResults", () => {
expect(r.net.total).toBe(-500);
});
it("defaults an untyped category (e.g. Other) to expense", () => {
const data = makeData(
[{ month: "2025-01", Salary: 1000, Other: 300 }],
{ Salary: "income" }, // Other has no type entry
);
it("defaults a leaf with an unknown type to the expense section (defensive)", () => {
// The tree normally stamps a valid type on every leaf; the reducer still
// guards against a stray value by folding it into expenses.
const untyped = {
...leaf(9, "Other", [300], "expense"),
category_type: undefined as unknown as "expense",
};
const data = makeData(["2025-01"], [leaf(1, "Salary", [1000], "income"), untyped]);
const r = computeOverTimeResults(data, ["Salary", "Other"]);
const r = computeOverTimeResults(data);
const expense = r.sections.find((s) => s.type === "expense")!;
expect(expense.categories).toEqual(["Other"]);
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Other"]);
expect(expense.total).toBe(300);
expect(r.net.monthly["2025-01"]).toBe(700); // 1000 300
});
it("drops empty sections and still nets a lone transfer section", () => {
const data = makeData([{ month: "2025-01", Move: 100 }], { Move: "transfer" });
const data = makeData(["2025-01"], [leaf(1, "Move", [100], "transfer")]);
const r = computeOverTimeResults(data, ["Move"]);
const r = computeOverTimeResults(data);
expect(r.sections.map((s) => s.type)).toEqual(["transfer"]);
expect(r.hasTransfers).toBe(true);
@ -105,29 +141,51 @@ describe("computeOverTimeResults", () => {
expect(r.net.monthly["2025-01"]).toBe(100);
});
it("only counts the categories passed as visible (hidden ones excluded)", () => {
it("keeps homonym categories of different types apart (Résultat net correct) — Issue #264", () => {
// Two DIFFERENT categories both named "Divers": one income, one expense.
// A name-keyed pivot would merge them into a single cell and a single type;
// the id-keyed tree keeps them distinct, so the net stays correct.
const data = makeData(
[{ month: "2025-01", Salary: 2000, Rent: 500, Hidden: 999 }],
{ Salary: "income", Rent: "expense", Hidden: "expense" },
["2025-01"],
[leaf(1, "Divers", [1000], "income"), leaf(2, "Divers", [300], "expense")],
);
const r = computeOverTimeResults(data, ["Salary", "Rent"]); // Hidden filtered out upstream
const r = computeOverTimeResults(data);
const income = r.sections.find((s) => s.type === "income")!;
const expense = r.sections.find((s) => s.type === "expense")!;
expect(income.total).toBe(1000);
expect(expense.total).toBe(300);
// Net = 1000 300 = 700, NOT 0 (which a name collision would produce).
expect(r.net.monthly["2025-01"]).toBe(700);
});
it("sums leaves only — a parent subtotal row is never double-counted — Issue #264", () => {
// The tree carries both a parent subtotal (is_parent) and its leaves. The
// reducer must add up leaves only, or the parent's 800 would be counted twice.
const parent = { ...leaf(10, "Dépenses", [800], "expense"), is_parent: true };
const child1 = { ...leaf(11, "Épicerie", [500], "expense"), parent_id: 10, depth: 1 };
const child2 = { ...leaf(12, "Restaurant", [300], "expense"), parent_id: 10, depth: 1 };
const income = leaf(1, "Salary", [2000], "income");
const data = makeData(["2025-01"], [income, parent, child1, child2]);
const r = computeOverTimeResults(data);
const expense = r.sections.find((s) => s.type === "expense")!;
expect(expense.total).toBe(500);
expect(r.net.monthly["2025-01"]).toBe(1500); // 2000 500
// 500 + 300 = 800 (the parent's own 800 subtotal is excluded).
expect(expense.total).toBe(800);
expect(expense.rows.map((row) => row.categoryName)).toEqual(["Épicerie", "Restaurant"]);
// Net = 2000 800 = 1200, not 2000 1600.
expect(r.net.monthly["2025-01"]).toBe(1200);
});
it("treats a category absent from a month as zero", () => {
const data = makeData(
[
{ month: "2025-01", Salary: 1000 },
{ month: "2025-02", Salary: 1000, Bonus: 500 },
],
{ Salary: "income", Bonus: "income" },
["2025-01", "2025-02"],
[leaf(1, "Salary", [1000, 1000], "income"), leaf(2, "Bonus", [0, 500], "income")],
);
const r = computeOverTimeResults(data, ["Salary", "Bonus"]);
const r = computeOverTimeResults(data);
const income = r.sections.find((s) => s.type === "income")!;
expect(income.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 });

View file

@ -1,12 +1,12 @@
import type { CategoryOverTimeData, CategoryOverTimeItem } from "../../shared/types";
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
export type OverTimeType = "income" | "expense" | "transfer";
/**
* Income-statement reading order: revenue first, then expenses, then transfers,
* so the "net result" row reads naturally at the bottom (Income Expenses +
* Transfers). Note this is income-first, unlike the compare report's
* `COMPARE_TYPE_ORDER` (expense-first) see issue #256.
* Transfers). Matches the compare report's `COMPARE_TYPE_ORDER`, which is also
* income-first since issue #253 (the earlier expense-first note is obsolete).
*/
export const OVER_TIME_TYPE_ORDER: Record<OverTimeType, number> = {
income: 0,
@ -22,10 +22,18 @@ export interface OverTimeSeries {
total: number;
}
/** One rendered section: a type, its member categories, and per-month + total subtotals. */
export interface OverTimeSection extends OverTimeSeries {
/**
* One rendered section: a type, its member leaf rows (id-keyed, from the tree),
* and per-month + total subtotals. `rows` are the actual leaves in the section
* (never subtotal rows), each carrying its own index-aligned `monthly[]`.
*/
export interface OverTimeSection {
type: OverTimeType;
categories: string[];
rows: OverTimeRow[];
/** month (YYYY-MM) -> section subtotal */
monthly: Record<string, number>;
/** sum across every month */
total: number;
}
export interface OverTimeAnalysis {
@ -41,60 +49,53 @@ export interface OverTimeAnalysis {
beforeTransfers: OverTimeSeries;
}
/** Reads a numeric category cell off a pivot row, treating anything non-numeric as 0. */
function cellValue(item: CategoryOverTimeItem | undefined, category: string): number {
const v = (item as Record<string, unknown> | undefined)?.[category];
return typeof v === "number" ? v : 0;
}
/**
* Pure reducer that turns the category-over-time pivot into an "income statement"
* shape: per-type sections with per-month subtotals, plus the net-result rows.
* Pure reducer that turns the category-over-time report into an "income
* statement" shape: per-type sections with per-month subtotals, plus the
* net-result rows.
*
* Values coming from `getCategoryOverTime` are positive magnitudes (`ABS(SUM())`
* in SQL), so income/expense/transfer subtotals are all 0, and the net is
* `income expense + transfer`. A category whose type is unknown (e.g. the
* "Other" bucket that aggregates non-top-N categories) defaults to `expense`,
* mirroring the `COALESCE(c.type, 'expense')` used by the service.
* Consumes the **id-keyed tree** (`data.tree`) NOT the name-keyed pivot so
* two homonym categories of different types never collide: each leaf carries its
* own `category_id`-resolved `category_type`, and the subtotals + result lines
* are summed over the tree's LEAVES only (never its `is_parent` subtotal rows,
* which would double-count their own descendants). Leaf `monthly[]` values are
* positive magnitudes (`ABS(SUM())` in SQL), so income/expense/transfer
* subtotals are all 0 and the net is `income expense + transfer`.
*
* Extracted from `CategoryOverTimeTable` and unit-tested independently the
* project has no React render harness, so the arithmetic lives in a pure module.
* The tree is already ordered income expense transfer, so section ordering
* falls out of the leaves' own `category_type`.
*/
export function computeOverTimeResults(
data: CategoryOverTimeData,
visibleCategories: string[],
): OverTimeAnalysis {
export function computeOverTimeResults(data: CategoryOverTimeData): OverTimeAnalysis {
const months = data.data.map((d) => d.month);
const itemByMonth = new Map<string, CategoryOverTimeItem>();
for (const item of data.data) itemByMonth.set(item.month, item);
const typeOf = (cat: string): OverTimeType => {
const t = data.types[cat];
// Leaves only — subtotals (`is_parent`) are excluded so a parent group can
// never double-count against its own descendants.
const typeOf = (row: OverTimeRow): OverTimeType => {
const t = row.category_type;
return t === "income" || t === "transfer" ? t : "expense";
};
// Bucket visible categories by type, preserving their incoming (magnitude) order.
const byType: Record<OverTimeType, string[]> = { income: [], expense: [], transfer: [] };
for (const cat of visibleCategories) byType[typeOf(cat)].push(cat);
const byType: Record<OverTimeType, OverTimeRow[]> = { income: [], expense: [], transfer: [] };
for (const row of data.tree) {
if (!row.is_parent) byType[typeOf(row)].push(row);
}
const buildSection = (type: OverTimeType): OverTimeSection => {
const categories = byType[type];
const rows = byType[type];
const monthly: Record<string, number> = {};
let total = 0;
for (const month of months) {
const item = itemByMonth.get(month);
const sum = categories.reduce((s, cat) => s + cellValue(item, cat), 0);
months.forEach((month, i) => {
const sum = rows.reduce((s, row) => s + (row.monthly[i] ?? 0), 0);
monthly[month] = sum;
total += sum;
}
return { type, categories, monthly, total };
});
return { type, rows, monthly, total };
};
const income = buildSection("income");
const expense = buildSection("expense");
const transfer = buildSection("transfer");
const hasTransfers = transfer.categories.length > 0;
const hasTransfers = transfer.rows.length > 0;
const net: OverTimeSeries = { monthly: {}, total: 0 };
const beforeTransfers: OverTimeSeries = { monthly: {}, total: 0 };
@ -108,7 +109,7 @@ export function computeOverTimeResults(
}
const sections = [income, expense, transfer]
.filter((s) => s.categories.length > 0)
.filter((s) => s.rows.length > 0)
.sort((a, b) => OVER_TIME_TYPE_ORDER[a.type] - OVER_TIME_TYPE_ORDER[b.type]);
return { months, sections, hasTransfers, net, beforeTransfers };

View file

@ -51,7 +51,7 @@ const yearStartStr = `${now.getFullYear()}-01-01`;
const initialState: DashboardState = {
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
categoryBreakdown: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} },
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
budgetVsActual: [],
period: "year",
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),

View file

@ -23,7 +23,7 @@ type Action =
const initialState: State = {
subView: "global",
monthlyTrends: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} },
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
isLoading: false,
error: null,
};

View file

@ -123,7 +123,7 @@ export default function ReportsTrendsPage() {
chartType={chartType}
/>
) : (
<CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} />
<CategoryOverTimeTable data={categoryOverTime} />
)}
</div>
);

View file

@ -6,8 +6,9 @@ import {
getCompareYearOverYear,
getCategoryZoom,
buildCompareTree,
buildOverTimeTree,
} from "./reportService";
import type { CategoryDelta } from "../shared/types";
import type { CategoryDelta, OverTimeRow } from "../shared/types";
// Mock the db module
vi.mock("./db", () => {
@ -163,23 +164,66 @@ describe("getCategoryOverTime", () => {
expect(result.types).toEqual({ Food: "expense" });
});
it("groups non-top-N categories into Other (Other left out of the types map)", async () => {
it("keeps the pivot top-N + Other but the id-keyed tree carries every category (Issue #264)", async () => {
mockSelect
// 1. top categories — top-N = 1, so only Food.
.mockResolvedValueOnce([
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
])
// 2. monthly breakdown — every category (no LIMIT here).
.mockResolvedValueOnce([
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
{ month: "2025-01", category_id: 2, category_name: "Transport", total: 100 },
{ month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 },
])
// 3. category metadata for the tree.
.mockResolvedValueOnce([
{ id: 1, name: "Food", color: "#ff0000", type: "expense", parent_id: null },
{ id: 2, name: "Transport", color: "#00ff00", type: "expense", parent_id: null },
{ id: 3, name: "Entertainment", color: "#0000ff", type: "expense", parent_id: null },
]);
const result = await getCategoryOverTime("2025-01-01", "2025-01-31", 1, undefined, "expense");
// Pivot path is UNCHANGED — top-N (Food) plus an "Other" bucket for the rest.
expect(result.categories).toEqual(["Food", "Other"]);
expect(result.colors["Other"]).toBe("#9ca3af");
expect(result.types).toEqual({ Food: "expense" });
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
// Sidecar tree — EVERY category (no top-N, no "Other"), keyed by id.
const treeNames = result.tree.map((r) => r.categoryName);
expect(treeNames).toEqual(expect.arrayContaining(["Food", "Transport", "Entertainment"]));
expect(treeNames).not.toContain("Other");
const food = result.tree.find((r) => r.categoryId === 1)!;
expect(food.monthly).toEqual([300]);
expect(food.total).toBe(300);
expect(food.category_type).toBe("expense");
expect(food.is_parent).toBe(false);
});
it("separates homonym categories of different types in the tree (Issue #264)", async () => {
mockSelect
.mockResolvedValueOnce([]) // top categories (irrelevant to the tree)
.mockResolvedValueOnce([
// Two DIFFERENT categories both named "Divers".
{ month: "2025-01", category_id: 1, category_name: "Divers", total: 1000 },
{ month: "2025-01", category_id: 2, category_name: "Divers", total: 300 },
])
.mockResolvedValueOnce([
{ id: 1, name: "Divers", color: null, type: "income", parent_id: null },
{ id: 2, name: "Divers", color: null, type: "expense", parent_id: null },
]);
const result = await getCategoryOverTime("2025-01-01", "2025-01-31");
// The pivot merges them (single "Divers" cell); the tree keeps them apart.
const incomeDivers = result.tree.find((r) => r.categoryId === 1)!;
const expenseDivers = result.tree.find((r) => r.categoryId === 2)!;
expect(incomeDivers.category_type).toBe("income");
expect(incomeDivers.total).toBe(1000);
expect(expenseDivers.category_type).toBe("expense");
expect(expenseDivers.total).toBe(300);
});
});
@ -800,6 +844,114 @@ describe("buildCompareTree — hierarchical real-vs-real (Issue #247)", () => {
});
});
describe("buildOverTimeTree — hierarchical trends (Issue #264)", () => {
/** Flat id-keyed leaf, mirroring what getCategoryOverTime feeds the builder. */
function otLeaf(categoryId: number | null, categoryName: string, monthly: number[]): OverTimeRow {
return {
categoryId,
categoryName,
categoryColor: "#000",
monthly,
total: monthly.reduce((s, v) => s + v, 0),
parent_id: null,
is_parent: false,
depth: 0,
category_type: "expense",
};
}
const cats = [
{ id: 2, name: "Dépenses", color: null, type: "expense", parent_id: null },
{ id: 22, name: "Épicerie", color: "#10b981", type: "expense", parent_id: 2 },
{ id: 24, name: "Restaurant", color: "#f97316", type: "expense", parent_id: 2 },
{ id: 8, name: "Revenus", color: null, type: "income", parent_id: null },
{ id: 80, name: "Paie", color: null, type: "income", parent_id: 8 },
{ id: 5, name: "Placements", color: null, type: "transfer", parent_id: null },
{ id: 50, name: "REER", color: null, type: "transfer", parent_id: 5 },
] as Parameters<typeof buildOverTimeTree>[1];
it("nests leaves under a parent subtotal equal to the per-month sum of its children", () => {
const rows = buildOverTimeTree(
[otLeaf(22, "Épicerie", [500, 400]), otLeaf(24, "Restaurant", [120, 200])],
cats,
2,
);
// Parent subtotal on top, then children ordered by |total| desc.
expect(rows.map((r) => r.categoryName)).toEqual(["Dépenses", "Épicerie", "Restaurant"]);
const parent = rows[0];
expect(parent.is_parent).toBe(true);
expect(parent.categoryId).toBe(2);
expect(parent.depth).toBe(0);
expect(parent.category_type).toBe("expense");
// Subtotal series is index-aligned and equals the per-month child sums.
expect(parent.monthly).toEqual([620, 600]);
expect(parent.total).toBe(1220);
const epicerie = rows.find((r) => r.categoryName === "Épicerie")!;
expect(epicerie.is_parent).toBe(false);
expect(epicerie.depth).toBe(1);
expect(epicerie.parent_id).toBe(2);
expect(epicerie.monthly).toEqual([500, 400]);
});
it("orders sections income → expense → transfer and keeps subtrees contiguous", () => {
const rows = buildOverTimeTree(
[otLeaf(80, "Paie", [4200]), otLeaf(22, "Épicerie", [500]), otLeaf(50, "REER", [0])],
cats,
1,
);
// Each leaf sits under a parent, so every section yields [subtotal, leaf].
expect(rows.map((r) => r.category_type)).toEqual([
"income",
"income",
"expense",
"expense",
"transfer",
"transfer",
]);
});
it("preserves grand-total invariance vs the flat leaves", () => {
const flat = [
otLeaf(22, "Épicerie", [500, 400]),
otLeaf(24, "Restaurant", [120, 200]),
otLeaf(50, "REER", [0, 0]),
];
const rows = buildOverTimeTree(flat, cats, 2);
const sumLeaves = (rs: OverTimeRow[]) =>
rs.filter((r) => !r.is_parent).reduce((s, r) => s + r.total, 0);
expect(sumLeaves(rows)).toBe(sumLeaves(flat));
});
it("surfaces an uncategorized (null id) leaf as a top-level row", () => {
const rows = buildOverTimeTree([otLeaf(null, "Uncategorized", [90])], cats, 1);
expect(rows).toHaveLength(1);
expect(rows[0].categoryName).toBe("Uncategorized");
expect(rows[0].is_parent).toBe(false);
expect(rows[0].depth).toBe(0);
expect(rows[0].monthly).toEqual([90]);
});
it("terminates on a cyclic parent_id chain (A → B → A) without overflowing", () => {
const cyclic = [
{ id: 1, name: "A", color: null, type: "expense", parent_id: 2 },
{ id: 2, name: "B", color: null, type: "expense", parent_id: 1 },
] as Parameters<typeof buildOverTimeTree>[1];
// The relevant-ancestor walk and buildNode both cap at MAX_TREE_DEPTH, so a
// corrupted cycle returns (no root emerges) instead of recursing forever —
// .not.toThrow() catches a "Maximum call stack" RangeError if it overflowed.
let rows: OverTimeRow[] = [];
expect(() => {
rows = buildOverTimeTree([otLeaf(1, "A", [100])], cyclic, 1);
}).not.toThrow();
expect(Array.isArray(rows)).toBe(true);
for (const r of rows) expect(r.depth).toBeLessThanOrEqual(5);
});
});
describe("getCompareMonthOverMonth — wires the hierarchy (Issue #247)", () => {
it("returns parent subtotal rows when category metadata is available", async () => {
mockSelect

View file

@ -5,6 +5,7 @@ import type {
CategoryBreakdownItem,
CategoryOverTimeData,
CategoryOverTimeItem,
OverTimeRow,
HighlightsData,
HighlightMover,
CategoryDelta,
@ -183,12 +184,58 @@ export async function getCategoryOverTime(
categories.push("Other");
}
const data = Array.from(monthMap.values());
// --- Id-keyed hierarchical sidecar (Issue #264) ---
// The pivot above stays byte-identical (top-N + "Other", name-keyed) so the
// chart and dashboard are untouched. The tree is a SEPARATE view of the exact
// same `monthlyRows`, keyed by category_id, carrying EVERY category (no top-N,
// no "Other" bucket) so the trends table + its result lines are exact and two
// homonym categories of different types never collide.
const months = data.map((d) => d.month);
const monthIndex = new Map<string, number>();
months.forEach((m, i) => monthIndex.set(m, i));
// Aggregate every category into an id-keyed per-month magnitude series. A null
// category_id (truly uncategorized) collapses to one bucket; a non-null id that
// has no metadata row (hard-deleted) stays distinct and surfaces as an orphan.
const leafByKey = new Map<number | string, OverTimeRow>();
for (const row of monthlyRows) {
const key = row.category_id ?? "__uncategorized__";
let leaf = leafByKey.get(key);
if (!leaf) {
leaf = {
categoryId: row.category_id,
categoryName: row.category_name,
categoryColor: "#9ca3af",
monthly: new Array(months.length).fill(0),
total: 0,
parent_id: null,
is_parent: false,
depth: 0,
category_type: "expense",
};
leafByKey.set(key, leaf);
}
const idx = monthIndex.get(row.month);
if (idx != null) {
leaf.monthly[idx] += row.total;
leaf.total += row.total;
}
}
const cats = await db.select<TreeCatMeta[]>(
`SELECT id, name, color, type, parent_id FROM categories`,
);
const tree = buildOverTimeTree(Array.from(leafByKey.values()), cats ?? [], months.length);
return {
categories,
data: Array.from(monthMap.values()),
data,
colors,
categoryIds,
types,
tree,
};
}
@ -739,6 +786,78 @@ export function buildCompareTree(
});
}
// --- Trends hierarchy (Issue #264) ---
/**
* Turns the flat per-category monthly leaves of `getCategoryOverTime` into a
* parent/child tree with subtotal (`is_parent`) rows the id-keyed sidecar to
* the name-keyed pivot. Another thin specialisation of `buildLeafDrivenTree`
* (Issue #263): the generic skeleton drives the hierarchy walk, sibling
* ordering, `MAX_TREE_DEPTH` cycle guard and the income expense transfer
* section sort (reusing `COMPARE_TYPE_ORDER`, income-first since #253); this only
* supplies the per-month magnitude arithmetic. A subtotal's `monthly[i]` is the
* sum of its descendant leaves' `monthly[i]`, so section subtotals and the
* result lines can be read straight off the leaves. `monthCount` fixes the
* length of every subtotal's series so it stays index-aligned with the pivot.
*
* Exported for unit testing.
*/
export function buildOverTimeTree(
leaves: OverTimeRow[],
categories: TreeCatMeta[],
monthCount: number,
): OverTimeRow[] {
const typeOf = (c: TreeCatMeta): TreeSectionType => c.type ?? "expense";
return buildLeafDrivenTree<OverTimeRow>(leaves, {
categories,
categoryIdOf: (l) => l.categoryId,
makeLeaf: (cat, l, parentId, depth) => ({
...l,
categoryColor: cat.color ?? l.categoryColor,
parent_id: parentId,
is_parent: false,
depth,
category_type: typeOf(cat),
}),
makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
const monthly = new Array<number>(monthCount).fill(0);
let total = 0;
for (const dl of descendantLeaves) {
for (let i = 0; i < monthCount; i++) monthly[i] += dl.monthly[i] ?? 0;
total += dl.total;
}
return {
categoryId: cat.id,
categoryName: cat.name,
categoryColor: cat.color ?? "#9ca3af",
monthly,
total,
parent_id: parentId,
is_parent: true,
depth,
category_type: typeOf(cat),
};
},
// A parent with both children and its own transactions surfaces the direct
// spend as a "(direct)" leaf (mirrors buildCompareTree / getBudgetVsActualData).
decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
// Orphans (Uncategorized / hard-deleted) keep their own series; only the
// hierarchy fields are stamped. No metadata → default 'expense'.
makeOrphan: (l) => ({
...l,
parent_id: null,
is_parent: false,
depth: 0,
category_type: l.category_type ?? "expense",
}),
sortKey: (row) => Math.abs(row.total),
isSubtotal: (row) => row.is_parent === true,
sectionOf: (row) => row.category_type ?? "expense",
sectionOrder: COMPARE_TYPE_ORDER,
});
}
function previousMonth(year: number, month: number): { year: number; month: number } {
if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 };

View file

@ -367,6 +367,31 @@ export interface CategoryOverTimeItem {
[categoryName: string]: number | string;
}
/**
* One row of the id-keyed trends tree (Issue #264) the sidecar to the
* name-keyed pivot below. Carries a per-month magnitude series instead of a
* single delta, but mirrors `CategoryDelta`'s snake_case hierarchy block
* (`parent_id` / `is_parent` / `depth` / `category_type`) so it composes with
* `collapsibleRows` / `useCollapsibleGroups` without friction. Section/type is
* resolved by `category_id` (never by name), so two homonym categories of
* different types never collide the fix the name-keyed pivot cannot express.
*/
export interface OverTimeRow {
categoryId: number | null;
categoryName: string;
categoryColor: string;
/** Positive magnitude per month, index-aligned to `CategoryOverTimeData.data`. */
monthly: number[];
/** Sum of `monthly` — sibling ordering + row total convenience. */
total: number;
// Hierarchy block — same snake_case names as CategoryDelta (Issue #247/#264).
// `is_parent` rows are subtotals whose series = the sum of their leaf subtree.
parent_id: number | null;
is_parent: boolean;
depth: number;
category_type: "expense" | "income" | "transfer";
}
export interface CategoryOverTimeData {
categories: string[];
data: CategoryOverTimeItem[];
@ -374,6 +399,13 @@ export interface CategoryOverTimeData {
categoryIds: Record<string, number | null>;
/** Category name -> its type. Built from the top-N rows (mirrors `colors`). */
types: Record<string, "expense" | "income" | "transfer">;
/**
* Id-keyed hierarchical sidecar (Issue #264): EVERY category (no top-N, no
* "Other" bucket), parent-first depth-first, income expense transfer.
* Drives the trends table + its result lines. The pivot fields above are left
* unchanged and still drive the (top-N-capped) chart and the dashboard.
*/
tree: OverTimeRow[];
}
export interface BudgetVsActualRow {