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;
+}