feat(analysis): shared bar-list breakdowns in transaction drawer (#517)

## What

Unifies the "top categories"-style bar lists behind one reusable
component and applies it to the transaction **analysis drawer**.

### Shared component
New `resources/js/components/shared/category-breakdown-list.tsx` —
generic `CategoryBreakdownRow<T>` + adapter (leading marker, truncated
name, optional trend + %, amount, proportional bar, expand/collapse
recursion). Now used by:
- Dashboard **Top spending categories** (was a local `CategoryRow`)
- Cash-flow **Income Sources / Expense Categories** (was a local
`BreakdownRow`)
- Analysis drawer breakdowns

### Analysis drawer
- **Top categories** — pie+list → bar list, now with **expand/collapse**
of subcategories (children already in the payload, served through
`useExpandableCategories` with no extra fetch).
- **Top tags** — recharts bar → bar list, colour-dot leading marker.
- **Top accounts** — plain list → bar list, with the **bank/account
logo** as the leading marker instead of a category icon.

### Largest expenses table
Long category names pushed the amount onto two lines (minus sign split
from the digits). Category + description columns are now capped to the
same width, the chip truncates, and the amount cell is
`whitespace-nowrap`.

### Backend
`icon` added to the category breakdown payload (parent, child,
Uncategorized) so the drawer can render the icon circle.

## Tests
- Drawer: category expand/collapse + tag/account render tests.
- Backend: asserts `color`/`icon` on the category breakdown.
- Full JS suite (191) + analysis feature tests green; pint / eslint /
prettier clean.

## Note
Dashboard card has no test file — refactor preserves its public
behaviour; worth a visual check.
This commit is contained in:
Víctor Falcón 2026-06-10 12:36:20 +02:00 committed by GitHub
parent fcf2d3d1ad
commit bcd025f1b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 579 additions and 542 deletions

View File

@ -141,11 +141,13 @@ class TransactionAnalysisController extends Controller
'category_id' => $node['category_id'],
'name' => $node['category']->name,
'color' => $node['category']->color,
'icon' => $node['category']->icon,
'amount' => $node['amount'],
'children' => array_map(fn (array $child): array => [
'category_id' => $child['category_id'],
'name' => $child['category']->name,
'color' => $child['category']->color,
'icon' => $child['category']->icon,
'amount' => $child['amount'],
], $node['children']),
], $this->tree->spendingBreakdown($grouped, $userId));
@ -159,6 +161,7 @@ class TransactionAnalysisController extends Controller
'category_id' => null,
'name' => __('Uncategorized'),
'color' => 'gray',
'icon' => 'HelpCircle',
'amount' => $uncategorized,
'children' => [],
];

View File

@ -1,7 +1,9 @@
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator';
import {
CategoryBreakdownRow,
type CategoryBreakdownAdapter,
} from '@/components/shared/category-breakdown-list';
import { AmountDisplay } from '@/components/ui/amount-display';
import { AnimatedCollapse } from '@/components/ui/animated-collapse';
import {
Card,
CardContent,
@ -9,30 +11,19 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
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 { useExpandableCategories } from '@/hooks/use-expandable-categories';
import { cn } from '@/lib/utils';
import {
getCategoryColorClasses,
type CategoryColor,
type CategoryIcon,
getCategoryColorClasses,
} from '@/types/category';
import { __ } from '@/utils/i18n';
import { Link } from '@inertiajs/react';
import { format } from 'date-fns';
import * as Icons from 'lucide-react';
import {
ChevronsDown,
ChevronsUp,
Loader2,
LucideIcon,
Minus,
} from 'lucide-react';
import { LucideIcon } from 'lucide-react';
import { useCallback } from 'react';
interface BreakdownCardProps {
@ -53,167 +44,6 @@ 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,
@ -221,6 +51,8 @@ export function BreakdownCard({
currency = 'USD',
period,
}: BreakdownCardProps) {
const { categoryBarColor } = useChartColors();
const title =
type === 'income' ? __('Income Sources') : __('Expense Categories');
const description =
@ -262,6 +94,61 @@ export function BreakdownCard({
periodKey,
);
const adapter: CategoryBreakdownAdapter<BreakdownItem> = {
getId: (item) => item.category_id ?? '',
getKey: (item) => rowKey(item),
getName: (item) => (item.category ?? fallbackCategory).name,
getAmount: (item) => item.amount,
getPercentage: (item) => item.percentage,
getBarColor: (item, index) =>
categoryBarColor((item.category ?? fallbackCategory).color, index),
renderLeading: (item) => {
const category = item.category ?? fallbackCategory;
const color = getCategoryColorClasses(category.color);
const Icon = (Icons[category.icon as keyof typeof Icons] ||
Icons.HelpCircle) as LucideIcon;
return (
<div
className={cn([
'flex size-6 shrink-0 items-center justify-center rounded-full',
`${color.bg} ${color.text}`,
])}
>
<Icon className="size-4" />
</div>
);
},
getHref: (item) =>
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,
getTrend: (item) =>
item.previous_amount > 0
? {
change:
((item.amount - item.previous_amount) /
item.previous_amount) *
100,
previousAmount: item.previous_amount,
currentAmount: item.amount,
}
: null,
canExpand: (item) =>
Boolean(
item.has_children &&
!item.is_direct &&
item.category_id &&
period,
),
};
if (loading) {
return (
<Card>
@ -305,14 +192,16 @@ export function BreakdownCard({
<CardContent>
<div className="space-y-2.5">
{data.data.map((item, index) => (
<BreakdownRow
<CategoryBreakdownRow
key={rowKey(item)}
item={item}
index={index}
type={type}
currency={currency}
period={period}
currencyCode={currency}
adapter={adapter}
expandable={expandable}
expandColumn
showPercentage
invertTrendColors={type === 'expense'}
/>
))}
{data.data.length === 0 && (

View File

@ -1,6 +1,8 @@
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 {
CategoryBreakdownRow,
type CategoryBreakdownAdapter,
} from '@/components/shared/category-breakdown-list';
import {
Card,
CardContent,
@ -8,32 +10,21 @@ import {
CardHeader,
CardTitle,
} 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 { useExpandableCategories } from '@/hooks/use-expandable-categories';
import { cn } from '@/lib/utils';
import { SharedData } from '@/types';
import {
Category,
type CategoryColor,
getCategoryColorClasses,
type CategoryColor,
} from '@/types/category';
import { __ } from '@/utils/i18n';
import { Link, usePage } from '@inertiajs/react';
import { usePage } from '@inertiajs/react';
import { format, subDays } from 'date-fns';
import * as Icons from 'lucide-react';
import {
ChevronsDown,
ChevronsUp,
Loader2,
LucideIcon,
Minus,
} from 'lucide-react';
import { LucideIcon } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { PercentageTrendIndicator } from './percentage-trend-indicator';
interface CategoryData {
category: Category | null;
@ -54,161 +45,12 @@ 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();
@ -238,6 +80,64 @@ export function TopCategoriesCard({
dateFrom,
);
const adapter: CategoryBreakdownAdapter<CategoryData> = {
getId: (item) =>
item.category?.id ?? item.category_id ?? 'uncategorized',
getKey: (item) => rowKey(item),
getName: (item) => item.category?.name ?? __('Uncategorized'),
getAmount: (item) => item.amount,
getPercentage: (item) =>
item.total_amount > 0 ? (item.amount / item.total_amount) * 100 : 0,
getBarColor: (item, index) =>
categoryBarColor(
(item.category?.color ?? 'gray') as CategoryColor,
index,
),
renderLeading: (item) => {
const color = getCategoryColorClasses(
(item.category?.color ?? 'gray') as CategoryColor,
);
const Icon = (Icons[
(item.category?.icon ?? 'HelpCircle') as keyof typeof Icons
] || Icons.HelpCircle) as LucideIcon;
return (
<div
className={cn([
'flex size-6 shrink-0 items-center justify-center rounded-full',
`${color.bg} ${color.text}`,
])}
>
<Icon className="size-4" />
</div>
);
},
getHref: (item) =>
transactionsIndex({
query: {
category_ids:
item.category?.id ??
item.category_id ??
'uncategorized',
date_from: dateFrom,
date_to: dateTo,
},
}).url,
getTrend: (item) =>
item.previous_amount > 0
? {
change:
((item.amount - item.previous_amount) /
item.previous_amount) *
100,
previousAmount: item.previous_amount,
currentAmount: item.amount,
}
: null,
canExpand: (item) =>
Boolean(item.has_children && !item.is_direct && item.category),
};
if (loading || !auth?.user) {
return (
<Card className="w-full">
@ -271,14 +171,14 @@ export function TopCategoriesCard({
<CardContent>
<div className="space-y-3">
{categories.map((item, index) => (
<CategoryRow
<CategoryBreakdownRow
key={rowKey(item)}
item={item}
index={index}
currencyCode={auth.user.currency_code}
dateFrom={dateFrom}
dateTo={dateTo}
adapter={adapter}
expandable={expandable}
expandColumn
/>
))}
{categories.length === 0 && (

View File

@ -0,0 +1,174 @@
import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator';
import { AmountDisplay } from '@/components/ui/amount-display';
import { AnimatedCollapse } from '@/components/ui/animated-collapse';
import { Progress } from '@/components/ui/progress';
import { type ExpandableCategories } from '@/hooks/use-expandable-categories';
import { __ } from '@/utils/i18n';
import { Link } from '@inertiajs/react';
import { ChevronsDown, ChevronsUp, Loader2, Minus } from 'lucide-react';
import { type ReactNode } from 'react';
export interface BreakdownTrend {
change: number;
previousAmount: number;
currentAmount: number;
}
/**
* Maps an arbitrary item to the fields a breakdown row renders. Each widget
* (dashboard categories, cash-flow income/expenses, the analysis drawer's
* categories, tags and accounts) supplies one of these so they can all share
* the exact same row: a leading marker, a truncated name, an optional trend
* and percentage, the amount, and a proportional bar underneath.
*/
export interface CategoryBreakdownAdapter<T> {
getId: (item: T) => string;
getKey: (item: T, index: number) => string;
getName: (item: T) => string;
getAmount: (item: T) => number;
/** Bar fill, 0100. */
getPercentage: (item: T) => number;
getBarColor: (item: T, index: number) => string;
renderLeading: (item: T, index: number) => ReactNode;
getHref?: (item: T) => string | null;
getTrend?: (item: T) => BreakdownTrend | null;
canExpand?: (item: T) => boolean;
}
interface CategoryBreakdownRowProps<T> {
item: T;
index: number;
currencyCode: string;
adapter: CategoryBreakdownAdapter<T>;
expandable?: ExpandableCategories<T>;
/** Render the expand/collapse gutter (and its placeholder) on every row. */
expandColumn?: boolean;
showPercentage?: boolean;
invertTrendColors?: boolean;
}
export function CategoryBreakdownRow<T>({
item,
index,
currencyCode,
adapter,
expandable,
expandColumn = false,
showPercentage = false,
invertTrendColors = false,
}: CategoryBreakdownRowProps<T>) {
const id = adapter.getId(item);
const percentage = adapter.getPercentage(item);
const href = adapter.getHref?.(item) ?? null;
const trend = adapter.getTrend?.(item) ?? null;
const canExpand = Boolean(expandable && adapter.canExpand?.(item));
const expanded = canExpand && expandable!.isExpanded(id);
const loading = canExpand && expandable!.isLoading(id);
const header = (
<div className="flex min-w-0 items-center gap-2">
{adapter.renderLeading(item, index)}
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{adapter.getName(item)}
</span>
{trend && (
<PercentageTrendIndicator
trend={trend.change}
label=""
previousAmount={trend.previousAmount}
currentAmount={trend.currentAmount}
currencyCode={currencyCode}
invertColors={invertTrendColors}
className="shrink-0 text-xs"
/>
)}
{showPercentage && (
<span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
{percentage.toFixed(0)}%
</span>
)}
<AmountDisplay
amountInCents={adapter.getAmount(item)}
currencyCode={currencyCode}
variant="compact"
minimumFractionDigits={0}
maximumFractionDigits={0}
className="shrink-0 whitespace-nowrap"
/>
</div>
);
return (
<div className="space-y-2">
<div className="flex items-center gap-0">
{expandColumn &&
(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>
))}
{href ? (
<Link
href={href}
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={percentage}
className="h-2 w-full"
indicatorColor={adapter.getBarColor(item, index)}
/>
{canExpand && (
<AnimatedCollapse open={expanded}>
<div className="ml-[11px] space-y-2 border-l border-border pt-2 pl-3">
{expandable!
.getChildren(id)
.map((child, childIndex) => (
<CategoryBreakdownRow
key={adapter.getKey(child, childIndex)}
item={child}
index={childIndex}
currencyCode={currencyCode}
adapter={adapter}
expandable={expandable}
expandColumn={expandColumn}
showPercentage={showPercentage}
invertTrendColors={invertTrendColors}
/>
))}
</div>
</AnimatedCollapse>
)}
</div>
);
}

View File

@ -6,6 +6,7 @@ import {
waitFor,
within,
} from '@testing-library/react';
import type React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TransactionAnalysisDrawer } from './transaction-analysis-drawer';
@ -29,6 +30,13 @@ vi.mock('@/components/ui/amount-display', () => ({
),
}));
vi.mock('@inertiajs/react', () => ({
Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
usePage: () => ({ props: { chartColorScheme: 'colorful' } }),
}));
const filters: TransactionFilters = {
dateFrom: null,
dateTo: null,
@ -399,3 +407,106 @@ describe('TransactionAnalysisDrawer largest expenses columns', () => {
expect(screen.getByText('Labels')).toBeInTheDocument();
});
});
describe('TransactionAnalysisDrawer breakdowns', () => {
const breakdownResponse = {
...analysisResponse,
by_category: [
{
category_id: 'c1',
name: 'Food',
color: 'amber',
icon: 'Utensils',
amount: 40000,
children: [
{
category_id: 'c2',
name: 'Groceries',
color: 'amber',
icon: 'ShoppingBag',
amount: 30000,
},
],
},
{
category_id: 'c3',
name: 'Hotel',
color: 'blue',
icon: 'Building',
amount: 20000,
children: [],
},
],
distinct_category_count: 2,
by_tag: [
{ id: 't1', name: 'Miami', color: 'blue', amount: 10000 },
{ id: 't2', name: 'Snacks', color: 'amber', amount: 3000 },
],
distinct_label_count: 2,
by_account: [
{
id: 'a1',
name: 'Visa',
bank: { name: 'Acme', logo: null },
amount: 5000,
},
{ id: 'a2', name: 'Amex', bank: null, amount: 3000 },
],
distinct_account_count: 2,
};
it('renders a category bar list and expands children on demand', async () => {
axiosGet.mockResolvedValue({ data: { data: [] } });
mockAnalysisFetch(breakdownResponse);
render(
<TransactionAnalysisDrawer
open
onOpenChange={vi.fn()}
filters={filters}
/>,
);
await waitFor(() =>
expect(
screen.getByText('Spending by category'),
).toBeInTheDocument(),
);
expect(screen.getByText('Food')).toBeInTheDocument();
expect(screen.getByText('Hotel')).toBeInTheDocument();
// The nested category is hidden until its parent is expanded.
expect(screen.queryByText('Groceries')).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole('button', { name: 'Show subcategories' }),
);
await waitFor(() =>
expect(screen.getByText('Groceries')).toBeInTheDocument(),
);
});
it('renders the tag and account bar lists', async () => {
axiosGet.mockResolvedValue({ data: { data: [] } });
mockAnalysisFetch(breakdownResponse);
render(
<TransactionAnalysisDrawer
open
onOpenChange={vi.fn()}
filters={filters}
/>,
);
await waitFor(() =>
expect(screen.getByText('Spending by tag')).toBeInTheDocument(),
);
expect(screen.getByText('Miami')).toBeInTheDocument();
expect(screen.getByText('Snacks')).toBeInTheDocument();
expect(screen.getByText('Spending by account')).toBeInTheDocument();
expect(screen.getByText('Visa')).toBeInTheDocument();
expect(screen.getByText('Amex')).toBeInTheDocument();
});
});

View File

@ -1,5 +1,9 @@
import { AccountName } from '@/components/accounts/account-name';
import { BankLogo } from '@/components/bank-logo';
import {
CategoryBreakdownRow,
type CategoryBreakdownAdapter,
} from '@/components/shared/category-breakdown-list';
import { LabelBadges } from '@/components/shared/label-combobox';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Button } from '@/components/ui/button';
@ -18,6 +22,8 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import { useExpandableCategories } from '@/hooks/use-expandable-categories';
import { useLocale } from '@/hooks/use-locale';
import {
filtersFingerprint,
@ -54,11 +60,8 @@ import {
} from 'react';
import {
Bar,
Cell,
ComposedChart,
Line,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
@ -93,6 +96,7 @@ interface CategorySlice {
category_id: string | null;
name: string;
color: string;
icon: string | null;
amount: number;
children: CategorySlice[];
}
@ -163,40 +167,6 @@ interface TransactionAnalysisDrawerProps {
filters: TransactionFilters;
}
const CHART_PALETTE = [
'var(--color-chart-1)',
'var(--color-chart-2)',
'var(--color-chart-3)',
'var(--color-chart-4)',
'var(--color-chart-5)',
'var(--color-chart-6)',
'var(--color-chart-7)',
'var(--color-chart-8)',
];
/**
* Reorders a sequential palette so neighbouring entries alternate between its
* dark and light ends. Monochrome schemes (blue, pink, neutral) run from
* darkest to lightest, so picking adjacent shades leaves consecutive slices
* nearly indistinguishable; zipping the two halves maximises the contrast
* between one slice and the next.
*/
function alternateContrast(palette: string[]): string[] {
const half = Math.ceil(palette.length / 2);
const result: string[] = [];
for (let index = 0; index < half; index++) {
result.push(palette[index]);
if (index + half < palette.length) {
result.push(palette[index + half]);
}
}
return result;
}
const CHART_COLORS = alternateContrast(CHART_PALETTE);
function buildQueryString(filters: SerializedFilters): string {
const params = new URLSearchParams();
@ -515,7 +485,6 @@ export function TransactionAnalysisDrawer({
<TagBreakdown
slices={data.by_tag}
currency={currency}
locale={locale}
/>
)}
</div>
@ -1078,7 +1047,7 @@ function LargestTransactions({
)}
</td>
{showCategory && (
<td className="py-2 pr-3">
<td className="max-w-[160px] py-2 pr-3">
<CategoryChip
category={item.category}
/>
@ -1113,7 +1082,7 @@ function LargestTransactions({
)}
</td>
)}
<td className="max-w-[200px] truncate py-2 pr-3">
<td className="max-w-[160px] truncate py-2 pr-3">
{item.description || (
<span className="text-muted-foreground">
@ -1130,7 +1099,7 @@ function LargestTransactions({
/>
</td>
)}
<td className="py-2 text-right">
<td className="py-2 text-right whitespace-nowrap">
<AmountDisplay
amountInCents={-item.amount}
currencyCode={currency}
@ -1170,17 +1139,39 @@ function CategoryChip({ category }: { category: LargestExpense['category'] }) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-xs whitespace-nowrap',
'inline-flex max-w-full items-center gap-1.5 rounded-md px-1.5 py-0.5 text-xs',
classes.bg,
classes.text,
)}
>
<Icon className="size-3" />
{category.name}
<Icon className="size-3 shrink-0" />
<span className="truncate">{category.name}</span>
</span>
);
}
/**
* Builds the icon-in-a-coloured-circle marker the dashboard uses for a
* category, reused for every category row in the drawer's breakdowns.
*/
function categoryLeading(color: string | null, icon: string | null): ReactNode {
const classes = getCategoryColorClasses((color ?? 'gray') as CategoryColor);
const Icon = (Icons[(icon ?? 'HelpCircle') as CategoryIcon] ??
HelpCircle) as LucideIcon;
return (
<div
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-full',
classes.bg,
classes.text,
)}
>
<Icon className="size-4" />
</div>
);
}
function CategoryBreakdown({
slices,
currency,
@ -1188,120 +1179,50 @@ function CategoryBreakdown({
slices: CategorySlice[];
currency: string;
}) {
const { categoryBarColor } = useChartColors();
const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
const config: ChartConfig = { amount: { label: __('Spent') } };
const childrenById = useMemo(() => {
const map: Record<string, CategorySlice[]> = {};
for (const slice of slices) {
if (slice.category_id) {
map[slice.category_id] = slice.children ?? [];
}
}
return map;
}, [slices]);
const expandable = useExpandableCategories<CategorySlice>(
async (categoryId) => childrenById[categoryId] ?? [],
slices,
);
const adapter: CategoryBreakdownAdapter<CategorySlice> = {
getId: (item) => item.category_id ?? '',
getKey: (item, index) => item.category_id ?? `category-${index}`,
getName: (item) => item.name,
getAmount: (item) => item.amount,
getPercentage: (item) => (total > 0 ? (item.amount / total) * 100 : 0),
getBarColor: (item, index) =>
categoryBarColor((item.color ?? 'gray') as CategoryColor, index),
renderLeading: (item) => categoryLeading(item.color, item.icon),
canExpand: (item) => (item.children?.length ?? 0) > 0,
};
return (
<Panel title={__('Spending by category')}>
<div className="flex flex-col items-center gap-6 sm:flex-row">
<ChartContainer config={config} className="h-52 w-52 shrink-0">
<ResponsiveContainer>
<PieChart>
<Pie
data={slices}
dataKey="amount"
nameKey="name"
innerRadius={55}
outerRadius={85}
paddingAngle={2}
>
{slices.map((slice, index) => (
<Cell
key={
slice.category_id ??
`slice-${index}`
}
fill={
CHART_COLORS[
index % CHART_COLORS.length
]
}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</ChartContainer>
<ul className="flex w-full flex-col gap-3">
{slices.map((slice, index) => {
const color = CHART_COLORS[index % CHART_COLORS.length];
return (
<li
key={slice.category_id ?? `row-${index}`}
className="flex flex-col gap-1.5"
>
<div className="flex items-center gap-3 text-sm">
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="flex-1 truncate font-medium">
{slice.name}
</span>
<span className="text-xs text-muted-foreground">
{total > 0
? Math.round(
(slice.amount / total) * 100,
)
: 0}
%
</span>
<AmountDisplay
amountInCents={slice.amount}
currencyCode={currency}
className="font-mono tabular-nums"
/>
</div>
{slice.children.length > 0 && (
<ul className="flex flex-col gap-1 pl-6">
{slice.children.map(
(child, childIndex) => (
<li
key={
child.category_id ??
`sub-${index}-${childIndex}`
}
className="flex items-center gap-2.5 text-xs text-muted-foreground"
>
<span
className="h-2 w-2 shrink-0 rounded-full opacity-60"
style={{
backgroundColor:
color,
}}
/>
<span className="flex-1 truncate">
{child.name}
</span>
<span>
{slice.amount > 0
? Math.round(
(child.amount /
slice.amount) *
100,
)
: 0}
%
</span>
<AmountDisplay
amountInCents={
child.amount
}
currencyCode={currency}
className="font-mono tabular-nums"
/>
</li>
),
)}
</ul>
)}
</li>
);
})}
</ul>
<div className="flex flex-col gap-3">
{slices.map((slice, index) => (
<CategoryBreakdownRow
key={slice.category_id ?? `category-${index}`}
item={slice}
index={index}
currencyCode={currency}
adapter={adapter}
expandable={expandable}
expandColumn
/>
))}
</div>
</Panel>
);
@ -1373,20 +1294,48 @@ function HorizontalBarBreakdown({
function TagBreakdown({
slices,
currency,
locale,
}: {
slices: TagSlice[];
currency: string;
locale: string;
}) {
const { categoryBarColor } = useChartColors();
const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
const adapter: CategoryBreakdownAdapter<TagSlice> = {
getId: (item) => item.id,
getKey: (item, index) => item.id ?? `tag-${index}`,
getName: (item) => item.name,
getAmount: (item) => item.amount,
getPercentage: (item) => (total > 0 ? (item.amount / total) * 100 : 0),
getBarColor: (item, index) =>
categoryBarColor((item.color ?? 'gray') as CategoryColor, index),
renderLeading: (item, index) => (
<span
className="size-3 shrink-0 rounded-full"
style={{
backgroundColor: categoryBarColor(
(item.color ?? 'gray') as CategoryColor,
index,
),
}}
/>
),
};
return (
<HorizontalBarBreakdown
title={__('Spending by tag')}
data={slices}
currency={currency}
locale={locale}
color="var(--color-chart-1)"
/>
<Panel title={__('Spending by tag')}>
<div className="flex flex-col gap-3">
{slices.map((slice, index) => (
<CategoryBreakdownRow
key={slice.id ?? `tag-${index}`}
item={slice}
index={index}
currencyCode={currency}
adapter={adapter}
/>
))}
</div>
</Panel>
);
}
@ -1410,6 +1359,17 @@ function PayeeBreakdown({
);
}
const ACCOUNT_BAR_COLORS = [
'var(--color-chart-1)',
'var(--color-chart-2)',
'var(--color-chart-3)',
'var(--color-chart-4)',
'var(--color-chart-5)',
'var(--color-chart-6)',
'var(--color-chart-7)',
'var(--color-chart-8)',
];
function AccountBreakdown({
slices,
currency,
@ -1419,37 +1379,37 @@ function AccountBreakdown({
}) {
const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
const adapter: CategoryBreakdownAdapter<AccountSlice> = {
getId: (item) => item.id ?? '',
getKey: (item, index) => item.id ?? `account-${index}`,
getName: (item) => item.name,
getAmount: (item) => item.amount,
getPercentage: (item) => (total > 0 ? (item.amount / total) * 100 : 0),
getBarColor: (_item, index) =>
ACCOUNT_BAR_COLORS[index % ACCOUNT_BAR_COLORS.length],
renderLeading: (item) => (
<BankLogo
src={item.bank?.logo}
name={item.bank?.name}
className="size-6 shrink-0"
fallback="icon"
/>
),
};
return (
<Panel title={__('Spending by account')}>
<ul className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
{slices.map((slice, index) => (
<li
<CategoryBreakdownRow
key={slice.id ?? `account-${index}`}
className="flex items-center gap-3 text-sm"
>
<BankLogo
src={slice.bank?.logo}
name={slice.bank?.name}
className="h-5 w-5"
fallback="icon"
/>
<span className="flex-1 truncate font-medium">
{slice.name}
</span>
<span className="text-xs text-muted-foreground">
{total > 0
? Math.round((slice.amount / total) * 100)
: 0}
%
</span>
<AmountDisplay
amountInCents={slice.amount}
currencyCode={currency}
className="font-mono tabular-nums"
/>
</li>
item={slice}
index={index}
currencyCode={currency}
adapter={adapter}
/>
))}
</ul>
</div>
</Panel>
);
}

View File

@ -75,7 +75,7 @@ test('summary totals income, expense, net and count from the filtered set', func
});
test('category breakdown groups expenses by top-level category', function () {
$hotel = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Hotel']);
$hotel = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Hotel', 'color' => 'blue', 'icon' => 'Building']);
$meals = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Meals']);
makeTransaction(['amount' => -50000, 'category_id' => $hotel->id, 'transaction_date' => '2026-01-10']);
@ -85,7 +85,7 @@ test('category breakdown groups expenses by top-level category', function () {
$response->assertOk();
expect($response->json('distinct_category_count'))->toBe(2);
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Hotel', 'amount' => 50000, 'children' => []]);
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Hotel', 'amount' => 50000, 'color' => 'blue', 'icon' => 'Building', 'children' => []]);
expect($response->json('by_category.1'))->toMatchArray(['name' => 'Meals', 'amount' => 20000, 'children' => []]);
});