diff --git a/resources/js/components/charts/sankey-chart.test.tsx b/resources/js/components/charts/sankey-chart.test.tsx index 0f744ab3..ab8807e8 100644 --- a/resources/js/components/charts/sankey-chart.test.tsx +++ b/resources/js/components/charts/sankey-chart.test.tsx @@ -1,17 +1,47 @@ import { SankeyData } from '@/hooks/use-cashflow-data'; import { Category } from '@/types/category'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import * as React from 'react'; 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 +// chart never lays out. Clone the child chart with a fixed size instead — +// Sankey reports whatever width/height it receives as props. +vi.mock('recharts', async (importOriginal) => { + const actual = (await importOriginal()) as typeof import('recharts'); + return { + ...actual, + ResponsiveContainer: ({ + children, + }: { + children: React.ReactElement<{ width?: number; height?: number }>; + }) => React.cloneElement(children, { width: 800, height: 400 }), + }; +}); + +const privacyMode = { + isPrivacyModeEnabled: false, + togglePrivacyMode: vi.fn(), + setPrivacyMode: vi.fn(), +}; + vi.mock('@/contexts/privacy-mode-context', () => ({ - usePrivacyMode: () => ({ isPrivacyModeEnabled: false }), + usePrivacyMode: vi.fn(() => privacyMode), })); vi.mock('@/hooks/use-locale', () => ({ useLocale: () => 'en', })); +vi.mock('@/hooks/use-chart-color-scheme', () => ({ + useChartColors: () => ({ + cashflowIncomeColor: '#22c55e', + cashflowExpenseColor: '#ef4444', + categoryBarColor: (color: string) => color || '#999', + }), +})); + vi.mock('@inertiajs/react', () => ({ router: { visit: vi.fn() }, })); @@ -20,6 +50,8 @@ vi.mock('@/actions/App/Http/Controllers/TransactionController', () => ({ index: () => ({ url: '/transactions' }), })); +import { usePrivacyMode } from '@/contexts/privacy-mode-context'; + function category(id: string, name: string, color = '#ccc'): Category { return { id, name, color } as Category; } @@ -58,8 +90,8 @@ const foodChildren: SankeyData = { amount: 200, }, { - category: category('other-groceries', 'Other groceries'), - category_id: 'other-groceries', + category: category('eating-out', 'Eating out'), + category_id: 'eating-out', amount: 110, }, ], @@ -83,119 +115,77 @@ describe('SankeyChart', () => { vi.clearAllMocks(); }); - it('renders an expand affordance on parent categories with children', () => { + it('renders income, center and expense nodes', () => { 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('Salary')).toBeInTheDocument(); + expect(screen.getByText('Net')).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'; + it('shows an empty state when there is no cashflow', () => { 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'); + expect( + screen.getByText('No cashflow data for this period'), + ).toBeInTheDocument(); }); - 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, - }, - ], - }; + it('groups small categories into an "Other" node', () => { + const many = Array.from({ length: 8 }, (_, i) => ({ + category: category(`c${i}`, `Category ${i}`), + category_id: `c${i}`, + // First three are large, the rest are tiny (<3%). + amount: i < 3 ? 300 : 5, + })); - const rentChildren: SankeyData = { - income_categories: [], - expense_categories: [ - { - category: category('mortgage', 'Mortgage'), - category_id: 'mortgage', - amount: 500, - }, - ], - total_income: 0, - total_expense: 500, - }; + render( + , + ); - 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(); + expect(screen.getByText('Other')).toBeInTheDocument(); + // The tiny categories are folded away, not rendered individually. + expect(screen.queryByText('Category 7')).not.toBeInTheDocument(); }); - it('collapses an expanded category when toggled again', async () => { + 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' })); @@ -204,10 +194,25 @@ describe('SankeyChart', () => { expect(screen.getByText('Groceries')).toBeInTheDocument(); }); - fireEvent.click(screen.getByRole('button', { name: 'Collapse Food' })); + 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(); + }); - await waitFor(() => { - expect(screen.queryByText('Groceries')).not.toBeInTheDocument(); + it('masks amounts in privacy mode', () => { + vi.mocked(usePrivacyMode).mockReturnValue({ + ...privacyMode, + isPrivacyModeEnabled: true, }); + + render(); + + // No raw digits leak into the labels when privacy mode is on. + expect(screen.getByText('Salary')).toBeInTheDocument(); + expect(document.body.textContent).not.toMatch(/1,?000/); }); }); diff --git a/resources/js/components/charts/sankey-chart.tsx b/resources/js/components/charts/sankey-chart.tsx index b1f11c1c..d1ba08b5 100644 --- a/resources/js/components/charts/sankey-chart.tsx +++ b/resources/js/components/charts/sankey-chart.tsx @@ -1,26 +1,24 @@ 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 { SankeyData } from '@/hooks/use-cashflow-data'; +import { useChartColors } from '@/hooks/use-chart-color-scheme'; import { useLocale } from '@/hooks/use-locale'; -import { - calculatePercentage, - GroupedCategory, - groupSmallCategories, -} from '@/lib/sankey-utils'; +import { 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'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { + type ComponentProps, + type KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Layer, ResponsiveContainer, Sankey } from 'recharts'; interface SankeyChartProps { data: SankeyData; @@ -31,131 +29,45 @@ interface SankeyChartProps { period?: { from: Date; to: Date }; } -type ColumnKey = - | 'incomeChild' - | 'income' - | 'center' - | 'expense' - | 'expenseChild'; +type FlowKind = 'income' | 'center' | 'expense'; +type LabelSide = 'left' | 'right' | 'onbar'; -interface NodeData { - id: string; - label: string; - value: number; +// 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. +interface FlowNode { + name: string; + amount: number; color: string; - y: number; - height: number; - column: ColumnKey; - columnFraction: number; - category?: Category; - hasChildren?: boolean; + kind: FlowKind; + labelSide: LabelSide; + categoryId?: string; expandable?: boolean; + expanded?: boolean; } -interface LinkData { - source: string; - target: string; +interface FlowLink { + source: number; + target: number; 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 NODE_WIDTH = 12; +const NODE_PADDING = 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)} -
-
-
- ); -} +const LABEL_HEIGHT = 30; +// 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; +const MUTED_COLOR = 'var(--color-muted)'; +const CENTER_COLOR = 'var(--color-chart-1)'; export function SankeyChart({ data, @@ -165,34 +77,30 @@ export function SankeyChart({ 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 [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, categoryBarColor } = + useChartColors(); const periodKey = period ? `${period.from.getTime()}-${period.to.getTime()}` : ''; - const maskIfPrivate = (value: number) => { + const maskIfPrivate = (value: number): string => { 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]); - }); + setExpandedId((previous) => + previous === categoryId ? null : categoryId, + ); }; useEffect(() => { @@ -202,17 +110,7 @@ export function SankeyChart({ return; } - const updateWidth = () => { - setRenderedWidth( - Math.round( - Math.min( - MAX_RENDERED_WIDTH, - Math.max(MIN_RENDERED_WIDTH, container.clientWidth), - ), - ), - ); - }; - + const updateWidth = () => setContainerWidth(container.clientWidth); updateWidth(); if (typeof ResizeObserver === 'undefined') { @@ -227,21 +125,15 @@ export function SankeyChart({ return () => observer.disconnect(); }, []); - // Changing the period invalidates any expanded subcategories. + // A new period (or refreshed data) invalidates any open drill-down. useEffect(() => { - setExpandedIds(new Set()); + setExpandedId(null); setChildrenById({}); }, [periodKey]); - // Lazily fetch the children of each newly expanded category. + // Lazily fetch the subcategories of the expanded parent. useEffect(() => { - if (!period) { - return; - } - - const missing = [...expandedIds].filter((id) => !(id in childrenById)); - - if (missing.length === 0) { + if (!period || !expandedId || childrenById[expandedId]) { return; } @@ -249,406 +141,192 @@ export function SankeyChart({ 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(); - + fetch(`/api/cashflow/sankey?from=${from}&to=${to}&parent=${expandedId}`) + .then((response) => response.json()) + .then((json: SankeyData) => { if (!cancelled) { setChildrenById((previous) => ({ ...previous, - [id]: json, + [expandedId]: json, })); } - } catch (error) { + }) + .catch((error) => { + // Collapse back so the node doesn't stay stuck "expanded" with + // no subcategories and no way forward. + if (!cancelled) { + setExpandedId((current) => + current === expandedId ? null : current, + ); + } console.error('Failed to fetch subcategories:', error); - } - }); + }); return () => { cancelled = true; }; - }, [expandedIds, childrenById, period, periodKey]); + }, [expandedId, childrenById, period, periodKey]); - const { nodes, links, isEmpty, otherGroups, viewBoxTop, viewBoxHeight } = - useMemo(() => { - const { - income_categories, - expense_categories, - total_income, - total_expense, - } = data; + const { chartData, isEmpty, nodeRows, subcatRows } = 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 nodes: FlowNode[] = []; + const links: FlowLink[] = []; + + const groupedIncome = groupSmallCategories( + income_categories, + total_income, + groupingThreshold, + ); + groupedIncome.main.forEach((item, index) => { + if (item.amount <= 0) { + return; } - const otherGroupsMap: Record = {}; - const availableHeight = height - 40; // padding - const maxTotal = Math.max(total_income, total_expense); + nodes.push({ + name: item.category.name, + amount: item.amount, + color: categoryBarColor(item.category.color, index), + kind: 'income', + labelSide: 'left', + categoryId: item.category.id, + }); + }); + if (groupedIncome.other) { + nodes.push({ + name: __('Other'), + amount: groupedIncome.other.total, + color: MUTED_COLOR, + kind: 'income', + labelSide: 'left', + }); + } - // Income parent nodes (left column) - const groupedIncome = groupSmallCategories( - income_categories, - total_income, - groupingThreshold, + const centerIndex = nodes.length; + nodes.push({ + // "Net" rather than "Cashflow": the card title already reads + // "Cashflow", so the hub only needs to carry the net amount. + name: __('Net'), + amount: total_income - total_expense, + color: CENTER_COLOR, + kind: 'center', + labelSide: 'onbar', + }); + + const groupedExpense = groupSmallCategories( + expense_categories, + total_expense, + groupingThreshold, + ); + groupedExpense.main.forEach((item, index) => { + if (item.amount <= 0) { + return; + } + + const isExpanded = item.category.id === expandedId; + // Keep the label beside the bar until the subcategories actually + // load, so it doesn't jump onto the bar and back during the fetch. + const childrenLoaded = + isExpanded && + (childrenById[item.category.id]?.expense_categories?.length ?? + 0) > 0; + + nodes.push({ + name: item.category.name, + amount: item.amount, + color: categoryBarColor(item.category.color, index), + kind: 'expense', + // An expanded parent sits between the hub and its subcategory + // column, so its label moves onto the bar to clear the way. + labelSide: childrenLoaded ? 'onbar' : 'right', + categoryId: item.category.id, + expandable: !!item.has_children, + expanded: isExpanded, + }); + }); + if (groupedExpense.other) { + nodes.push({ + name: __('Other'), + amount: groupedExpense.other.total, + color: MUTED_COLOR, + kind: 'expense', + labelSide: 'right', + }); + } + + nodes.forEach((node, index) => { + if (node.amount <= 0) { + return; + } + + if (node.kind === 'income') { + links.push({ + source: index, + target: centerIndex, + value: node.amount, + }); + } else if (node.kind === 'expense') { + links.push({ + source: centerIndex, + target: index, + value: node.amount, + }); + } + }); + + // 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); - 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) { + if (parentIndex >= 0) { + kids.forEach((kid, index) => { + if (kid.amount <= 0) { 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; + const childIndex = nodes.length; + nodes.push({ + name: kid.category.name, + amount: kid.amount, + color: categoryBarColor(kid.category.color, index), + kind: 'expense', + labelSide: 'right', + categoryId: kid.category.id, }); + links.push({ + source: parentIndex, + target: childIndex, + value: kid.amount, + }); + subcatRows += 1; }); - }; + } + } - buildChildren(incomeNodes, incomeChildren, 'incomeChild', 'income'); - buildChildren( - expenseNodes, - expenseChildren, - 'expenseChild', - 'expense', - ); + const incomeRows = + groupedIncome.main.length + (groupedIncome.other ? 1 : 0); + const expenseRows = + groupedExpense.main.length + (groupedExpense.other ? 1 : 0); - // 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]); + return { + chartData: { nodes, links }, + isEmpty: links.length === 0, + nodeRows: Math.max(incomeRows, expenseRows, subcatRows), + subcatRows, + }; + }, [data, groupingThreshold, categoryBarColor, expandedId, childrenById]); if (isEmpty) { return ( @@ -664,325 +342,238 @@ export function SankeyChart({ ); } - const width = renderedWidth; + const labelWidth = Math.max( + 64, + Math.min(140, Math.round(containerWidth * 0.26)), + ); + const sideMargin = labelWidth + LABEL_GAP; + // 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 isLeftAligned = (column: ColumnKey): boolean => - column === 'incomeChild' || column === 'income'; - const isRightAligned = (column: ColumnKey): boolean => - column === 'expense' || column === 'expenseChild'; + const goToCategory = (categoryId: string) => { + if (!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, + ); + }; + + const renderNode = ({ + x, + y, + width, + height: nodeHeight, + index, + payload, + }: { + x: number; + y: number; + width: number; + height: number; + index: number; + payload: FlowNode; + }) => { + const node = payload; + const isPill = node.labelSide === 'onbar'; + const expandable = !!node.expandable && !!node.categoryId && !!period; + const navigable = !expandable && !!node.categoryId && !!period; + const interactive = expandable || navigable; + + const activate = () => { + if (expandable) { + toggleExpand(node.categoryId!); + } else if (navigable) { + goToCategory(node.categoryId!); + } + }; + + const labelBoxHeight = isPill ? PILL_LABEL_HEIGHT : LABEL_HEIGHT; + const labelY = y + nodeHeight / 2 - labelBoxHeight / 2; + let labelX: number; + let labelBoxWidth: number; + let alignClass: string; + + if (node.labelSide === 'left') { + labelX = 2; + labelBoxWidth = Math.max(0, x - LABEL_GAP - 2); + alignClass = 'items-end text-right'; + } else if (node.labelSide === 'right') { + labelX = x + width + LABEL_GAP; + // Cap the width so a non-rightmost parent (one sitting to the left + // of an expanded subcategory column) can't stretch its label across + // that column. + labelBoxWidth = Math.max( + 0, + Math.min(labelWidth, containerWidth - labelX - 2), + ); + alignClass = 'items-start text-left'; + } else { + labelBoxWidth = labelWidth; + labelX = x + width / 2 - labelWidth / 2; + alignClass = 'items-center text-center'; + } + + const ChevronIcon = node.expanded ? ChevronDown : ChevronRight; + + return ( + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + activate(); + } + } + : undefined + } + > + + +
+
+ + {node.name} + + {expandable && ( +
+ + {maskIfPrivate(node.amount)} + +
+
+
+ ); + }; + + const renderLink = ({ + sourceX, + sourceY, + sourceControlX, + targetX, + targetY, + targetControlX, + linkWidth, + index, + payload, + }: { + sourceX: number; + sourceY: number; + sourceControlX: number; + targetX: number; + targetY: number; + targetControlX: number; + linkWidth: number; + index: number; + payload: { source: FlowNode; target: FlowNode }; + }) => { + const kind = + payload.source.kind === 'center' + ? payload.target.kind + : payload.source.kind; + const stroke = + kind === 'income' ? cashflowIncomeColor : cashflowExpenseColor; + + return ( + + ); + }; return ( -
- - {/* 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 ( - setHoveredLink(linkId)} - onMouseLeave={() => setHoveredLink(null)} - /> - ); - })} - - - {/* 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 = ( - 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', - )} - > - - - {/* Label + amount */} - -
-
- - {node.label} - {isOtherNode && ( - - {' '} - ⋯ - - )} - - {canExpand && ( -
- - {maskIfPrivate(node.value)} - -
-
-
- ); - - // Wrap "Other" nodes in Popover - if (isOtherNode && otherGroup) { - const grandTotal = node.id.startsWith('income-') - ? data.total_income - : data.total_expense; - - return ( - - - {nodeContent} - - - - - - ); +
+
+ + ['node'] } - - return nodeContent; - })} - - + link={ + renderLink as ComponentProps['link'] + } + nodeWidth={NODE_WIDTH} + nodePadding={NODE_PADDING} + sort={false} + // 'left' keeps sink nodes at their natural depth instead + // of shoving them all into the last column, so an + // expanded parent's subcategories get their own column + // and line up beside it rather than crossing every flow. + align="left" + margin={{ + top: 12, + right: sideMargin, + bottom: 12, + left: sideMargin, + }} + /> + +
); } diff --git a/resources/js/lib/sankey-utils.ts b/resources/js/lib/sankey-utils.ts index 4ea2bc6c..d34de838 100644 --- a/resources/js/lib/sankey-utils.ts +++ b/resources/js/lib/sankey-utils.ts @@ -74,11 +74,3 @@ export function groupSmallCategories( // 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..281b9141 100644 --- a/resources/js/pages/cashflow/index.tsx +++ b/resources/js/pages/cashflow/index.tsx @@ -22,7 +22,7 @@ import { startOfQuarter, startOfYear, } from 'date-fns'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; const breadcrumbs: BreadcrumbItem[] = [ { @@ -125,7 +125,12 @@ export default function CashflowPage() { parsePeriodParam(initialPeriod, initialPeriodType), ); - const period = getPeriodRange(currentDate, periodType); + // Memoized so children (e.g. the Sankey drill-down fetch) don't see a new + // object identity on every render. + const period = useMemo( + () => getPeriodRange(currentDate, periodType), + [currentDate, periodType], + ); const { summary,