feat(reports): getCategoryOverTime -> arbre tendances (sidecar id-keyed) + resultats (#264) #268
12 changed files with 493 additions and 127 deletions
|
|
@ -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).
|
- 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
|
## [0.12.0] - 2026-07-05
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
|
||||||
|
|
@ -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).
|
- 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
|
## [0.12.0] - 2026-07-05
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ export default function CategoryOverTimeChart({
|
||||||
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
|
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: 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 visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
|
||||||
const categoryEntries = visibleCategories.map((name, index) => ({
|
const categoryEntries = visibleCategories.map((name, index) => ({
|
||||||
name,
|
name,
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,9 @@ const SECTION_TOTAL_KEY: Record<OverTimeType, string> = {
|
||||||
|
|
||||||
interface CategoryOverTimeTableProps {
|
interface CategoryOverTimeTableProps {
|
||||||
data: CategoryOverTimeData;
|
data: CategoryOverTimeData;
|
||||||
hiddenCategories?: Set<string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CategoryOverTimeTable({ data, hiddenCategories }: CategoryOverTimeTableProps) {
|
export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (data.data.length === 0) {
|
if (data.data.length === 0) {
|
||||||
|
|
@ -47,11 +46,9 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleCategories = hiddenCategories?.size
|
// Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and
|
||||||
? data.categories.filter((name) => !hiddenCategories.has(name))
|
// the result lines are exact, and homonym categories never collide.
|
||||||
: data.categories;
|
const { months, sections, hasTransfers, net, beforeTransfers } = computeOverTimeResults(data);
|
||||||
|
|
||||||
const { months, sections, hasTransfers, net, beforeTransfers } = computeOverTimeResults(data, visibleCategories);
|
|
||||||
const colSpan = months.length + 2;
|
const colSpan = months.length + 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -85,38 +82,34 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
||||||
{t(SECTION_LABEL_KEY[section.type])}
|
{t(SECTION_LABEL_KEY[section.type])}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/* Category rows — neutral levels, not deltas */}
|
{/* Category rows — neutral leaf magnitudes (not deltas) */}
|
||||||
{section.categories.map((category) => {
|
{section.rows.map((row, rowIdx) => (
|
||||||
const rowTotal = data.data.reduce(
|
<tr
|
||||||
(sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0),
|
key={`${row.categoryId ?? "u"}-${rowIdx}`}
|
||||||
0,
|
className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40"
|
||||||
);
|
>
|
||||||
return (
|
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
|
||||||
<tr key={category} className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40">
|
<span className="flex items-center gap-2">
|
||||||
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
|
<span
|
||||||
<span className="flex items-center gap-2">
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
<span
|
style={{ backgroundColor: row.categoryColor }}
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
/>
|
||||||
style={{ backgroundColor: data.colors[category] }}
|
{row.categoryName}
|
||||||
/>
|
</span>
|
||||||
{category}
|
</td>
|
||||||
</span>
|
{months.map((month, monthIdx) => {
|
||||||
</td>
|
const value = row.monthly[monthIdx] ?? 0;
|
||||||
{months.map((month) => {
|
return (
|
||||||
const monthData = data.data.find((d) => d.month === month);
|
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
|
||||||
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
|
{value ? cadFormatter(value) : "—"}
|
||||||
return (
|
</td>
|
||||||
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
|
);
|
||||||
{value ? cadFormatter(value) : "—"}
|
})}
|
||||||
</td>
|
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
|
||||||
);
|
{cadFormatter(row.total)}
|
||||||
})}
|
</td>
|
||||||
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
|
</tr>
|
||||||
{cadFormatter(rowTotal)}
|
))}
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{/* Section subtotal */}
|
{/* Section subtotal */}
|
||||||
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold">
|
<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">
|
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,41 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { computeOverTimeResults, OVER_TIME_TYPE_ORDER } from "./overTimeResults";
|
import { computeOverTimeResults, OVER_TIME_TYPE_ORDER } from "./overTimeResults";
|
||||||
import type { CategoryOverTimeData } from "../../shared/types";
|
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
|
||||||
|
|
||||||
function makeData(
|
/** Builds an id-keyed leaf row; `monthly` is index-aligned to the months list. */
|
||||||
data: CategoryOverTimeData["data"],
|
function leaf(
|
||||||
types: Record<string, "income" | "expense" | "transfer">,
|
categoryId: number | null,
|
||||||
): CategoryOverTimeData {
|
categoryName: string,
|
||||||
return { categories: Object.keys(types), data, colors: {}, categoryIds: {}, types };
|
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", () => {
|
describe("OVER_TIME_TYPE_ORDER", () => {
|
||||||
|
|
@ -16,19 +45,21 @@ describe("OVER_TIME_TYPE_ORDER", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("computeOverTimeResults", () => {
|
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(
|
const data = makeData(
|
||||||
|
["2025-01", "2025-02"],
|
||||||
[
|
[
|
||||||
{ month: "2025-01", Salary: 3000, Rent: 1000, Groceries: 500, Savings: 200 },
|
leaf(2, "Rent", [1000, 1000], "expense"),
|
||||||
{ month: "2025-02", Salary: 3000, Rent: 1000, Groceries: 700, Savings: 200 },
|
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"]);
|
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"]);
|
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense", "transfer"]);
|
||||||
|
|
||||||
const income = r.sections.find((s) => s.type === "income")!;
|
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.monthly).toEqual({ "2025-01": 3000, "2025-02": 3000 });
|
||||||
expect(income.total).toBe(6000);
|
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.monthly).toEqual({ "2025-01": 1500, "2025-02": 1700 });
|
||||||
expect(expense.total).toBe(3200);
|
expect(expense.total).toBe(3200);
|
||||||
expect(transfer.monthly).toEqual({ "2025-01": 200, "2025-02": 200 });
|
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", () => {
|
it("computes net (income − expense + transfer) and before-transfers per month", () => {
|
||||||
const data = makeData(
|
const data = makeData(
|
||||||
|
["2025-01", "2025-02"],
|
||||||
[
|
[
|
||||||
{ month: "2025-01", Salary: 3000, Rent: 1000, Groceries: 500, Savings: 200 },
|
leaf(1, "Salary", [3000, 3000], "income"),
|
||||||
{ month: "2025-02", Salary: 3000, Rent: 1000, Groceries: 700, Savings: 200 },
|
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);
|
expect(r.hasTransfers).toBe(true);
|
||||||
// before = income − expense
|
// before = income − expense
|
||||||
|
|
@ -66,11 +99,11 @@ describe("computeOverTimeResults", () => {
|
||||||
|
|
||||||
it("collapses net to before-transfers when there are no transfers", () => {
|
it("collapses net to before-transfers when there are no transfers", () => {
|
||||||
const data = makeData(
|
const data = makeData(
|
||||||
[{ month: "2025-01", Salary: 2000, Rent: 2500 }],
|
["2025-01"],
|
||||||
{ Salary: "income", Rent: "expense" },
|
[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.hasTransfers).toBe(false);
|
||||||
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense"]);
|
expect(r.sections.map((s) => s.type)).toEqual(["income", "expense"]);
|
||||||
|
|
@ -80,24 +113,27 @@ describe("computeOverTimeResults", () => {
|
||||||
expect(r.net.total).toBe(-500);
|
expect(r.net.total).toBe(-500);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults an untyped category (e.g. Other) to expense", () => {
|
it("defaults a leaf with an unknown type to the expense section (defensive)", () => {
|
||||||
const data = makeData(
|
// The tree normally stamps a valid type on every leaf; the reducer still
|
||||||
[{ month: "2025-01", Salary: 1000, Other: 300 }],
|
// guards against a stray value by folding it into expenses.
|
||||||
{ Salary: "income" }, // Other has no type entry
|
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")!;
|
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(expense.total).toBe(300);
|
||||||
expect(r.net.monthly["2025-01"]).toBe(700); // 1000 − 300
|
expect(r.net.monthly["2025-01"]).toBe(700); // 1000 − 300
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops empty sections and still nets a lone transfer section", () => {
|
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.sections.map((s) => s.type)).toEqual(["transfer"]);
|
||||||
expect(r.hasTransfers).toBe(true);
|
expect(r.hasTransfers).toBe(true);
|
||||||
|
|
@ -105,29 +141,51 @@ describe("computeOverTimeResults", () => {
|
||||||
expect(r.net.monthly["2025-01"]).toBe(100);
|
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(
|
const data = makeData(
|
||||||
[{ month: "2025-01", Salary: 2000, Rent: 500, Hidden: 999 }],
|
["2025-01"],
|
||||||
{ Salary: "income", Rent: "expense", Hidden: "expense" },
|
[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")!;
|
const expense = r.sections.find((s) => s.type === "expense")!;
|
||||||
expect(expense.total).toBe(500);
|
// 500 + 300 = 800 (the parent's own 800 subtotal is excluded).
|
||||||
expect(r.net.monthly["2025-01"]).toBe(1500); // 2000 − 500
|
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", () => {
|
it("treats a category absent from a month as zero", () => {
|
||||||
const data = makeData(
|
const data = makeData(
|
||||||
[
|
["2025-01", "2025-02"],
|
||||||
{ month: "2025-01", Salary: 1000 },
|
[leaf(1, "Salary", [1000, 1000], "income"), leaf(2, "Bonus", [0, 500], "income")],
|
||||||
{ month: "2025-02", Salary: 1000, Bonus: 500 },
|
|
||||||
],
|
|
||||||
{ Salary: "income", Bonus: "income" },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const r = computeOverTimeResults(data, ["Salary", "Bonus"]);
|
const r = computeOverTimeResults(data);
|
||||||
|
|
||||||
const income = r.sections.find((s) => s.type === "income")!;
|
const income = r.sections.find((s) => s.type === "income")!;
|
||||||
expect(income.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 });
|
expect(income.monthly).toEqual({ "2025-01": 1000, "2025-02": 1500 });
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import type { CategoryOverTimeData, CategoryOverTimeItem } from "../../shared/types";
|
import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types";
|
||||||
|
|
||||||
export type OverTimeType = "income" | "expense" | "transfer";
|
export type OverTimeType = "income" | "expense" | "transfer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Income-statement reading order: revenue first, then expenses, then transfers,
|
* Income-statement reading order: revenue first, then expenses, then transfers,
|
||||||
* so the "net result" row reads naturally at the bottom (Income − Expenses +
|
* so the "net result" row reads naturally at the bottom (Income − Expenses +
|
||||||
* Transfers). Note this is income-first, unlike the compare report's
|
* Transfers). Matches the compare report's `COMPARE_TYPE_ORDER`, which is also
|
||||||
* `COMPARE_TYPE_ORDER` (expense-first) — see issue #256.
|
* income-first since issue #253 (the earlier expense-first note is obsolete).
|
||||||
*/
|
*/
|
||||||
export const OVER_TIME_TYPE_ORDER: Record<OverTimeType, number> = {
|
export const OVER_TIME_TYPE_ORDER: Record<OverTimeType, number> = {
|
||||||
income: 0,
|
income: 0,
|
||||||
|
|
@ -22,10 +22,18 @@ export interface OverTimeSeries {
|
||||||
total: number;
|
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;
|
type: OverTimeType;
|
||||||
categories: string[];
|
rows: OverTimeRow[];
|
||||||
|
/** month (YYYY-MM) -> section subtotal */
|
||||||
|
monthly: Record<string, number>;
|
||||||
|
/** sum across every month */
|
||||||
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OverTimeAnalysis {
|
export interface OverTimeAnalysis {
|
||||||
|
|
@ -41,60 +49,53 @@ export interface OverTimeAnalysis {
|
||||||
beforeTransfers: OverTimeSeries;
|
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"
|
* Pure reducer that turns the category-over-time report into an "income
|
||||||
* shape: per-type sections with per-month subtotals, plus the net-result rows.
|
* statement" shape: per-type sections with per-month subtotals, plus the
|
||||||
|
* net-result rows.
|
||||||
*
|
*
|
||||||
* Values coming from `getCategoryOverTime` are positive magnitudes (`ABS(SUM())`
|
* Consumes the **id-keyed tree** (`data.tree`) — NOT the name-keyed pivot — so
|
||||||
* in SQL), so income/expense/transfer subtotals are all ≥ 0, and the net is
|
* two homonym categories of different types never collide: each leaf carries its
|
||||||
* `income − expense + transfer`. A category whose type is unknown (e.g. the
|
* own `category_id`-resolved `category_type`, and the subtotals + result lines
|
||||||
* "Other" bucket that aggregates non-top-N categories) defaults to `expense`,
|
* are summed over the tree's LEAVES only (never its `is_parent` subtotal rows,
|
||||||
* mirroring the `COALESCE(c.type, 'expense')` used by the service.
|
* 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
|
* The tree is already ordered income → expense → transfer, so section ordering
|
||||||
* project has no React render harness, so the arithmetic lives in a pure module.
|
* falls out of the leaves' own `category_type`.
|
||||||
*/
|
*/
|
||||||
export function computeOverTimeResults(
|
export function computeOverTimeResults(data: CategoryOverTimeData): OverTimeAnalysis {
|
||||||
data: CategoryOverTimeData,
|
|
||||||
visibleCategories: string[],
|
|
||||||
): OverTimeAnalysis {
|
|
||||||
const months = data.data.map((d) => d.month);
|
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 => {
|
// Leaves only — subtotals (`is_parent`) are excluded so a parent group can
|
||||||
const t = data.types[cat];
|
// never double-count against its own descendants.
|
||||||
|
const typeOf = (row: OverTimeRow): OverTimeType => {
|
||||||
|
const t = row.category_type;
|
||||||
return t === "income" || t === "transfer" ? t : "expense";
|
return t === "income" || t === "transfer" ? t : "expense";
|
||||||
};
|
};
|
||||||
|
const byType: Record<OverTimeType, OverTimeRow[]> = { income: [], expense: [], transfer: [] };
|
||||||
// Bucket visible categories by type, preserving their incoming (magnitude) order.
|
for (const row of data.tree) {
|
||||||
const byType: Record<OverTimeType, string[]> = { income: [], expense: [], transfer: [] };
|
if (!row.is_parent) byType[typeOf(row)].push(row);
|
||||||
for (const cat of visibleCategories) byType[typeOf(cat)].push(cat);
|
}
|
||||||
|
|
||||||
const buildSection = (type: OverTimeType): OverTimeSection => {
|
const buildSection = (type: OverTimeType): OverTimeSection => {
|
||||||
const categories = byType[type];
|
const rows = byType[type];
|
||||||
const monthly: Record<string, number> = {};
|
const monthly: Record<string, number> = {};
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const month of months) {
|
months.forEach((month, i) => {
|
||||||
const item = itemByMonth.get(month);
|
const sum = rows.reduce((s, row) => s + (row.monthly[i] ?? 0), 0);
|
||||||
const sum = categories.reduce((s, cat) => s + cellValue(item, cat), 0);
|
|
||||||
monthly[month] = sum;
|
monthly[month] = sum;
|
||||||
total += sum;
|
total += sum;
|
||||||
}
|
});
|
||||||
return { type, categories, monthly, total };
|
return { type, rows, monthly, total };
|
||||||
};
|
};
|
||||||
|
|
||||||
const income = buildSection("income");
|
const income = buildSection("income");
|
||||||
const expense = buildSection("expense");
|
const expense = buildSection("expense");
|
||||||
const transfer = buildSection("transfer");
|
const transfer = buildSection("transfer");
|
||||||
|
|
||||||
const hasTransfers = transfer.categories.length > 0;
|
const hasTransfers = transfer.rows.length > 0;
|
||||||
|
|
||||||
const net: OverTimeSeries = { monthly: {}, total: 0 };
|
const net: OverTimeSeries = { monthly: {}, total: 0 };
|
||||||
const beforeTransfers: OverTimeSeries = { monthly: {}, total: 0 };
|
const beforeTransfers: OverTimeSeries = { monthly: {}, total: 0 };
|
||||||
|
|
@ -108,7 +109,7 @@ export function computeOverTimeResults(
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections = [income, expense, transfer]
|
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]);
|
.sort((a, b) => OVER_TIME_TYPE_ORDER[a.type] - OVER_TIME_TYPE_ORDER[b.type]);
|
||||||
|
|
||||||
return { months, sections, hasTransfers, net, beforeTransfers };
|
return { months, sections, hasTransfers, net, beforeTransfers };
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ const yearStartStr = `${now.getFullYear()}-01-01`;
|
||||||
const initialState: DashboardState = {
|
const initialState: DashboardState = {
|
||||||
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
||||||
categoryBreakdown: [],
|
categoryBreakdown: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
|
||||||
budgetVsActual: [],
|
budgetVsActual: [],
|
||||||
period: "year",
|
period: "year",
|
||||||
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ type Action =
|
||||||
const initialState: State = {
|
const initialState: State = {
|
||||||
subView: "global",
|
subView: "global",
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ export default function ReportsTrendsPage() {
|
||||||
chartType={chartType}
|
chartType={chartType}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} />
|
<CategoryOverTimeTable data={categoryOverTime} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ import {
|
||||||
getCompareYearOverYear,
|
getCompareYearOverYear,
|
||||||
getCategoryZoom,
|
getCategoryZoom,
|
||||||
buildCompareTree,
|
buildCompareTree,
|
||||||
|
buildOverTimeTree,
|
||||||
} from "./reportService";
|
} from "./reportService";
|
||||||
import type { CategoryDelta } from "../shared/types";
|
import type { CategoryDelta, OverTimeRow } from "../shared/types";
|
||||||
|
|
||||||
// Mock the db module
|
// Mock the db module
|
||||||
vi.mock("./db", () => {
|
vi.mock("./db", () => {
|
||||||
|
|
@ -163,23 +164,66 @@ describe("getCategoryOverTime", () => {
|
||||||
expect(result.types).toEqual({ Food: "expense" });
|
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
|
mockSelect
|
||||||
|
// 1. top categories — top-N = 1, so only Food.
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
|
{ category_id: 1, category_name: "Food", category_color: "#ff0000", category_type: "expense", total: 500 },
|
||||||
])
|
])
|
||||||
|
// 2. monthly breakdown — every category (no LIMIT here).
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ month: "2025-01", category_id: 1, category_name: "Food", total: 300 },
|
{ 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: 2, category_name: "Transport", total: 100 },
|
||||||
{ month: "2025-01", category_id: 3, category_name: "Entertainment", total: 50 },
|
{ 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");
|
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.categories).toEqual(["Food", "Other"]);
|
||||||
expect(result.colors["Other"]).toBe("#9ca3af");
|
expect(result.colors["Other"]).toBe("#9ca3af");
|
||||||
expect(result.types).toEqual({ Food: "expense" });
|
expect(result.types).toEqual({ Food: "expense" });
|
||||||
expect(result.data[0]).toEqual({ month: "2025-01", Food: 300, Other: 150 });
|
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)", () => {
|
describe("getCompareMonthOverMonth — wires the hierarchy (Issue #247)", () => {
|
||||||
it("returns parent subtotal rows when category metadata is available", async () => {
|
it("returns parent subtotal rows when category metadata is available", async () => {
|
||||||
mockSelect
|
mockSelect
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
CategoryOverTimeData,
|
CategoryOverTimeData,
|
||||||
CategoryOverTimeItem,
|
CategoryOverTimeItem,
|
||||||
|
OverTimeRow,
|
||||||
HighlightsData,
|
HighlightsData,
|
||||||
HighlightMover,
|
HighlightMover,
|
||||||
CategoryDelta,
|
CategoryDelta,
|
||||||
|
|
@ -183,12 +184,58 @@ export async function getCategoryOverTime(
|
||||||
categories.push("Other");
|
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 {
|
return {
|
||||||
categories,
|
categories,
|
||||||
data: Array.from(monthMap.values()),
|
data,
|
||||||
colors,
|
colors,
|
||||||
categoryIds,
|
categoryIds,
|
||||||
types,
|
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 } {
|
function previousMonth(year: number, month: number): { year: number; month: number } {
|
||||||
if (month === 1) return { year: year - 1, month: 12 };
|
if (month === 1) return { year: year - 1, month: 12 };
|
||||||
return { year, month: month - 1 };
|
return { year, month: month - 1 };
|
||||||
|
|
|
||||||
|
|
@ -367,6 +367,31 @@ export interface CategoryOverTimeItem {
|
||||||
[categoryName: string]: number | string;
|
[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 {
|
export interface CategoryOverTimeData {
|
||||||
categories: string[];
|
categories: string[];
|
||||||
data: CategoryOverTimeItem[];
|
data: CategoryOverTimeItem[];
|
||||||
|
|
@ -374,6 +399,13 @@ export interface CategoryOverTimeData {
|
||||||
categoryIds: Record<string, number | null>;
|
categoryIds: Record<string, number | null>;
|
||||||
/** Category name -> its type. Built from the top-N rows (mirrors `colors`). */
|
/** Category name -> its type. Built from the top-N rows (mirrors `colors`). */
|
||||||
types: Record<string, "expense" | "income" | "transfer">;
|
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 {
|
export interface BudgetVsActualRow {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue