From 832cd1245b9caa98d9a5dd5edd4e4fbd72648204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Sat, 11 Jul 2026 19:16:45 +0200 Subject: [PATCH] feat(cashflow): add multi-level donut money-flow chart (prototype) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled SVG Sankey on the Cashflow page with a concentric multi-level donut (recharts), aimed at working on both desktop and mobile where the Sankey was wide and hard to use. - Backend: `sankey?nested=1` returns the full nested Income/Expense tree (up to MAX_DEPTH) via a new `CategoryTree::nest()`, strictly by category type so its totals match the summary and breakdown cards (savings, investments and transfers excluded). A synthetic "Direct" child keeps each node's children summing to the node total. - Frontend: `MultiLevelDonut` renders concentric rings — income grows toward the centre, expense toward the rim — with a `showIncome` prop exposed as a Combined / Expenses-only toggle for comparison. Inside labels on the innermost ring; leader-line labels (name + share) on the outermost ring with per-side vertical collision avoidance on wide screens, suppressed below 480px where the outer ring is read on tap. - Pure ring-building logic lives in `lib/donut-utils` with unit tests; the nested endpoint has a feature test. Prototype for comparing the Combined vs Expenses-only layouts before picking a final direction; the old SankeyChart component is kept but no longer rendered on this page. --- .../Api/CashflowAnalyticsController.php | 81 +++- app/Services/CategoryTree.php | 110 +++++ lang/es.json | 1 + resources/js/components/charts/index.ts | 1 + .../components/charts/multi-level-donut.tsx | 444 ++++++++++++++++++ resources/js/hooks/use-cashflow-data.ts | 10 +- resources/js/lib/donut-utils.test.ts | 119 +++++ resources/js/lib/donut-utils.ts | 208 ++++++++ resources/js/pages/cashflow/index.tsx | 41 +- tests/Feature/CashflowAnalyticsTest.php | 59 +++ 10 files changed, 1063 insertions(+), 11 deletions(-) create mode 100644 resources/js/components/charts/multi-level-donut.tsx create mode 100644 resources/js/lib/donut-utils.test.ts create mode 100644 resources/js/lib/donut-utils.ts diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index d6543ab0..c5f56dea 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -51,6 +51,7 @@ class CashflowAnalyticsController extends Controller 'from' => 'required|date', 'to' => 'required|date', 'parent' => 'nullable|uuid', + 'nested' => 'nullable|boolean', ]); $from = Carbon::parse($validated['from']); @@ -58,10 +59,18 @@ class CashflowAnalyticsController extends Controller $user = $request->user(); $drillParentId = $validated['parent'] ?? null; - // Split by sign, not by category type: a single category can appear on - // both sides when it has both incoming and outgoing transactions. - $incomeCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '>', $drillParentId); - $expenseCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '<', $drillParentId); + if ($request->boolean('nested')) { + // The nested donut mirrors the income/expense breakdown cards: + // strictly typed Income/Expense, so savings, investments and + // transfers are left out and its totals match the rest of the page. + $incomeCategories = $this->getNestedCategoryTree($user->id, $user->currency_code, $from, $to, CategoryType::Income); + $expenseCategories = $this->getNestedCategoryTree($user->id, $user->currency_code, $from, $to, CategoryType::Expense); + } else { + // Split by sign, not by category type: a single category can appear + // on both sides when it has both incoming and outgoing transactions. + $incomeCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '>', $drillParentId); + $expenseCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '<', $drillParentId); + } $totalIncome = $incomeCategories->sum('amount'); $totalExpense = $expenseCategories->sum('amount'); @@ -335,6 +344,70 @@ class CashflowAnalyticsController extends Controller return $categorized; } + /** + * The full nested Income/Expense tree for the donut, strictly by category + * type (no savings, investments or transfers), so its totals match the + * summary and breakdown cards. Uncategorized amounts on the matching sign + * surface as a single top-level node. + */ + private function getNestedCategoryTree(string $userId, string $userCurrency, Carbon $from, Carbon $to, CategoryType $type): Collection + { + $transactions = Transaction::query() + ->where('transactions.user_id', $userId) + ->whereBetween('transactions.transaction_date', [$from, $to]) + ->with(['account', 'category']) + ->get(); + + $this->preloadExchangeRates($transactions, $userCurrency); + + $categorized = $transactions + ->filter(fn (Transaction $transaction): bool => $transaction->categoryType() === $type) + ->groupBy('category_id') + ->map(function (Collection $transactions) use ($userCurrency): array { + $totalAmount = $transactions->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)); + + return [ + 'category_id' => $transactions->first()->category_id, + 'category' => $transactions->first()->category, + 'amount' => abs($totalAmount), + 'total_amount' => $totalAmount, + ]; + }) + ->filter(fn (array $item): bool => $this->categoryNetAmountMatchesSide($item['total_amount'], $type)) + ->map(fn (array $item): array => [ + 'category_id' => $item['category_id'], + 'category' => $item['category'], + 'amount' => $item['amount'], + ]); + + $tree = collect($this->tree->nest($categorized->values()->all(), $userId)); + + $uncategorized = $transactions + ->filter(function (Transaction $transaction) use ($type): bool { + return $transaction->category_id === null + && $this->matchesSign($transaction->amount, $type === CategoryType::Income ? '>' : '<'); + }) + ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)); + + if ($uncategorized != 0) { + $tree->push([ + 'category_id' => null, + 'category' => (new Category)->forceFill([ + 'id' => null, + 'name' => $type === CategoryType::Income ? __('Unknown Income') : __('Unknown Expense'), + 'type' => $type, + 'color' => 'gray', + 'icon' => 'HelpCircle', + ]), + 'amount' => abs($uncategorized), + 'is_direct' => false, + 'children' => [], + ]); + } + + return $tree; + } + private function getMonthlyTrendTotals(string $userId, string $userCurrency, Carbon $from, Carbon $to): Collection { $transactions = Transaction::query() diff --git a/app/Services/CategoryTree.php b/app/Services/CategoryTree.php index cd427776..256281a2 100644 --- a/app/Services/CategoryTree.php +++ b/app/Services/CategoryTree.php @@ -219,6 +219,116 @@ class CategoryTree return array_values($nodes); } + /** + * Build the full nested spend tree for a set of leaf amounts. + * + * Every category keeps the amount that sits directly on it plus a recursive + * `children` array, so a caller can render all levels at once (e.g. a + * concentric multi-level donut). A node that carries both direct spend and + * real children surfaces the direct portion as a synthetic "Direct" child, + * so a node's children always sum to the node total and the rings align. + * Bounded by MAX_DEPTH; the category tree is acyclic by construction. + * + * @param array $categorized + * @return array}> + */ + public function nest(array $categorized, string $userId): array + { + $categories = Category::query() + ->where('user_id', $userId) + ->forDisplay() + ->get() + ->keyBy('id'); + + $parentMap = $categories->mapWithKeys(fn (Category $category): array => [$category->id => $category->parent_id])->all(); + + $childrenMap = []; + foreach ($parentMap as $id => $parentId) { + if ($parentId !== null) { + $childrenMap[$parentId][] = $id; + } + } + + $directAmount = []; + foreach ($categorized as $item) { + $categoryId = $item['category_id']; + + if ($categoryId === null || ! array_key_exists($categoryId, $parentMap)) { + continue; + } + + $directAmount[$categoryId] = ($directAmount[$categoryId] ?? 0) + $item['amount']; + } + + $build = function (string $id, int $depth) use (&$build, $categories, $childrenMap, $directAmount): ?array { + if ($depth > Category::MAX_DEPTH) { + return null; + } + + $direct = $directAmount[$id] ?? 0; + + $children = []; + foreach ($childrenMap[$id] ?? [] as $childId) { + $childNode = $build($childId, $depth + 1); + + if ($childNode !== null) { + $children[] = $childNode; + } + } + + $total = $direct + array_sum(array_column($children, 'amount')); + + if ($total <= 0) { + return null; + } + + $category = $categories->get($id); + + if ($direct > 0 && $children !== []) { + $children[] = [ + 'category_id' => $category->id, + 'category' => (new Category)->forceFill([ + 'id' => $category->id, + 'name' => __('Direct'), + 'icon' => $category->icon, + 'color' => $category->color, + 'type' => $category->type, + 'cashflow_direction' => $category->cashflow_direction, + 'parent_id' => $category->id, + ]), + 'amount' => $direct, + 'is_direct' => true, + 'children' => [], + ]; + } + + usort($children, fn (array $a, array $b): int => $b['amount'] <=> $a['amount']); + + return [ + 'category_id' => $category->id, + 'category' => $category, + 'amount' => $total, + 'is_direct' => false, + 'children' => $children, + ]; + }; + + $roots = []; + foreach ($parentMap as $id => $parentId) { + if ($parentId === null) { + $node = $build($id, 1); + + if ($node !== null) { + $roots[] = $node; + } + } + } + + usort($roots, fn (array $a, array $b): int => $b['amount'] <=> $a['amount']); + + return $roots; + } + /** * Build a two-level spending breakdown: each top-level category with its * rolled-up total, and the immediate sub-categories that carry spending diff --git a/lang/es.json b/lang/es.json index 9845cae4..cc5186e8 100644 --- a/lang/es.json +++ b/lang/es.json @@ -104,6 +104,7 @@ "Show less": "Ver menos", "Analysis view": "Vista de análisis", "Automatic": "Automático", + "Combined": "Combinado", "Expenses only": "Solo gastos", "Income & expenses": "Ingresos y gastos", "adjusted": "ajustado", diff --git a/resources/js/components/charts/index.ts b/resources/js/components/charts/index.ts index 394b1a13..e1ae3850 100644 --- a/resources/js/components/charts/index.ts +++ b/resources/js/components/charts/index.ts @@ -11,4 +11,5 @@ export { ChartSettingsPopover } from './chart-settings-popover'; export { ChartViewToggle } from './chart-view-toggle'; export { MoMChart } from './mom-chart'; export { MoMPercentChart } from './mom-percent-chart'; +export { MultiLevelDonut } from './multi-level-donut'; export { SankeyChart } from './sankey-chart'; diff --git a/resources/js/components/charts/multi-level-donut.tsx b/resources/js/components/charts/multi-level-donut.tsx new file mode 100644 index 00000000..796845b7 --- /dev/null +++ b/resources/js/components/charts/multi-level-donut.tsx @@ -0,0 +1,444 @@ +import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController'; +import { usePrivacyMode } from '@/contexts/privacy-mode-context'; +import { SankeyData } from '@/hooks/use-cashflow-data'; +import { useLocale } from '@/hooks/use-locale'; +import { buildDonutRings, DonutRing, DonutSegment } from '@/lib/donut-utils'; +import { cn } from '@/lib/utils'; +import { getCategoryChartColor } from '@/types/category'; +import { formatCurrency } from '@/utils/currency'; +import { __ } from '@/utils/i18n'; +import { router } from '@inertiajs/react'; +import { format } from 'date-fns'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; + +interface MultiLevelDonutProps { + data: SankeyData; + showIncome: boolean; + height?: number; + className?: string; + currency?: string; + period?: { from: Date; to: Date }; +} + +const RAD = Math.PI / 180; +// Radii as a fraction of the chart radius (half the smaller dimension). +const HOLE_FRACTION = 0.3; +const RIM_FRACTION = 0.6; +const RING_GAP = 2; +// Minimum arc (degrees) a slice must span to earn a label. +const MIN_INSIDE_DEG = 18; +const MIN_OUTER_DEG = 4; +// Outer leader-line label layout. +const LEADER_ELBOW = 12; +const LEADER_RUN = 12; +const LABEL_GAP = 14; +const MAX_LABEL_CHARS = 18; +// Below this width the leader labels don't fit; the outer ring is read on tap. +const LEADER_MIN_WIDTH = 480; + +function segmentColor(segment: DonutSegment): string { + return getCategoryChartColor(segment.color ?? 'gray'); +} + +function truncate(name: string): string { + return name.length > MAX_LABEL_CHARS + ? `${name.slice(0, MAX_LABEL_CHARS - 1)}…` + : name; +} + +interface OuterLabel { + key: string; + side: 1 | -1; + edgeX: number; + edgeY: number; + y: number; + name: string; + percentage: number; +} + +/** + * Position the outermost ring's labels outside the donut with a leader line, + * pushing them apart vertically per side so thin slices stay readable without + * overlapping (Highcharts pie-donut style). + */ +function layoutOuterLabels( + ring: DonutRing, + cx: number, + cy: number, + radius: number, + height: number, +): OuterLabel[] { + if (ring.total <= 0) { + return []; + } + + let cursor = 0; + const labels: OuterLabel[] = []; + + for (const segment of ring.segments) { + const midFraction = (cursor + segment.value / 2) / ring.total; + cursor += segment.value; + + if ((segment.value / ring.total) * 360 < MIN_OUTER_DEG) { + continue; + } + + const angle = -(90 - midFraction * 360) * RAD; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + labels.push({ + key: segment.key, + side: cos >= 0 ? 1 : -1, + edgeX: cx + radius * cos, + edgeY: cy + radius * sin, + y: cy + (radius + LEADER_ELBOW) * sin, + name: truncate(segment.name), + percentage: (segment.value / ring.total) * 100, + }); + } + + for (const side of [1, -1] as const) { + const column = labels + .filter((label) => label.side === side) + .sort((a, b) => a.y - b.y); + + let previous = -Infinity; + for (const label of column) { + label.y = Math.max(label.y, previous + LABEL_GAP); + previous = label.y; + } + + const bottom = height - 6; + if (column.length > 0 && column[column.length - 1].y > bottom) { + let next = bottom; + for (let i = column.length - 1; i >= 0; i--) { + column[i].y = Math.min(column[i].y, next); + next = column[i].y - LABEL_GAP; + } + } + } + + return labels; +} + +interface PieLabelProps { + cx?: number; + cy?: number; + midAngle?: number; + innerRadius?: number; + outerRadius?: number; + percent?: number; + name?: string | number; +} + +/** Inside label for the innermost ring's wide-enough slices. */ +function InsideLabel({ + cx = 0, + cy = 0, + midAngle = 0, + innerRadius = 0, + outerRadius = 0, + percent = 0, + name = '', +}: PieLabelProps) { + if (percent * 360 < MIN_INSIDE_DEG) { + return null; + } + + const r = (innerRadius + outerRadius) / 2; + const angle = -midAngle * RAD; + + return ( + + {truncate(String(name))} + + ); +} + +interface DonutTooltipProps { + active?: boolean; + payload?: { payload?: DonutSegment }[]; + totalIncome: number; + totalExpense: number; + mask: (value: number) => string; +} + +function DonutTooltip({ + active, + payload, + totalIncome, + totalExpense, + mask, +}: DonutTooltipProps) { + const segment = active ? payload?.[0]?.payload : undefined; + + if (!segment) { + return null; + } + + const directionTotal = + segment.direction === 'income' ? totalIncome : totalExpense; + const percentage = + directionTotal > 0 ? (segment.value / directionTotal) * 100 : 0; + + return ( +
+
+ + {segment.name} +
+
+ + {mask(segment.value)} + + + {percentage.toFixed(1)}% + +
+
+ ); +} + +export function MultiLevelDonut({ + data, + showIncome, + height = 400, + className, + currency = 'USD', + period, +}: MultiLevelDonutProps) { + const locale = useLocale(); + const { isPrivacyModeEnabled } = usePrivacyMode(); + const containerRef = useRef(null); + const [width, setWidth] = useState(0); + + useEffect(() => { + const container = containerRef.current; + + if (!container || typeof ResizeObserver === 'undefined') { + return; + } + + const observer = new ResizeObserver(() => + setWidth(container.clientWidth), + ); + observer.observe(container); + + return () => observer.disconnect(); + }, []); + + const rings = useMemo( + () => + buildDonutRings(data.income_categories, data.expense_categories, { + showIncome, + }), + [data.income_categories, data.expense_categories, showIncome], + ); + + const geometry = useMemo(() => { + const base = Math.min(height, width || height); + const chartRadius = base / 2; + const holeRadius = chartRadius * HOLE_FRACTION; + const rimRadius = chartRadius * RIM_FRACTION; + const band = + rings.length > 0 ? (rimRadius - holeRadius) / rings.length : 0; + + return { + cx: (width || height) / 2, + cy: height / 2, + holeRadius, + band, + rimRadius, + }; + }, [width, height, rings.length]); + + const outerLabels = useMemo(() => { + if (rings.length === 0 || width < LEADER_MIN_WIDTH) { + return []; + } + + return layoutOuterLabels( + rings[rings.length - 1], + geometry.cx, + geometry.cy, + geometry.rimRadius - RING_GAP, + height, + ); + }, [rings, geometry, width, height]); + + const mask = (value: number) => { + const formatted = formatCurrency(value, currency, locale, 0, 0); + return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted; + }; + + if (rings.length === 0) { + return ( +
+ {__('No cashflow data for this period')} +
+ ); + } + + const handleClick = (segment: DonutSegment) => { + if (!segment.categoryId || !period) { + return; + } + + router.visit( + transactionsIndex({ + query: { + category_ids: segment.categoryId, + date_from: format(period.from, 'yyyy-MM-dd'), + date_to: format(period.to, 'yyyy-MM-dd'), + }, + }).url, + ); + }; + + const centerLabel = showIncome ? __('Net') : __('Expenses'); + const centerValue = showIncome + ? data.total_income - data.total_expense + : data.total_expense; + + return ( +
+ + + + } + /> + {rings.map((ring, index) => ( + { + const withPayload = entry as { + payload?: DonutSegment; + }; + handleClick( + withPayload.payload ?? + (entry as unknown as DonutSegment), + ); + }} + > + {ring.segments.map((segment) => ( + + ))} + + ))} + + + + {outerLabels.length > 0 && ( + + {outerLabels.map((label) => { + const elbowX = + geometry.cx + label.side * (geometry.rimRadius + 2); + const textX = elbowX + label.side * LEADER_RUN; + + return ( + + + 0 ? 'start' : 'end' + } + dominantBaseline="central" + fontSize={10} + fill="var(--color-foreground)" + > + {label.name} + + {' '} + {label.percentage.toFixed(1)}% + + + + ); + })} + + )} + +
+ + {centerLabel} + + + {mask(centerValue)} + +
+
+ ); +} diff --git a/resources/js/hooks/use-cashflow-data.ts b/resources/js/hooks/use-cashflow-data.ts index 69fcb745..289a0acb 100644 --- a/resources/js/hooks/use-cashflow-data.ts +++ b/resources/js/hooks/use-cashflow-data.ts @@ -15,10 +15,14 @@ export interface CashflowSummary { export interface SankeyCategory { category: Category; - category_id: string; + category_id: string | null; amount: number; has_children?: boolean; is_direct?: boolean; + /** Present when the sankey endpoint is queried with `nested=1`. */ + children?: SankeyCategory[]; + /** Client-side synthetic node (e.g. a grouped "Other" slice). */ + synthetic?: boolean; } export interface SankeyData { @@ -122,8 +126,8 @@ export function useCashflowData({ fetch(`/api/cashflow/summary${periodQuery}`).then((r) => r.json(), ), - fetch(`/api/cashflow/sankey${periodQuery}`).then((r) => - r.json(), + fetch(`/api/cashflow/sankey${periodQuery}&nested=1`).then( + (r) => r.json(), ), fetch(`/api/cashflow/trend${trendQuery}`).then((r) => r.json(), diff --git a/resources/js/lib/donut-utils.test.ts b/resources/js/lib/donut-utils.test.ts new file mode 100644 index 00000000..42886225 --- /dev/null +++ b/resources/js/lib/donut-utils.test.ts @@ -0,0 +1,119 @@ +import { SankeyCategory } from '@/hooks/use-cashflow-data'; +import { Category } from '@/types/category'; +import { describe, expect, it } from 'vitest'; + +import { buildDonutRings } from './donut-utils'; + +function node( + id: string, + amount: number, + children?: SankeyCategory[], +): SankeyCategory { + return { + category: { + id, + name: id, + icon: 'HelpCircle', + color: 'blue', + type: 'expense', + cashflow_direction: 'hidden', + parent_id: null, + } as Category, + category_id: id, + amount, + children, + }; +} + +function direct(parentId: string, amount: number): SankeyCategory { + return { ...node(parentId, amount), is_direct: true }; +} + +// Food 180 = groceries 80 (organic 30 + direct 50) + direct 100 +const threeLevelExpense: SankeyCategory[] = [ + node('food', 180, [ + node('groceries', 80, [node('organic', 30), direct('groceries', 50)]), + direct('food', 100), + ]), +]; + +describe('buildDonutRings', () => { + it('renders one ring per level, top level innermost, for expenses only', () => { + const rings = buildDonutRings([], threeLevelExpense, { + showIncome: false, + }); + + expect(rings.map((r) => r.level)).toEqual([1, 2, 3]); + expect(rings.every((r) => r.direction === 'expense')).toBe(true); + expect(rings[0].segments).toHaveLength(1); + expect(rings[0].segments[0].name).toBe('food'); + expect(rings[1].segments).toHaveLength(2); + expect(rings[2].segments).toHaveLength(3); + }); + + it('keeps every ring summing to the direction total (arc alignment)', () => { + const rings = buildDonutRings([], threeLevelExpense, { + showIncome: false, + }); + + for (const ring of rings) { + const sum = ring.segments.reduce((s, seg) => s + seg.value, 0); + expect(sum).toBe(180); + } + }); + + it('extends a shallow leaf outward so it fills the deeper rings', () => { + // rent is a plain leaf; groceries splits into a child. + const expense = [ + node('rent', 100), + node('groceries', 200, [node('supermarket', 200)]), + ]; + const rings = buildDonutRings([], expense, { showIncome: false }); + + expect(rings).toHaveLength(2); + // Rings are ordered by amount desc, so groceries (200) precedes rent + // (100). On ring 2 groceries resolves to its child; rent extends as + // itself. + expect(rings[1].segments.map((s) => s.name)).toEqual([ + 'supermarket', + 'rent', + ]); + expect(rings[1].segments.reduce((s, seg) => s + seg.value, 0)).toBe( + 300, + ); + }); + + it('nests income toward the centre and expense toward the rim', () => { + const income = [node('salary', 300), node('bonus', 100)]; + const expense = [node('rent', 200, [node('flat', 200)])]; + const rings = buildDonutRings(income, expense, { showIncome: true }); + + expect(rings.map((r) => r.direction)).toEqual([ + 'income', + 'expense', + 'expense', + ]); + expect(rings[0].total).toBe(400); + expect(rings[1].total).toBe(200); + }); + + it('folds small siblings into a synthetic non-navigable "Other" slice', () => { + const expense = [ + node('big1', 500), + node('big2', 400), + node('big3', 300), + node('tiny1', 5), + node('tiny2', 4), + node('tiny3', 3), + ]; + const rings = buildDonutRings([], expense, { showIncome: false }); + + const other = rings[0].segments.find((s) => s.name === 'Other'); + expect(other).toBeDefined(); + expect(other?.value).toBe(12); + expect(other?.categoryId).toBeNull(); + expect(rings[0].segments.reduce((s, seg) => s + seg.value, 0)).toBe( + 1212, + ); + }); +}); diff --git a/resources/js/lib/donut-utils.ts b/resources/js/lib/donut-utils.ts new file mode 100644 index 00000000..9a71e9f1 --- /dev/null +++ b/resources/js/lib/donut-utils.ts @@ -0,0 +1,208 @@ +import { SankeyCategory } from '@/hooks/use-cashflow-data'; +import { groupSmallCategories } from '@/lib/sankey-utils'; +import { CategoryColor } from '@/types/category'; +import { __ } from '@/utils/i18n'; + +export type DonutDirection = 'income' | 'expense'; + +export interface DonutSegment { + /** Stable identity for React keys and cross-ring arc alignment. */ + key: string; + name: string; + value: number; + /** Real category id to link to, or null when not navigable. */ + categoryId: string | null; + color: CategoryColor | null; + isDirect: boolean; + /** 1-based depth of the resolved node in its tree. */ + depth: number; + direction: DonutDirection; +} + +export interface DonutRing { + /** 1-based level of the tree this ring renders. */ + level: number; + direction: DonutDirection; + /** Sum of the segment values; equals the direction total for every ring. */ + total: number; + segments: DonutSegment[]; +} + +interface Leaf { + path: SankeyCategory[]; + value: number; +} + +const DEFAULT_THRESHOLD = 0.03; + +function nodeId(node: SankeyCategory): string { + if (node.is_direct) { + return `${node.category_id}:direct`; + } + + return node.category_id ?? `unknown:${node.category.name}`; +} + +/** + * Fold small sibling categories into a synthetic "Other" node at every level, + * so no ring drowns in unreadable slivers. Mirrors the sankey grouping. + */ +function groupTree( + nodes: SankeyCategory[], + total: number, + threshold: number, + keyPrefix: string, +): SankeyCategory[] { + const { main, other } = groupSmallCategories(nodes, total, threshold); + + const grouped = main.map( + (node): SankeyCategory => + node.children && node.children.length > 0 + ? { + ...node, + children: groupTree( + node.children, + node.amount, + threshold, + `${keyPrefix}/${nodeId(node)}`, + ), + } + : node, + ); + + if (other) { + grouped.push({ + category: { + ...main[0].category, + name: __('Other'), + color: 'gray', + icon: 'HelpCircle', + }, + category_id: `${keyPrefix}/other`, + amount: other.total, + is_direct: false, + synthetic: true, + }); + } + + return grouped; +} + +function collectLeaves( + nodes: SankeyCategory[], + prefix: SankeyCategory[] = [], +): Leaf[] { + const leaves: Leaf[] = []; + + for (const node of nodes) { + const path = [...prefix, node]; + + if (node.children && node.children.length > 0) { + leaves.push(...collectLeaves(node.children, path)); + } else { + leaves.push({ path, value: node.amount }); + } + } + + return leaves; +} + +function maxDepth(leaves: Leaf[]): number { + return leaves.reduce((max, leaf) => Math.max(max, leaf.path.length), 0); +} + +/** + * Build one ring's arcs. A leaf shorter than `level` is rendered as its own + * terminal node (it visually extends outward); adjacent leaves resolving to the + * same ancestor merge into a single arc. Every ring sums to the same total, so + * arcs stay angularly aligned with the rings inside and outside them. + */ +function ringSegments( + leaves: Leaf[], + level: number, + direction: DonutDirection, +): DonutSegment[] { + const segments: DonutSegment[] = []; + + for (const leaf of leaves) { + const index = Math.min(level, leaf.path.length) - 1; + const node = leaf.path[index]; + const key = `${direction}:${level}:${nodeId(node)}`; + const last = segments[segments.length - 1]; + + if (last && last.key === key) { + last.value += leaf.value; + + continue; + } + + segments.push({ + key, + name: node.category.name, + value: leaf.value, + categoryId: node.synthetic ? null : node.category_id, + color: node.category.color ?? null, + isDirect: !!node.is_direct, + depth: index + 1, + direction, + }); + } + + return segments; +} + +function directionRings( + nodes: SankeyCategory[], + direction: DonutDirection, + threshold: number, +): DonutRing[] { + const total = nodes.reduce((sum, node) => sum + node.amount, 0); + + if (total <= 0) { + return []; + } + + const grouped = groupTree(nodes, total, threshold, direction); + const leaves = collectLeaves(grouped); + const depth = maxDepth(leaves); + const rings: DonutRing[] = []; + + for (let level = 1; level <= depth; level++) { + const segments = ringSegments(leaves, level, direction); + + if (segments.length > 0) { + rings.push({ level, direction, total, segments }); + } + } + + return rings; +} + +export interface BuildDonutRingsOptions { + showIncome: boolean; + threshold?: number; +} + +/** + * Assemble the concentric ring stack, innermost first. + * + * Combined: income grows toward the centre (deepest income level innermost), + * expense grows outward (deepest expense level at the rim). With `showIncome` + * off only the expense rings remain, its top level innermost. + */ +export function buildDonutRings( + income: SankeyCategory[], + expense: SankeyCategory[], + { showIncome, threshold = DEFAULT_THRESHOLD }: BuildDonutRingsOptions, +): DonutRing[] { + const rings: DonutRing[] = []; + + if (showIncome) { + // Reverse so the deepest income level sits innermost. + rings.push(...directionRings(income, 'income', threshold).reverse()); + } + + rings.push(...directionRings(expense, 'expense', threshold)); + + return rings; +} diff --git a/resources/js/pages/cashflow/index.tsx b/resources/js/pages/cashflow/index.tsx index 49b0a9fe..bf09d32e 100644 --- a/resources/js/pages/cashflow/index.tsx +++ b/resources/js/pages/cashflow/index.tsx @@ -2,9 +2,10 @@ import { BreakdownCard } from '@/components/cashflow/breakdown-card'; import { NetCashflowCard } from '@/components/cashflow/net-cashflow-card'; import { PeriodNavigation } from '@/components/cashflow/period-navigation'; import { SavedInvestedCard } from '@/components/cashflow/saved-invested-card'; -import { CashflowTrendChart, SankeyChart } from '@/components/charts'; +import { CashflowTrendChart, MultiLevelDonut } 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,10 @@ export default function CashflowPage() { const [periodType, setPeriodType] = useState(initialPeriodType); + const [donutView, setDonutView] = useState<'combined' | 'expense'>( + 'combined', + ); + const [currentDate, setCurrentDate] = useState(() => parsePeriodParam(initialPeriod, initialPeriodType), ); @@ -202,19 +207,47 @@ export default function CashflowPage() { periodType={periodType} /> - {/* Sankey Diagram */} + {/* Money Flow donut */} - + {__('Money Flow')} + + { + if (value) { + setDonutView( + value as 'combined' | 'expense', + ); + } + }} + variant="outline" + size="sm" + > + + {__('Combined')} + + + {__('Expenses only')} + + {isLoading ? (
) : ( - toBe($parent->id) ->and($directNode['amount'])->toBe(10000); }); + +test('sankey nested=1 returns the full three-level tree with rolled-up amounts', function () { + $account = Account::factory()->create(['user_id' => $this->user->id]); + + $food = Category::factory()->create([ + 'user_id' => $this->user->id, + 'name' => 'Food', + 'type' => CategoryType::Expense, + ]); + $groceries = Category::factory()->childOf($food)->create([ + 'user_id' => $this->user->id, + 'name' => 'Groceries', + ]); + $organic = Category::factory()->childOf($groceries)->create([ + 'user_id' => $this->user->id, + 'name' => 'Organic', + ]); + + // $100 directly on Food, $50 directly on Groceries, $30 on Organic. + foreach ([[$food, -10000], [$groceries, -5000], [$organic, -3000]] as [$category, $amount]) { + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $category->id, + 'amount' => $amount, + 'transaction_date' => now(), + ]); + } + + $response = $this->getJson('/api/cashflow/sankey?'.http_build_query([ + 'from' => now()->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + 'nested' => 1, + ])); + + $response->assertOk()->assertJsonPath('total_expense', 18000); + + $expense = collect($response->json('expense_categories')); + expect($expense)->toHaveCount(1); + + $foodNode = $expense->first(); + expect($foodNode['category_id'])->toBe($food->id) + ->and($foodNode['amount'])->toBe(18000); + + $foodChildren = collect($foodNode['children']); + expect($foodChildren)->toHaveCount(2); + + $groceriesNode = $foodChildren->firstWhere('category_id', $groceries->id); + expect($groceriesNode['is_direct'])->toBeFalse() + ->and($groceriesNode['amount'])->toBe(8000); + + $foodDirect = $foodChildren->firstWhere('is_direct', true); + expect($foodDirect['category_id'])->toBe($food->id) + ->and($foodDirect['amount'])->toBe(10000); + + $groceriesChildren = collect($groceriesNode['children']); + expect($groceriesChildren->firstWhere('category_id', $organic->id)['amount'])->toBe(3000) + ->and($groceriesChildren->firstWhere('is_direct', true)['amount'])->toBe(5000); +});