diff --git a/resources/js/components/charts/sankey-chart.tsx b/resources/js/components/charts/sankey-chart.tsx index 92150f91..3dd71c6e 100644 --- a/resources/js/components/charts/sankey-chart.tsx +++ b/resources/js/components/charts/sankey-chart.tsx @@ -1,4 +1,15 @@ -import { SankeyData } from '@/hooks/use-cashflow-data'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Separator } from '@/components/ui/separator'; +import { SankeyCategory, SankeyData } from '@/hooks/use-cashflow-data'; +import { + calculatePercentage, + GroupedCategory, + groupSmallCategories, +} from '@/lib/sankey-utils'; import { cn } from '@/lib/utils'; import { Category } from '@/types/category'; import { useMemo, useState } from 'react'; @@ -8,6 +19,7 @@ interface SankeyChartProps { height?: number; className?: string; currency?: string; + groupingThreshold?: number; } interface NodeData { @@ -45,16 +57,90 @@ function formatAmount(amountInCents: number, currency: string): string { }).format(amountInCents / 100); } +interface OtherCategoriesBreakdownProps { + categories: SankeyCategory[]; + total: number; + currency: string; + grandTotal: number; +} + +function OtherCategoriesBreakdown({ + categories, + total, + currency, + grandTotal, +}: OtherCategoriesBreakdownProps) { + return ( +
+
+
+

+ Other Categories ({categories.length}) +

+

+ Categories below 5% of total +

+
+ +
+ {categories.map((item) => { + const percentage = calculatePercentage( + item.amount, + grandTotal, + ); + return ( +
+
+
+ + {item.category.name} + +
+
+ + {formatAmount(item.amount, currency)} + + + {percentage.toFixed(1)}% + +
+
+ ); + })} +
+ + + +
+ Total + {formatAmount(total, currency)} +
+
+
+ ); +} + export function SankeyChart({ data, height = 400, className, currency = 'USD', + groupingThreshold = 0.03, }: SankeyChartProps) { const [hoveredNode, setHoveredNode] = useState(null); const [hoveredLink, setHoveredLink] = useState(null); - const { nodes, links, isEmpty } = useMemo(() => { + const { nodes, links, isEmpty, otherGroups } = useMemo(() => { const { income_categories, expense_categories, @@ -63,38 +149,69 @@ export function SankeyChart({ } = data; if (total_income === 0 && total_expense === 0) { - return { nodes: [], links: [], isEmpty: true }; + return { + nodes: [], + links: [], + isEmpty: true, + otherGroups: {}, + }; } const nodeMap: Record = {}; const linkList: LinkData[] = []; + const otherGroupsMap: Record = {}; // Calculate available height for nodes const availableHeight = height - 40; // padding const maxTotal = Math.max(total_income, total_expense); + // Group income categories + const groupedIncome = groupSmallCategories( + income_categories, + total_income, + groupingThreshold, + ); + // Create income nodes (left column) let incomeY = 20; - const incomeNodes = income_categories - .sort((a, b) => b.amount - a.amount) - .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: 0, - category: item.category, - }; - incomeY += nodeHeight + NODE_PADDING; - return node; - }); + const incomeNodes = 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: 0, + category: item.category, + }; + incomeY += nodeHeight + NODE_PADDING; + return node; + }); + + // Add "Other" income node if needed + if (groupedIncome.other) { + const nodeHeight = Math.max( + MIN_NODE_HEIGHT, + (groupedIncome.other.total / maxTotal) * availableHeight * 0.5, + ); + const otherNode: NodeData = { + id: 'income-other', + label: 'Other', + value: groupedIncome.other.total, + color: 'var(--color-muted)', + y: incomeY, + height: nodeHeight, + column: 0, + }; + incomeNodes.push(otherNode); + otherGroupsMap['income-other'] = groupedIncome.other; + incomeY += nodeHeight + NODE_PADDING; + } // Create center node (total cashflow) const centerHeight = Math.max( @@ -114,28 +231,53 @@ export function SankeyChart({ column: 1, }; + // Group expense categories + const groupedExpense = groupSmallCategories( + expense_categories, + total_expense, + groupingThreshold, + ); + // Create expense nodes (right column) let expenseY = 20; - const expenseNodes = expense_categories - .sort((a, b) => b.amount - a.amount) - .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: 2, - category: item.category, - }; - expenseY += nodeHeight + NODE_PADDING; - return node; - }); + const expenseNodes = 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: 2, + category: item.category, + }; + expenseY += nodeHeight + NODE_PADDING; + return node; + }); + + // Add "Other" expense node if needed + if (groupedExpense.other) { + const nodeHeight = Math.max( + MIN_NODE_HEIGHT, + (groupedExpense.other.total / maxTotal) * availableHeight * 0.5, + ); + const otherNode: NodeData = { + id: 'expense-other', + label: 'Other', + value: groupedExpense.other.total, + color: 'var(--color-muted)', + y: expenseY, + height: nodeHeight, + column: 2, + }; + expenseNodes.push(otherNode); + otherGroupsMap['expense-other'] = groupedExpense.other; + expenseY += nodeHeight + NODE_PADDING; + } // Add all nodes to map incomeNodes.forEach((n) => (nodeMap[n.id] = n)); @@ -179,8 +321,9 @@ export function SankeyChart({ nodes: Object.values(nodeMap), links: linkList, isEmpty: false, + otherGroups: otherGroupsMap, }; - }, [data, height]); + }, [data, height, groupingThreshold]); if (isEmpty) { return ( @@ -268,13 +411,21 @@ export function SankeyChart({ COLUMN_POSITIONS[node.column] * width - NODE_WIDTH / 2; const isHovered = hoveredNode === node.id; + const isOtherNode = node.id.endsWith('-other'); + const otherGroup = isOtherNode + ? otherGroups[node.id] + : null; - return ( + const nodeContent = ( setHoveredNode(node.id)} onMouseLeave={() => setHoveredNode(null)} - className="cursor-pointer" + className={cn( + 'transition-all duration-200', + isOtherNode && 'cursor-pointer', + !isOtherNode && 'cursor-default', + )} > {/* Label */} @@ -311,6 +474,12 @@ export function SankeyChart({ className="fill-foreground text-[9px] font-medium" > {node.label} + {isOtherNode && ( + + {' '} + ⋯ + + )} {/* Amount */} ); + + // Wrap "Other" nodes in Popover + if (isOtherNode && otherGroup) { + const grandTotal = node.id.startsWith('income-') + ? data.total_income + : data.total_expense; + + return ( + + + {nodeContent} + + + + + + ); + } + + return nodeContent; })} diff --git a/resources/js/lib/sankey-utils.ts b/resources/js/lib/sankey-utils.ts new file mode 100644 index 00000000..4ea2bc6c --- /dev/null +++ b/resources/js/lib/sankey-utils.ts @@ -0,0 +1,84 @@ +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; +}