feat(reports): CategoryOverTimeTable hierarchique + collapse + resultat interleave (#265) #269
5 changed files with 456 additions and 107 deletions
|
|
@ -5,6 +5,7 @@
|
||||||
### Ajouté
|
### 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 → 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é
|
### Corrigé
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
### Added
|
### 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 → 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
|
### Fixed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { CategoryOverTimeData } from "../../shared/types";
|
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||||
import { computeOverTimeResults, type OverTimeType } from "./overTimeResults";
|
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) =>
|
const cadFormatter = (value: number) =>
|
||||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
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) {
|
export default function CategoryOverTimeTable({ data }: CategoryOverTimeTableProps) {
|
||||||
const { t } = useTranslation();
|
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<OverTimeRow>(
|
||||||
|
OVERTIME_EXPANDED_STORAGE_KEY,
|
||||||
|
OVERTIME_COLLAPSE_ACCESSORS,
|
||||||
|
);
|
||||||
|
|
||||||
if (data.data.length === 0) {
|
if (data.data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||||
|
|
@ -48,11 +64,161 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
|
||||||
|
|
||||||
// Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and
|
// Consumes the id-keyed tree (every category, no top-N/"Other"): subtotals and
|
||||||
// the result lines are exact, and homonym categories never collide.
|
// 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 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) => (
|
||||||
|
<Fragment key={section.type}>
|
||||||
|
{/* Section header */}
|
||||||
|
<tr className="bg-[var(--muted)]">
|
||||||
|
<td
|
||||||
|
colSpan={colSpan}
|
||||||
|
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{t(SECTION_LABEL_KEY[section.type])}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/* 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 (
|
||||||
|
<tr
|
||||||
|
key={`${row.categoryId ?? "uncat"}-${isParent}-${depth}-${row.categoryName}`}
|
||||||
|
className={`border-b border-[var(--border)]/50 ${
|
||||||
|
isTopParent
|
||||||
|
? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold"
|
||||||
|
: isIntermediateParent
|
||||||
|
? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium"
|
||||||
|
: "hover:bg-[var(--muted)]/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className={`py-1.5 sticky left-0 z-10 ${
|
||||||
|
isTopParent
|
||||||
|
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
|
||||||
|
: isIntermediateParent
|
||||||
|
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
||||||
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isTopParent ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => groups.toggle(row)}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: row.categoryColor }}
|
||||||
|
/>
|
||||||
|
{row.categoryName}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: row.categoryColor }}
|
||||||
|
/>
|
||||||
|
{row.categoryName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{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) : "—"}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
|
||||||
|
{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">
|
||||||
|
{t(SECTION_TOTAL_KEY[section.type])}
|
||||||
|
</td>
|
||||||
|
{months.map((month) => (
|
||||||
|
<td key={month} className="text-right px-3 py-2.5 tabular-nums">
|
||||||
|
{cadFormatter(section.monthly[month])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
|
||||||
|
{cadFormatter(section.total)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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) => (
|
||||||
|
<tr
|
||||||
|
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
|
||||||
|
strong ? "border-t-2 border-[var(--border)]" : "border-b border-[var(--border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
|
||||||
|
{t(labelKey)}
|
||||||
|
</td>
|
||||||
|
{months.map((month) => (
|
||||||
|
<td
|
||||||
|
key={month}
|
||||||
|
className="text-right px-3 py-3 tabular-nums"
|
||||||
|
style={{ color: resultColor(series.monthly[month]) }}
|
||||||
|
>
|
||||||
|
{cadFormatter(series.monthly[month])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td
|
||||||
|
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
|
||||||
|
style={{ color: resultColor(series.total) }}
|
||||||
|
>
|
||||||
|
{cadFormatter(series.total)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
{hasGroups && (
|
||||||
|
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data.tree))}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||||
|
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||||
<table className="w-full text-sm whitespace-nowrap">
|
<table className="w-full text-sm whitespace-nowrap">
|
||||||
<thead className="sticky top-0 z-20">
|
<thead className="sticky top-0 z-20">
|
||||||
|
|
@ -71,110 +237,14 @@ export default function CategoryOverTimeTable({ data }: CategoryOverTimeTablePro
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sections.map((section) => (
|
{nonTransferSections.map(renderSection)}
|
||||||
<Fragment key={section.type}>
|
{/* Operating result (revenues − expenses), interleaved before the
|
||||||
{/* Section header */}
|
transfers section only when transfers exist — otherwise it equals
|
||||||
<tr className="bg-[var(--muted)]">
|
the net total below and would just be noise. */}
|
||||||
<td
|
{hasTransfers && renderResultRow("reports.compare.resultBeforeTransfers", beforeTransfers, false)}
|
||||||
colSpan={colSpan}
|
{transferSection && renderSection(transferSection)}
|
||||||
className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]"
|
{/* Bottom line: result after netting transfers. */}
|
||||||
>
|
{renderResultRow("reports.compare.resultNet", net, true)}
|
||||||
{t(SECTION_LABEL_KEY[section.type])}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/* 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: row.categoryColor }}
|
|
||||||
/>
|
|
||||||
{row.categoryName}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
{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) : "—"}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
|
|
||||||
{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">
|
|
||||||
{t(SECTION_TOTAL_KEY[section.type])}
|
|
||||||
</td>
|
|
||||||
{months.map((month) => (
|
|
||||||
<td key={month} className="text-right px-3 py-2.5 tabular-nums">
|
|
||||||
{cadFormatter(section.monthly[month])}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50 tabular-nums">
|
|
||||||
{cadFormatter(section.total)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Result before transfers — shown only when transfers exist */}
|
|
||||||
{hasTransfers && (
|
|
||||||
<tr className="border-t-2 border-[var(--border)] font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
|
|
||||||
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
|
|
||||||
{t("reports.compare.resultBeforeTransfers")}
|
|
||||||
</td>
|
|
||||||
{months.map((month) => (
|
|
||||||
<td
|
|
||||||
key={month}
|
|
||||||
className="text-right px-3 py-3 tabular-nums"
|
|
||||||
style={{ color: resultColor(beforeTransfers.monthly[month]) }}
|
|
||||||
>
|
|
||||||
{cadFormatter(beforeTransfers.monthly[month])}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td
|
|
||||||
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
|
|
||||||
style={{ color: resultColor(beforeTransfers.total) }}
|
|
||||||
>
|
|
||||||
{cadFormatter(beforeTransfers.total)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{/* Net result — bottom line (income − expenses + transfers) */}
|
|
||||||
<tr
|
|
||||||
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
|
|
||||||
hasTransfers ? "border-b border-[var(--border)]" : "border-t-2 border-[var(--border)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
|
|
||||||
{t("reports.compare.resultNet")}
|
|
||||||
</td>
|
|
||||||
{months.map((month) => (
|
|
||||||
<td
|
|
||||||
key={month}
|
|
||||||
className="text-right px-3 py-3 tabular-nums"
|
|
||||||
style={{ color: resultColor(net.monthly[month]) }}
|
|
||||||
>
|
|
||||||
{cadFormatter(net.monthly[month])}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td
|
|
||||||
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
|
|
||||||
style={{ color: resultColor(net.total) }}
|
|
||||||
>
|
|
||||||
{cadFormatter(net.total)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
188
src/components/reports/overTimeTableModel.test.ts
Normal file
188
src/components/reports/overTimeTableModel.test.ts
Normal file
|
|
@ -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> = {},
|
||||||
|
): 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<string>) => (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);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
src/components/reports/overTimeTableModel.ts
Normal file
89
src/components/reports/overTimeTableModel.ts
Normal file
|
|
@ -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<OverTimeRow> = {
|
||||||
|
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<string, number>;
|
||||||
|
/** 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<OverTimeType, OverTimeRow[]> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue