diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 46ce5f6..6318cb8 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -5,6 +5,7 @@ ### Ajouté - 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 → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265). ### Corrigé diff --git a/CHANGELOG.md b/CHANGELOG.md index 194089a..2f4b3e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - 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 → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265). ### Fixed diff --git a/src/components/reports/CategoryOverTimeTable.tsx b/src/components/reports/CategoryOverTimeTable.tsx index ebb785f..42f9fed 100644 --- a/src/components/reports/CategoryOverTimeTable.tsx +++ b/src/components/reports/CategoryOverTimeTable.tsx @@ -1,7 +1,15 @@ import { Fragment } from "react"; import { useTranslation } from "react-i18next"; -import type { CategoryOverTimeData } from "../../shared/types"; -import { computeOverTimeResults, type OverTimeType } from "./overTimeResults"; +import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react"; +import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types"; +import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups"; +import { computeOverTimeResults, type OverTimeSeries, type OverTimeType } from "./overTimeResults"; +import { + OVERTIME_COLLAPSE_ACCESSORS, + OVERTIME_EXPANDED_STORAGE_KEY, + groupOverTimeSections, + type OverTimeRenderSection, +} from "./overTimeTableModel"; const cadFormatter = (value: number) => new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value); @@ -38,6 +46,14 @@ interface CategoryOverTimeTableProps { export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) { const { t } = useTranslation(); + // Collapse/expand of sub-category groups — collapsed by default (issue #265), + // persisted under a key distinct from the comparable tables. Called before the + // early return so the hook order stays stable. + const groups = useCollapsibleGroups( + OVERTIME_EXPANDED_STORAGE_KEY, + OVERTIME_COLLAPSE_ACCESSORS, + ); + if (data.data.length === 0) { return (
@@ -48,11 +64,161 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro // 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 analysis = computeOverTimeResults(data); + const { months, hasTransfers, net, beforeTransfers } = analysis; + const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree); const colSpan = months.length + 2; + const hasGroups = groups.groupCount(data.tree) > 0; + const allExpanded = groups.allExpanded(data.tree); + + // A section = its header, its (visibly) indented hierarchy rows, and a + // leaf-summed subtotal row. Only top-level parents collapse (parity with the + // comparable tables); the subtotal is always the reducer's leaf sum, so it + // never changes when a group is folded away. + const renderSection = (section: OverTimeRenderSection) => ( + + {/* Section header */} + + + {t(SECTION_LABEL_KEY[section.type])} + + + {/* Category rows — neutral leaf/parent magnitudes (not deltas) */} + {groups.visible(section.rows).map((row) => { + const isParent = row.is_parent; + const depth = row.depth; + const isTopParent = isParent && depth === 0; + const isIntermediateParent = isParent && depth >= 1; + const collapsed = isTopParent && groups.isCollapsed(row); + const paddingClass = + depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3"; + return ( + + + {isTopParent ? ( + + ) : ( + + + {row.categoryName} + + )} + + {months.map((month, monthIdx) => { + const value = row.monthly[monthIdx] ?? 0; + return ( + + {value ? cadFormatter(value) : "—"} + + ); + })} + + {cadFormatter(row.total)} + + + ); + })} + {/* Section subtotal */} + + + {t(SECTION_TOTAL_KEY[section.type])} + + {months.map((month) => ( + + {cadFormatter(section.monthly[month])} + + ))} + + {cadFormatter(section.total)} + + + + ); + + // A result line (before-transfers subtotal or the net bottom line). Amounts are + // coloured by sign (surplus green / deficit red). Computed from the raw tree, + // so folding groups never moves these figures. + const renderResultRow = (labelKey: string, series: OverTimeSeries, strong: boolean) => ( + + + {t(labelKey)} + + {months.map((month) => ( + + {cadFormatter(series.monthly[month])} + + ))} + + {cadFormatter(series.total)} + + + ); + return (
+ {hasGroups && ( +
+ +
+ )}
@@ -71,110 +237,14 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro - {sections.map((section) => ( - - {/* Section header */} - - - - {/* Category rows — neutral leaf magnitudes (not deltas) */} - {section.rows.map((row, rowIdx) => ( - - - {months.map((month, monthIdx) => { - const value = row.monthly[monthIdx] ?? 0; - return ( - - ); - })} - - - ))} - {/* Section subtotal */} - - - {months.map((month) => ( - - ))} - - - - ))} - - {/* Result before transfers — shown only when transfers exist */} - {hasTransfers && ( - - - {months.map((month) => ( - - ))} - - - )} - {/* Net result — bottom line (income − expenses + transfers) */} - - - {months.map((month) => ( - - ))} - - + {nonTransferSections.map(renderSection)} + {/* Operating result (revenues − expenses), interleaved before the + transfers section only when transfers exist — otherwise it equals + the net total below and would just be noise. */} + {hasTransfers && renderResultRow("reports.compare.resultBeforeTransfers", beforeTransfers, false)} + {transferSection && renderSection(transferSection)} + {/* Bottom line: result after netting transfers. */} + {renderResultRow("reports.compare.resultNet", net, true)}
- {t(SECTION_LABEL_KEY[section.type])} -
- - - {row.categoryName} - - - {value ? cadFormatter(value) : "—"} - - {cadFormatter(row.total)} -
- {t(SECTION_TOTAL_KEY[section.type])} - - {cadFormatter(section.monthly[month])} - - {cadFormatter(section.total)} -
- {t("reports.compare.resultBeforeTransfers")} - - {cadFormatter(beforeTransfers.monthly[month])} - - {cadFormatter(beforeTransfers.total)} -
- {t("reports.compare.resultNet")} - - {cadFormatter(net.monthly[month])} - - {cadFormatter(net.total)} -
diff --git a/src/components/reports/overTimeTableModel.test.ts b/src/components/reports/overTimeTableModel.test.ts new file mode 100644 index 0000000..04073eb --- /dev/null +++ b/src/components/reports/overTimeTableModel.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from "vitest"; +import type { CategoryOverTimeData, OverTimeRow } from "../../shared/types"; +import { visibleRows } from "../../utils/collapsibleRows"; +import { computeOverTimeResults } from "./overTimeResults"; +import { + OVERTIME_COLLAPSE_ACCESSORS, + OVERTIME_EXPANDED_STORAGE_KEY, + groupOverTimeSections, +} from "./overTimeTableModel"; + +type RowType = "income" | "expense" | "transfer"; + +/** Builds one id-keyed tree row; `monthly` is index-aligned to the months list. */ +function mkRow( + categoryId: number, + categoryName: string, + monthly: number[], + category_type: RowType, + extra: Partial = {}, +): 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, + }; +} + +function makeData(months: string[], tree: OverTimeRow[]): CategoryOverTimeData { + return { + categories: [], + data: months.map((month) => ({ month })), + colors: {}, + categoryIds: {}, + types: {}, + tree, + }; +} + +const MONTHS = ["2025-01", "2025-02"]; + +// A realistic parent-first depth-first tree (income → expense → transfer): +// Revenus (parent, id 1) +// Paie (leaf, id 11) +// Bonus (leaf, id 12) +// Dépenses (parent, id 2) +// Épicerie (leaf, id 21) +// Resto (leaf, id 22) +// Loyer (top-level leaf, id 23) +// Épargne (transfer leaf, id 3) +function buildTree(): OverTimeRow[] { + return [ + mkRow(1, "Revenus", [3000, 3500], "income", { is_parent: true, depth: 0 }), + mkRow(11, "Paie", [3000, 3000], "income", { parent_id: 1, depth: 1 }), + mkRow(12, "Bonus", [0, 500], "income", { parent_id: 1, depth: 1 }), + mkRow(2, "Dépenses", [500, 800], "expense", { is_parent: true, depth: 0 }), + mkRow(21, "Épicerie", [400, 600], "expense", { parent_id: 2, depth: 1 }), + mkRow(22, "Resto", [100, 200], "expense", { parent_id: 2, depth: 1 }), + mkRow(23, "Loyer", [1000, 1000], "expense", { depth: 0 }), + mkRow(3, "Épargne", [200, 200], "transfer", { depth: 0 }), + ]; +} + +const acc = OVERTIME_COLLAPSE_ACCESSORS; +/** Mirrors the hook: a group is collapsed unless its key is in the expanded set. */ +const isCollapsed = (expanded: Set) => (row: OverTimeRow) => !expanded.has(acc.keyOf(row)); + +describe("overTimeTableModel — storage key", () => { + it("uses a trends-specific key, distinct from the comparable tables", () => { + expect(OVERTIME_EXPANDED_STORAGE_KEY).toBe("reports-trends-expanded"); + expect(OVERTIME_EXPANDED_STORAGE_KEY).not.toBe("reports-compare-expanded"); + }); +}); + +describe("overTimeTableModel — collapse accessors", () => { + it("keys by category id and reads the snake_case hierarchy block", () => { + const parent = buildTree()[0]; + const leaf = buildTree()[1]; + expect(acc.keyOf(parent)).toBe("1"); + expect(acc.depthOf(leaf)).toBe(1); + expect(acc.isParent(parent)).toBe(true); + expect(acc.isParent(leaf)).toBe(false); + }); +}); + +describe("overTimeTableModel.groupOverTimeSections", () => { + it("splits the tree into ordered sections carrying the FULL hierarchy (parents + leaves)", () => { + const data = makeData(MONTHS, buildTree()); + const analysis = computeOverTimeResults(data); + const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree); + + // Income-first, transfer split out for interleaving. + expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]); + expect(transferSection?.type).toBe("transfer"); + + // Full hierarchy: the parent row is present (unlike the leaves-only reducer sections). + const income = nonTransferSections.find((s) => s.type === "income")!; + expect(income.rows.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]); + expect(income.rows[0].is_parent).toBe(true); + + const expense = nonTransferSections.find((s) => s.type === "expense")!; + expect(expense.rows.map((r) => r.categoryName)).toEqual([ + "Dépenses", + "Épicerie", + "Resto", + "Loyer", + ]); + expect(transferSection?.rows.map((r) => r.categoryName)).toEqual(["Épargne"]); + }); + + it("keeps subtotals as the reducer's leaf sums — a parent is never double-counted", () => { + const data = makeData(MONTHS, buildTree()); + const analysis = computeOverTimeResults(data); + const { nonTransferSections } = groupOverTimeSections(analysis, data.tree); + + const income = nonTransferSections.find((s) => s.type === "income")!; + // Paie 6000 + Bonus 500 = 6500 (the Revenus parent's own 6500 is NOT added again). + expect(income.total).toBe(6500); + expect(income.monthly).toEqual({ "2025-01": 3000, "2025-02": 3500 }); + + const expense = nonTransferSections.find((s) => s.type === "expense")!; + // Épicerie 1000 + Resto 300 + Loyer 2000 = 3300 (Dépenses parent excluded). + expect(expense.total).toBe(3300); + }); + + it("drops empty sections: no transfers → transferSection is null", () => { + const tree = buildTree().filter((r) => r.category_type !== "transfer"); + const data = makeData(MONTHS, tree); + const analysis = computeOverTimeResults(data); + const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree); + + expect(transferSection).toBeNull(); + expect(nonTransferSections.map((s) => s.type)).toEqual(["income", "expense"]); + }); +}); + +describe("overTimeTableModel — collapse behaviour (via visibleRows)", () => { + it("is collapsed by default: only depth-0 rows survive an empty expanded set", () => { + const data = makeData(MONTHS, buildTree()); + const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree); + + const income = nonTransferSections.find((s) => s.type === "income")!; + const visIncome = visibleRows(income.rows, acc, isCollapsed(new Set())); + // Revenus stays (its own subtotal row); Paie/Bonus are folded away. + expect(visIncome.map((r) => r.categoryName)).toEqual(["Revenus"]); + + const expense = nonTransferSections.find((s) => s.type === "expense")!; + const visExpense = visibleRows(expense.rows, acc, isCollapsed(new Set())); + // Dépenses folds its children; the top-level leaf Loyer always stays. + expect(visExpense.map((r) => r.categoryName)).toEqual(["Dépenses", "Loyer"]); + }); + + it("reveals Paie (revenue) under Revenus once its group is expanded", () => { + const data = makeData(MONTHS, buildTree()); + const { nonTransferSections } = groupOverTimeSections(computeOverTimeResults(data), data.tree); + const income = nonTransferSections.find((s) => s.type === "income")!; + + const visible = visibleRows(income.rows, acc, isCollapsed(new Set(["1"]))); + expect(visible.map((r) => r.categoryName)).toEqual(["Revenus", "Paie", "Bonus"]); + expect(visible.some((r) => r.categoryName === "Paie")).toBe(true); + }); + + it("collapse is purely visual: subtotals, before-transfers and net are unchanged", () => { + const data = makeData(MONTHS, buildTree()); + const analysis = computeOverTimeResults(data); + const { nonTransferSections } = groupOverTimeSections(analysis, data.tree); + const income = nonTransferSections.find((s) => s.type === "income")!; + + // Figures come from the raw tree (never the visible rows), so they hold in + // every collapse state. + expect(analysis.beforeTransfers.total).toBe(3200); // 6500 income − 3300 expense + expect(analysis.net.total).toBe(3600); // 3200 + 400 transfer + expect(income.total).toBe(6500); + + // Folding vs expanding a group only changes how many rows are visible. + const collapsed = visibleRows(income.rows, acc, isCollapsed(new Set())); + const expanded = visibleRows(income.rows, acc, isCollapsed(new Set(["1"]))); + expect(collapsed.length).toBeLessThan(expanded.length); + // The subtotal the table shows is identical in both states. + expect(income.total).toBe(6500); + }); +}); diff --git a/src/components/reports/overTimeTableModel.ts b/src/components/reports/overTimeTableModel.ts new file mode 100644 index 0000000..a0d5cd7 --- /dev/null +++ b/src/components/reports/overTimeTableModel.ts @@ -0,0 +1,89 @@ +import type { OverTimeRow } from "../../shared/types"; +import type { CollapseAccessors } from "../../utils/collapsibleRows"; +import type { OverTimeAnalysis, OverTimeType } from "./overTimeResults"; + +/** + * Render-model helpers for the hierarchical trends table (issue #265). Kept in a + * pure module — the project has no React render harness, so the grouping and + * result-interleaving logic is unit-tested here while `CategoryOverTimeTable` + * stays a thin renderer. + */ + +/** + * Collapse groups keyed by category id; depth/parent mirror the tree the render + * indents by, so a collapsed group hides exactly its indented descendants. The + * `OverTimeRow` hierarchy block is snake_case (mirrors `CategoryDelta`), so these + * accessors compose with `collapsibleRows` / `useCollapsibleGroups` unchanged. + */ +export const OVERTIME_COLLAPSE_ACCESSORS: CollapseAccessors = { + keyOf: (row) => String(row.categoryId), + depthOf: (row) => row.depth, + isParent: (row) => row.is_parent, +}; + +/** Distinct localStorage key so the trends expansion state never collides with + * the comparable tables' `reports-compare-expanded` (issue #265). */ +export const OVERTIME_EXPANDED_STORAGE_KEY = "reports-trends-expanded"; + +/** + * One rendered section: its type, the FULL hierarchy rows of that type (parents + * + leaves, parent-first depth-first — straight off `data.tree`), and the + * leaf-summed per-month + total subtotals carried over from the reducer. + * + * `rows` carries the parents (unlike `OverTimeSection.rows`, which is leaves + * only) so the table can render the hierarchy and collapse it; `monthly`/`total` + * stay the reducer's leaf sums, so they are unaffected by which rows are folded + * away — the "collapse is purely visual" invariant. + */ +export interface OverTimeRenderSection { + type: OverTimeType; + rows: OverTimeRow[]; + /** month (YYYY-MM) -> section subtotal (leaf-summed, collapse-invariant). */ + monthly: Record; + /** sum across every month (leaf-summed, collapse-invariant). */ + total: number; +} + +const typeOf = (row: OverTimeRow): OverTimeType => { + const t = row.category_type; + return t === "income" || t === "transfer" ? t : "expense"; +}; + +/** + * Splits the id-keyed tree into render sections, driven by the reducer's own + * (ordered, non-empty, income → expense → transfer) `analysis.sections` so the + * ordering and the subtotals match `computeOverTimeResults` exactly. Each + * section is given the FULL hierarchy rows of its type (from `tree`), while its + * subtotals stay the reducer's leaf sums. + * + * Returns the non-transfer sections and the transfer section separately so the + * caller can interleave the "result before transfers" line between them + * (parity with `ComparePeriodTable`). + */ +export function groupOverTimeSections( + analysis: OverTimeAnalysis, + tree: OverTimeRow[], +): { + nonTransferSections: OverTimeRenderSection[]; + transferSection: OverTimeRenderSection | null; +} { + // Full hierarchy rows (parents + leaves) bucketed by type, tree order kept. + const rowsByType: Record = { + income: [], + expense: [], + transfer: [], + }; + for (const row of tree) rowsByType[typeOf(row)].push(row); + + const sections: OverTimeRenderSection[] = analysis.sections.map((s) => ({ + type: s.type, + rows: rowsByType[s.type], + monthly: s.monthly, + total: s.total, + })); + + return { + nonTransferSections: sections.filter((s) => s.type !== "transfer"), + transferSection: sections.find((s) => s.type === "transfer") ?? null, + }; +}