Merge PR #267/#268/#269: tendances hierarchiques (#263/#264/#265)
This commit is contained in:
commit
fe87313ba3
14 changed files with 1136 additions and 329 deletions
|
|
@ -5,6 +5,11 @@
|
||||||
### 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é
|
||||||
|
|
||||||
|
- 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).
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
### 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
|
||||||
|
|
||||||
|
- 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).
|
||||||
|
|
||||||
### 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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -33,12 +41,19 @@ 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();
|
||||||
|
|
||||||
|
// 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)]">
|
||||||
|
|
@ -47,34 +62,21 @@ 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 analysis = computeOverTimeResults(data);
|
||||||
|
const { months, hasTransfers, net, beforeTransfers } = analysis;
|
||||||
const { months, sections, hasTransfers, net, beforeTransfers } = computeOverTimeResults(data, visibleCategories);
|
const { nonTransferSections, transferSection } = groupOverTimeSections(analysis, data.tree);
|
||||||
const colSpan = months.length + 2;
|
const colSpan = months.length + 2;
|
||||||
|
|
||||||
return (
|
const hasGroups = groups.groupCount(data.tree) > 0;
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
const allExpanded = groups.allExpanded(data.tree);
|
||||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
|
||||||
<table className="w-full text-sm whitespace-nowrap">
|
// A section = its header, its (visibly) indented hierarchy rows, and a
|
||||||
<thead className="sticky top-0 z-20">
|
// leaf-summed subtotal row. Only top-level parents collapse (parity with the
|
||||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
// comparable tables); the subtotal is always the reducer's leaf sum, so it
|
||||||
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] sticky left-0 z-30 min-w-[140px]">
|
// never changes when a group is folded away.
|
||||||
{t("budget.category")}
|
const renderSection = (section: OverTimeRenderSection) => (
|
||||||
</th>
|
|
||||||
{months.map((month) => (
|
|
||||||
<th key={month} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] min-w-[90px]">
|
|
||||||
{formatMonth(month)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] border-l border-[var(--border)] min-w-[90px]">
|
|
||||||
{t("common.total")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sections.map((section) => (
|
|
||||||
<Fragment key={section.type}>
|
<Fragment key={section.type}>
|
||||||
{/* Section header */}
|
{/* Section header */}
|
||||||
<tr className="bg-[var(--muted)]">
|
<tr className="bg-[var(--muted)]">
|
||||||
|
|
@ -85,26 +87,65 @@ 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/parent magnitudes (not deltas) */}
|
||||||
{section.categories.map((category) => {
|
{groups.visible(section.rows).map((row) => {
|
||||||
const rowTotal = data.data.reduce(
|
const isParent = row.is_parent;
|
||||||
(sum, d) => sum + ((d as Record<string, unknown>)[category] as number || 0),
|
const depth = row.depth;
|
||||||
0,
|
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 (
|
return (
|
||||||
<tr key={category} className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40">
|
<tr
|
||||||
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
|
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="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
style={{ backgroundColor: data.colors[category] }}
|
style={{ backgroundColor: row.categoryColor }}
|
||||||
/>
|
/>
|
||||||
{category}
|
{row.categoryName}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
{months.map((month) => {
|
{months.map((month, monthIdx) => {
|
||||||
const monthData = data.data.find((d) => d.month === month);
|
const value = row.monthly[monthIdx] ?? 0;
|
||||||
const value = (monthData as Record<string, unknown>)?.[category] as number || 0;
|
|
||||||
return (
|
return (
|
||||||
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
|
<td key={month} className="text-right px-3 py-1.5 tabular-nums">
|
||||||
{value ? cadFormatter(value) : "—"}
|
{value ? cadFormatter(value) : "—"}
|
||||||
|
|
@ -112,7 +153,7 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
|
<td className="text-right px-3 py-1.5 font-semibold border-l border-[var(--border)]/50 tabular-nums">
|
||||||
{cadFormatter(rowTotal)}
|
{cadFormatter(row.total)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
@ -132,56 +173,78 @@ export default function CategoryOverTimeTable({ data, hiddenCategories }: Catego
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
);
|
||||||
|
|
||||||
{/* Result before transfers — shown only when transfers exist */}
|
// A result line (before-transfers subtotal or the net bottom line). Amounts are
|
||||||
{hasTransfers && (
|
// coloured by sign (surplus green / deficit red). Computed from the raw tree,
|
||||||
<tr className="border-t-2 border-[var(--border)] font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
|
// so folding groups never moves these figures.
|
||||||
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
|
const renderResultRow = (labelKey: string, series: OverTimeSeries, strong: boolean) => (
|
||||||
{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
|
<tr
|
||||||
className={`font-bold bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] ${
|
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)]"
|
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">
|
<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")}
|
{t(labelKey)}
|
||||||
</td>
|
</td>
|
||||||
{months.map((month) => (
|
{months.map((month) => (
|
||||||
<td
|
<td
|
||||||
key={month}
|
key={month}
|
||||||
className="text-right px-3 py-3 tabular-nums"
|
className="text-right px-3 py-3 tabular-nums"
|
||||||
style={{ color: resultColor(net.monthly[month]) }}
|
style={{ color: resultColor(series.monthly[month]) }}
|
||||||
>
|
>
|
||||||
{cadFormatter(net.monthly[month])}
|
{cadFormatter(series.monthly[month])}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
<td
|
<td
|
||||||
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
|
className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums"
|
||||||
style={{ color: resultColor(net.total) }}
|
style={{ color: resultColor(series.total) }}
|
||||||
>
|
>
|
||||||
{cadFormatter(net.total)}
|
{cadFormatter(series.total)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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)" }}>
|
||||||
|
<table className="w-full text-sm whitespace-nowrap">
|
||||||
|
<thead className="sticky top-0 z-20">
|
||||||
|
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||||
|
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] sticky left-0 z-30 min-w-[140px]">
|
||||||
|
{t("budget.category")}
|
||||||
|
</th>
|
||||||
|
{months.map((month) => (
|
||||||
|
<th key={month} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] min-w-[90px]">
|
||||||
|
{formatMonth(month)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)] border-l border-[var(--border)] min-w-[90px]">
|
||||||
|
{t("common.total")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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: "byCategory",
|
subView: "byCategory",
|
||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,6 +482,206 @@ function monthBoundaries(year: number, month: number): { start: string; end: str
|
||||||
return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` };
|
return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Generic leaf-driven tree builder (Issue #263) ---
|
||||||
|
|
||||||
|
/** Section key shared by every hierarchical report (income-statement order). */
|
||||||
|
export type TreeSectionType = "expense" | "income" | "transfer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal category metadata every hierarchical report needs: id, display
|
||||||
|
* name/color, income-statement `type`, and `parent_id` to walk the hierarchy.
|
||||||
|
* Shared by the compare tree (Issue #247) and the trends tree (Issue #264).
|
||||||
|
*/
|
||||||
|
export interface TreeCatMeta {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string | null;
|
||||||
|
type: TreeSectionType | null;
|
||||||
|
parent_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
|
||||||
|
const MAX_TREE_DEPTH = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection points that specialise the generic tree builder for a concrete row
|
||||||
|
* type `T`. `T` is BOTH the flat leaf payload fed in and the enriched row shape
|
||||||
|
* emitted — leaf/subtotal rows just add the hierarchy fields (`parent_id`,
|
||||||
|
* `is_parent`, `depth`, …) the caller writes in `makeLeaf` / `makeSubtotal`.
|
||||||
|
*/
|
||||||
|
export interface LeafDrivenTreeOptions<T> {
|
||||||
|
/** Category hierarchy (fetched without an is_active filter — keeps history). */
|
||||||
|
categories: TreeCatMeta[];
|
||||||
|
/** Category a leaf belongs to; a null/absent id → orphan (top-level leaf). */
|
||||||
|
categoryIdOf: (leaf: T) => number | null;
|
||||||
|
/** Enrich a leaf payload into a leaf row parented at `parentId` / `depth`. */
|
||||||
|
makeLeaf: (cat: TreeCatMeta, leaf: T, parentId: number | null, depth: number) => T;
|
||||||
|
/** Reduce a node's descendant leaves into its subtotal (is_parent) row. */
|
||||||
|
makeSubtotal: (
|
||||||
|
cat: TreeCatMeta,
|
||||||
|
descendantLeaves: T[],
|
||||||
|
parentId: number | null,
|
||||||
|
depth: number,
|
||||||
|
) => T;
|
||||||
|
/** Annotate the "(direct)" leaf of a parent that also has own transactions. */
|
||||||
|
decorateDirectLeaf: (leafRow: T, cat: TreeCatMeta) => T;
|
||||||
|
/** Enrich an orphan (Uncategorized / hard-deleted) payload into a root leaf. */
|
||||||
|
makeOrphan: (leaf: T) => T;
|
||||||
|
/** Magnitude used to order siblings (descending) — read off a block's head. */
|
||||||
|
sortKey: (row: T) => number;
|
||||||
|
/** True when a row is a subtotal, so descendant *leaves* can be summed. */
|
||||||
|
isSubtotal: (row: T) => boolean;
|
||||||
|
/** Section key of a row for the final contiguous type sort. */
|
||||||
|
sectionOf: (row: T) => string;
|
||||||
|
/** Section order, income-first e.g. `{ income: 0, expense: 1, transfer: 2 }`. */
|
||||||
|
sectionOrder: Record<string, number>;
|
||||||
|
/** Rank for a section absent from `sectionOrder` (default 9 — sorts last). */
|
||||||
|
unknownSectionRank?: number;
|
||||||
|
/** Cyclic-chain depth cap (default `MAX_TREE_DEPTH`). */
|
||||||
|
maxDepth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic leaf-driven hierarchy builder. Turns a flat list of per-category
|
||||||
|
* leaves into a parent/child tree with subtotal rows, ordered by magnitude
|
||||||
|
* within each type and grouped into contiguous income → expense → transfer
|
||||||
|
* sections. Driven by the leaves that actually exist (not the full category
|
||||||
|
* list), so netted-to-zero groups and soft-deleted categories with history are
|
||||||
|
* preserved and no empty rows appear.
|
||||||
|
*
|
||||||
|
* Behavior — including the `MAX_TREE_DEPTH` guard and buildNode loop-break
|
||||||
|
* against a corrupted parent_id cycle — is shared verbatim by every
|
||||||
|
* hierarchical report; the row arithmetic is injected via `opts`. Extracted
|
||||||
|
* from `buildCompareTree` (Issue #263) so the trends tree (Issue #264) reuses
|
||||||
|
* the exact same skeleton.
|
||||||
|
*/
|
||||||
|
export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<T>): T[] {
|
||||||
|
const {
|
||||||
|
categories,
|
||||||
|
categoryIdOf,
|
||||||
|
makeLeaf,
|
||||||
|
makeSubtotal,
|
||||||
|
decorateDirectLeaf,
|
||||||
|
makeOrphan,
|
||||||
|
sortKey,
|
||||||
|
isSubtotal,
|
||||||
|
sectionOf,
|
||||||
|
sectionOrder,
|
||||||
|
unknownSectionRank = 9,
|
||||||
|
maxDepth = MAX_TREE_DEPTH,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const catById = new Map<number, TreeCatMeta>();
|
||||||
|
for (const c of categories) catById.set(c.id, c);
|
||||||
|
|
||||||
|
// Flat leaf lookup by category id. Rows whose id is null (Uncategorized) or
|
||||||
|
// absent from the table (hard-deleted) become orphans: depth-0 leaves
|
||||||
|
// preserved in their incoming order.
|
||||||
|
const leafByCat = new Map<number, T>();
|
||||||
|
const orphans: T[] = [];
|
||||||
|
for (const l of leaves) {
|
||||||
|
const id = categoryIdOf(l);
|
||||||
|
if (id != null && catById.has(id)) {
|
||||||
|
leafByCat.set(id, l);
|
||||||
|
} else {
|
||||||
|
orphans.push(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Relevant" = every category that has a leaf plus all of its ancestors.
|
||||||
|
const relevant = new Set<number>();
|
||||||
|
for (const id of leafByCat.keys()) {
|
||||||
|
let cur: number | null | undefined = id;
|
||||||
|
let guard = 0;
|
||||||
|
while (cur != null && guard <= maxDepth) {
|
||||||
|
if (relevant.has(cur)) break;
|
||||||
|
relevant.add(cur);
|
||||||
|
cur = catById.get(cur)?.parent_id ?? null;
|
||||||
|
guard++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjacency among relevant categories only, preserving DB order.
|
||||||
|
const childrenByParent = new Map<number, TreeCatMeta[]>();
|
||||||
|
for (const c of categories) {
|
||||||
|
if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) {
|
||||||
|
let arr = childrenByParent.get(c.parent_id);
|
||||||
|
if (!arr) childrenByParent.set(c.parent_id, (arr = []));
|
||||||
|
arr.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Block {
|
||||||
|
rows: T[];
|
||||||
|
sortKey: number; // magnitude of the block head — orders siblings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a node's block: a pure leaf, or a subtotal followed by its
|
||||||
|
// (recursively built) child blocks, siblings ordered by magnitude desc.
|
||||||
|
const buildNode = (cat: TreeCatMeta, depth: number): Block | null => {
|
||||||
|
// Stop descending past the depth cap so a corrupted parent_id cycle can
|
||||||
|
// never recurse forever — deeper nodes collapse to leaves (real category
|
||||||
|
// trees are ≤ 3 levels).
|
||||||
|
const children = depth >= maxDepth ? [] : (childrenByParent.get(cat.id) ?? []);
|
||||||
|
const hasDirect = leafByCat.has(cat.id);
|
||||||
|
|
||||||
|
if (children.length === 0) {
|
||||||
|
if (!hasDirect) return null;
|
||||||
|
const leaf = makeLeaf(cat, leafByCat.get(cat.id)!, cat.parent_id ?? null, depth);
|
||||||
|
return { rows: [leaf], sortKey: sortKey(leaf) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const childBlocks: Block[] = [];
|
||||||
|
// A category with both children AND its own transactions surfaces the direct
|
||||||
|
// spend as a "(direct)" leaf so the subtotal stays the sum of its visible
|
||||||
|
// rows. Rare: a parent auto-loses is_inputable when a child is added.
|
||||||
|
if (hasDirect) {
|
||||||
|
const direct = decorateDirectLeaf(
|
||||||
|
makeLeaf(cat, leafByCat.get(cat.id)!, cat.id, depth + 1),
|
||||||
|
cat,
|
||||||
|
);
|
||||||
|
childBlocks.push({ rows: [direct], sortKey: sortKey(direct) });
|
||||||
|
}
|
||||||
|
for (const child of children) {
|
||||||
|
const b = buildNode(child, depth + 1);
|
||||||
|
if (b) childBlocks.push(b);
|
||||||
|
}
|
||||||
|
childBlocks.sort((a, b) => b.sortKey - a.sortKey);
|
||||||
|
|
||||||
|
const childRows = childBlocks.flatMap((b) => b.rows);
|
||||||
|
const descendantLeaves = childRows.filter((r) => !isSubtotal(r));
|
||||||
|
const subtotal = makeSubtotal(cat, descendantLeaves, cat.parent_id ?? null, depth);
|
||||||
|
return { rows: [subtotal, ...childRows], sortKey: sortKey(subtotal) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Roots = relevant categories with no relevant parent. Ordered by magnitude.
|
||||||
|
const rootBlocks: Block[] = [];
|
||||||
|
for (const c of categories) {
|
||||||
|
if (!relevant.has(c.id)) continue;
|
||||||
|
if (c.parent_id != null && relevant.has(c.parent_id)) continue;
|
||||||
|
const b = buildNode(c, 0);
|
||||||
|
if (b) rootBlocks.push(b);
|
||||||
|
}
|
||||||
|
rootBlocks.sort((a, b) => b.sortKey - a.sortKey);
|
||||||
|
|
||||||
|
const rows = rootBlocks.flatMap((b) => b.rows);
|
||||||
|
// Orphans (Uncategorized + hard-deleted) appended in their incoming order.
|
||||||
|
for (const l of orphans) rows.push(makeOrphan(l));
|
||||||
|
|
||||||
|
// Stable sort by section so types (income → expense → transfer) are
|
||||||
|
// contiguous; magnitude/tree order within a type is preserved via the index.
|
||||||
|
const order = new Map<T, number>();
|
||||||
|
rows.forEach((r, i) => order.set(r, i));
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const ta = sectionOrder[sectionOf(a)] ?? unknownSectionRank;
|
||||||
|
const tb = sectionOrder[sectionOf(b)] ?? unknownSectionRank;
|
||||||
|
if (ta !== tb) return ta - tb;
|
||||||
|
return order.get(a)! - order.get(b)!;
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Compare hierarchy (Issue #247) ---
|
// --- Compare hierarchy (Issue #247) ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -443,13 +690,7 @@ function monthBoundaries(year: number, month: number): { start: string; end: str
|
||||||
* historic transactions keep their place in the hierarchy — matching the raw
|
* historic transactions keep their place in the hierarchy — matching the raw
|
||||||
* LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown).
|
* LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown).
|
||||||
*/
|
*/
|
||||||
interface CompareCatMeta {
|
type CompareCatMeta = TreeCatMeta;
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
color: string | null;
|
|
||||||
type: "expense" | "income" | "transfer" | null;
|
|
||||||
parent_id: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
|
const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`;
|
||||||
|
|
||||||
|
|
@ -457,9 +698,6 @@ const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM cat
|
||||||
// (netted) transfers — the two result lines are injected around them in the UI.
|
// (netted) transfers — the two result lines are injected around them in the UI.
|
||||||
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
||||||
|
|
||||||
/** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */
|
|
||||||
const MAX_TREE_DEPTH = 5;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns the flat per-category deltas returned by COMPARE_DELTA_SQL into a
|
* Turns the flat per-category deltas returned by COMPARE_DELTA_SQL into a
|
||||||
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
|
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
|
||||||
|
|
@ -468,9 +706,12 @@ const MAX_TREE_DEPTH = 5;
|
||||||
* The #243 transfer netting is untouched: leaf values are exactly what the SQL
|
* The #243 transfer netting is untouched: leaf values are exactly what the SQL
|
||||||
* returned, and a subtotal is the arithmetic sum of its descendant leaves — so a
|
* returned, and a subtotal is the arithmetic sum of its descendant leaves — so a
|
||||||
* group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's
|
* group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's
|
||||||
* net. Driven by `leaves` (the categories that actually had transactions in the
|
* net.
|
||||||
* window) rather than the full category list, so netted-to-zero transfers and
|
*
|
||||||
* soft-deleted categories with history are preserved and no empty rows appear.
|
* A thin specialisation of `buildLeafDrivenTree` (Issue #263): the generic
|
||||||
|
* skeleton drives the hierarchy walk, sibling ordering, depth guard and section
|
||||||
|
* sort; this only supplies the delta arithmetic. Behavior is byte-identical to
|
||||||
|
* the previous hand-rolled builder — the compare tests are the guard.
|
||||||
*
|
*
|
||||||
* Exported for unit testing.
|
* Exported for unit testing.
|
||||||
*/
|
*/
|
||||||
|
|
@ -478,65 +719,19 @@ export function buildCompareTree(
|
||||||
leaves: CategoryDelta[],
|
leaves: CategoryDelta[],
|
||||||
categories: CompareCatMeta[],
|
categories: CompareCatMeta[],
|
||||||
): CategoryDelta[] {
|
): CategoryDelta[] {
|
||||||
const catById = new Map<number, CompareCatMeta>();
|
const typeOf = (c: TreeCatMeta): TreeSectionType => c.type ?? "expense";
|
||||||
for (const c of categories) catById.set(c.id, c);
|
|
||||||
|
|
||||||
// Flat delta lookup by category id. Rows whose category id is null
|
return buildLeafDrivenTree<CategoryDelta>(leaves, {
|
||||||
// (Uncategorized) or absent from the table (hard-deleted) become orphans:
|
categories,
|
||||||
// depth-0 leaves preserved in their incoming order.
|
categoryIdOf: (d) => d.categoryId,
|
||||||
const deltaByCat = new Map<number, CategoryDelta>();
|
makeLeaf: (cat, d, parentId, depth) => ({
|
||||||
const orphans: CategoryDelta[] = [];
|
...d,
|
||||||
for (const d of leaves) {
|
|
||||||
if (d.categoryId != null && catById.has(d.categoryId)) {
|
|
||||||
deltaByCat.set(d.categoryId, d);
|
|
||||||
} else {
|
|
||||||
orphans.push(d);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Relevant" = every category that has a delta plus all of its ancestors.
|
|
||||||
const relevant = new Set<number>();
|
|
||||||
for (const id of deltaByCat.keys()) {
|
|
||||||
let cur: number | null | undefined = id;
|
|
||||||
let guard = 0;
|
|
||||||
while (cur != null && guard <= MAX_TREE_DEPTH) {
|
|
||||||
if (relevant.has(cur)) break;
|
|
||||||
relevant.add(cur);
|
|
||||||
cur = catById.get(cur)?.parent_id ?? null;
|
|
||||||
guard++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjacency among relevant categories only, preserving DB order.
|
|
||||||
const childrenByParent = new Map<number, CompareCatMeta[]>();
|
|
||||||
for (const c of categories) {
|
|
||||||
if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) {
|
|
||||||
let arr = childrenByParent.get(c.parent_id);
|
|
||||||
if (!arr) childrenByParent.set(c.parent_id, (arr = []));
|
|
||||||
arr.push(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeOf = (c: CompareCatMeta): "expense" | "income" | "transfer" => c.type ?? "expense";
|
|
||||||
|
|
||||||
const leafRow = (
|
|
||||||
cat: CompareCatMeta,
|
|
||||||
parentId: number | null,
|
|
||||||
depth: number,
|
|
||||||
): CategoryDelta => ({
|
|
||||||
...deltaByCat.get(cat.id)!,
|
|
||||||
parent_id: parentId,
|
parent_id: parentId,
|
||||||
is_parent: false,
|
is_parent: false,
|
||||||
depth,
|
depth,
|
||||||
category_type: typeOf(cat),
|
category_type: typeOf(cat),
|
||||||
});
|
}),
|
||||||
|
makeSubtotal: (cat, descendantLeaves, parentId, depth) => {
|
||||||
const subtotalRow = (
|
|
||||||
cat: CompareCatMeta,
|
|
||||||
descendantLeaves: CategoryDelta[],
|
|
||||||
parentId: number | null,
|
|
||||||
depth: number,
|
|
||||||
): CategoryDelta => {
|
|
||||||
let previousAmount = 0;
|
let previousAmount = 0;
|
||||||
let currentAmount = 0;
|
let currentAmount = 0;
|
||||||
let cumulativePreviousAmount = 0;
|
let cumulativePreviousAmount = 0;
|
||||||
|
|
@ -571,80 +766,96 @@ export function buildCompareTree(
|
||||||
depth,
|
depth,
|
||||||
category_type: typeOf(cat),
|
category_type: typeOf(cat),
|
||||||
};
|
};
|
||||||
};
|
},
|
||||||
|
// A parent with both children and its own transactions surfaces the direct
|
||||||
interface Block {
|
// spend as a "(direct)" leaf (mirrors getBudgetVsActualData).
|
||||||
rows: CategoryDelta[];
|
decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
|
||||||
sortKey: number; // |monthly delta| of the block head — orders siblings
|
// Orphans keep their own already-computed delta; only the hierarchy fields
|
||||||
}
|
// are stamped. Uncategorized rows (category_type undefined) → 'expense'.
|
||||||
|
makeOrphan: (d) => ({
|
||||||
// Builds a node's block: a pure leaf, or a subtotal followed by its
|
...d,
|
||||||
// (recursively built) child blocks, siblings ordered by |monthly delta| desc.
|
parent_id: null,
|
||||||
const buildNode = (cat: CompareCatMeta, depth: number): Block | null => {
|
is_parent: false,
|
||||||
// Stop descending past the depth cap so a corrupted parent_id cycle can
|
depth: 0,
|
||||||
// never recurse forever — deeper nodes collapse to leaves (real category
|
category_type: d.category_type ?? "expense",
|
||||||
// trees are ≤ 3 levels).
|
}),
|
||||||
const children = depth >= MAX_TREE_DEPTH ? [] : (childrenByParent.get(cat.id) ?? []);
|
sortKey: (row) => Math.abs(row.deltaAbs),
|
||||||
const hasDirect = deltaByCat.has(cat.id);
|
isSubtotal: (row) => row.is_parent === true,
|
||||||
|
sectionOf: (row) => row.category_type ?? "expense",
|
||||||
if (children.length === 0) {
|
sectionOrder: COMPARE_TYPE_ORDER,
|
||||||
if (!hasDirect) return null;
|
|
||||||
const leaf = leafRow(cat, cat.parent_id ?? null, depth);
|
|
||||||
return { rows: [leaf], sortKey: Math.abs(leaf.deltaAbs) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const childBlocks: Block[] = [];
|
|
||||||
// A category with both children AND its own transactions surfaces the direct
|
|
||||||
// spend as a "(direct)" leaf so the subtotal stays the sum of its visible
|
|
||||||
// rows (mirrors getBudgetVsActualData). Rare: a parent auto-loses
|
|
||||||
// is_inputable when a child is added.
|
|
||||||
if (hasDirect) {
|
|
||||||
const direct = leafRow(cat, cat.id, depth + 1);
|
|
||||||
childBlocks.push({
|
|
||||||
rows: [{ ...direct, categoryName: `${cat.name} (direct)` }],
|
|
||||||
sortKey: Math.abs(direct.deltaAbs),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const child of children) {
|
|
||||||
const b = buildNode(child, depth + 1);
|
|
||||||
if (b) childBlocks.push(b);
|
|
||||||
}
|
|
||||||
childBlocks.sort((a, b) => b.sortKey - a.sortKey);
|
|
||||||
|
|
||||||
const childRows = childBlocks.flatMap((b) => b.rows);
|
// --- Trends hierarchy (Issue #264) ---
|
||||||
const descendantLeaves = childRows.filter((r) => !r.is_parent);
|
|
||||||
const subtotal = subtotalRow(cat, descendantLeaves, cat.parent_id ?? null, depth);
|
/**
|
||||||
return { rows: [subtotal, ...childRows], sortKey: Math.abs(subtotal.deltaAbs) };
|
* 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),
|
||||||
};
|
};
|
||||||
|
},
|
||||||
// Roots = relevant categories with no relevant parent. Ordered by magnitude.
|
// A parent with both children and its own transactions surfaces the direct
|
||||||
const rootBlocks: Block[] = [];
|
// spend as a "(direct)" leaf (mirrors buildCompareTree / getBudgetVsActualData).
|
||||||
for (const c of categories) {
|
decorateDirectLeaf: (row, cat) => ({ ...row, categoryName: `${cat.name} (direct)` }),
|
||||||
if (!relevant.has(c.id)) continue;
|
// Orphans (Uncategorized / hard-deleted) keep their own series; only the
|
||||||
if (c.parent_id != null && relevant.has(c.parent_id)) continue;
|
// hierarchy fields are stamped. No metadata → default 'expense'.
|
||||||
const b = buildNode(c, 0);
|
makeOrphan: (l) => ({
|
||||||
if (b) rootBlocks.push(b);
|
...l,
|
||||||
}
|
parent_id: null,
|
||||||
rootBlocks.sort((a, b) => b.sortKey - a.sortKey);
|
is_parent: false,
|
||||||
|
depth: 0,
|
||||||
const rows = rootBlocks.flatMap((b) => b.rows);
|
category_type: l.category_type ?? "expense",
|
||||||
// Orphans (Uncategorized + hard-deleted) appended in their incoming order.
|
}),
|
||||||
for (const d of orphans) {
|
sortKey: (row) => Math.abs(row.total),
|
||||||
rows.push({ ...d, parent_id: null, is_parent: false, depth: 0, category_type: d.category_type ?? "expense" });
|
isSubtotal: (row) => row.is_parent === true,
|
||||||
}
|
sectionOf: (row) => row.category_type ?? "expense",
|
||||||
|
sectionOrder: COMPARE_TYPE_ORDER,
|
||||||
// Stable sort by type so sections (income → expense → transfer) are
|
|
||||||
// contiguous; magnitude/tree order within a type is preserved via the index.
|
|
||||||
const order = new Map<CategoryDelta, number>();
|
|
||||||
rows.forEach((r, i) => order.set(r, i));
|
|
||||||
rows.sort((a, b) => {
|
|
||||||
const ta = COMPARE_TYPE_ORDER[a.category_type ?? "expense"] ?? 9;
|
|
||||||
const tb = COMPARE_TYPE_ORDER[b.category_type ?? "expense"] ?? 9;
|
|
||||||
if (ta !== tb) return ta - tb;
|
|
||||||
return order.get(a)! - order.get(b)!;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function previousMonth(year: number, month: number): { year: number; month: number } {
|
function previousMonth(year: number, month: number): { year: number; month: number } {
|
||||||
|
|
|
||||||
|
|
@ -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