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:
parent
dada23cd84
commit
832cd1245b
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue