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',
|
'from' => 'required|date',
|
||||||
'to' => 'required|date',
|
'to' => 'required|date',
|
||||||
'parent' => 'nullable|uuid',
|
'parent' => 'nullable|uuid',
|
||||||
|
'nested' => 'nullable|boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$from = Carbon::parse($validated['from']);
|
$from = Carbon::parse($validated['from']);
|
||||||
|
|
@ -58,10 +59,18 @@ class CashflowAnalyticsController extends Controller
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$drillParentId = $validated['parent'] ?? null;
|
$drillParentId = $validated['parent'] ?? null;
|
||||||
|
|
||||||
// Split by sign, not by category type: a single category can appear on
|
if ($request->boolean('nested')) {
|
||||||
// both sides when it has both incoming and outgoing transactions.
|
// The nested donut mirrors the income/expense breakdown cards:
|
||||||
$incomeCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '>', $drillParentId);
|
// strictly typed Income/Expense, so savings, investments and
|
||||||
$expenseCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '<', $drillParentId);
|
// 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');
|
$totalIncome = $incomeCategories->sum('amount');
|
||||||
$totalExpense = $expenseCategories->sum('amount');
|
$totalExpense = $expenseCategories->sum('amount');
|
||||||
|
|
@ -335,6 +344,70 @@ class CashflowAnalyticsController extends Controller
|
||||||
return $categorized;
|
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
|
private function getMonthlyTrendTotals(string $userId, string $userCurrency, Carbon $from, Carbon $to): Collection
|
||||||
{
|
{
|
||||||
$transactions = Transaction::query()
|
$transactions = Transaction::query()
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,116 @@ class CategoryTree
|
||||||
return array_values($nodes);
|
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
|
* Build a two-level spending breakdown: each top-level category with its
|
||||||
* rolled-up total, and the immediate sub-categories that carry spending
|
* rolled-up total, and the immediate sub-categories that carry spending
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@
|
||||||
"Show less": "Ver menos",
|
"Show less": "Ver menos",
|
||||||
"Analysis view": "Vista de análisis",
|
"Analysis view": "Vista de análisis",
|
||||||
"Automatic": "Automático",
|
"Automatic": "Automático",
|
||||||
|
"Combined": "Combinado",
|
||||||
"Expenses only": "Solo gastos",
|
"Expenses only": "Solo gastos",
|
||||||
"Income & expenses": "Ingresos y gastos",
|
"Income & expenses": "Ingresos y gastos",
|
||||||
"adjusted": "ajustado",
|
"adjusted": "ajustado",
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,5 @@ export { ChartSettingsPopover } from './chart-settings-popover';
|
||||||
export { ChartViewToggle } from './chart-view-toggle';
|
export { ChartViewToggle } from './chart-view-toggle';
|
||||||
export { MoMChart } from './mom-chart';
|
export { MoMChart } from './mom-chart';
|
||||||
export { MoMPercentChart } from './mom-percent-chart';
|
export { MoMPercentChart } from './mom-percent-chart';
|
||||||
|
export { MultiLevelDonut } from './multi-level-donut';
|
||||||
export { SankeyChart } from './sankey-chart';
|
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 {
|
export interface SankeyCategory {
|
||||||
category: Category;
|
category: Category;
|
||||||
category_id: string;
|
category_id: string | null;
|
||||||
amount: number;
|
amount: number;
|
||||||
has_children?: boolean;
|
has_children?: boolean;
|
||||||
is_direct?: 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 {
|
export interface SankeyData {
|
||||||
|
|
@ -122,8 +126,8 @@ export function useCashflowData({
|
||||||
fetch(`/api/cashflow/summary${periodQuery}`).then((r) =>
|
fetch(`/api/cashflow/summary${periodQuery}`).then((r) =>
|
||||||
r.json(),
|
r.json(),
|
||||||
),
|
),
|
||||||
fetch(`/api/cashflow/sankey${periodQuery}`).then((r) =>
|
fetch(`/api/cashflow/sankey${periodQuery}&nested=1`).then(
|
||||||
r.json(),
|
(r) => r.json(),
|
||||||
),
|
),
|
||||||
fetch(`/api/cashflow/trend${trendQuery}`).then((r) =>
|
fetch(`/api/cashflow/trend${trendQuery}`).then((r) =>
|
||||||
r.json(),
|
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 { NetCashflowCard } from '@/components/cashflow/net-cashflow-card';
|
||||||
import { PeriodNavigation } from '@/components/cashflow/period-navigation';
|
import { PeriodNavigation } from '@/components/cashflow/period-navigation';
|
||||||
import { SavedInvestedCard } from '@/components/cashflow/saved-invested-card';
|
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 HeadingSmall from '@/components/heading-small';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { CashflowPeriodType, useCashflowData } from '@/hooks/use-cashflow-data';
|
||||||
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||||
import { cashflow } from '@/routes';
|
import { cashflow } from '@/routes';
|
||||||
|
|
@ -121,6 +122,10 @@ export default function CashflowPage() {
|
||||||
const [periodType, setPeriodType] =
|
const [periodType, setPeriodType] =
|
||||||
useState<CashflowPeriodType>(initialPeriodType);
|
useState<CashflowPeriodType>(initialPeriodType);
|
||||||
|
|
||||||
|
const [donutView, setDonutView] = useState<'combined' | 'expense'>(
|
||||||
|
'combined',
|
||||||
|
);
|
||||||
|
|
||||||
const [currentDate, setCurrentDate] = useState<Date>(() =>
|
const [currentDate, setCurrentDate] = useState<Date>(() =>
|
||||||
parsePeriodParam(initialPeriod, initialPeriodType),
|
parsePeriodParam(initialPeriod, initialPeriodType),
|
||||||
);
|
);
|
||||||
|
|
@ -202,19 +207,47 @@ export default function CashflowPage() {
|
||||||
periodType={periodType}
|
periodType={periodType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sankey Diagram */}
|
{/* Money Flow donut */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-4">
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
{__('Money Flow')}
|
{__('Money Flow')}
|
||||||
</CardTitle>
|
</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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="h-[400px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
<div className="h-[400px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
) : (
|
) : (
|
||||||
<SankeyChart
|
<MultiLevelDonut
|
||||||
data={sankey}
|
data={sankey}
|
||||||
|
showIncome={donutView === 'combined'}
|
||||||
height={400}
|
height={400}
|
||||||
currency={auth.user.currency_code}
|
currency={auth.user.currency_code}
|
||||||
period={period}
|
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)
|
expect($directNode['category_id'])->toBe($parent->id)
|
||||||
->and($directNode['amount'])->toBe(10000);
|
->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