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:
parent
8375fd490e
commit
c944a2c37e
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue