feat(cashflow): self-controlled treemap drill-down with breadcrumb to root

This commit is contained in:
Víctor Falcón 2026-07-11 19:22:31 +02:00
parent edaddc9c90
commit 44f578f060
3 changed files with 116 additions and 107 deletions

View File

@ -416,6 +416,7 @@
"Bitcoin": "Bitcoin", "Bitcoin": "Bitcoin",
"Blue": "Azul", "Blue": "Azul",
"Bolivian Boliviano": "Boliviano", "Bolivian Boliviano": "Boliviano",
"Breadcrumb": "Ruta de navegación",
"British Pound": "Libra esterlina", "British Pound": "Libra esterlina",
"Browse Files": "Explorar Archivos", "Browse Files": "Explorar Archivos",
"Budget Name": "Nombre del Presupuesto", "Budget Name": "Nombre del Presupuesto",

View File

@ -394,6 +394,7 @@
"Bitcoin": "Bitcoin", "Bitcoin": "Bitcoin",
"Blue": "Bleu", "Blue": "Bleu",
"Bolivian Boliviano": "bolivien", "Bolivian Boliviano": "bolivien",
"Breadcrumb": "Fil d'Ariane",
"British Pound": "Livre sterling", "British Pound": "Livre sterling",
"Browse Files": "Parcourir les fichiers", "Browse Files": "Parcourir les fichiers",
"Budget Name": "Nom du budget", "Budget Name": "Nom du budget",

View File

@ -8,7 +8,8 @@ import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n'; import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react'; import { router } from '@inertiajs/react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useEffect, useMemo, useState } from 'react'; import { ChevronRight } from 'lucide-react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { ResponsiveContainer, Tooltip, Treemap } from 'recharts'; import { ResponsiveContainer, Tooltip, Treemap } from 'recharts';
interface TreemapChartProps { interface TreemapChartProps {
@ -26,7 +27,7 @@ interface TreemapDatum {
size: number; size: number;
color: string; color: string;
categoryId: string; categoryId: string;
children?: TreemapDatum[]; hasChildren: boolean;
// Satisfies recharts' TreemapDataType index signature. // Satisfies recharts' TreemapDataType index signature.
[key: string]: unknown; [key: string]: unknown;
} }
@ -73,19 +74,18 @@ function CategoryNode({
size = 0, size = 0,
color = 'var(--color-chart-4)', color = 'var(--color-chart-4)',
categoryId, categoryId,
children, hasChildren = false,
currency, currency,
locale, locale,
isPrivacyModeEnabled, isPrivacyModeEnabled,
}: TreemapNodeProps) { }: TreemapNodeProps) {
// depth 0 is the invisible root; only leaves/parents carry a category. // depth 0 is the invisible root; only leaves carry a category.
if (depth < 1 || !categoryId) { if (depth < 1 || !categoryId) {
return null; return null;
} }
const showName = width >= 40 && height >= 22; const showName = width >= 40 && height >= 22;
const showAmount = width >= 55 && height >= 40; const showAmount = width >= 55 && height >= 40;
const hasChildren = Array.isArray(children) && children.length > 0;
return ( return (
<g style={{ cursor: 'pointer' }}> <g style={{ cursor: 'pointer' }}>
@ -176,10 +176,11 @@ export function TreemapChart({
const { isPrivacyModeEnabled } = usePrivacyMode(); const { isPrivacyModeEnabled } = usePrivacyMode();
const { categoryBarColor } = useChartColors(); const { categoryBarColor } = useChartColors();
// Nest mode needs the full tree upfront, so prefetch each parent's children. // Self-controlled drill-down: recharts' native "nest" breadcrumb can't
// ponytail: one level deep — matches the old sankey; deeper nesting would // offer a way back to the root, so we keep our own path + children cache
// need recursive fetching and rollUp already caps categories at depth 3. // and render a flat treemap per level.
const [childrenByParent, setChildrenByParent] = useState< const [path, setPath] = useState<Array<{ id: string; name: string }>>([]);
const [childrenById, setChildrenById] = useState<
Record<string, SankeyCategory[]> Record<string, SankeyCategory[]>
>({}); >({});
@ -188,91 +189,29 @@ export function TreemapChart({
: ''; : '';
useEffect(() => { useEffect(() => {
if (!period) { setPath([]);
return; setChildrenById({});
} }, [periodKey, mode]);
const parents = categories.filter(
(item) => item.has_children && item.category_id,
);
if (parents.length === 0) {
setChildrenByParent({});
return;
}
const from = format(period.from, 'yyyy-MM-dd');
const to = format(period.to, 'yyyy-MM-dd');
let cancelled = false;
Promise.all(
parents.map(async (parent) => {
const response = await fetch(
`/api/cashflow/sankey?from=${from}&to=${to}&parent=${parent.category_id}`,
);
const json: SankeyData = await response.json();
const kids =
mode === 'income'
? json.income_categories
: json.expense_categories;
return [parent.category_id, kids] as const;
}),
)
.then((entries) => {
if (cancelled) {
return;
}
const next: Record<string, SankeyCategory[]> = {};
entries.forEach(([id, kids]) => {
next[id] = kids;
});
setChildrenByParent(next);
})
.catch((error) => {
console.error('Failed to fetch subcategories:', error);
});
return () => {
cancelled = true;
};
// categoryBarColor is intentionally excluded: it changes identity every
// render and colours are applied in the memo below, not here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categories, periodKey, mode]);
const data = useMemo<TreemapDatum[]>(() => { const data = useMemo<TreemapDatum[]>(() => {
const toDatum = ( const current =
item: SankeyCategory, path.length === 0
index: number, ? categories
): TreemapDatum => ({ : (childrenById[path[path.length - 1].id] ?? []);
name: item.category.name,
size: item.amount,
color: categoryBarColor(item.category.color, index),
categoryId: item.category_id ?? '',
});
const sortPositive = (items: SankeyCategory[]): SankeyCategory[] => return [...current]
[...items] .filter((item) => item.amount > 0)
.filter((item) => item.amount > 0) .sort((a, b) => b.amount - a.amount)
.sort((a, b) => b.amount - a.amount); .map((item, index) => ({
name: item.category.name,
size: item.amount,
color: categoryBarColor(item.category.color, index),
categoryId: item.category_id ?? '',
hasChildren: !!item.has_children,
}));
}, [categories, childrenById, path, categoryBarColor]);
return sortPositive(categories).map((item, index) => { if (total === 0 || categories.length === 0) {
const datum = toDatum(item, index);
const kids = item.category_id
? childrenByParent[item.category_id]
: undefined;
if (kids && kids.length > 0) {
datum.children = sortPositive(kids).map(toDatum);
}
return datum;
});
}, [categories, childrenByParent, categoryBarColor]);
if (total === 0 || data.length === 0) {
return ( return (
<div <div
className={cn( className={cn(
@ -286,8 +225,38 @@ export function TreemapChart({
); );
} }
const navigate = (categoryId: unknown) => { const drillInto = async (categoryId: string, name: string) => {
if (!categoryId || typeof categoryId !== 'string' || !period) { if (!period) {
return;
}
if (!(categoryId in childrenById)) {
try {
const from = format(period.from, 'yyyy-MM-dd');
const to = format(period.to, 'yyyy-MM-dd');
const response = await fetch(
`/api/cashflow/sankey?from=${from}&to=${to}&parent=${categoryId}`,
);
const json: SankeyData = await response.json();
const kids =
mode === 'income'
? json.income_categories
: json.expense_categories;
setChildrenById((previous) => ({
...previous,
[categoryId]: kids,
}));
} catch (error) {
console.error('Failed to fetch subcategories:', error);
return;
}
}
setPath((previous) => [...previous, { id: categoryId, name }]);
};
const navigate = (categoryId: string) => {
if (!categoryId || !period) {
return; return;
} }
@ -302,30 +271,68 @@ export function TreemapChart({
); );
}; };
const rootLabel = mode === 'income' ? __('Income') : __('Expenses');
return ( return (
<div <div className={cn('w-full', className)}>
className={cn( {path.length > 0 && (
'w-full', <nav
// Recharts renders the nest breadcrumb with hardcoded inline aria-label={__('Breadcrumb')}
// styles; override them to match the design system. className="mb-3 flex flex-wrap items-center gap-1 text-sm"
'[&_.recharts-treemap-nest-index-box]:!mr-1 [&_.recharts-treemap-nest-index-box]:!rounded [&_.recharts-treemap-nest-index-box]:!bg-muted [&_.recharts-treemap-nest-index-box]:!px-2 [&_.recharts-treemap-nest-index-box]:!text-foreground', >
className, <button
type="button"
onClick={() => setPath([])}
className="cursor-pointer font-medium text-muted-foreground hover:text-foreground"
>
{rootLabel}
</button>
{path.map((item, index) => {
const isLast = index === path.length - 1;
return (
<Fragment key={item.id}>
<ChevronRight className="size-3.5 text-muted-foreground" />
{isLast ? (
<span className="font-medium text-foreground">
{item.name}
</span>
) : (
<button
type="button"
onClick={() =>
setPath(path.slice(0, index + 1))
}
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
{item.name}
</button>
)}
</Fragment>
);
})}
</nav>
)} )}
>
<ResponsiveContainer width="100%" height={height}> <ResponsiveContainer width="100%" height={height}>
<Treemap <Treemap
data={data} data={data}
dataKey="size" dataKey="size"
type="nest"
nodeGap={2} nodeGap={2}
// ponytail: key by mode so a toggle switch re-lays out cleanly // ponytail: key by level so switching mode/level re-lays out cleanly
key={mode} key={`${mode}-${path.length}`}
isAnimationActive={false} isAnimationActive={false}
// In nest mode onClick fires on every node; parents zoom in,
// so only leaves navigate to their transactions.
onClick={(node) => { onClick={(node) => {
if (!node.children) { const id = node.categoryId;
navigate(node.categoryId);
if (typeof id !== 'string' || !id) {
return;
}
if (node.hasChildren) {
drillInto(id, String(node.name ?? ''));
} else {
navigate(id);
} }
}} }}
content={ content={