feat(cashflow): add multi-level donut money-flow chart (prototype)

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.
This commit is contained in:
Víctor Falcón 2026-07-11 19:16:45 +02:00
parent dada23cd84
commit 832cd1245b
10 changed files with 1063 additions and 11 deletions

View File

@ -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()

View File

@ -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<int, array{category_id: ?string, category: Category|null, amount: int}> $categorized
* @return array<int, array{category_id: string, category: Category, amount: int, is_direct: bool, children: array<int, mixed>}>
*/
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

View File

@ -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",

View File

@ -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';

View File

@ -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 (
<text
x={cx + r * Math.cos(angle)}
y={cy + r * Math.sin(angle)}
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontWeight={600}
fill="#fff"
style={{
paintOrder: 'stroke',
stroke: 'rgba(0,0,0,0.45)',
strokeWidth: 2.5,
}}
>
{truncate(String(name))}
</text>
);
}
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 (
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-xs shadow-xl">
<div className="flex items-center gap-2 font-medium">
<span
className="size-2 rounded-full"
style={{ background: segmentColor(segment) }}
/>
{segment.name}
</div>
<div className="mt-1 flex items-center justify-between gap-4 tabular-nums">
<span className="font-mono font-medium">
{mask(segment.value)}
</span>
<span className="text-muted-foreground">
{percentage.toFixed(1)}%
</span>
</div>
</div>
);
}
export function MultiLevelDonut({
data,
showIncome,
height = 400,
className,
currency = 'USD',
period,
}: MultiLevelDonutProps) {
const locale = useLocale();
const { isPrivacyModeEnabled } = usePrivacyMode();
const containerRef = useRef<HTMLDivElement>(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<DonutRing[]>(
() =>
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 (
<div
className={cn(
'flex items-center justify-center text-muted-foreground',
className,
)}
style={{ height }}
>
{__('No cashflow data for this period')}
</div>
);
}
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 (
<div
ref={containerRef}
className={cn('relative w-full', className)}
style={{ height }}
>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Tooltip
wrapperStyle={{ zIndex: 40 }}
content={
<DonutTooltip
totalIncome={data.total_income}
totalExpense={data.total_expense}
mask={mask}
/>
}
/>
{rings.map((ring, index) => (
<Pie
key={`${ring.direction}-${ring.level}`}
data={ring.segments}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={
geometry.holeRadius + index * geometry.band
}
outerRadius={
geometry.holeRadius +
(index + 1) * geometry.band -
RING_GAP
}
startAngle={90}
endAngle={-270}
paddingAngle={0}
stroke="var(--color-background)"
strokeWidth={1}
isAnimationActive={false}
labelLine={false}
label={index === 0 ? InsideLabel : false}
onClick={(entry) => {
const withPayload = entry as {
payload?: DonutSegment;
};
handleClick(
withPayload.payload ??
(entry as unknown as DonutSegment),
);
}}
>
{ring.segments.map((segment) => (
<Cell
key={segment.key}
fill={segmentColor(segment)}
className={cn(
segment.categoryId && 'cursor-pointer',
)}
/>
))}
</Pie>
))}
</PieChart>
</ResponsiveContainer>
{outerLabels.length > 0 && (
<svg
className="pointer-events-none absolute inset-0"
width={width}
height={height}
>
{outerLabels.map((label) => {
const elbowX =
geometry.cx + label.side * (geometry.rimRadius + 2);
const textX = elbowX + label.side * LEADER_RUN;
return (
<g key={label.key}>
<polyline
points={`${label.edgeX},${label.edgeY} ${elbowX},${label.y} ${textX},${label.y}`}
fill="none"
stroke="var(--color-border)"
strokeWidth={1}
/>
<text
x={textX + label.side * 3}
y={label.y}
textAnchor={
label.side > 0 ? 'start' : 'end'
}
dominantBaseline="central"
fontSize={10}
fill="var(--color-foreground)"
>
{label.name}
<tspan fill="var(--color-muted-foreground)">
{' '}
{label.percentage.toFixed(1)}%
</tspan>
</text>
</g>
);
})}
</svg>
)}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="text-xs text-muted-foreground">
{centerLabel}
</span>
<span className="font-mono text-sm font-semibold tabular-nums">
{mask(centerValue)}
</span>
</div>
</div>
);
}

View File

@ -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(),

View File

@ -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,
);
});
});

View File

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

View File

@ -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<CashflowPeriodType>(initialPeriodType);
const [donutView, setDonutView] = useState<'combined' | 'expense'>(
'combined',
);
const [currentDate, setCurrentDate] = useState<Date>(() =>
parsePeriodParam(initialPeriod, initialPeriodType),
);
@ -202,19 +207,47 @@ export default function CashflowPage() {
periodType={periodType}
/>
{/* Sankey Diagram */}
{/* Money Flow donut */}
<Card>
<CardHeader className="pb-4">
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-4">
<CardTitle className="text-base">
{__('Money Flow')}
</CardTitle>
<ToggleGroup
type="single"
value={donutView}
onValueChange={(value) => {
if (value) {
setDonutView(
value as 'combined' | 'expense',
);
}
}}
variant="outline"
size="sm"
>
<ToggleGroupItem
value="combined"
className="cursor-pointer px-3 text-xs aria-checked:bg-primary/10"
>
{__('Combined')}
</ToggleGroupItem>
<ToggleGroupItem
value="expense"
className="cursor-pointer px-3 text-xs aria-checked:bg-primary/10"
>
{__('Expenses only')}
</ToggleGroupItem>
</ToggleGroup>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="h-[400px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
) : (
<SankeyChart
<MultiLevelDonut
data={sankey}
showIncome={donutView === 'combined'}
height={400}
currency={auth.user.currency_code}
period={period}

View File

@ -1480,3 +1480,62 @@ test('drilling into a parent splits it into children plus a direct node', functi
expect($directNode['category_id'])->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);
});