feat(cashflow): expand income categories and fix Sankey label overlap (#672)
## Summary
Two improvements to the Cashflow Sankey chart, plus a fix reported for
real users whose chart rendered with overlapping labels.
### 1. Fix overlapping labels on small categories
recharts spaces same-column Sankey nodes by exactly `nodePadding` and
distributes the height **proportionally to value**, so tiny categories
end up separated by only that padding regardless of canvas height. It
was 24px while a two-line label is 30px tall, so adjacent labels
overlapped (e.g. *Regalos* / *Otros* in the monthly view, *Ingreso
desconocido* / *Pagos devueltos* in the yearly view).
Set `NODE_PADDING` above the label height so two adjacent labels can
never collide, whatever the bar size. This is independent of width, so
it fixes desktop and mobile alike.
### 2. Expand income categories (drill-down)
Income categories can now be drilled down into their subcategories,
mirroring the existing expense behaviour. The `sankey` endpoint already
returns both sides for a given `parent`, so this is frontend-only:
- The open drill-down is keyed by `{id, kind}` — a category can
legitimately appear on both the income and expense side.
- Income subcategories link `child -> parent` and form a column to the
**left** of the parent (expense subcategories stay on the right).
- The chart-height budget now accounts for the combined leftmost column
(income siblings + subcategories share it), so a many-source drill-down
can't drive recharts to a negative row scale and break the whole chart.
- The expand chevron points the way the column opens: income shows `<`
collapsed / `>` expanded; expense keeps `>` collapsed / `v` expanded.
The income/expense node-building was also de-duplicated into a single
helper.
## Test plan
- `sankey-chart.test.tsx`: 7 unit tests green (added income-expansion
coverage alongside the existing expense one).
- `tsc --noEmit`, `eslint`, `prettier` clean.
- Manual browser QA (Playwright) on a real account, desktop **and**
mobile: monthly + yearly views (overlap gone), expand income, switch to
expand expense (no regression), collapse, chevron direction, responsive
horizontal scroll.
## Demo
<!-- PLACEHOLDER: drag the demo video here -->
_📎 Attach the demo video here — file generated at
`~/Downloads/sankey-cashflow-demo.mp4`_
This commit is contained in:
parent
cbeea2fc34
commit
b9b507005f
|
|
@ -99,6 +99,44 @@ const foodChildren: SankeyData = {
|
||||||
total_expense: 310,
|
total_expense: 310,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const incomeData: SankeyData = {
|
||||||
|
income_categories: [
|
||||||
|
{
|
||||||
|
category: category('salary', 'Salary'),
|
||||||
|
category_id: 'salary',
|
||||||
|
amount: 1000,
|
||||||
|
has_children: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expense_categories: [
|
||||||
|
{
|
||||||
|
category: category('rent', 'Rent'),
|
||||||
|
category_id: 'rent',
|
||||||
|
amount: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total_income: 1000,
|
||||||
|
total_expense: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const salaryChildren: SankeyData = {
|
||||||
|
income_categories: [
|
||||||
|
{
|
||||||
|
category: category('base-salary', 'Base salary'),
|
||||||
|
category_id: 'base-salary',
|
||||||
|
amount: 700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: category('bonus', 'Bonus'),
|
||||||
|
category_id: 'bonus',
|
||||||
|
amount: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expense_categories: [],
|
||||||
|
total_income: 1000,
|
||||||
|
total_expense: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const period = {
|
const period = {
|
||||||
from: new Date('2026-06-01'),
|
from: new Date('2026-06-01'),
|
||||||
to: new Date('2026-06-30'),
|
to: new Date('2026-06-30'),
|
||||||
|
|
@ -203,6 +241,28 @@ describe('SankeyChart', () => {
|
||||||
expect(screen.getByText('Net')).toBeInTheDocument();
|
expect(screen.getByText('Net')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('expands an income category into its subcategories on click', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => salaryChildren,
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
render(<SankeyChart data={incomeData} period={period} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Expand Salary' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Base salary')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Bonus')).toBeInTheDocument();
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('parent=salary'),
|
||||||
|
);
|
||||||
|
// The rest of the chart stays in place.
|
||||||
|
expect(screen.getByText('Rent')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Net')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('masks amounts in privacy mode', () => {
|
it('masks amounts in privacy mode', () => {
|
||||||
vi.mocked(usePrivacyMode).mockReturnValue({
|
vi.mocked(usePrivacyMode).mockReturnValue({
|
||||||
...privacyMode,
|
...privacyMode,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||||
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
|
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
|
||||||
import { SankeyData } from '@/hooks/use-cashflow-data';
|
import { SankeyCategory, SankeyData } from '@/hooks/use-cashflow-data';
|
||||||
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
||||||
import { useLocale } from '@/hooks/use-locale';
|
import { useLocale } from '@/hooks/use-locale';
|
||||||
import { groupSmallCategories } from '@/lib/sankey-utils';
|
import { groupSmallCategories } from '@/lib/sankey-utils';
|
||||||
|
|
@ -9,7 +9,7 @@ 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 { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
|
|
@ -52,9 +52,12 @@ interface FlowLink {
|
||||||
}
|
}
|
||||||
|
|
||||||
const NODE_WIDTH = 12;
|
const NODE_WIDTH = 12;
|
||||||
const NODE_PADDING = 24;
|
|
||||||
const LABEL_GAP = 6;
|
const LABEL_GAP = 6;
|
||||||
const LABEL_HEIGHT = 30;
|
const LABEL_HEIGHT = 30;
|
||||||
|
// recharts stacks same-column nodes with exactly this vertical gap, so tiny
|
||||||
|
// bars end up NODE_PADDING apart regardless of the canvas height. Keep it above
|
||||||
|
// LABEL_HEIGHT (+ breathing room) so two adjacent labels can never overlap.
|
||||||
|
const NODE_PADDING = LABEL_HEIGHT + 6;
|
||||||
// On-bar labels (the hub, and an expanded parent) are bordered pills with two
|
// On-bar labels (the hub, and an expanded parent) are bordered pills with two
|
||||||
// lines, so they need a little more room than the plain side labels.
|
// lines, so they need a little more room than the plain side labels.
|
||||||
const PILL_LABEL_HEIGHT = 44;
|
const PILL_LABEL_HEIGHT = 44;
|
||||||
|
|
@ -78,7 +81,12 @@ export function SankeyChart({
|
||||||
period,
|
period,
|
||||||
}: SankeyChartProps) {
|
}: SankeyChartProps) {
|
||||||
const [containerWidth, setContainerWidth] = useState(600);
|
const [containerWidth, setContainerWidth] = useState(600);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
// A category id can appear on both sides (in- and outflows), so the open
|
||||||
|
// drill-down is keyed by side too, not just id.
|
||||||
|
const [expanded, setExpanded] = useState<{
|
||||||
|
id: string;
|
||||||
|
kind: FlowKind;
|
||||||
|
} | null>(null);
|
||||||
const [childrenById, setChildrenById] = useState<
|
const [childrenById, setChildrenById] = useState<
|
||||||
Record<string, SankeyData>
|
Record<string, SankeyData>
|
||||||
>({});
|
>({});
|
||||||
|
|
@ -97,9 +105,14 @@ export function SankeyChart({
|
||||||
return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted;
|
return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleExpand = (categoryId: string) => {
|
const expandedId = expanded?.id ?? null;
|
||||||
setExpandedId((previous) =>
|
const expandedKind = expanded?.kind ?? null;
|
||||||
previous === categoryId ? null : categoryId,
|
|
||||||
|
const toggleExpand = (categoryId: string, kind: FlowKind) => {
|
||||||
|
setExpanded((previous) =>
|
||||||
|
previous?.id === categoryId && previous.kind === kind
|
||||||
|
? null
|
||||||
|
: { id: categoryId, kind },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -127,7 +140,7 @@ export function SankeyChart({
|
||||||
|
|
||||||
// A new period (or refreshed data) invalidates any open drill-down.
|
// A new period (or refreshed data) invalidates any open drill-down.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExpandedId(null);
|
setExpanded(null);
|
||||||
setChildrenById({});
|
setChildrenById({});
|
||||||
}, [periodKey]);
|
}, [periodKey]);
|
||||||
|
|
||||||
|
|
@ -155,8 +168,8 @@ export function SankeyChart({
|
||||||
// Collapse back so the node doesn't stay stuck "expanded" with
|
// Collapse back so the node doesn't stay stuck "expanded" with
|
||||||
// no subcategories and no way forward.
|
// no subcategories and no way forward.
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setExpandedId((current) =>
|
setExpanded((current) =>
|
||||||
current === expandedId ? null : current,
|
current?.id === expandedId ? null : current,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.error('Failed to fetch subcategories:', error);
|
console.error('Failed to fetch subcategories:', error);
|
||||||
|
|
@ -183,20 +196,48 @@ export function SankeyChart({
|
||||||
total_income,
|
total_income,
|
||||||
groupingThreshold,
|
groupingThreshold,
|
||||||
);
|
);
|
||||||
groupedIncome.main.forEach((item, index) => {
|
// Both sides build identical node shapes; only the children key and
|
||||||
if (item.amount <= 0) {
|
// the collapsed-label side (income to the left of the hub, expense to
|
||||||
return;
|
// the right) differ.
|
||||||
}
|
const pushCategoryNodes = (
|
||||||
|
items: SankeyCategory[],
|
||||||
|
kind: 'income' | 'expense',
|
||||||
|
) => {
|
||||||
|
const collapsedSide: LabelSide =
|
||||||
|
kind === 'income' ? 'left' : 'right';
|
||||||
|
const childrenKey =
|
||||||
|
kind === 'income' ? 'income_categories' : 'expense_categories';
|
||||||
|
|
||||||
nodes.push({
|
items.forEach((item, index) => {
|
||||||
name: item.category.name,
|
if (item.amount <= 0) {
|
||||||
amount: item.amount,
|
return;
|
||||||
color: categoryBarColor(item.category.color, index),
|
}
|
||||||
kind: 'income',
|
|
||||||
labelSide: 'left',
|
const isExpanded =
|
||||||
categoryId: item.category.id,
|
expandedKind === kind && item.category.id === expandedId;
|
||||||
|
// Keep the label beside the bar until the subcategories
|
||||||
|
// actually load, so it doesn't jump onto the bar and back
|
||||||
|
// during the fetch; an expanded parent's label moves onto the
|
||||||
|
// bar to clear room for its subcategory column.
|
||||||
|
const childrenLoaded =
|
||||||
|
isExpanded &&
|
||||||
|
(childrenById[item.category.id]?.[childrenKey]?.length ??
|
||||||
|
0) > 0;
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
name: item.category.name,
|
||||||
|
amount: item.amount,
|
||||||
|
color: categoryBarColor(item.category.color, index),
|
||||||
|
kind,
|
||||||
|
labelSide: childrenLoaded ? 'onbar' : collapsedSide,
|
||||||
|
categoryId: item.category.id,
|
||||||
|
expandable: !!item.has_children,
|
||||||
|
expanded: isExpanded,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
|
pushCategoryNodes(groupedIncome.main, 'income');
|
||||||
if (groupedIncome.other) {
|
if (groupedIncome.other) {
|
||||||
nodes.push({
|
nodes.push({
|
||||||
name: __('Other'),
|
name: __('Other'),
|
||||||
|
|
@ -223,32 +264,7 @@ export function SankeyChart({
|
||||||
total_expense,
|
total_expense,
|
||||||
groupingThreshold,
|
groupingThreshold,
|
||||||
);
|
);
|
||||||
groupedExpense.main.forEach((item, index) => {
|
pushCategoryNodes(groupedExpense.main, 'expense');
|
||||||
if (item.amount <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExpanded = item.category.id === expandedId;
|
|
||||||
// Keep the label beside the bar until the subcategories actually
|
|
||||||
// load, so it doesn't jump onto the bar and back during the fetch.
|
|
||||||
const childrenLoaded =
|
|
||||||
isExpanded &&
|
|
||||||
(childrenById[item.category.id]?.expense_categories?.length ??
|
|
||||||
0) > 0;
|
|
||||||
|
|
||||||
nodes.push({
|
|
||||||
name: item.category.name,
|
|
||||||
amount: item.amount,
|
|
||||||
color: categoryBarColor(item.category.color, index),
|
|
||||||
kind: 'expense',
|
|
||||||
// An expanded parent sits between the hub and its subcategory
|
|
||||||
// column, so its label moves onto the bar to clear the way.
|
|
||||||
labelSide: childrenLoaded ? 'onbar' : 'right',
|
|
||||||
categoryId: item.category.id,
|
|
||||||
expandable: !!item.has_children,
|
|
||||||
expanded: isExpanded,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (groupedExpense.other) {
|
if (groupedExpense.other) {
|
||||||
nodes.push({
|
nodes.push({
|
||||||
name: __('Other'),
|
name: __('Other'),
|
||||||
|
|
@ -280,14 +296,20 @@ export function SankeyChart({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drill-down: split the expanded parent into its subcategory column.
|
// Drill-down: split the expanded parent into its subcategory column.
|
||||||
|
// Expenses flow out of the hub (subcategories extend to the right);
|
||||||
|
// income flows into it (subcategories sit to the left), so the link
|
||||||
|
// direction and label side mirror by side.
|
||||||
let subcatRows = 0;
|
let subcatRows = 0;
|
||||||
if (expandedId) {
|
if (expandedId && expandedKind) {
|
||||||
const parentIndex = nodes.findIndex(
|
const parentIndex = nodes.findIndex(
|
||||||
(node) =>
|
(node) =>
|
||||||
node.kind === 'expense' && node.categoryId === expandedId,
|
node.kind === expandedKind &&
|
||||||
|
node.categoryId === expandedId,
|
||||||
);
|
);
|
||||||
const kids = [
|
const kids = [
|
||||||
...(childrenById[expandedId]?.expense_categories ?? []),
|
...(expandedKind === 'income'
|
||||||
|
? (childrenById[expandedId]?.income_categories ?? [])
|
||||||
|
: (childrenById[expandedId]?.expense_categories ?? [])),
|
||||||
].sort((a, b) => b.amount - a.amount);
|
].sort((a, b) => b.amount - a.amount);
|
||||||
|
|
||||||
if (parentIndex >= 0) {
|
if (parentIndex >= 0) {
|
||||||
|
|
@ -301,15 +323,23 @@ export function SankeyChart({
|
||||||
name: kid.category.name,
|
name: kid.category.name,
|
||||||
amount: kid.amount,
|
amount: kid.amount,
|
||||||
color: categoryBarColor(kid.category.color, index),
|
color: categoryBarColor(kid.category.color, index),
|
||||||
kind: 'expense',
|
kind: expandedKind,
|
||||||
labelSide: 'right',
|
labelSide: expandedKind === 'income' ? 'left' : 'right',
|
||||||
categoryId: kid.category.id,
|
categoryId: kid.category.id,
|
||||||
});
|
});
|
||||||
links.push({
|
links.push(
|
||||||
source: parentIndex,
|
expandedKind === 'income'
|
||||||
target: childIndex,
|
? {
|
||||||
value: kid.amount,
|
source: childIndex,
|
||||||
});
|
target: parentIndex,
|
||||||
|
value: kid.amount,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
source: parentIndex,
|
||||||
|
target: childIndex,
|
||||||
|
value: kid.amount,
|
||||||
|
},
|
||||||
|
);
|
||||||
subcatRows += 1;
|
subcatRows += 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -320,13 +350,31 @@ export function SankeyChart({
|
||||||
const expenseRows =
|
const expenseRows =
|
||||||
groupedExpense.main.length + (groupedExpense.other ? 1 : 0);
|
groupedExpense.main.length + (groupedExpense.other ? 1 : 0);
|
||||||
|
|
||||||
|
// An income drill-down links children -> parent, so the subcategories
|
||||||
|
// land in the same leftmost column as the (non-expanded) income
|
||||||
|
// siblings rather than in their own column. That combined column — not
|
||||||
|
// any single side — bounds the height; under-counting it would let
|
||||||
|
// recharts derive a negative row scale and break the whole chart.
|
||||||
|
// Expense subcategories get their own column, so the plain max holds.
|
||||||
|
const leftmostRows =
|
||||||
|
expandedKind === 'income'
|
||||||
|
? incomeRows - 1 + subcatRows
|
||||||
|
: incomeRows;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chartData: { nodes, links },
|
chartData: { nodes, links },
|
||||||
isEmpty: links.length === 0,
|
isEmpty: links.length === 0,
|
||||||
nodeRows: Math.max(incomeRows, expenseRows, subcatRows),
|
nodeRows: Math.max(leftmostRows, expenseRows, subcatRows),
|
||||||
subcatRows,
|
subcatRows,
|
||||||
};
|
};
|
||||||
}, [data, groupingThreshold, categoryBarColor, expandedId, childrenById]);
|
}, [
|
||||||
|
data,
|
||||||
|
groupingThreshold,
|
||||||
|
categoryBarColor,
|
||||||
|
expandedId,
|
||||||
|
expandedKind,
|
||||||
|
childrenById,
|
||||||
|
]);
|
||||||
|
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -392,7 +440,7 @@ export function SankeyChart({
|
||||||
|
|
||||||
const activate = () => {
|
const activate = () => {
|
||||||
if (expandable) {
|
if (expandable) {
|
||||||
toggleExpand(node.categoryId!);
|
toggleExpand(node.categoryId!, node.kind);
|
||||||
} else if (navigable) {
|
} else if (navigable) {
|
||||||
goToCategory(node.categoryId!);
|
goToCategory(node.categoryId!);
|
||||||
}
|
}
|
||||||
|
|
@ -424,7 +472,17 @@ export function SankeyChart({
|
||||||
alignClass = 'items-center text-center';
|
alignClass = 'items-center text-center';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChevronIcon = node.expanded ? ChevronDown : ChevronRight;
|
// The chevron points the way the subcategory column will open: income
|
||||||
|
// expands to the left (`<`, then `>` once open), expense to the right
|
||||||
|
// (`>`, then a downward `v` once open).
|
||||||
|
const ChevronIcon =
|
||||||
|
node.kind === 'income'
|
||||||
|
? node.expanded
|
||||||
|
? ChevronRight
|
||||||
|
: ChevronLeft
|
||||||
|
: node.expanded
|
||||||
|
? ChevronDown
|
||||||
|
: ChevronRight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layer
|
<Layer
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue