diff --git a/resources/js/components/charts/index.ts b/resources/js/components/charts/index.ts
index 394b1a13..5f4eb46d 100644
--- a/resources/js/components/charts/index.ts
+++ b/resources/js/components/charts/index.ts
@@ -11,4 +11,4 @@ export { ChartSettingsPopover } from './chart-settings-popover';
export { ChartViewToggle } from './chart-view-toggle';
export { MoMChart } from './mom-chart';
export { MoMPercentChart } from './mom-percent-chart';
-export { SankeyChart } from './sankey-chart';
+export { TreemapChart } from './treemap-chart';
diff --git a/resources/js/components/charts/sankey-chart.test.tsx b/resources/js/components/charts/sankey-chart.test.tsx
deleted file mode 100644
index 0f744ab3..00000000
--- a/resources/js/components/charts/sankey-chart.test.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-import { SankeyData } from '@/hooks/use-cashflow-data';
-import { Category } from '@/types/category';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { SankeyChart } from './sankey-chart';
-
-vi.mock('@/contexts/privacy-mode-context', () => ({
- usePrivacyMode: () => ({ isPrivacyModeEnabled: false }),
-}));
-
-vi.mock('@/hooks/use-locale', () => ({
- useLocale: () => 'en',
-}));
-
-vi.mock('@inertiajs/react', () => ({
- router: { visit: vi.fn() },
-}));
-
-vi.mock('@/actions/App/Http/Controllers/TransactionController', () => ({
- index: () => ({ url: '/transactions' }),
-}));
-
-function category(id: string, name: string, color = '#ccc'): Category {
- return { id, name, color } as Category;
-}
-
-const data: SankeyData = {
- income_categories: [
- {
- category: category('salary', 'Salary'),
- category_id: 'salary',
- amount: 1000,
- },
- ],
- expense_categories: [
- {
- category: category('food', 'Food'),
- category_id: 'food',
- amount: 310,
- has_children: true,
- },
- {
- category: category('rent', 'Rent'),
- category_id: 'rent',
- amount: 500,
- },
- ],
- total_income: 1000,
- total_expense: 810,
-};
-
-const foodChildren: SankeyData = {
- income_categories: [],
- expense_categories: [
- {
- category: category('groceries', 'Groceries'),
- category_id: 'groceries',
- amount: 200,
- },
- {
- category: category('other-groceries', 'Other groceries'),
- category_id: 'other-groceries',
- 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();
- });
-
- it('renders an expand affordance on parent categories with children', () => {
- render();
-
- expect(
- screen.getByRole('button', { name: 'Expand Food' }),
- ).toBeInTheDocument();
- });
-
- it('expands a category into its subcategories without replacing the chart', async () => {
- render();
-
- fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
-
- await waitFor(() => {
- expect(screen.getByText('Groceries')).toBeInTheDocument();
- });
-
- expect(screen.getByText('Other groceries')).toBeInTheDocument();
- // The original chart is untouched: center + parent nodes remain.
- expect(screen.getByText('Cashflow')).toBeInTheDocument();
- expect(screen.getByText('Food')).toBeInTheDocument();
- expect(screen.getByText('Rent')).toBeInTheDocument();
-
- expect(global.fetch).toHaveBeenCalledWith(
- expect.stringContaining('parent=food'),
- );
- });
-
- it('truncates long category names with an ellipsis and keeps the full name as a title', () => {
- const longName = 'Sports, and sport goods and equipment rentals';
- render(
- ,
- );
-
- // The full name stays in the DOM (with a title for hover); CSS handles
- // the visual ellipsis via the `truncate` utility.
- const label = screen.getByTitle(longName);
- expect(label).toHaveTextContent(longName);
- expect(label).toHaveClass('truncate');
- });
-
- it('collapses any other expanded category so only one stays open', async () => {
- const multiParent: SankeyData = {
- ...data,
- expense_categories: [
- {
- category: category('food', 'Food'),
- category_id: 'food',
- amount: 310,
- has_children: true,
- },
- {
- category: category('rent', 'Rent'),
- category_id: 'rent',
- amount: 500,
- has_children: true,
- },
- ],
- };
-
- const rentChildren: SankeyData = {
- income_categories: [],
- expense_categories: [
- {
- category: category('mortgage', 'Mortgage'),
- category_id: 'mortgage',
- amount: 500,
- },
- ],
- total_income: 0,
- total_expense: 500,
- };
-
- global.fetch = vi.fn().mockImplementation((url: string) => {
- const json = url.includes('parent=rent')
- ? rentChildren
- : foodChildren;
-
- return Promise.resolve({ json: async () => json });
- }) as unknown as typeof fetch;
-
- render();
-
- fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
-
- await waitFor(() => {
- expect(screen.getByText('Groceries')).toBeInTheDocument();
- });
-
- fireEvent.click(screen.getByRole('button', { name: 'Expand Rent' }));
-
- await waitFor(() => {
- expect(screen.getByText('Mortgage')).toBeInTheDocument();
- });
-
- // Food's subcategories are gone now that Rent is open.
- expect(screen.queryByText('Groceries')).not.toBeInTheDocument();
- });
-
- it('collapses an expanded category when toggled again', async () => {
- render();
-
- fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
-
- await waitFor(() => {
- expect(screen.getByText('Groceries')).toBeInTheDocument();
- });
-
- fireEvent.click(screen.getByRole('button', { name: 'Collapse Food' }));
-
- await waitFor(() => {
- expect(screen.queryByText('Groceries')).not.toBeInTheDocument();
- });
- });
-});
diff --git a/resources/js/components/charts/sankey-chart.tsx b/resources/js/components/charts/sankey-chart.tsx
deleted file mode 100644
index b1f11c1c..00000000
--- a/resources/js/components/charts/sankey-chart.tsx
+++ /dev/null
@@ -1,988 +0,0 @@
-import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from '@/components/ui/popover';
-import { Separator } from '@/components/ui/separator';
-import { usePrivacyMode } from '@/contexts/privacy-mode-context';
-import { SankeyCategory, SankeyData } from '@/hooks/use-cashflow-data';
-import { useLocale } from '@/hooks/use-locale';
-import {
- calculatePercentage,
- GroupedCategory,
- groupSmallCategories,
-} from '@/lib/sankey-utils';
-import { cn } from '@/lib/utils';
-import { Category } from '@/types/category';
-import { formatCurrency } from '@/utils/currency';
-import { __ } from '@/utils/i18n';
-import { router } from '@inertiajs/react';
-import { format } from 'date-fns';
-import { ChevronsRight } from 'lucide-react';
-import { useEffect, useMemo, useRef, useState } from 'react';
-
-interface SankeyChartProps {
- data: SankeyData;
- height?: number;
- className?: string;
- currency?: string;
- groupingThreshold?: number;
- period?: { from: Date; to: Date };
-}
-
-type ColumnKey =
- | 'incomeChild'
- | 'income'
- | 'center'
- | 'expense'
- | 'expenseChild';
-
-interface NodeData {
- id: string;
- label: string;
- value: number;
- color: string;
- y: number;
- height: number;
- column: ColumnKey;
- columnFraction: number;
- category?: Category;
- hasChildren?: boolean;
- expandable?: boolean;
-}
-
-interface LinkData {
- source: string;
- target: string;
- value: number;
- sourceY: number;
- targetY: number;
- sourceHeight: number;
- targetHeight: number;
- kind: 'income' | 'expense';
-}
-
-const NODE_WIDTH = 8;
-const NODE_PADDING = 6;
-const MIN_NODE_HEIGHT = 28;
-const MIN_RENDERED_WIDTH = 400;
-const MAX_RENDERED_WIDTH = 800;
-const LABEL_BLOCK_HEIGHT = 24;
-const LABEL_GAP = 6;
-const LABEL_PAD = 4;
-const LABEL_CENTER_WIDTH = 90;
-
-interface OtherCategoriesBreakdownProps {
- categories: SankeyCategory[];
- total: number;
- currency: string;
- grandTotal: number;
- locale: string;
- isPrivacyModeEnabled: boolean;
-}
-
-function OtherCategoriesBreakdown({
- categories,
- total,
- currency,
- grandTotal,
- locale,
- isPrivacyModeEnabled,
-}: OtherCategoriesBreakdownProps) {
- const maskIfPrivate = (value: number) => {
- const formatted = formatCurrency(value, currency, locale, 0, 0);
- return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted;
- };
-
- return (
-
-
-
-
- {__('Other Categories (')}
- {categories.length})
-
-
- {__('Categories below 5% of total')}
-
-
-
-
- {categories.map((item) => {
- const percentage = calculatePercentage(
- item.amount,
- grandTotal,
- );
- return (
-
-
-
-
-
- {item.category.name}
-
-
-
-
- {maskIfPrivate(item.amount)}
-
-
- {percentage.toFixed(1)}%
-
-
-
- );
- })}
-
-
-
-
-
- {__('Total')}
- {maskIfPrivate(total)}
-
-
-
- );
-}
-
-export function SankeyChart({
- data,
- height = 400,
- className,
- currency = 'USD',
- groupingThreshold = 0.03,
- period,
-}: SankeyChartProps) {
- const [hoveredNode, setHoveredNode] = useState(null);
- const [hoveredLink, setHoveredLink] = useState(null);
- const [renderedWidth, setRenderedWidth] = useState(MAX_RENDERED_WIDTH);
- const [expandedIds, setExpandedIds] = useState>(new Set());
- const [childrenById, setChildrenById] = useState<
- Record
- >({});
- const containerRef = useRef(null);
- const locale = useLocale();
- const { isPrivacyModeEnabled } = usePrivacyMode();
-
- const periodKey = period
- ? `${period.from.getTime()}-${period.to.getTime()}`
- : '';
-
- const maskIfPrivate = (value: number) => {
- const formatted = formatCurrency(value, currency, locale, 0, 0);
- return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted;
- };
-
- const toggleExpand = (categoryId: string) => {
- setExpandedIds((previous) => {
- if (previous.has(categoryId)) {
- return new Set();
- }
-
- return new Set([categoryId]);
- });
- };
-
- useEffect(() => {
- const container = containerRef.current;
-
- if (!container) {
- return;
- }
-
- const updateWidth = () => {
- setRenderedWidth(
- Math.round(
- Math.min(
- MAX_RENDERED_WIDTH,
- Math.max(MIN_RENDERED_WIDTH, container.clientWidth),
- ),
- ),
- );
- };
-
- updateWidth();
-
- if (typeof ResizeObserver === 'undefined') {
- window.addEventListener('resize', updateWidth);
-
- return () => window.removeEventListener('resize', updateWidth);
- }
-
- const observer = new ResizeObserver(updateWidth);
- observer.observe(container);
-
- return () => observer.disconnect();
- }, []);
-
- // Changing the period invalidates any expanded subcategories.
- useEffect(() => {
- setExpandedIds(new Set());
- setChildrenById({});
- }, [periodKey]);
-
- // Lazily fetch the children of each newly expanded category.
- useEffect(() => {
- if (!period) {
- return;
- }
-
- const missing = [...expandedIds].filter((id) => !(id in childrenById));
-
- if (missing.length === 0) {
- return;
- }
-
- const from = format(period.from, 'yyyy-MM-dd');
- const to = format(period.to, 'yyyy-MM-dd');
- let cancelled = false;
-
- missing.forEach(async (id) => {
- try {
- const response = await fetch(
- `/api/cashflow/sankey?from=${from}&to=${to}&parent=${id}`,
- );
- const json: SankeyData = await response.json();
-
- if (!cancelled) {
- setChildrenById((previous) => ({
- ...previous,
- [id]: json,
- }));
- }
- } catch (error) {
- console.error('Failed to fetch subcategories:', error);
- }
- });
-
- return () => {
- cancelled = true;
- };
- }, [expandedIds, childrenById, period, periodKey]);
-
- const { nodes, links, isEmpty, otherGroups, viewBoxTop, viewBoxHeight } =
- useMemo(() => {
- const {
- income_categories,
- expense_categories,
- total_income,
- total_expense,
- } = data;
-
- if (total_income === 0 && total_expense === 0) {
- return {
- nodes: [] as NodeData[],
- links: [] as LinkData[],
- isEmpty: true,
- otherGroups: {} as Record,
- viewBoxTop: 0,
- viewBoxHeight: height,
- };
- }
-
- const otherGroupsMap: Record = {};
- const availableHeight = height - 40; // padding
- const maxTotal = Math.max(total_income, total_expense);
-
- // Income parent nodes (left column)
- const groupedIncome = groupSmallCategories(
- income_categories,
- total_income,
- groupingThreshold,
- );
-
- let incomeY = 20;
- const incomeNodes: NodeData[] = groupedIncome.main.map((item) => {
- const nodeHeight = Math.max(
- MIN_NODE_HEIGHT,
- (item.amount / maxTotal) * availableHeight * 0.5,
- );
- const node: NodeData = {
- id: `income-${item.category_id}`,
- label: item.category.name,
- value: item.amount,
- color: item.category.color || 'var(--color-chart-2)',
- y: incomeY,
- height: nodeHeight,
- column: 'income',
- columnFraction: 0,
- category: item.category,
- hasChildren: item.has_children,
- expandable: !!item.has_children,
- };
- incomeY += nodeHeight + NODE_PADDING;
- return node;
- });
-
- if (groupedIncome.other) {
- const nodeHeight = Math.max(
- MIN_NODE_HEIGHT,
- (groupedIncome.other.total / maxTotal) *
- availableHeight *
- 0.5,
- );
- incomeNodes.push({
- id: 'income-other',
- label: __('Other'),
- value: groupedIncome.other.total,
- color: 'var(--color-muted)',
- y: incomeY,
- height: nodeHeight,
- column: 'income',
- columnFraction: 0,
- });
- otherGroupsMap['income-other'] = groupedIncome.other;
- incomeY += nodeHeight + NODE_PADDING;
- }
-
- // Center node (total cashflow)
- const centerHeight = Math.max(
- MIN_NODE_HEIGHT * 1.5,
- (Math.max(total_income, total_expense) / maxTotal) *
- availableHeight *
- 0.6,
- );
- const centerY = (height - centerHeight) / 2;
- const centerNode: NodeData = {
- id: 'center',
- label: __('Cashflow'),
- value: total_income - total_expense,
- color: 'var(--color-chart-1)',
- y: centerY,
- height: centerHeight,
- column: 'center',
- columnFraction: 0,
- };
-
- // Expense parent nodes (right column)
- const groupedExpense = groupSmallCategories(
- expense_categories,
- total_expense,
- groupingThreshold,
- );
-
- let expenseY = 20;
- const expenseNodes: NodeData[] = groupedExpense.main.map((item) => {
- const nodeHeight = Math.max(
- MIN_NODE_HEIGHT,
- (item.amount / maxTotal) * availableHeight * 0.5,
- );
- const node: NodeData = {
- id: `expense-${item.category_id}`,
- label: item.category.name,
- value: item.amount,
- color: item.category.color || 'var(--color-chart-3)',
- y: expenseY,
- height: nodeHeight,
- column: 'expense',
- columnFraction: 0,
- category: item.category,
- hasChildren: item.has_children,
- expandable: !!item.has_children,
- };
- expenseY += nodeHeight + NODE_PADDING;
- return node;
- });
-
- if (groupedExpense.other) {
- const nodeHeight = Math.max(
- MIN_NODE_HEIGHT,
- (groupedExpense.other.total / maxTotal) *
- availableHeight *
- 0.5,
- );
- expenseNodes.push({
- id: 'expense-other',
- label: __('Other'),
- value: groupedExpense.other.total,
- color: 'var(--color-muted)',
- y: expenseY,
- height: nodeHeight,
- column: 'expense',
- columnFraction: 0,
- });
- otherGroupsMap['expense-other'] = groupedExpense.other;
- expenseY += nodeHeight + NODE_PADDING;
- }
-
- // Resolve which expanded parents actually have loaded children.
- const sortByAmount = (
- categories: SankeyCategory[],
- ): SankeyCategory[] =>
- [...categories].sort((a, b) => b.amount - a.amount);
-
- const incomeChildren: Record = {};
- incomeNodes.forEach((node) => {
- if (node.category && expandedIds.has(node.category.id)) {
- const kids =
- childrenById[node.category.id]?.income_categories;
-
- if (kids && kids.length > 0) {
- incomeChildren[node.id] = sortByAmount(kids);
- }
- }
- });
-
- const expenseChildren: Record = {};
- expenseNodes.forEach((node) => {
- if (node.category && expandedIds.has(node.category.id)) {
- const kids =
- childrenById[node.category.id]?.expense_categories;
-
- if (kids && kids.length > 0) {
- expenseChildren[node.id] = sortByAmount(kids);
- }
- }
- });
-
- const hasIncomeChildColumn = Object.keys(incomeChildren).length > 0;
- const hasExpenseChildColumn =
- Object.keys(expenseChildren).length > 0;
-
- // Lay out the active columns left-to-right.
- const columns: ColumnKey[] = [];
- if (hasIncomeChildColumn) {
- columns.push('incomeChild');
- }
- columns.push('income', 'center', 'expense');
- if (hasExpenseChildColumn) {
- columns.push('expenseChild');
- }
-
- const pad = columns.length <= 3 ? 0.25 : 0.12;
- const fractionFor = (index: number): number =>
- columns.length <= 1
- ? 0.5
- : pad + (index / (columns.length - 1)) * (1 - 2 * pad);
- const fractionByColumn = {} as Record;
- columns.forEach((column, index) => {
- fractionByColumn[column] = fractionFor(index);
- });
-
- incomeNodes.forEach((node) => {
- node.columnFraction = fractionByColumn.income;
- });
- expenseNodes.forEach((node) => {
- node.columnFraction = fractionByColumn.expense;
- });
- centerNode.columnFraction = fractionByColumn.center;
-
- const linkList: LinkData[] = [];
-
- // Income parents -> center
- let incomeLinkY = centerY;
- incomeNodes.forEach((incomeNode) => {
- const linkHeight =
- total_income > 0
- ? (incomeNode.value / total_income) * centerHeight
- : 0;
- linkList.push({
- source: incomeNode.id,
- target: 'center',
- value: incomeNode.value,
- sourceY: incomeNode.y + incomeNode.height / 2,
- targetY: incomeLinkY + linkHeight / 2,
- sourceHeight: incomeNode.height,
- targetHeight: linkHeight,
- kind: 'income',
- });
- incomeLinkY += linkHeight;
- });
-
- // Center -> expense parents
- let expenseLinkY = centerY;
- expenseNodes.forEach((expenseNode) => {
- const linkHeight =
- total_expense > 0
- ? (expenseNode.value / total_expense) * centerHeight
- : 0;
- linkList.push({
- source: 'center',
- target: expenseNode.id,
- value: expenseNode.value,
- sourceY: expenseLinkY + linkHeight / 2,
- targetY: expenseNode.y + expenseNode.height / 2,
- sourceHeight: linkHeight,
- targetHeight: expenseNode.height,
- kind: 'expense',
- });
- expenseLinkY += linkHeight;
- });
-
- // Child nodes stack within their parent's vertical band so the parent
- // visibly splits into its subcategories.
- const childNodes: NodeData[] = [];
- const buildChildren = (
- parents: NodeData[],
- childrenByParent: Record,
- childColumn: ColumnKey,
- kind: 'income' | 'expense',
- ) => {
- parents.forEach((parent) => {
- const kids = childrenByParent[parent.id];
-
- if (!kids) {
- return;
- }
-
- const childFraction = fractionByColumn[childColumn];
- const kidsSum = kids.reduce(
- (sum, kid) => sum + kid.amount,
- 0,
- );
-
- if (kidsSum <= 0) {
- return;
- }
-
- // Size each child like a top-level node (same minimum height
- // and gap), then center the stack on the parent so the links
- // fan out from the parent's proportional slices.
- const childHeights = kids.map((kid) =>
- Math.max(
- MIN_NODE_HEIGHT,
- (kid.amount / maxTotal) * availableHeight * 0.5,
- ),
- );
- const stackHeight =
- childHeights.reduce((sum, h) => sum + h, 0) +
- NODE_PADDING * (kids.length - 1);
- let childCursor =
- parent.y + parent.height / 2 - stackHeight / 2;
- let parentCursor = parent.y;
-
- kids.forEach((kid, index) => {
- const childHeight = childHeights[index];
- const parentSlice =
- (kid.amount / kidsSum) * parent.height;
- const node: NodeData = {
- id: `${childColumn}-${parent.id}-${index}`,
- label: kid.category.name,
- value: kid.amount,
- color:
- kid.category.color ||
- (kind === 'income'
- ? 'var(--color-chart-2)'
- : 'var(--color-chart-3)'),
- y: childCursor,
- height: childHeight,
- column: childColumn,
- columnFraction: childFraction,
- category: kid.category,
- hasChildren: kid.has_children,
- expandable: false,
- };
- childNodes.push(node);
-
- if (kind === 'income') {
- linkList.push({
- source: node.id,
- target: parent.id,
- value: kid.amount,
- sourceY: node.y + childHeight / 2,
- targetY: parentCursor + parentSlice / 2,
- sourceHeight: childHeight,
- targetHeight: parentSlice,
- kind: 'income',
- });
- } else {
- linkList.push({
- source: parent.id,
- target: node.id,
- value: kid.amount,
- sourceY: parentCursor + parentSlice / 2,
- targetY: node.y + childHeight / 2,
- sourceHeight: parentSlice,
- targetHeight: childHeight,
- kind: 'expense',
- });
- }
-
- childCursor += childHeight + NODE_PADDING;
- parentCursor += parentSlice;
- });
- });
- };
-
- buildChildren(incomeNodes, incomeChildren, 'incomeChild', 'income');
- buildChildren(
- expenseNodes,
- expenseChildren,
- 'expenseChild',
- 'expense',
- );
-
- // Expanded child stacks can be taller than their parent band and reach
- // above the top or below the bottom of the base canvas. Grow the
- // viewBox to fit so nothing gets clipped.
- const allNodes = [
- ...incomeNodes,
- centerNode,
- ...expenseNodes,
- ...childNodes,
- ];
- const contentTop = Math.min(...allNodes.map((node) => node.y));
- const contentBottom = Math.max(
- ...allNodes.map((node) => node.y + node.height),
- );
- const viewBoxTop = Math.min(0, contentTop - 20);
- const viewBoxBottom = Math.max(height, contentBottom + 20);
-
- return {
- nodes: allNodes,
- links: linkList,
- isEmpty: false,
- otherGroups: otherGroupsMap,
- viewBoxTop,
- viewBoxHeight: viewBoxBottom - viewBoxTop,
- };
- }, [data, height, groupingThreshold, expandedIds, childrenById]);
-
- if (isEmpty) {
- return (
-
- {__('No cashflow data for this period')}
-
- );
- }
-
- const width = renderedWidth;
-
- const isLeftAligned = (column: ColumnKey): boolean =>
- column === 'incomeChild' || column === 'income';
- const isRightAligned = (column: ColumnKey): boolean =>
- column === 'expense' || column === 'expenseChild';
-
- return (
-
-
-
- );
-}
diff --git a/resources/js/components/charts/treemap-chart.test.tsx b/resources/js/components/charts/treemap-chart.test.tsx
new file mode 100644
index 00000000..19ca44c6
--- /dev/null
+++ b/resources/js/components/charts/treemap-chart.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { TreemapChart, truncate } from './treemap-chart';
+
+vi.mock('@/contexts/privacy-mode-context', () => ({
+ usePrivacyMode: () => ({ isPrivacyModeEnabled: false }),
+}));
+
+vi.mock('@/hooks/use-locale', () => ({
+ useLocale: () => 'en',
+}));
+
+vi.mock('@/hooks/use-chart-color-scheme', () => ({
+ useChartColors: () => ({
+ categoryBarColor: () => '#123456',
+ }),
+}));
+
+vi.mock('@inertiajs/react', () => ({
+ router: { visit: vi.fn() },
+}));
+
+vi.mock('@/actions/App/Http/Controllers/TransactionController', () => ({
+ index: () => ({ url: '/transactions' }),
+}));
+
+describe('truncate', () => {
+ it('leaves short names untouched', () => {
+ expect(truncate('Rent', 200)).toBe('Rent');
+ });
+
+ it('shortens long names to fit the box and appends an ellipsis', () => {
+ const result = truncate('A very long category name', 60);
+
+ expect(result.endsWith('…')).toBe(true);
+ expect(result.length).toBeLessThan('A very long category name'.length);
+ });
+});
+
+describe('TreemapChart', () => {
+ it('shows an empty state when there is nothing to plot', () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText('No cashflow data for this period'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/resources/js/components/charts/treemap-chart.tsx b/resources/js/components/charts/treemap-chart.tsx
new file mode 100644
index 00000000..011bad8b
--- /dev/null
+++ b/resources/js/components/charts/treemap-chart.tsx
@@ -0,0 +1,207 @@
+import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
+import { usePrivacyMode } from '@/contexts/privacy-mode-context';
+import { SankeyCategory } from '@/hooks/use-cashflow-data';
+import { useChartColors } from '@/hooks/use-chart-color-scheme';
+import { useLocale } from '@/hooks/use-locale';
+import { cn } from '@/lib/utils';
+import { formatCurrency } from '@/utils/currency';
+import { __ } from '@/utils/i18n';
+import { router } from '@inertiajs/react';
+import { format } from 'date-fns';
+import { useMemo } from 'react';
+import { ResponsiveContainer, Treemap } from 'recharts';
+
+interface TreemapChartProps {
+ categories: SankeyCategory[];
+ total: number;
+ mode: 'income' | 'expense';
+ height?: number;
+ className?: string;
+ currency?: string;
+ period?: { from: Date; to: Date };
+}
+
+interface TreemapDatum {
+ name: string;
+ size: number;
+ color: string;
+ categoryId: string;
+ // Satisfies recharts' TreemapDataType index signature.
+ [key: string]: unknown;
+}
+
+// Recharts merges each datum's fields into the node passed to `content`/`onClick`.
+interface TreemapNodeProps extends Partial {
+ x?: number;
+ y?: number;
+ width?: number;
+ height?: number;
+ depth?: number;
+ currency: string;
+ locale: string;
+ isPrivacyModeEnabled: boolean;
+}
+
+function maskIfPrivate(
+ value: number,
+ currency: string,
+ locale: string,
+ isPrivacyModeEnabled: boolean,
+): string {
+ const formatted = formatCurrency(value, currency, locale, 0, 0);
+ return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted;
+}
+
+export function truncate(name: string, width: number): string {
+ const maxChars = Math.floor((width - 12) / 7);
+
+ if (name.length <= maxChars) {
+ return name;
+ }
+
+ return `${name.slice(0, Math.max(0, maxChars - 1))}…`;
+}
+
+function CategoryNode({
+ x = 0,
+ y = 0,
+ width = 0,
+ height = 0,
+ depth = 0,
+ name = '',
+ size = 0,
+ color = 'var(--color-chart-4)',
+ categoryId,
+ currency,
+ locale,
+ isPrivacyModeEnabled,
+}: TreemapNodeProps) {
+ // depth 0 is the invisible root; only leaves carry a category.
+ if (depth < 1 || !categoryId) {
+ return null;
+ }
+
+ const showName = width >= 40 && height >= 22;
+ const showAmount = width >= 55 && height >= 40;
+
+ return (
+
+
+ {name}:{' '}
+ {maskIfPrivate(size, currency, locale, isPrivacyModeEnabled)}
+
+
+ {showName && (
+
+ {truncate(name, width)}
+
+ )}
+ {showAmount && (
+
+ {maskIfPrivate(
+ size,
+ currency,
+ locale,
+ isPrivacyModeEnabled,
+ )}
+
+ )}
+
+ );
+}
+
+export function TreemapChart({
+ categories,
+ total,
+ mode,
+ height = 400,
+ className,
+ currency = 'USD',
+ period,
+}: TreemapChartProps) {
+ const locale = useLocale();
+ const { isPrivacyModeEnabled } = usePrivacyMode();
+ const { categoryBarColor } = useChartColors();
+
+ const data = useMemo(() => {
+ return [...categories]
+ .filter((item) => item.amount > 0)
+ .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,
+ }));
+ }, [categories, categoryBarColor]);
+
+ if (total === 0 || data.length === 0) {
+ return (
+
+ {__('No cashflow data for this period')}
+
+ );
+ }
+
+ const navigate = (categoryId: unknown) => {
+ if (typeof categoryId !== 'string' || !period) {
+ return;
+ }
+
+ router.visit(
+ transactionsIndex({
+ query: {
+ category_ids: categoryId,
+ date_from: format(period.from, 'yyyy-MM-dd'),
+ date_to: format(period.to, 'yyyy-MM-dd'),
+ },
+ }).url,
+ );
+ };
+
+ return (
+
+
+ navigate(node.categoryId)}
+ content={
+
+ }
+ />
+
+
+ );
+}
diff --git a/resources/js/lib/sankey-utils.ts b/resources/js/lib/sankey-utils.ts
deleted file mode 100644
index 4ea2bc6c..00000000
--- a/resources/js/lib/sankey-utils.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { SankeyCategory } from '@/hooks/use-cashflow-data';
-
-export interface GroupedCategory {
- categories: SankeyCategory[];
- total: number;
-}
-
-export interface GroupedCategoryResult {
- main: SankeyCategory[];
- other: GroupedCategory | null;
-}
-
-/**
- * Groups small categories (below threshold) into an "Other" category
- *
- * @param categories - Array of categories to potentially group
- * @param total - Total amount for calculating percentages
- * @param threshold - Percentage threshold (e.g., 0.05 for 5%)
- * @returns Object with main categories to display and grouped "other" categories
- */
-export function groupSmallCategories(
- categories: SankeyCategory[],
- total: number,
- threshold: number = 0.03,
-): GroupedCategoryResult {
- // If no total or empty categories, return as-is
- if (total === 0 || categories.length === 0) {
- return { main: categories, other: null };
- }
-
- // Sort by amount descending
- const sortedCategories = [...categories].sort(
- (a, b) => b.amount - a.amount,
- );
-
- // If we have 5 or fewer categories total, don't group
- if (sortedCategories.length <= 5) {
- return { main: sortedCategories, other: null };
- }
-
- const thresholdAmount = total * threshold;
- const mainCategories: SankeyCategory[] = [];
- const otherCategories: SankeyCategory[] = [];
-
- for (const category of sortedCategories) {
- // Keep categories that are:
- // 1. Above threshold amount, OR
- // 2. In the top 3 (ensure minimum visibility)
- if (category.amount >= thresholdAmount || mainCategories.length < 3) {
- mainCategories.push(category);
- } else {
- otherCategories.push(category);
- }
- }
-
- // Only create "Other" group if:
- // 1. We have at least 2 categories to group
- // 2. We're showing at least 3 main categories
- if (otherCategories.length >= 2 && mainCategories.length >= 3) {
- const otherTotal = otherCategories.reduce(
- (sum, cat) => sum + cat.amount,
- 0,
- );
-
- return {
- main: mainCategories,
- other: {
- categories: otherCategories,
- total: otherTotal,
- },
- };
- }
-
- // Don't group - return all categories as main
- return { main: sortedCategories, other: null };
-}
-
-/**
- * Calculates the percentage of a value relative to a total
- */
-export function calculatePercentage(value: number, total: number): number {
- if (total === 0) return 0;
- return (value / total) * 100;
-}
diff --git a/resources/js/pages/cashflow/index.tsx b/resources/js/pages/cashflow/index.tsx
index 49b0a9fe..f8af22a8 100644
--- a/resources/js/pages/cashflow/index.tsx
+++ b/resources/js/pages/cashflow/index.tsx
@@ -2,9 +2,10 @@ import { BreakdownCard } from '@/components/cashflow/breakdown-card';
import { NetCashflowCard } from '@/components/cashflow/net-cashflow-card';
import { PeriodNavigation } from '@/components/cashflow/period-navigation';
import { SavedInvestedCard } from '@/components/cashflow/saved-invested-card';
-import { CashflowTrendChart, SankeyChart } from '@/components/charts';
+import { CashflowTrendChart, TreemapChart } from '@/components/charts';
import HeadingSmall from '@/components/heading-small';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { CashflowPeriodType, useCashflowData } from '@/hooks/use-cashflow-data';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { cashflow } from '@/routes';
@@ -121,6 +122,8 @@ export default function CashflowPage() {
const [periodType, setPeriodType] =
useState(initialPeriodType);
+ const [flowMode, setFlowMode] = useState<'income' | 'expense'>('expense');
+
const [currentDate, setCurrentDate] = useState(() =>
parsePeriodParam(initialPeriod, initialPeriodType),
);
@@ -202,19 +205,54 @@ export default function CashflowPage() {
periodType={periodType}
/>
- {/* Sankey Diagram */}
+ {/* Treemap */}
-
+
{__('Money Flow')}
+
+ {
+ if (value) {
+ setFlowMode(value as 'income' | 'expense');
+ }
+ }}
+ variant="outline"
+ size="sm"
+ >
+
+ {__('Income')}
+
+
+ {__('Expenses')}
+
+
{isLoading ? (
) : (
-