feat(cashflow): replace sankey with treemap + income/expense toggle
This commit is contained in:
parent
dada23cd84
commit
a58a012c98
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(<SankeyChart data={data} period={period} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Expand Food' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands a category into its subcategories without replacing the chart', async () => {
|
||||
render(<SankeyChart data={data} period={period} />);
|
||||
|
||||
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(
|
||||
<SankeyChart
|
||||
data={{
|
||||
...data,
|
||||
expense_categories: [
|
||||
{
|
||||
category: category('long', longName),
|
||||
category_id: 'long',
|
||||
amount: 500,
|
||||
},
|
||||
],
|
||||
total_expense: 500,
|
||||
}}
|
||||
period={period}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(<SankeyChart data={multiParent} period={period} />);
|
||||
|
||||
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(<SankeyChart data={data} period={period} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<div className="w-64">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{__('Other Categories (')}
|
||||
{categories.length})
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Categories below 5% of total')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 space-y-1.5 overflow-y-auto">
|
||||
{categories.map((item) => {
|
||||
const percentage = calculatePercentage(
|
||||
item.amount,
|
||||
grandTotal,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={item.category_id}
|
||||
className="flex items-center justify-between gap-3 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<div
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
item.category.color ||
|
||||
'var(--color-chart-4)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="truncate">
|
||||
{item.category.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{maskIfPrivate(item.amount)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between text-sm font-medium">
|
||||
<span>{__('Total')}</span>
|
||||
<span>{maskIfPrivate(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SankeyChart({
|
||||
data,
|
||||
height = 400,
|
||||
className,
|
||||
currency = 'USD',
|
||||
groupingThreshold = 0.03,
|
||||
period,
|
||||
}: SankeyChartProps) {
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
|
||||
const [hoveredLink, setHoveredLink] = useState<string | null>(null);
|
||||
const [renderedWidth, setRenderedWidth] = useState(MAX_RENDERED_WIDTH);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [childrenById, setChildrenById] = useState<
|
||||
Record<string, SankeyData>
|
||||
>({});
|
||||
const containerRef = useRef<HTMLDivElement>(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<string>();
|
||||
}
|
||||
|
||||
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<string, GroupedCategory>,
|
||||
viewBoxTop: 0,
|
||||
viewBoxHeight: height,
|
||||
};
|
||||
}
|
||||
|
||||
const otherGroupsMap: Record<string, GroupedCategory> = {};
|
||||
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<string, SankeyCategory[]> = {};
|
||||
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<string, SankeyCategory[]> = {};
|
||||
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<ColumnKey, number>;
|
||||
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<string, SankeyCategory[]>,
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
{__('No cashflow data for this period')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const width = renderedWidth;
|
||||
|
||||
const isLeftAligned = (column: ColumnKey): boolean =>
|
||||
column === 'incomeChild' || column === 'income';
|
||||
const isRightAligned = (column: ColumnKey): boolean =>
|
||||
column === 'expense' || column === 'expenseChild';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('w-full overflow-x-auto', className)}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 ${viewBoxTop} ${width} ${viewBoxHeight}`}
|
||||
className="mx-auto block"
|
||||
style={{ width: renderedWidth, minWidth: MIN_RENDERED_WIDTH }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
{/* Links */}
|
||||
<g className="links">
|
||||
{links.map((link) => {
|
||||
const sourceNode = nodes.find(
|
||||
(n) => n.id === link.source,
|
||||
);
|
||||
const targetNode = nodes.find(
|
||||
(n) => n.id === link.target,
|
||||
);
|
||||
if (!sourceNode || !targetNode) return null;
|
||||
|
||||
const sourceX =
|
||||
sourceNode.columnFraction * width + NODE_WIDTH / 2;
|
||||
const targetX =
|
||||
targetNode.columnFraction * width - NODE_WIDTH / 2;
|
||||
|
||||
const linkId = `${link.source}-${link.target}`;
|
||||
const isHovered =
|
||||
hoveredLink === linkId ||
|
||||
hoveredNode === link.source ||
|
||||
hoveredNode === link.target;
|
||||
|
||||
// Create a curved path
|
||||
const path = `
|
||||
M ${sourceX} ${link.sourceY - link.sourceHeight / 2}
|
||||
C ${(sourceX + targetX) / 2} ${link.sourceY - link.sourceHeight / 2},
|
||||
${(sourceX + targetX) / 2} ${link.targetY - link.targetHeight / 2},
|
||||
${targetX} ${link.targetY - link.targetHeight / 2}
|
||||
L ${targetX} ${link.targetY + link.targetHeight / 2}
|
||||
C ${(sourceX + targetX) / 2} ${link.targetY + link.targetHeight / 2},
|
||||
${(sourceX + targetX) / 2} ${link.sourceY + link.sourceHeight / 2},
|
||||
${sourceX} ${link.sourceY + link.sourceHeight / 2}
|
||||
Z
|
||||
`;
|
||||
|
||||
return (
|
||||
<path
|
||||
key={linkId}
|
||||
d={path}
|
||||
fill={
|
||||
link.kind === 'income'
|
||||
? 'var(--color-chart-2)'
|
||||
: 'var(--color-chart-3)'
|
||||
}
|
||||
fillOpacity={isHovered ? 0.6 : 0.3}
|
||||
className="transition-all duration-200"
|
||||
onMouseEnter={() => setHoveredLink(linkId)}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Nodes */}
|
||||
<g className="nodes">
|
||||
{nodes.map((node) => {
|
||||
const x = node.columnFraction * width - NODE_WIDTH / 2;
|
||||
const isHovered = hoveredNode === node.id;
|
||||
const isOtherNode = node.id.endsWith('-other');
|
||||
const otherGroup = isOtherNode
|
||||
? otherGroups[node.id]
|
||||
: null;
|
||||
const categoryUrl =
|
||||
node.category && period
|
||||
? transactionsIndex({
|
||||
query: {
|
||||
category_ids: node.category.id,
|
||||
date_from: format(
|
||||
period.from,
|
||||
'yyyy-MM-dd',
|
||||
),
|
||||
date_to: format(
|
||||
period.to,
|
||||
'yyyy-MM-dd',
|
||||
),
|
||||
},
|
||||
}).url
|
||||
: null;
|
||||
const canExpand =
|
||||
!isOtherNode &&
|
||||
!!node.expandable &&
|
||||
!!node.category &&
|
||||
!!period;
|
||||
const isExpanded =
|
||||
!!node.category &&
|
||||
expandedIds.has(node.category.id);
|
||||
const isNavigable =
|
||||
!isOtherNode && !canExpand && categoryUrl !== null;
|
||||
const isInteractive = canExpand || isNavigable;
|
||||
|
||||
const activate = () => {
|
||||
if (canExpand && node.category) {
|
||||
toggleExpand(node.category.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (categoryUrl) {
|
||||
router.visit(categoryUrl);
|
||||
}
|
||||
};
|
||||
|
||||
// The label/amount block is rendered as real HTML in a
|
||||
// foreignObject so flexbox handles spacing, vertical
|
||||
// centering, and ellipsis truncation reliably.
|
||||
const rightAligned = isRightAligned(node.column);
|
||||
const leftAligned = isLeftAligned(node.column);
|
||||
const labelX = rightAligned
|
||||
? x + NODE_WIDTH + LABEL_GAP
|
||||
: leftAligned
|
||||
? LABEL_PAD
|
||||
: x + NODE_WIDTH / 2 - LABEL_CENTER_WIDTH / 2;
|
||||
const labelWidth = rightAligned
|
||||
? Math.max(0, width - labelX - LABEL_PAD)
|
||||
: leftAligned
|
||||
? Math.max(0, x - LABEL_GAP - LABEL_PAD)
|
||||
: LABEL_CENTER_WIDTH;
|
||||
const labelY =
|
||||
node.y + node.height / 2 - LABEL_BLOCK_HEIGHT / 2;
|
||||
const iconRotationClass = isExpanded
|
||||
? node.column === 'income'
|
||||
? ''
|
||||
: 'rotate-180'
|
||||
: node.column === 'income'
|
||||
? 'rotate-180'
|
||||
: '';
|
||||
|
||||
const nodeContent = (
|
||||
<g
|
||||
key={node.id}
|
||||
onMouseEnter={() => setHoveredNode(node.id)}
|
||||
onMouseLeave={() => setHoveredNode(null)}
|
||||
onClick={() => {
|
||||
if (isInteractive) {
|
||||
activate();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === 'Enter' ||
|
||||
event.key === ' '
|
||||
) {
|
||||
event.preventDefault();
|
||||
activate();
|
||||
}
|
||||
}}
|
||||
role={
|
||||
canExpand
|
||||
? 'button'
|
||||
: isNavigable
|
||||
? 'link'
|
||||
: undefined
|
||||
}
|
||||
tabIndex={isInteractive ? 0 : undefined}
|
||||
aria-label={
|
||||
canExpand
|
||||
? isExpanded
|
||||
? `Collapse ${node.label}`
|
||||
: `Expand ${node.label}`
|
||||
: isNavigable
|
||||
? `View ${node.label} transactions`
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'transition-all duration-200',
|
||||
isOtherNode && 'cursor-pointer',
|
||||
isInteractive && 'cursor-pointer',
|
||||
!isOtherNode &&
|
||||
!isInteractive &&
|
||||
'cursor-default',
|
||||
)}
|
||||
>
|
||||
<rect
|
||||
x={x}
|
||||
y={node.y}
|
||||
width={NODE_WIDTH}
|
||||
height={node.height}
|
||||
rx={2}
|
||||
fill={
|
||||
node.id === 'center'
|
||||
? 'var(--color-chart-1)'
|
||||
: node.color
|
||||
}
|
||||
fillOpacity={
|
||||
isOtherNode
|
||||
? isHovered
|
||||
? 1
|
||||
: 0.6
|
||||
: isHovered
|
||||
? 1
|
||||
: 0.8
|
||||
}
|
||||
stroke={
|
||||
isOtherNode ? 'var(--border)' : 'none'
|
||||
}
|
||||
strokeWidth={isOtherNode ? 1 : 0}
|
||||
className="transition-all duration-200"
|
||||
/>
|
||||
|
||||
{/* Label + amount */}
|
||||
<foreignObject
|
||||
x={labelX}
|
||||
y={labelY}
|
||||
width={labelWidth}
|
||||
height={LABEL_BLOCK_HEIGHT}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full flex-col justify-center gap-0.5 leading-tight',
|
||||
rightAligned &&
|
||||
'items-start text-left',
|
||||
leftAligned &&
|
||||
'items-end text-right',
|
||||
!rightAligned &&
|
||||
!leftAligned &&
|
||||
'items-center text-center',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-w-full items-center gap-2',
|
||||
node.column === 'income' &&
|
||||
'flex-row-reverse',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
title={node.label}
|
||||
className="min-w-0 truncate text-[11px] font-medium text-foreground"
|
||||
>
|
||||
{node.label}
|
||||
{isOtherNode && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
⋯
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{canExpand && (
|
||||
<ChevronsRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-3 shrink-0 text-muted-foreground',
|
||||
iconRotationClass,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{maskIfPrivate(node.value)}
|
||||
</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
|
||||
// Wrap "Other" nodes in Popover
|
||||
if (isOtherNode && otherGroup) {
|
||||
const grandTotal = node.id.startsWith('income-')
|
||||
? data.total_income
|
||||
: data.total_expense;
|
||||
|
||||
return (
|
||||
<Popover key={node.id}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
aria-label={`View ${otherGroup.categories.length} grouped categories totaling ${maskIfPrivate(otherGroup.total)}`}
|
||||
>
|
||||
{nodeContent}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={
|
||||
node.id.startsWith('income-')
|
||||
? 'start'
|
||||
: 'end'
|
||||
}
|
||||
side="top"
|
||||
className="p-4"
|
||||
>
|
||||
<OtherCategoriesBreakdown
|
||||
categories={otherGroup.categories}
|
||||
total={otherGroup.total}
|
||||
currency={currency}
|
||||
grandTotal={grandTotal}
|
||||
locale={locale}
|
||||
isPrivacyModeEnabled={
|
||||
isPrivacyModeEnabled
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return nodeContent;
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
<TreemapChart
|
||||
categories={[]}
|
||||
total={0}
|
||||
mode="expense"
|
||||
currency="EUR"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('No cashflow data for this period'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<TreemapDatum> {
|
||||
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 (
|
||||
<g style={{ cursor: 'pointer' }}>
|
||||
<title>
|
||||
{name}:{' '}
|
||||
{maskIfPrivate(size, currency, locale, isPrivacyModeEnabled)}
|
||||
</title>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
rx={4}
|
||||
fill={color}
|
||||
fillOpacity={0.85}
|
||||
stroke="var(--color-background)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{showName && (
|
||||
<text
|
||||
x={x + 8}
|
||||
y={y + 18}
|
||||
className="fill-white text-[12px] font-medium"
|
||||
>
|
||||
{truncate(name, width)}
|
||||
</text>
|
||||
)}
|
||||
{showAmount && (
|
||||
<text
|
||||
x={x + 8}
|
||||
y={y + 34}
|
||||
className="fill-white/80 text-[11px] tabular-nums"
|
||||
>
|
||||
{maskIfPrivate(
|
||||
size,
|
||||
currency,
|
||||
locale,
|
||||
isPrivacyModeEnabled,
|
||||
)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
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<TreemapDatum[]>(() => {
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
{__('No cashflow data for this period')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('w-full', className)}>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<Treemap
|
||||
data={data}
|
||||
dataKey="size"
|
||||
// ponytail: key by mode so a toggle switch re-lays out cleanly
|
||||
key={mode}
|
||||
isAnimationActive={false}
|
||||
onClick={(node) => navigate(node.categoryId)}
|
||||
content={
|
||||
<CategoryNode
|
||||
currency={currency}
|
||||
locale={locale}
|
||||
isPrivacyModeEnabled={isPrivacyModeEnabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<CashflowPeriodType>(initialPeriodType);
|
||||
|
||||
const [flowMode, setFlowMode] = useState<'income' | 'expense'>('expense');
|
||||
|
||||
const [currentDate, setCurrentDate] = useState<Date>(() =>
|
||||
parsePeriodParam(initialPeriod, initialPeriodType),
|
||||
);
|
||||
|
|
@ -202,19 +205,54 @@ export default function CashflowPage() {
|
|||
periodType={periodType}
|
||||
/>
|
||||
|
||||
{/* Sankey Diagram */}
|
||||
{/* Treemap */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4 pb-4">
|
||||
<CardTitle className="text-base">
|
||||
{__('Money Flow')}
|
||||
</CardTitle>
|
||||
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={flowMode}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setFlowMode(value as 'income' | 'expense');
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="income"
|
||||
className="cursor-pointer px-3 text-xs aria-checked:bg-primary/10"
|
||||
>
|
||||
{__('Income')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="expense"
|
||||
className="cursor-pointer px-3 text-xs aria-checked:bg-primary/10"
|
||||
>
|
||||
{__('Expenses')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="h-[400px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
) : (
|
||||
<SankeyChart
|
||||
data={sankey}
|
||||
<TreemapChart
|
||||
categories={
|
||||
flowMode === 'income'
|
||||
? sankey.income_categories
|
||||
: sankey.expense_categories
|
||||
}
|
||||
total={
|
||||
flowMode === 'income'
|
||||
? sankey.total_income
|
||||
: sankey.total_expense
|
||||
}
|
||||
mode={flowMode}
|
||||
height={400}
|
||||
currency={auth.user.currency_code}
|
||||
period={period}
|
||||
|
|
|
|||
Loading…
Reference in New Issue