feat: expand parent categories inline in breakdowns (#486)

## What

Expand/collapse child categories inline in three places:
- Dashboard → **Top spending categories**
- Cashflow → **Income sources**
- Cashflow → **Expense categories**

## How it looks

<img width="1225" height="928" alt="image"
src="https://github.com/user-attachments/assets/aadce904-bfdd-46eb-b919-8695577845d7"
/>

## Implementation

**Frontend**
- `useExpandableCategories` hook: tracks expanded set, lazy-fetches
children once, caches, resets on period change.
- `AnimatedCollapse` component for the height animation.
- Recursive rows in `BreakdownCard` and `TopCategoriesCard`.

**Backend**
- Extracted `rollUpByTree`/`displayNodeFor` out of
`CashflowAnalyticsController` into `CategoryTree::rollUp` (now shared by
cashflow + dashboard).
- Dashboard `top-categories` emits `has_children`/`is_direct` and
accepts `?parent` to drill into a category's children (children + a
direct "Parent" node), mirroring the existing cashflow breakdown drill.
- Added Spanish translations for the new toggle labels.

## Tests
- Backend: `has_children` true/false + parent-drill split (children +
direct node) for dashboard top categories.
- Component: chevron expands, lazily fetches `parent=…`, renders the
child, flips to "Hide subcategories".
- Full suite green; pint / eslint / prettier clean.

> Note: children fetch lazily on first expand (same pattern as the
Sankey inline expand).
This commit is contained in:
Víctor Falcón 2026-06-04 11:19:21 +02:00 committed by GitHub
parent 679c8d7bff
commit 53d051800b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 846 additions and 387 deletions

View File

@ -7,6 +7,7 @@ use App\Enums\CategoryType;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Transaction;
use App\Services\CategoryTree;
use App\Services\ExchangeRateService;
use App\Services\PeriodComparator;
use Carbon\Carbon;
@ -16,7 +17,10 @@ use Illuminate\Support\Collection;
class CashflowAnalyticsController extends Controller
{
public function __construct(private ExchangeRateService $exchangeRateService) {}
public function __construct(
private ExchangeRateService $exchangeRateService,
private CategoryTree $tree,
) {}
public function summary(Request $request): JsonResponse
{
@ -290,7 +294,7 @@ class CashflowAnalyticsController extends Controller
'amount' => $item['amount'],
]);
$categorized = collect($this->rollUpByTree(
$categorized = collect($this->tree->rollUp(
$regularCategories->concat($transferCategories)->values()->all(),
$userId,
$drillParentId,
@ -410,7 +414,7 @@ class CashflowAnalyticsController extends Controller
'amount' => $item['amount'],
]);
$categorized = collect($this->rollUpByTree($categorized->values()->all(), $userId, $drillParentId));
$categorized = collect($this->tree->rollUp($categorized->values()->all(), $userId, $drillParentId));
$uncategorized = $transactions
->filter(function (Transaction $transaction) use ($type): bool {
@ -502,123 +506,4 @@ class CashflowAnalyticsController extends Controller
{
return $type === CategoryType::Income ? $amount > 0 : $amount < 0;
}
/**
* Roll category amounts up the tree.
*
* With no drill target, every amount folds into its top-level ancestor.
* When drilling into a parent, the parent's children become the nodes (each
* rolled up over its own subtree) plus a "Parent" node for transactions
* sitting on the parent itself. Items outside the drilled subtree drop out.
*
* @param array<int, array{category_id: ?string, category: Category|null, amount: int}> $categorized
* @return array<int, array{category_id: ?string, category: Category|null, amount: int, has_children: bool, is_direct: bool}>
*/
private function rollUpByTree(array $categorized, string $userId, ?string $drillParentId): 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();
$childCounts = [];
foreach ($parentMap as $parentId) {
if ($parentId !== null) {
$childCounts[$parentId] = ($childCounts[$parentId] ?? 0) + 1;
}
}
$nodes = [];
foreach ($categorized as $item) {
$categoryId = $item['category_id'];
if ($categoryId === null || ! array_key_exists($categoryId, $parentMap)) {
// Uncategorized only belongs in the top-level view.
if ($drillParentId === null) {
$key = 'uncategorized';
$nodes[$key] ??= ['category_id' => null, 'category' => $item['category'], 'amount' => 0, 'has_children' => false, 'is_direct' => false];
$nodes[$key]['amount'] += $item['amount'];
}
continue;
}
$target = $this->displayNodeFor($categoryId, $parentMap, $drillParentId);
if ($target === null) {
continue;
}
$displayCategory = $categories->get($target['id']);
if ($displayCategory === null) {
continue;
}
if ($target['is_direct']) {
$key = $target['id'].':direct';
$category = (new Category)->forceFill([
'id' => $displayCategory->id,
'name' => __('Parent'),
'icon' => $displayCategory->icon,
'color' => $displayCategory->color,
'type' => $displayCategory->type,
'cashflow_direction' => $displayCategory->cashflow_direction,
'parent_id' => $displayCategory->parent_id,
]);
$nodes[$key] ??= ['category_id' => $displayCategory->id, 'category' => $category, 'amount' => 0, 'has_children' => false, 'is_direct' => true];
$nodes[$key]['amount'] += $item['amount'];
continue;
}
$key = $target['id'];
$nodes[$key] ??= [
'category_id' => $displayCategory->id,
'category' => $displayCategory,
'amount' => 0,
'has_children' => ($childCounts[$displayCategory->id] ?? 0) > 0,
'is_direct' => false,
];
$nodes[$key]['amount'] += $item['amount'];
}
return array_values($nodes);
}
/**
* Resolve which node a category's amount should be attributed to.
*
* @param array<string, ?string> $parentMap
* @return array{id: string, is_direct: bool}|null
*/
private function displayNodeFor(string $categoryId, array $parentMap, ?string $drillParentId): ?array
{
$chain = [];
$current = $categoryId;
$guard = 0;
while ($current !== null && $guard++ < Category::MAX_DEPTH + 1) {
array_unshift($chain, $current);
$current = $parentMap[$current] ?? null;
}
if ($drillParentId === null) {
return ['id' => $chain[0], 'is_direct' => false];
}
$index = array_search($drillParentId, $chain, true);
if ($index === false) {
return null;
}
if ($index === count($chain) - 1) {
return ['id' => $drillParentId, 'is_direct' => true];
}
return ['id' => $chain[$index + 1], 'is_direct' => false];
}
}

View File

@ -432,13 +432,15 @@ class DashboardAnalyticsController extends Controller
$validated = $request->validate([
'from' => 'required|date',
'to' => 'required|date',
'parent' => 'nullable|uuid',
]);
$period = PeriodComparator::fromRequest($validated);
$previousPeriod = $period->previous();
$drillParentId = $validated['parent'] ?? null;
$currentSpending = $this->getCategorySpending($request->user()->id, $period->from, $period->to);
$previousSpending = $this->getCategorySpending($request->user()->id, $previousPeriod->from, $previousPeriod->to);
$currentSpending = $this->getCategorySpending($request->user()->id, $period->from, $period->to, $drillParentId);
$previousSpending = $this->getCategorySpending($request->user()->id, $previousPeriod->from, $previousPeriod->to, $drillParentId);
$totalAmount = $currentSpending->sum('amount');
@ -450,9 +452,12 @@ class DashboardAnalyticsController extends Controller
return [
'category' => $item['category'],
'category_id' => $item['category_id'],
'amount' => $item['amount'],
'previous_amount' => $previousAmount,
'total_amount' => $totalAmount,
'has_children' => $item['has_children'],
'is_direct' => $item['is_direct'],
];
})
->values();
@ -461,16 +466,15 @@ class DashboardAnalyticsController extends Controller
}
/**
* Spending per top-level category: child category amounts roll up into
* their root ancestor so the dashboard only lists parents.
* Expense spending rolled up the category tree.
*
* @return Collection<int, array{category_id: string, amount: int, category: Category}>
* Without a drill target, child amounts fold into their top-level ancestor
* so only parents are listed. With one, the parent's children become the
* rows (plus a direct node for transactions sitting on the parent itself).
*/
private function getCategorySpending(string $userId, Carbon $from, Carbon $to): Collection
private function getCategorySpending(string $userId, Carbon $from, Carbon $to, ?string $drillParentId = null): Collection
{
$rootMap = $this->tree->rootAncestorMap($userId);
$rolledUp = Transaction::query()
$perCategory = Transaction::query()
->where('transactions.user_id', $userId)
->whereBetween('transactions.transaction_date', [$from, $to])
->join('categories', function ($join) {
@ -481,21 +485,16 @@ class DashboardAnalyticsController extends Controller
->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount'))
->groupBy('transactions.category_id')
->get()
->groupBy(fn ($item): string => $rootMap[$item->category_id] ?? $item->category_id)
->map(fn (Collection $items, string $rootId): array => [
'category_id' => $rootId,
'amount' => (int) -$items->sum('total_amount'),
->map(fn ($item): array => [
'category_id' => $item->category_id,
'category' => null,
'amount' => (int) -$item->total_amount,
])
->filter(fn (array $item): bool => $item['amount'] > 0);
->values()
->all();
$categories = Category::query()
->whereIn('id', $rolledUp->keys())
->get()
->keyBy('id');
return $rolledUp
->map(fn (array $item): array => [...$item, 'category' => $categories->get($item['category_id'])])
->filter(fn (array $item): bool => $item['category'] !== null)
return collect($this->tree->rollUp($perCategory, $userId, $drillParentId))
->filter(fn (array $item): bool => $item['amount'] > 0)
->values();
}

View File

@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\Category;
use App\Models\Transaction;
use App\Services\AccountMetricsService;
use App\Services\CategoryTree;
@ -71,9 +70,12 @@ class DashboardController extends Controller
return [
'category' => $item['category'],
'category_id' => $item['category_id'],
'amount' => $item['amount'],
'previous_amount' => $previousAmount,
'total_amount' => $totalAmount,
'has_children' => $item['has_children'],
'is_direct' => $item['is_direct'],
];
})
->values()
@ -99,14 +101,10 @@ class DashboardController extends Controller
/**
* Spending per top-level category: child category amounts roll up into
* their root ancestor so the dashboard only lists parents.
*
* @return Collection<int, array{category_id: string, amount: int, category: Category}>
*/
private function getCategorySpending(string $userId, Carbon $from, Carbon $to): Collection
{
$rootMap = $this->tree->rootAncestorMap($userId);
$rolledUp = Transaction::query()
$perCategory = Transaction::query()
->where('transactions.user_id', $userId)
->whereBetween('transactions.transaction_date', [$from, $to])
->join('categories', function ($join) {
@ -117,21 +115,16 @@ class DashboardController extends Controller
->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount'))
->groupBy('transactions.category_id')
->get()
->groupBy(fn ($item): string => $rootMap[$item->category_id] ?? $item->category_id)
->map(fn (Collection $items, string $rootId): array => [
'category_id' => $rootId,
'amount' => (int) -$items->sum('total_amount'),
->map(fn ($item): array => [
'category_id' => $item->category_id,
'category' => null,
'amount' => (int) -$item->total_amount,
])
->filter(fn (array $item): bool => $item['amount'] > 0);
->values()
->all();
$categories = Category::query()
->whereIn('id', $rolledUp->keys())
->get()
->keyBy('id');
return $rolledUp
->map(fn (array $item): array => [...$item, 'category' => $categories->get($item['category_id'])])
->filter(fn (array $item): bool => $item['category'] !== null)
return collect($this->tree->rollUp($perCategory, $userId, null))
->filter(fn (array $item): bool => $item['amount'] > 0)
->values();
}

View File

@ -135,33 +135,122 @@ class CategoryTree
}
/**
* Map every category id of a user to its top-level ancestor id (roots map
* to themselves). Used to roll child amounts up into their parent.
* Roll category amounts up the tree.
*
* @return array<string, string>
* With no drill target, every amount folds into its top-level ancestor.
* When drilling into a parent, the parent's children become the nodes (each
* rolled up over its own subtree) plus a "Parent" node for transactions
* sitting on the parent itself. Items outside the drilled subtree drop out.
*
* @param array<int, array{category_id: ?string, category: Category|null, amount: int}> $categorized
* @return array<int, array{category_id: ?string, category: Category|null, amount: int, has_children: bool, is_direct: bool}>
*/
public function rootAncestorMap(string $userId): array
public function rollUp(array $categorized, string $userId, ?string $drillParentId): array
{
$parents = Category::query()
$categories = Category::query()
->where('user_id', $userId)
->pluck('parent_id', 'id')
->all();
->forDisplay()
->get()
->keyBy('id');
$map = [];
foreach ($parents as $id => $parentId) {
$rootId = $id;
$guard = 0;
while ($parentId !== null && array_key_exists($parentId, $parents) && $guard++ < Category::MAX_DEPTH) {
$rootId = $parentId;
$parentId = $parents[$parentId];
$parentMap = $categories->mapWithKeys(fn (Category $category): array => [$category->id => $category->parent_id])->all();
$childCounts = [];
foreach ($parentMap as $parentId) {
if ($parentId !== null) {
$childCounts[$parentId] = ($childCounts[$parentId] ?? 0) + 1;
}
$map[$id] = $rootId;
}
return $map;
$nodes = [];
foreach ($categorized as $item) {
$categoryId = $item['category_id'];
if ($categoryId === null || ! array_key_exists($categoryId, $parentMap)) {
// Uncategorized only belongs in the top-level view.
if ($drillParentId === null) {
$key = 'uncategorized';
$nodes[$key] ??= ['category_id' => null, 'category' => $item['category'], 'amount' => 0, 'has_children' => false, 'is_direct' => false];
$nodes[$key]['amount'] += $item['amount'];
}
continue;
}
$target = $this->displayNodeFor($categoryId, $parentMap, $drillParentId);
if ($target === null) {
continue;
}
$displayCategory = $categories->get($target['id']);
if ($displayCategory === null) {
continue;
}
if ($target['is_direct']) {
$key = $target['id'].':direct';
$category = (new Category)->forceFill([
'id' => $displayCategory->id,
'name' => __('Parent'),
'icon' => $displayCategory->icon,
'color' => $displayCategory->color,
'type' => $displayCategory->type,
'cashflow_direction' => $displayCategory->cashflow_direction,
'parent_id' => $displayCategory->parent_id,
]);
$nodes[$key] ??= ['category_id' => $displayCategory->id, 'category' => $category, 'amount' => 0, 'has_children' => false, 'is_direct' => true];
$nodes[$key]['amount'] += $item['amount'];
continue;
}
$key = $target['id'];
$nodes[$key] ??= [
'category_id' => $displayCategory->id,
'category' => $displayCategory,
'amount' => 0,
'has_children' => ($childCounts[$displayCategory->id] ?? 0) > 0,
'is_direct' => false,
];
$nodes[$key]['amount'] += $item['amount'];
}
return array_values($nodes);
}
/**
* Resolve which node a category's amount should be attributed to.
*
* @param array<string, ?string> $parentMap
* @return array{id: string, is_direct: bool}|null
*/
private function displayNodeFor(string $categoryId, array $parentMap, ?string $drillParentId): ?array
{
$chain = [];
$current = $categoryId;
$guard = 0;
while ($current !== null && $guard++ < Category::MAX_DEPTH + 1) {
array_unshift($chain, $current);
$current = $parentMap[$current] ?? null;
}
if ($drillParentId === null) {
return ['id' => $chain[0], 'is_direct' => false];
}
$index = array_search($drillParentId, $chain, true);
if ($index === false) {
return null;
}
if ($index === count($chain) - 1) {
return ['id' => $drillParentId, 'is_direct' => true];
}
return ['id' => $chain[$index + 1], 'is_direct' => false];
}
/**

View File

@ -698,6 +698,7 @@
"Hey there!": "¡Hola!",
"Hey! A quick heads-up.": "¡Hola! Un aviso rápido.",
"Hey! We have some great news.": "¡Hola! Tenemos buenas noticias.",
"Hide subcategories": "Ocultar subcategorías",
"Hi :name,": "Hola :name,",
"Hi! I'm Victor, the founder of Whisper Money. I wanted to personally welcome you and thank you for joining us.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Quería darte la bienvenida personalmente y agradecerte por unirte.",
"Hi! It's Victor and Álvaro here, the founders of Whisper Money. You've been using the app for a few days now, and we'd love to hear how it's working for you.": "¡Hola! Somos Víctor y Álvaro, los fundadores de Whisper Money. Llevas unos días usando la app y nos encantaría saber cómo te está funcionando.",
@ -1300,6 +1301,7 @@
"Shared with": "Compartido con",
"Shopping (Clothing, Electronics, Gifts)": "Compras (Ropa, Electrónica, Regalos)",
"Show as cash inflow": "Mostrar como entrada de dinero",
"Show subcategories": "Mostrar subcategorías",
"Show as cash outflow": "Mostrar como salida de dinero",
"Show in account currency (:currency)": "Mostrar en moneda de la cuenta (:currency)",
"Show in cashflow chart as inflow": "Mostrar en el gráfico de flujo de caja como entrada",

View File

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BreakdownCard } from './breakdown-card';
vi.mock('@/components/ui/amount-display', () => ({
@ -45,4 +45,87 @@ describe('BreakdownCard', () => {
expect(screen.getByText('Uncategorized')).toBeInTheDocument();
expect(screen.getByText('100%')).toBeInTheDocument();
});
it('expands a parent category and loads its children on demand', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({
data: [
{
category: {
id: 'child-1',
name: 'Groceries',
icon: 'ShoppingCart',
color: 'blue',
type: 'expense',
cashflow_direction: 'outflow',
parent_id: 'parent-1',
},
category_id: 'child-1',
amount: 4000,
percentage: 100,
previous_amount: 0,
has_children: false,
is_direct: false,
},
],
total: 4000,
previous_total: 0,
}),
});
vi.stubGlobal('fetch', fetchMock);
render(
<BreakdownCard
type="expense"
data={{
data: [
{
category: {
id: 'parent-1',
name: 'Food',
icon: 'Utensils',
color: 'amber',
type: 'expense',
cashflow_direction: 'outflow',
parent_id: null,
},
category_id: 'parent-1',
amount: 4000,
percentage: 100,
previous_amount: 0,
has_children: true,
is_direct: false,
},
],
total: 4000,
previous_total: 0,
}}
currency="USD"
period={{
from: new Date('2026-06-01'),
to: new Date('2026-06-30'),
}}
/>,
);
expect(screen.queryByText('Groceries')).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole('button', { name: 'Show subcategories' }),
);
await waitFor(() =>
expect(screen.getByText('Groceries')).toBeInTheDocument(),
);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('parent=parent-1'),
);
expect(
screen.getByRole('button', { name: 'Hide subcategories' }),
).toBeInTheDocument();
});
});
afterEach(() => {
vi.unstubAllGlobals();
});

View File

@ -1,6 +1,7 @@
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator';
import { AmountDisplay } from '@/components/ui/amount-display';
import { AnimatedCollapse } from '@/components/ui/animated-collapse';
import {
Card,
CardContent,
@ -9,8 +10,12 @@ import {
CardTitle,
} from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { BreakdownData } from '@/hooks/use-cashflow-data';
import { BreakdownData, BreakdownItem } from '@/hooks/use-cashflow-data';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import {
type ExpandableCategories,
useExpandableCategories,
} from '@/hooks/use-expandable-categories';
import { cn } from '@/lib/utils';
import {
type CategoryColor,
@ -21,7 +26,14 @@ import { __ } from '@/utils/i18n';
import { Link } from '@inertiajs/react';
import { format } from 'date-fns';
import * as Icons from 'lucide-react';
import { LucideIcon } from 'lucide-react';
import {
ChevronsDown,
ChevronsUp,
Loader2,
LucideIcon,
Minus,
} from 'lucide-react';
import { useCallback } from 'react';
interface BreakdownCardProps {
type: 'income' | 'expense';
@ -37,6 +49,171 @@ const fallbackCategory = {
color: 'gray' as CategoryColor,
};
function rowKey(item: BreakdownItem): string {
return `${item.category_id ?? 'uncategorized'}:${item.is_direct ? 'direct' : 'node'}`;
}
interface BreakdownRowProps {
item: BreakdownItem;
index: number;
type: 'income' | 'expense';
currency: string;
period?: { from: Date; to: Date };
expandable: ExpandableCategories<BreakdownItem>;
}
function BreakdownRow({
item,
index,
type,
currency,
period,
expandable,
}: BreakdownRowProps) {
const { categoryBarColor } = useChartColors();
const category = item.category ?? fallbackCategory;
const Icon = (Icons[category.icon as keyof typeof Icons] ||
Icons.HelpCircle) as LucideIcon;
const percentageChange =
item.previous_amount > 0
? ((item.amount - item.previous_amount) / item.previous_amount) *
100
: null;
const categoryColor = getCategoryColorClasses(category.color);
const chartColor = categoryBarColor(category.color, index);
const canExpand = Boolean(
item.has_children && !item.is_direct && item.category_id && period,
);
const id = item.category_id ?? '';
const expanded = canExpand && expandable.isExpanded(id);
const loading = canExpand && expandable.isLoading(id);
const categoryUrl =
period && item.category_id
? transactionsIndex({
query: {
category_ids: item.category_id,
date_from: format(period.from, 'yyyy-MM-dd'),
date_to: format(period.to, 'yyyy-MM-dd'),
},
}).url
: null;
const header = (
<div className="flex min-w-0 items-center justify-between gap-2 overflow-hidden">
<div className="flex max-w-[60%] grow items-center gap-2">
<div
className={cn([
'flex size-6 shrink-0 items-center justify-center rounded-full',
`${categoryColor.bg} ${categoryColor.text}`,
])}
>
<Icon className="size-3.5" />
</div>
<span className="min-w-0 truncate text-sm font-medium">
{category.name}
</span>
</div>
<div className="flex items-center gap-2">
{percentageChange !== null && (
<PercentageTrendIndicator
trend={percentageChange}
label=""
previousAmount={item.previous_amount}
currentAmount={item.amount}
currencyCode={currency}
invertColors={type === 'expense'}
className="shrink-0 text-xs"
/>
)}
<div className="flex shrink-0 items-center gap-2">
<span className="hidden text-xs text-muted-foreground sm:inline">
{item.percentage.toFixed(0)}%
</span>
<AmountDisplay
amountInCents={item.amount}
currencyCode={currency}
variant="compact"
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
</div>
</div>
</div>
);
return (
<div className="space-y-1.5">
<div className="flex items-center gap-0">
{canExpand ? (
<button
type="button"
onClick={() => expandable.toggle(id)}
aria-expanded={expanded}
aria-label={
expanded
? __('Hide subcategories')
: __('Show subcategories')
}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : expanded ? (
<ChevronsUp className="size-4" />
) : (
<ChevronsDown className="size-4" />
)}
</button>
) : (
<span
aria-hidden="true"
className="flex size-6 shrink-0 items-center justify-center text-muted-foreground/30"
>
<Minus className="size-4" />
</span>
)}
{categoryUrl ? (
<Link
href={categoryUrl}
className="group block grow rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
>
{header}
</Link>
) : (
<div className="grow px-1.5 py-1">{header}</div>
)}
</div>
<Progress
value={item.percentage}
className="h-1.5 w-full"
indicatorColor={chartColor}
/>
{canExpand && (
<AnimatedCollapse open={expanded}>
<div className="ml-[11px] space-y-1.5 border-l border-border pt-1.5 pl-3">
{expandable.getChildren(id).map((child, childIndex) => (
<BreakdownRow
key={rowKey(child)}
item={child}
index={childIndex}
type={type}
currency={currency}
period={period}
expandable={expandable}
/>
))}
</div>
</AnimatedCollapse>
)}
</div>
);
}
export function BreakdownCard({
type,
data,
@ -44,7 +221,6 @@ export function BreakdownCard({
currency = 'USD',
period,
}: BreakdownCardProps) {
const { categoryBarColor } = useChartColors();
const title =
type === 'income' ? __('Income Sources') : __('Expense Categories');
const description =
@ -56,6 +232,36 @@ export function BreakdownCard({
? __('No income this period')
: __('No expenses this period');
const periodKey = period
? `${format(period.from, 'yyyy-MM-dd')}:${format(period.to, 'yyyy-MM-dd')}`
: null;
const fetchChildren = useCallback(
async (categoryId: string): Promise<BreakdownItem[]> => {
if (!period) {
return [];
}
const params = new URLSearchParams({
from: format(period.from, 'yyyy-MM-dd'),
to: format(period.to, 'yyyy-MM-dd'),
type,
parent: categoryId,
});
const response = await fetch(
`/api/cashflow/breakdown?${params.toString()}`,
);
const json: BreakdownData = await response.json();
return json.data;
},
[period, type],
);
const expandable = useExpandableCategories<BreakdownItem>(
fetchChildren,
periodKey,
);
if (loading) {
return (
<Card>
@ -97,109 +303,18 @@ export function BreakdownCard({
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.data.map((item, index) => {
const category = item.category ?? fallbackCategory;
const Icon = (Icons[
category.icon as keyof typeof Icons
] || Icons.HelpCircle) as LucideIcon;
const percentageChange =
item.previous_amount > 0
? ((item.amount - item.previous_amount) /
item.previous_amount) *
100
: null;
const categoryColor = getCategoryColorClasses(
category.color,
);
const chartColor = categoryBarColor(
category.color,
index,
);
const categoryUrl = period
? transactionsIndex({
query: {
category_ids: item.category_id,
date_from: format(
period.from,
'yyyy-MM-dd',
),
date_to: format(period.to, 'yyyy-MM-dd'),
},
}).url
: null;
const rowContent = (
<>
<div className="flex min-w-0 items-center justify-between gap-2 overflow-hidden">
<div className="flex max-w-[60%] grow gap-2">
<div
className={cn([
'flex size-6 shrink-0 items-center justify-center rounded-full',
`${categoryColor.bg} ${categoryColor.text}`,
])}
>
<Icon className="size-3.5" />
</div>
<span className="min-w-0 truncate text-sm font-medium">
{category.name}
</span>
</div>
<div className="flex gap-2">
{percentageChange !== null && (
<PercentageTrendIndicator
trend={percentageChange}
label=""
previousAmount={
item.previous_amount
}
currentAmount={item.amount}
currencyCode={currency}
invertColors={
type === 'expense'
}
className="shrink-0 text-xs"
/>
)}
<div className="flex shrink-0 items-center gap-2">
<span className="hidden text-xs text-muted-foreground sm:inline">
{item.percentage.toFixed(0)}%
</span>
<AmountDisplay
amountInCents={item.amount}
currencyCode={currency}
variant="compact"
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
</div>
</div>
</div>
<Progress
value={item.percentage}
className="h-1.5"
indicatorColor={chartColor}
/>
</>
);
return categoryUrl ? (
<Link
key={item.category_id}
href={categoryUrl}
className="group -mx-1.5 my-1.5 block space-y-1.5 rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
>
{rowContent}
</Link>
) : (
<div key={item.category_id} className="space-y-1.5">
{rowContent}
</div>
);
})}
<div className="space-y-2.5">
{data.data.map((item, index) => (
<BreakdownRow
key={rowKey(item)}
item={item}
index={index}
type={type}
currency={currency}
period={period}
expandable={expandable}
/>
))}
{data.data.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">
{emptyMessage}

View File

@ -1,5 +1,6 @@
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { AmountDisplay } from '@/components/ui/amount-display';
import { AnimatedCollapse } from '@/components/ui/animated-collapse';
import {
Card,
CardContent,
@ -9,6 +10,10 @@ import {
} from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import {
type ExpandableCategories,
useExpandableCategories,
} from '@/hooks/use-expandable-categories';
import { cn } from '@/lib/utils';
import { SharedData } from '@/types';
import {
@ -20,7 +25,14 @@ import { __ } from '@/utils/i18n';
import { Link, usePage } from '@inertiajs/react';
import { format, subDays } from 'date-fns';
import * as Icons from 'lucide-react';
import { LucideIcon } from 'lucide-react';
import {
ChevronsDown,
ChevronsUp,
Loader2,
LucideIcon,
Minus,
} from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { PercentageTrendIndicator } from './percentage-trend-indicator';
interface CategoryData {
@ -29,6 +41,8 @@ interface CategoryData {
amount: number;
previous_amount: number;
total_amount: number;
has_children?: boolean;
is_direct?: boolean;
}
interface TopCategoriesCardProps {
@ -36,12 +50,193 @@ interface TopCategoriesCardProps {
loading?: boolean;
}
function rowKey(item: CategoryData): string {
return `${item.category?.id ?? item.category_id ?? 'uncategorized'}:${item.is_direct ? 'direct' : 'node'}`;
}
interface CategoryRowProps {
item: CategoryData;
index: number;
currencyCode: string;
dateFrom: string;
dateTo: string;
expandable: ExpandableCategories<CategoryData>;
}
function CategoryRow({
item,
index,
currencyCode,
dateFrom,
dateTo,
expandable,
}: CategoryRowProps) {
const { categoryBarColor } = useChartColors();
const category = item.category;
const categoryId = category?.id ?? item.category_id ?? 'uncategorized';
const categoryName = category?.name ?? __('Uncategorized');
const categoryIcon = category?.icon ?? 'HelpCircle';
const categoryColorName = category?.color ?? ('gray' as CategoryColor);
const Icon = (Icons[categoryIcon as keyof typeof Icons] ||
Icons.HelpCircle) as LucideIcon;
const percentageChange =
item.previous_amount > 0
? ((item.amount - item.previous_amount) / item.previous_amount) *
100
: null;
const percentage =
item.total_amount > 0 ? (item.amount / item.total_amount) * 100 : 0;
const categoryColor = getCategoryColorClasses(categoryColorName);
const chartColor = categoryBarColor(categoryColorName, index);
const canExpand = Boolean(item.has_children && !item.is_direct && category);
const expanded = canExpand && expandable.isExpanded(categoryId);
const loading = canExpand && expandable.isLoading(categoryId);
const categoryUrl = transactionsIndex({
query: {
category_ids: categoryId,
date_from: dateFrom,
date_to: dateTo,
},
}).url;
const header = (
<div className="flex min-w-0 items-center gap-2">
<div
className={cn([
'flex size-6 shrink-0 items-center justify-center rounded-full',
`${categoryColor.bg} ${categoryColor.text}`,
])}
>
<Icon className="size-4" />
</div>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{categoryName}
</span>
{percentageChange !== null && (
<PercentageTrendIndicator
trend={percentageChange}
label=""
previousAmount={item.previous_amount}
currentAmount={item.amount}
currencyCode={currencyCode}
invertColors
className="shrink-0 text-xs"
/>
)}
<AmountDisplay
amountInCents={item.amount}
currencyCode={currencyCode}
variant="compact"
minimumFractionDigits={0}
maximumFractionDigits={0}
className="shrink-0"
/>
</div>
);
return (
<div className="space-y-2">
<div className="flex items-center gap-0">
{canExpand ? (
<button
type="button"
onClick={() => expandable.toggle(categoryId)}
aria-expanded={expanded}
aria-label={
expanded
? __('Hide subcategories')
: __('Show subcategories')
}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : expanded ? (
<ChevronsUp className="size-4" />
) : (
<ChevronsDown className="size-4" />
)}
</button>
) : (
<span
aria-hidden="true"
className="flex size-6 shrink-0 items-center justify-center text-muted-foreground/30"
>
<Minus className="size-4" />
</span>
)}
<Link
href={categoryUrl}
className="group block grow rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
>
{header}
</Link>
</div>
<Progress
value={percentage}
className="h-2 w-full"
indicatorColor={chartColor}
/>
{canExpand && (
<AnimatedCollapse open={expanded}>
<div className="ml-[11px] space-y-2 border-l border-border pt-2 pl-3">
{expandable
.getChildren(categoryId)
.map((child, childIndex) => (
<CategoryRow
key={rowKey(child)}
item={child}
index={childIndex}
currencyCode={currencyCode}
dateFrom={dateFrom}
dateTo={dateTo}
expandable={expandable}
/>
))}
</div>
</AnimatedCollapse>
)}
</div>
);
}
export function TopCategoriesCard({
categories,
loading,
}: TopCategoriesCardProps) {
const { auth } = usePage<SharedData>().props;
const { categoryBarColor } = useChartColors();
const { dateFrom, dateTo } = useMemo(() => {
const now = new Date();
return {
dateFrom: format(subDays(now, 30), 'yyyy-MM-dd'),
dateTo: format(now, 'yyyy-MM-dd'),
};
}, []);
const fetchChildren = useCallback(
async (categoryId: string): Promise<CategoryData[]> => {
const params = new URLSearchParams({
from: dateFrom,
to: dateTo,
parent: categoryId,
});
const response = await fetch(
`/api/dashboard/top-categories?${params.toString()}`,
);
return response.json();
},
[dateFrom, dateTo],
);
const expandable = useExpandableCategories<CategoryData>(
fetchChildren,
dateFrom,
);
if (loading || !auth?.user) {
return (
@ -67,10 +262,6 @@ export function TopCategoriesCard({
);
}
const now = new Date();
const dateFrom = format(subDays(now, 30), 'yyyy-MM-dd');
const dateTo = format(now, 'yyyy-MM-dd');
return (
<Card className="w-full">
<CardHeader className="gap-2">
@ -78,95 +269,18 @@ export function TopCategoriesCard({
<CardDescription>{__('on the last 30 days')}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{categories.map((item, index) => {
const category = item.category;
const categoryId =
category?.id ?? item.category_id ?? 'uncategorized';
const categoryName =
category?.name ?? __('Uncategorized');
const categoryIcon = category?.icon ?? 'HelpCircle';
const categoryColorName =
category?.color ?? ('gray' as CategoryColor);
const Icon = (Icons[
categoryIcon as keyof typeof Icons
] || Icons.HelpCircle) as LucideIcon;
const percentageChange =
item.previous_amount > 0
? ((item.amount - item.previous_amount) /
item.previous_amount) *
100
: null;
const percentage =
item.total_amount > 0
? (item.amount / item.total_amount) * 100
: 0;
const categoryColor =
getCategoryColorClasses(categoryColorName);
const chartColor = categoryBarColor(
categoryColorName,
index,
);
const categoryUrl = transactionsIndex({
query: {
category_ids: categoryId,
date_from: dateFrom,
date_to: dateTo,
},
}).url;
return (
<Link
key={categoryId}
href={categoryUrl}
className="group -mx-1.5 my-1.5 block space-y-2 rounded-md px-1.5 py-1 transition-colors hover:bg-muted"
>
<div className="flex min-w-0 items-center gap-2">
<div
className={cn([
'flex size-6 shrink-0 items-center justify-center rounded-full',
`${categoryColor.bg} ${categoryColor.text}`,
])}
>
<Icon className="size-4" />
</div>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{categoryName}
</span>
{percentageChange !== null && (
<PercentageTrendIndicator
trend={percentageChange}
label=""
previousAmount={
item.previous_amount
}
currentAmount={item.amount}
currencyCode={
auth.user.currency_code
}
invertColors
className="shrink-0 text-xs"
/>
)}
<AmountDisplay
amountInCents={item.amount}
currencyCode={auth.user.currency_code}
variant="compact"
minimumFractionDigits={0}
maximumFractionDigits={0}
className="shrink-0"
/>
</div>
<Progress
value={percentage}
className="h-2"
indicatorColor={chartColor}
/>
</Link>
);
})}
<div className="space-y-3">
{categories.map((item, index) => (
<CategoryRow
key={rowKey(item)}
item={item}
index={index}
currencyCode={auth.user.currency_code}
dateFrom={dateFrom}
dateTo={dateTo}
expandable={expandable}
/>
))}
{categories.length === 0 && (
<div className="py-8 text-center text-muted-foreground">
{__('No spending data this month')}

View File

@ -0,0 +1,33 @@
import { cn } from '@/lib/utils';
import type { ReactNode } from 'react';
interface AnimatedCollapseProps {
open: boolean;
children: ReactNode;
className?: string;
}
/**
* Animates its content open/closed by transitioning the grid row track from
* `0fr` to `1fr`, which lets an auto-height element grow and shrink smoothly
* without measuring it in JavaScript.
*/
export function AnimatedCollapse({
open,
children,
className,
}: AnimatedCollapseProps) {
return (
<div
aria-hidden={!open}
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className={cn('min-h-0 overflow-hidden', className)}>
{children}
</div>
</div>
);
}

View File

@ -46,10 +46,13 @@ export interface DashboardData {
netWorthEvolution: NetWorthEvolutionData;
accounts: AccountWithMetrics[];
topCategories: Array<{
category: Category;
category: Category | null;
category_id?: string | null;
amount: number;
previous_amount: number;
total_amount: number;
has_children?: boolean;
is_direct?: boolean;
}>;
isLoading: boolean;
}

View File

@ -0,0 +1,83 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export interface ExpandableCategories<T> {
isExpanded: (id: string) => boolean;
isLoading: (id: string) => boolean;
getChildren: (id: string) => T[];
toggle: (id: string) => void;
}
/**
* Tracks which parent categories are expanded and lazily fetches their
* children once, caching the result. Expansions and the cache are cleared
* whenever `resetKey` changes (e.g. the selected period).
*/
export function useExpandableCategories<T>(
fetchChildren: (categoryId: string) => Promise<T[]>,
resetKey?: unknown,
): ExpandableCategories<T> {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [childrenById, setChildrenById] = useState<Record<string, T[]>>({});
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
const fetchRef = useRef(fetchChildren);
fetchRef.current = fetchChildren;
const loadedRef = useRef<Set<string>>(new Set());
useEffect(() => {
setExpanded(new Set());
setChildrenById({});
setLoadingIds(new Set());
loadedRef.current = new Set();
}, [resetKey]);
const toggle = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
if (loadedRef.current.has(id)) {
return;
}
loadedRef.current.add(id);
setLoadingIds((prev) => new Set(prev).add(id));
fetchRef
.current(id)
.then((data) =>
setChildrenById((current) => ({ ...current, [id]: data })),
)
.catch((error) => {
loadedRef.current.delete(id);
console.error('Failed to load subcategories:', error);
})
.finally(() =>
setLoadingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
}),
);
}, []);
const isExpanded = useCallback(
(id: string) => expanded.has(id),
[expanded],
);
const isLoading = useCallback(
(id: string) => loadingIds.has(id),
[loadingIds],
);
const getChildren = useCallback(
(id: string) => childrenById[id] ?? [],
[childrenById],
);
return { isExpanded, isLoading, getChildren, toggle };
}

View File

@ -29,10 +29,13 @@ interface DashboardProps extends SharedData {
showEncryptionPrompt: boolean;
netWorthEvolution?: NetWorthEvolutionData;
topCategories?: Array<{
category: Category;
category: Category | null;
category_id?: string | null;
amount: number;
previous_amount: number;
total_amount: number;
has_children?: boolean;
is_direct?: boolean;
}>;
cashflowSummary?: {
current: CashflowSummary;

View File

@ -369,6 +369,63 @@ test('top categories rolls child spending up into the top-level parent', functio
expect($data[0]['category']['id'])->toBe($food->id);
expect($data[0]['amount'])->toBe(6000);
expect($data[0]['total_amount'])->toBe(6000);
expect($data[0]['has_children'])->toBeTrue();
});
test('top categories flags parents without children as not expandable', function () {
$rent = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Rent']);
Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $rent->id,
'amount' => -1000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/dashboard/top-categories?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
expect($response->json('0.has_children'))->toBeFalse();
});
test('drilling into a top category splits it into children plus a direct node', function () {
$food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
$groceries = Category::factory()->childOf($food)->create(['user_id' => $this->user->id, 'name' => 'Groceries']);
Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $food->id,
'amount' => -1000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'category_id' => $groceries->id,
'amount' => -2000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/dashboard/top-categories?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
'parent' => $food->id,
]));
$response->assertOk();
$data = collect($response->json());
expect($data)->toHaveCount(2);
$childNode = $data->firstWhere('is_direct', false);
expect($childNode['category_id'])->toBe($groceries->id)
->and($childNode['amount'])->toBe(2000);
$directNode = $data->firstWhere('is_direct', true);
expect($directNode['category_id'])->toBe($food->id)
->and($directNode['amount'])->toBe(1000);
});
test('net worth evolution returns monthly data points with per-account balances', function () {