feat(analysis): break down spending by sub-category (#508)

## What

Clicking **Analysis** on the transactions page opens a chart of spending
by category. Two improvements:

### Sub-category breakdown
Previously every expense rolled up to its top-level category. Now each
parent shows its total with the sub-categories that carry spending
nested beneath it.

- New `CategoryTree::spendingBreakdown()` builds a two-level tree: each
root with its rolled-up total + immediate sub-categories. Grand-children
fold into their level-2 ancestor. Spend booked directly on a parent that
is *also* split across sub-categories surfaces as a **Direct** child, so
the children always sum to the parent total.
- `by_category` slices now carry a `children[]` array.
- The legend renders each parent row, then indented sub-category rows
(name, % of parent, amount). The donut stays at parent level.

### Clearer colors
Monochrome schemes (blue, pink, neutral) run darkest→lightest, so
adjacent slices were nearly identical. A new `alternateContrast()` zips
the two palette halves (`chart-1,5,2,6,3,7,4,8`) so neighbouring slices
alternate between dark and light ends. Sub-category dots reuse the
parent color at reduced opacity.

## Tests
Added coverage for sub-category nesting, the Direct child, grand-child
folding, and the flat (no-children) case. All analysis + CategoryTree
tests pass.
This commit is contained in:
Víctor Falcón 2026-06-08 14:53:36 +02:00 committed by GitHub
parent 8375fd490e
commit c944a2c37e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 283 additions and 59 deletions

View File

@ -103,8 +103,9 @@ class TransactionAnalysisController extends Controller
}
/**
* Expenses grouped by their top-level category, rolled up through the
* category tree so parents absorb their children's spending.
* Expenses grouped by their top-level category, with the sub-categories
* that carry spending nested beneath each parent so the split is visible
* instead of folded into the parent total.
*/
private function categoryBreakdown(Collection $transactions, string $currency, string $userId): Collection
{
@ -115,40 +116,41 @@ class TransactionAnalysisController extends Controller
$grouped = $expenses
->filter(fn (Transaction $transaction): bool => $transaction->category_id !== null)
->groupBy('category_id')
->map(function (Collection $group) use ($currency): array {
$first = $group->first();
return [
'category_id' => $first->category_id,
'category' => $first->category,
'amount' => abs($group->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency))),
];
})
->map(fn (Collection $group): array => [
'category_id' => $group->first()->category_id,
'amount' => abs($group->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency))),
])
->values()
->all();
$breakdown = collect($this->tree->rollUp($grouped, $userId, null))
->map(fn (array $node): array => [
'category_id' => $node['category_id'],
'name' => $node['category']->name,
'color' => $node['category']->color,
'amount' => $node['amount'],
]);
$rows = array_map(fn (array $node): array => [
'category_id' => $node['category_id'],
'name' => $node['category']->name,
'color' => $node['category']->color,
'amount' => $node['amount'],
'children' => array_map(fn (array $child): array => [
'category_id' => $child['category_id'],
'name' => $child['category']->name,
'color' => $child['category']->color,
'amount' => $child['amount'],
], $node['children']),
], $this->tree->spendingBreakdown($grouped, $userId));
$uncategorized = abs($expenses
->filter(fn (Transaction $transaction): bool => $transaction->category_id === null)
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)));
if ($uncategorized > 0) {
$breakdown->push([
$rows[] = [
'category_id' => null,
'name' => __('Uncategorized'),
'color' => 'gray',
'amount' => $uncategorized,
]);
'children' => [],
];
}
return $breakdown
return collect($rows)
->filter(fn (array $node): bool => $node['amount'] > 0)
->sortByDesc('amount')
->values();

View File

@ -220,12 +220,100 @@ class CategoryTree
}
/**
* Resolve which node a category's amount should be attributed to.
* Build a two-level spending breakdown: each top-level category with its
* rolled-up total, and the immediate sub-categories that carry spending
* nested beneath it. Grand-children fold into their level-2 ancestor;
* spend sitting directly on a parent that is also split across
* sub-categories surfaces as a "Direct" child so the children always sum
* to the parent total. Uncategorized items are left for the caller.
*
* @param array<int, array{category_id: ?string, amount: int}> $categorized
* @return array<int, array{category_id: string, category: Category, amount: int, children: array<int, array{category_id: string, category: Category, amount: int}>}>
*/
public function spendingBreakdown(array $categorized, string $userId): array
{
$categories = Category::query()
->where('user_id', $userId)
->forDisplay()
->get()
->keyBy('id');
$parentMap = $categories->mapWithKeys(fn (Category $category): array => [$category->id => $category->parent_id])->all();
$roots = [];
foreach ($categorized as $item) {
$categoryId = $item['category_id'];
if ($categoryId === null || ! array_key_exists($categoryId, $parentMap)) {
continue;
}
$chain = $this->chainFromRoot($categoryId, $parentMap);
$rootId = $chain[0];
$roots[$rootId] ??= [
'category_id' => $rootId,
'category' => $categories->get($rootId),
'amount' => 0,
'direct' => 0,
'children' => [],
];
$roots[$rootId]['amount'] += $item['amount'];
if (count($chain) === 1) {
$roots[$rootId]['direct'] += $item['amount'];
continue;
}
$childId = $chain[1];
$roots[$rootId]['children'][$childId] ??= [
'category_id' => $childId,
'category' => $categories->get($childId),
'amount' => 0,
];
$roots[$rootId]['children'][$childId]['amount'] += $item['amount'];
}
return collect($roots)
->map(function (array $root): array {
$children = collect($root['children'])->values();
if ($root['direct'] > 0 && $children->isNotEmpty()) {
$parent = $root['category'];
$children->push([
'category_id' => $root['category_id'],
'category' => (new Category)->forceFill([
'id' => $parent->id,
'name' => __('Direct'),
'icon' => $parent->icon,
'color' => $parent->color,
'type' => $parent->type,
'cashflow_direction' => $parent->cashflow_direction,
'parent_id' => $parent->id,
]),
'amount' => $root['direct'],
]);
}
return [
'category_id' => $root['category_id'],
'category' => $root['category'],
'amount' => $root['amount'],
'children' => $children->sortByDesc('amount')->values()->all(),
];
})
->values()
->all();
}
/**
* The category ids from the top-level ancestor down to the category itself.
*
* @param array<string, ?string> $parentMap
* @return array{id: string, is_direct: bool}|null
* @return array<int, string>
*/
private function displayNodeFor(string $categoryId, array $parentMap, ?string $drillParentId): ?array
private function chainFromRoot(string $categoryId, array $parentMap): array
{
$chain = [];
$current = $categoryId;
@ -236,6 +324,19 @@ class CategoryTree
$current = $parentMap[$current] ?? null;
}
return $chain;
}
/**
* 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 = $this->chainFromRoot($categoryId, $parentMap);
if ($drillParentId === null) {
return ['id' => $chain[0], 'is_direct' => false];
}

View File

@ -1085,6 +1085,7 @@
"Page": "Página",
"Paraguayan Guarani": "Guaraní paraguayo",
"Parent": "Padre",
"Direct": "Directo",
"Parent category": "Categoría padre",
"Password": "Contraseña",
"Password changes are disabled on the demo account.": "Los cambios de contraseña están deshabilitados en la cuenta demo.",

View File

@ -54,6 +54,7 @@ interface CategorySlice {
name: string;
color: string;
amount: number;
children: CategorySlice[];
}
interface TagSlice {
@ -98,6 +99,29 @@ const CHART_PALETTE = [
'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();
@ -650,8 +674,8 @@ function CategoryBreakdown({
`slice-${index}`
}
fill={
CHART_PALETTE[
index % CHART_PALETTE.length
CHART_COLORS[
index % CHART_COLORS.length
]
}
/>
@ -661,37 +685,84 @@ function CategoryBreakdown({
</ResponsiveContainer>
</ChartContainer>
<ul className="flex w-full flex-col gap-2">
{slices.map((slice, index) => (
<li
key={slice.category_id ?? `row-${index}`}
className="flex items-center gap-3 text-sm"
>
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{
backgroundColor:
CHART_PALETTE[
index % CHART_PALETTE.length
],
}}
/>
<span className="flex-1 truncate">
{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>
))}
<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>
</section>

View File

@ -84,8 +84,57 @@ 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]);
expect($response->json('by_category.1'))->toMatchArray(['name' => 'Meals', 'amount' => 20000]);
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Hotel', 'amount' => 50000, 'children' => []]);
expect($response->json('by_category.1'))->toMatchArray(['name' => 'Meals', 'amount' => 20000, 'children' => []]);
});
test('category breakdown nests sub-categories under their parent total', function () {
$food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
$groceries = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Groceries', 'parent_id' => $food->id]);
$restaurants = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Restaurants', 'parent_id' => $food->id]);
makeTransaction(['amount' => -30000, 'category_id' => $groceries->id, 'transaction_date' => '2026-01-10']);
makeTransaction(['amount' => -10000, 'category_id' => $restaurants->id, 'transaction_date' => '2026-01-11']);
$response = $this->getJson('/api/transactions/analysis');
$response->assertOk();
expect($response->json('distinct_category_count'))->toBe(1);
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Food', 'amount' => 40000]);
expect($response->json('by_category.0.children.0'))->toMatchArray(['name' => 'Groceries', 'amount' => 30000]);
expect($response->json('by_category.0.children.1'))->toMatchArray(['name' => 'Restaurants', 'amount' => 10000]);
});
test('spend booked directly on a split parent surfaces as a Direct child', function () {
$food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
$groceries = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Groceries', 'parent_id' => $food->id]);
makeTransaction(['amount' => -30000, 'category_id' => $groceries->id, 'transaction_date' => '2026-01-10']);
makeTransaction(['amount' => -5000, 'category_id' => $food->id, 'transaction_date' => '2026-01-11']);
$response = $this->getJson('/api/transactions/analysis');
$response->assertOk();
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Food', 'amount' => 35000]);
$children = collect($response->json('by_category.0.children'));
expect($children)->toHaveCount(2);
expect($children->firstWhere('name', 'Groceries')['amount'])->toBe(30000);
expect($children->firstWhere('name', 'Direct')['amount'])->toBe(5000);
});
test('grand-children fold into their level-two sub-category', function () {
$food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
$groceries = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Groceries', 'parent_id' => $food->id]);
$organic = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Organic', 'parent_id' => $groceries->id]);
makeTransaction(['amount' => -12000, 'category_id' => $organic->id, 'transaction_date' => '2026-01-10']);
$response = $this->getJson('/api/transactions/analysis');
$response->assertOk();
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Food', 'amount' => 12000]);
expect($response->json('by_category.0.children.0'))->toMatchArray(['name' => 'Groceries', 'amount' => 12000]);
});
test('tag breakdown sums expenses per label', function () {