diff --git a/resources/js/components/charts/sankey-chart.test.tsx b/resources/js/components/charts/sankey-chart.test.tsx
index fe2fd33b..c9c0fee2 100644
--- a/resources/js/components/charts/sankey-chart.test.tsx
+++ b/resources/js/components/charts/sankey-chart.test.tsx
@@ -1,8 +1,8 @@
import { SankeyData } from '@/hooks/use-cashflow-data';
import { Category } from '@/types/category';
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import * as React from 'react';
-import { afterEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SankeyChart } from './sankey-chart';
// ResponsiveContainer measures its DOM parent, which is 0x0 in jsdom, so the
@@ -68,6 +68,7 @@ const data: SankeyData = {
category: category('food', 'Food'),
category_id: 'food',
amount: 310,
+ has_children: true,
},
{
category: category('rent', 'Rent'),
@@ -79,12 +80,36 @@ const data: SankeyData = {
total_expense: 810,
};
+const foodChildren: SankeyData = {
+ income_categories: [],
+ expense_categories: [
+ {
+ category: category('groceries', 'Groceries'),
+ category_id: 'groceries',
+ amount: 200,
+ },
+ {
+ category: category('eating-out', 'Eating out'),
+ category_id: 'eating-out',
+ amount: 110,
+ },
+ ],
+ total_income: 0,
+ total_expense: 310,
+};
+
const period = {
from: new Date('2026-06-01'),
to: new Date('2026-06-30'),
};
describe('SankeyChart', () => {
+ beforeEach(() => {
+ global.fetch = vi.fn().mockResolvedValue({
+ json: async () => foodChildren,
+ }) as unknown as typeof fetch;
+ });
+
afterEach(() => {
vi.clearAllMocks();
});
@@ -147,6 +172,36 @@ describe('SankeyChart', () => {
expect(screen.queryByText('Category 7')).not.toBeInTheDocument();
});
+ it('shows an expand affordance on expense categories with children', () => {
+ render();
+
+ expect(
+ screen.getByRole('button', { name: 'Expand Food' }),
+ ).toBeInTheDocument();
+ // Rent has no children, so it links straight to its transactions.
+ expect(
+ screen.getByRole('link', { name: 'View Rent transactions' }),
+ ).toBeInTheDocument();
+ });
+
+ it('expands an expense category into its subcategories on click', async () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Groceries')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('Eating out')).toBeInTheDocument();
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.stringContaining('parent=food'),
+ );
+ // The rest of the chart stays in place.
+ expect(screen.getByText('Rent')).toBeInTheDocument();
+ expect(screen.getByText('Net')).toBeInTheDocument();
+ });
+
it('masks amounts in privacy mode', () => {
vi.mocked(usePrivacyMode).mockReturnValue({
...privacyMode,
diff --git a/resources/js/components/charts/sankey-chart.tsx b/resources/js/components/charts/sankey-chart.tsx
index 2f737bfb..15e29b1f 100644
--- a/resources/js/components/charts/sankey-chart.tsx
+++ b/resources/js/components/charts/sankey-chart.tsx
@@ -9,6 +9,7 @@ import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { format } from 'date-fns';
+import { ChevronDown, ChevronRight } from 'lucide-react';
import {
type ComponentProps,
type KeyboardEvent,
@@ -29,6 +30,7 @@ interface SankeyChartProps {
}
type FlowKind = 'income' | 'center' | 'expense';
+type LabelSide = 'left' | 'right' | 'onbar';
// Fields we attach to each node; recharts spreads these onto the payload it
// hands back to the custom node renderer, alongside its own layout data.
@@ -37,7 +39,10 @@ interface FlowNode {
amount: number;
color: string;
kind: FlowKind;
+ labelSide: LabelSide;
categoryId?: string;
+ expandable?: boolean;
+ expanded?: boolean;
}
interface FlowLink {
@@ -50,12 +55,14 @@ const NODE_WIDTH = 12;
const NODE_PADDING = 24;
const LABEL_GAP = 6;
const LABEL_HEIGHT = 30;
-// The center label is a bordered pill (name + amount), so it needs a little
-// more room than the plain side labels or it clips vertically.
-const CENTER_LABEL_HEIGHT = 44;
+// 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.
+const PILL_LABEL_HEIGHT = 44;
// A Sankey is inherently horizontal, so on narrow screens we let it scroll
// sideways (same pattern as the trend chart) rather than crushing the flows.
const MIN_CHART_WIDTH = 560;
+// A drill-down adds a fourth column, so widen the canvas to keep it readable.
+const EXPANDED_MIN_CHART_WIDTH = 760;
// Gives each node enough vertical room that its two-line label stays legible
// even when a category's bar is tiny.
const ROW_HEIGHT = 46;
@@ -71,16 +78,30 @@ export function SankeyChart({
period,
}: SankeyChartProps) {
const [containerWidth, setContainerWidth] = useState(600);
+ const [expandedId, setExpandedId] = useState(null);
+ const [childrenById, setChildrenById] = useState<
+ Record
+ >({});
const containerRef = useRef(null);
const locale = useLocale();
const { isPrivacyModeEnabled } = usePrivacyMode();
const { cashflowIncomeColor, cashflowExpenseColor } = useChartColors();
+ const periodKey = period
+ ? `${period.from.getTime()}-${period.to.getTime()}`
+ : '';
+
const maskIfPrivate = (value: number): string => {
const formatted = formatCurrency(value, currency, locale, 0, 0);
return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted;
};
+ const toggleExpand = (categoryId: string) => {
+ setExpandedId((previous) =>
+ previous === categoryId ? null : categoryId,
+ );
+ };
+
useEffect(() => {
const container = containerRef.current;
@@ -103,7 +124,42 @@ export function SankeyChart({
return () => observer.disconnect();
}, []);
- const { chartData, isEmpty, nodeRows } = useMemo(() => {
+ // A new period (or refreshed data) invalidates any open drill-down.
+ useEffect(() => {
+ setExpandedId(null);
+ setChildrenById({});
+ }, [periodKey]);
+
+ // Lazily fetch the subcategories of the expanded parent.
+ useEffect(() => {
+ if (!period || !expandedId || childrenById[expandedId]) {
+ return;
+ }
+
+ const from = format(period.from, 'yyyy-MM-dd');
+ const to = format(period.to, 'yyyy-MM-dd');
+ let cancelled = false;
+
+ fetch(`/api/cashflow/sankey?from=${from}&to=${to}&parent=${expandedId}`)
+ .then((response) => response.json())
+ .then((json: SankeyData) => {
+ if (!cancelled) {
+ setChildrenById((previous) => ({
+ ...previous,
+ [expandedId]: json,
+ }));
+ }
+ })
+ .catch((error) => {
+ console.error('Failed to fetch subcategories:', error);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [expandedId, childrenById, period, periodKey]);
+
+ const { chartData, isEmpty, nodeRows, subcatRows } = useMemo(() => {
const {
income_categories,
expense_categories,
@@ -125,6 +181,7 @@ export function SankeyChart({
amount: item.amount,
color: item.category.color || cashflowIncomeColor,
kind: 'income',
+ labelSide: 'left',
categoryId: item.category.id,
});
});
@@ -134,6 +191,7 @@ export function SankeyChart({
amount: groupedIncome.other.total,
color: MUTED_COLOR,
kind: 'income',
+ labelSide: 'left',
});
}
@@ -145,6 +203,7 @@ export function SankeyChart({
amount: total_income - total_expense,
color: CENTER_COLOR,
kind: 'center',
+ labelSide: 'onbar',
});
const groupedExpense = groupSmallCategories(
@@ -153,12 +212,18 @@ export function SankeyChart({
groupingThreshold,
);
groupedExpense.main.forEach((item) => {
+ const expanded = item.category.id === expandedId;
nodes.push({
name: item.category.name,
amount: item.amount,
color: item.category.color || cashflowExpenseColor,
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: expanded ? 'onbar' : 'right',
categoryId: item.category.id,
+ expandable: !!item.has_children,
+ expanded,
});
});
if (groupedExpense.other) {
@@ -167,6 +232,7 @@ export function SankeyChart({
amount: groupedExpense.other.total,
color: MUTED_COLOR,
kind: 'expense',
+ labelSide: 'right',
});
}
@@ -190,15 +256,61 @@ export function SankeyChart({
}
});
- const incomeRows = nodes.filter((n) => n.kind === 'income').length;
- const expenseRows = nodes.filter((n) => n.kind === 'expense').length;
+ // Drill-down: split the expanded parent into its subcategory column.
+ let subcatRows = 0;
+ if (expandedId) {
+ const parentIndex = nodes.findIndex(
+ (node) =>
+ node.kind === 'expense' && node.categoryId === expandedId,
+ );
+ const kids = [
+ ...(childrenById[expandedId]?.expense_categories ?? []),
+ ].sort((a, b) => b.amount - a.amount);
+
+ if (parentIndex >= 0) {
+ kids.forEach((kid) => {
+ if (kid.amount <= 0) {
+ return;
+ }
+
+ const childIndex = nodes.length;
+ nodes.push({
+ name: kid.category.name,
+ amount: kid.amount,
+ color: kid.category.color || cashflowExpenseColor,
+ kind: 'expense',
+ labelSide: 'right',
+ categoryId: kid.category.id,
+ });
+ links.push({
+ source: parentIndex,
+ target: childIndex,
+ value: kid.amount,
+ });
+ subcatRows += 1;
+ });
+ }
+ }
+
+ const incomeRows =
+ groupedIncome.main.length + (groupedIncome.other ? 1 : 0);
+ const expenseRows =
+ groupedExpense.main.length + (groupedExpense.other ? 1 : 0);
return {
chartData: { nodes, links },
isEmpty: links.length === 0,
- nodeRows: Math.max(incomeRows, expenseRows),
+ nodeRows: Math.max(incomeRows, expenseRows, subcatRows),
+ subcatRows,
};
- }, [data, groupingThreshold, cashflowIncomeColor, cashflowExpenseColor]);
+ }, [
+ data,
+ groupingThreshold,
+ cashflowIncomeColor,
+ cashflowExpenseColor,
+ expandedId,
+ childrenById,
+ ]);
if (isEmpty) {
return (
@@ -222,6 +334,8 @@ export function SankeyChart({
// Grow the canvas so crowded sides (many expense categories) keep their
// labels legible instead of overlapping.
const chartHeight = Math.max(height, nodeRows * ROW_HEIGHT + 24);
+ const minChartWidth =
+ subcatRows > 0 ? EXPANDED_MIN_CHART_WIDTH : MIN_CHART_WIDTH;
const goToCategory = (categoryId: string) => {
if (!period) {
@@ -255,49 +369,63 @@ export function SankeyChart({
payload: FlowNode;
}) => {
const node = payload;
- const isCenter = node.kind === 'center';
- const navigable = !!node.categoryId && !!period;
+ const isPill = node.labelSide === 'onbar';
+ const expandable = !!node.expandable && !!node.categoryId && !!period;
+ const navigable = !expandable && !!node.categoryId && !!period;
+ const interactive = expandable || navigable;
- const foHeight = isCenter ? CENTER_LABEL_HEIGHT : LABEL_HEIGHT;
+ const activate = () => {
+ if (expandable) {
+ toggleExpand(node.categoryId!);
+ } else if (navigable) {
+ goToCategory(node.categoryId!);
+ }
+ };
+
+ const foHeight = isPill ? PILL_LABEL_HEIGHT : LABEL_HEIGHT;
const labelY = y + nodeHeight / 2 - foHeight / 2;
let labelX: number;
let foWidth: number;
let alignClass: string;
- if (node.kind === 'income') {
+ if (node.labelSide === 'left') {
labelX = 2;
foWidth = Math.max(0, x - LABEL_GAP - 2);
alignClass = 'items-end text-right';
- } else if (node.kind === 'expense') {
+ } else if (node.labelSide === 'right') {
labelX = x + width + LABEL_GAP;
foWidth = Math.max(0, containerWidth - labelX - 2);
alignClass = 'items-start text-left';
} else {
- // The center bar spans the full height, so its label rides on top of
- // the bar with a subtle background instead of sitting beside it.
foWidth = labelWidth;
labelX = x + width / 2 - labelWidth / 2;
alignClass = 'items-center text-center';
}
+ const ChevronIcon = node.expanded ? ChevronDown : ChevronRight;
+
return (
goToCategory(node.categoryId!) : undefined
+ expandable
+ ? node.expanded
+ ? `Collapse ${node.name}`
+ : `Expand ${node.name}`
+ : navigable
+ ? `View ${node.name} transactions`
+ : undefined
}
+ onClick={interactive ? activate : undefined}
onKeyDown={
- navigable
+ interactive
? (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
- goToCategory(node.categoryId!);
+ activate();
}
}
: undefined
@@ -323,16 +451,29 @@ export function SankeyChart({
className={cn(
'flex h-full flex-col justify-center gap-0.5 leading-tight',
alignClass,
- isCenter &&
+ isPill &&
'rounded-md border border-border bg-background/90 px-1.5 py-0.5 shadow-sm',
)}
>
-
- {node.name}
-
+
+ {node.name}
+
+ {expandable && (
+
+ )}
+
{maskIfPrivate(node.amount)}
@@ -384,7 +525,7 @@ export function SankeyChart({
return (