From 53d051800bb1e676563bbc1038e607efc186bebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Thu, 4 Jun 2026 11:19:21 +0200 Subject: [PATCH] feat: expand parent categories inline in breakdowns (#486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Expand/collapse child categories inline in three places: - Dashboard → **Top spending categories** - Cashflow → **Income sources** - Cashflow → **Expense categories** ## How it looks image ## 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). --- .../Api/CashflowAnalyticsController.php | 129 +------ .../Api/DashboardAnalyticsController.php | 43 ++- app/Http/Controllers/DashboardController.php | 31 +- app/Services/CategoryTree.php | 127 ++++++- lang/es.json | 2 + .../cashflow/breakdown-card.test.tsx | 87 ++++- .../js/components/cashflow/breakdown-card.tsx | 327 ++++++++++++------ .../dashboard/top-categories-card.tsx | 304 +++++++++++----- .../js/components/ui/animated-collapse.tsx | 33 ++ resources/js/hooks/use-dashboard-data.ts | 5 +- .../js/hooks/use-expandable-categories.ts | 83 +++++ resources/js/pages/dashboard.tsx | 5 +- tests/Feature/DashboardAnalyticsTest.php | 57 +++ 13 files changed, 846 insertions(+), 387 deletions(-) create mode 100644 resources/js/components/ui/animated-collapse.tsx create mode 100644 resources/js/hooks/use-expandable-categories.ts diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index ee92d583..c4edd40a 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -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 $categorized - * @return array - */ - 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 $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]; - } } diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php index adfec712..deb62c29 100644 --- a/app/Http/Controllers/Api/DashboardAnalyticsController.php +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -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 + * 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(); } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index d5807023..546e3603 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -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 */ 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(); } diff --git a/app/Services/CategoryTree.php b/app/Services/CategoryTree.php index 6d537054..fb0392df 100644 --- a/app/Services/CategoryTree.php +++ b/app/Services/CategoryTree.php @@ -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 + * 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 $categorized + * @return array */ - 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 $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]; } /** diff --git a/lang/es.json b/lang/es.json index 2068a41c..cdbced96 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", diff --git a/resources/js/components/cashflow/breakdown-card.test.tsx b/resources/js/components/cashflow/breakdown-card.test.tsx index 08901dac..9caabe3c 100644 --- a/resources/js/components/cashflow/breakdown-card.test.tsx +++ b/resources/js/components/cashflow/breakdown-card.test.tsx @@ -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( + , + ); + + 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(); }); diff --git a/resources/js/components/cashflow/breakdown-card.tsx b/resources/js/components/cashflow/breakdown-card.tsx index 88ba5236..3309a287 100644 --- a/resources/js/components/cashflow/breakdown-card.tsx +++ b/resources/js/components/cashflow/breakdown-card.tsx @@ -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; +} + +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 = ( +
+
+
+ +
+ + {category.name} + +
+
+ {percentageChange !== null && ( + + )} +
+ + {item.percentage.toFixed(0)}% + + +
+
+
+ ); + + return ( +
+
+ {canExpand ? ( + + ) : ( + + )} + {categoryUrl ? ( + + {header} + + ) : ( +
{header}
+ )} +
+ + + {canExpand && ( + +
+ {expandable.getChildren(id).map((child, childIndex) => ( + + ))} +
+
+ )} +
+ ); +} + 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 => { + 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( + fetchChildren, + periodKey, + ); + if (loading) { return ( @@ -97,109 +303,18 @@ export function BreakdownCard({ {description} -
- {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 = ( - <> -
-
-
- -
- - {category.name} - -
-
- {percentageChange !== null && ( - - )} -
- - {item.percentage.toFixed(0)}% - - -
-
-
- - - ); - - return categoryUrl ? ( - - {rowContent} - - ) : ( -
- {rowContent} -
- ); - })} +
+ {data.data.map((item, index) => ( + + ))} {data.data.length === 0 && (
{emptyMessage} diff --git a/resources/js/components/dashboard/top-categories-card.tsx b/resources/js/components/dashboard/top-categories-card.tsx index a5c47453..b8142549 100644 --- a/resources/js/components/dashboard/top-categories-card.tsx +++ b/resources/js/components/dashboard/top-categories-card.tsx @@ -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; +} + +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 = ( +
+
+ +
+ + {categoryName} + + {percentageChange !== null && ( + + )} + +
+ ); + + return ( +
+
+ {canExpand ? ( + + ) : ( + + )} + + {header} + +
+ + + {canExpand && ( + +
+ {expandable + .getChildren(categoryId) + .map((child, childIndex) => ( + + ))} +
+
+ )} +
+ ); +} + export function TopCategoriesCard({ categories, loading, }: TopCategoriesCardProps) { const { auth } = usePage().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 => { + 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( + 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 ( @@ -78,95 +269,18 @@ export function TopCategoriesCard({ {__('on the last 30 days')} -
- {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 ( - -
-
- -
- - {categoryName} - - {percentageChange !== null && ( - - )} - -
- - - ); - })} +
+ {categories.map((item, index) => ( + + ))} {categories.length === 0 && (
{__('No spending data this month')} diff --git a/resources/js/components/ui/animated-collapse.tsx b/resources/js/components/ui/animated-collapse.tsx new file mode 100644 index 00000000..98dbb492 --- /dev/null +++ b/resources/js/components/ui/animated-collapse.tsx @@ -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 ( +
+
+ {children} +
+
+ ); +} diff --git a/resources/js/hooks/use-dashboard-data.ts b/resources/js/hooks/use-dashboard-data.ts index 9cf7b6cc..ff03a393 100644 --- a/resources/js/hooks/use-dashboard-data.ts +++ b/resources/js/hooks/use-dashboard-data.ts @@ -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; } diff --git a/resources/js/hooks/use-expandable-categories.ts b/resources/js/hooks/use-expandable-categories.ts new file mode 100644 index 00000000..d41d9696 --- /dev/null +++ b/resources/js/hooks/use-expandable-categories.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface ExpandableCategories { + 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( + fetchChildren: (categoryId: string) => Promise, + resetKey?: unknown, +): ExpandableCategories { + const [expanded, setExpanded] = useState>(new Set()); + const [childrenById, setChildrenById] = useState>({}); + const [loadingIds, setLoadingIds] = useState>(new Set()); + + const fetchRef = useRef(fetchChildren); + fetchRef.current = fetchChildren; + const loadedRef = useRef>(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 }; +} diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 0a8ba820..eafa0981 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -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; diff --git a/tests/Feature/DashboardAnalyticsTest.php b/tests/Feature/DashboardAnalyticsTest.php index f9d31069..ad92f3a3 100644 --- a/tests/Feature/DashboardAnalyticsTest.php +++ b/tests/Feature/DashboardAnalyticsTest.php @@ -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 () {