feat(analysis): per-category 12-month spending drawer (#519)
## Summary Adds a **category-analysis drawer** so you can see how much you spend on a single category, month by month, over the last 12 months — answering "am I spending more or less on food delivery?". Reachable via an **Analyze** button on three cards: - Dashboard → Top spending categories - Cashflow → Income Sources - Cashflow → Expense Categories Each button opens a shared drawer with a full-width category chooser (all categories) and a 12-month stacked bar chart. ## Chart semantics - X = last 12 rolling months (gaps filled with zero). Y = user display currency. - Stacked segments = the chosen category's immediate children (grandchildren rolled in) + a **Direct** segment (spend on the parent itself) + an **Other** segment (children beyond the top 6, ranked by total). - A leaf category renders a single series named after the category. - Amounts convert to the user currency and **net per month**, oriented by category type so the expected direction reads as a positive bar; an against-grain month (e.g. refunds > spend) dips below zero. - Colors use the theme-aware chart palette (same as the net-worth chart). ## Summary stats Two glowing cards above the chart: **Monthly average** and **Trend** (recent 6-month average vs earlier 6-month average; null when there's no earlier baseline). ## Persistence Each widget remembers its own chosen category in `localStorage` (category only), prefilling the first category of its list otherwise; a remembered-but-deleted category falls back gracefully. ## Tests - 11 Pest feature tests (rollup, Direct/Other, net-with-refunds, 12-month window, currency conversion, income orientation, summary average + trend). - 5 vitest tests for the prefill/persistence precedence. - es.json keys added.
This commit is contained in:
parent
49acc8a884
commit
9c3c4d573e
|
|
@ -0,0 +1,307 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\ExchangeRateService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class CategoryMonthlyBreakdownController extends Controller
|
||||
{
|
||||
/**
|
||||
* The rolling window shown on the chart, in months (including the current).
|
||||
*/
|
||||
private const MONTHS = 12;
|
||||
|
||||
/**
|
||||
* The richest children get their own stacked segment; everything past this
|
||||
* folds into a single "Other" segment so the stack stays legible.
|
||||
*/
|
||||
private const TOP_CHILDREN = 6;
|
||||
|
||||
public function __construct(
|
||||
private ExchangeRateService $exchangeRateService,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, Category $category): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless($category->user_id === $user->id, 403);
|
||||
|
||||
$currency = $user->currency_code;
|
||||
$start = Carbon::now()->startOfMonth()->subMonths(self::MONTHS - 1);
|
||||
|
||||
$parentMap = Category::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('parent_id', 'id')
|
||||
->all();
|
||||
|
||||
$children = Category::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('parent_id', $category->id)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$subtreeIds = array_values(array_filter(
|
||||
array_keys($parentMap),
|
||||
fn (string $id): bool => $this->belongsToSubtree($id, $category->id, $parentMap),
|
||||
));
|
||||
|
||||
$transactions = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('category_id', $subtreeIds)
|
||||
->where('transaction_date', '>=', $start->toDateString())
|
||||
->with('account')
|
||||
->get();
|
||||
|
||||
$this->preloadExchangeRates($transactions, $currency);
|
||||
|
||||
// Outflow categories store spending as negative amounts; flip the sign so
|
||||
// the expected direction reads as a positive bar. A transaction that runs
|
||||
// against the grain (a refund on an expense category) then nets the bar
|
||||
// down and can dip below zero, which is truthful.
|
||||
$orientation = $category->type === CategoryType::Income ? 1 : -1;
|
||||
$hasChildren = $children->isNotEmpty();
|
||||
|
||||
$buckets = [];
|
||||
$childTotals = [];
|
||||
$directTotal = 0;
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency) * $orientation;
|
||||
$monthKey = $transaction->transaction_date->format('Y-m');
|
||||
$segment = $this->segmentFor($transaction->category_id, $category->id, $parentMap, $hasChildren);
|
||||
|
||||
$buckets[$monthKey][$segment] = ($buckets[$monthKey][$segment] ?? 0) + $amount;
|
||||
|
||||
if ($segment === 'direct') {
|
||||
$directTotal += $amount;
|
||||
} elseif ($hasChildren) {
|
||||
$childTotals[$segment] = ($childTotals[$segment] ?? 0) + $amount;
|
||||
}
|
||||
}
|
||||
|
||||
$series = $this->buildSeries($category, $children, $childTotals, $directTotal, $hasChildren);
|
||||
$months = $this->buildMonths($start, $buckets, $series);
|
||||
|
||||
return response()
|
||||
->json([
|
||||
'currency' => $currency,
|
||||
'category' => ['id' => $category->id, 'name' => $category->name],
|
||||
'series' => $series,
|
||||
'months' => $months,
|
||||
'summary' => $this->summarize($months),
|
||||
])
|
||||
->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
/**
|
||||
* Headline figures for the window: the average spent per month and the
|
||||
* trend, measured as the change between the average of the most recent half
|
||||
* and the average of the earlier half. The trend is null when the earlier
|
||||
* half is empty, since there is no baseline to compare against.
|
||||
*
|
||||
* @param array<int, array<string, int|string>> $months
|
||||
* @return array{average_per_month: int, trend_percentage: float|null}
|
||||
*/
|
||||
private function summarize(array $months): array
|
||||
{
|
||||
$totals = array_map(function (array $point): int {
|
||||
$total = 0;
|
||||
|
||||
foreach ($point as $key => $value) {
|
||||
if ($key !== 'key') {
|
||||
$total += (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}, $months);
|
||||
|
||||
$count = count($totals);
|
||||
$half = intdiv($count, 2);
|
||||
$earlier = array_slice($totals, 0, $half);
|
||||
$recent = array_slice($totals, $count - $half);
|
||||
|
||||
$earlierAverage = $earlier === [] ? 0 : array_sum($earlier) / count($earlier);
|
||||
$recentAverage = $recent === [] ? 0 : array_sum($recent) / count($recent);
|
||||
|
||||
return [
|
||||
'average_per_month' => $count > 0 ? (int) round(array_sum($totals) / $count) : 0,
|
||||
'trend_percentage' => $earlierAverage != 0
|
||||
? round((($recentAverage - $earlierAverage) / abs($earlierAverage)) * 100, 1)
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a category sits within the chosen category's subtree (itself or a
|
||||
* descendant, bounded by the tree's maximum depth).
|
||||
*
|
||||
* @param array<string, ?string> $parentMap
|
||||
*/
|
||||
private function belongsToSubtree(string $categoryId, string $rootId, array $parentMap): bool
|
||||
{
|
||||
$current = $categoryId;
|
||||
$guard = 0;
|
||||
|
||||
while ($current !== null && $guard++ <= Category::MAX_DEPTH) {
|
||||
if ($current === $rootId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$current = $parentMap[$current] ?? null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stacked segment a transaction's category contributes to: the
|
||||
* chosen category's own spend ("direct"), or the immediate child whose
|
||||
* subtree the category lives in. A leaf category has no children, so all of
|
||||
* its spend collapses onto a single segment keyed by the category itself.
|
||||
*
|
||||
* @param array<string, ?string> $parentMap
|
||||
*/
|
||||
private function segmentFor(?string $categoryId, string $rootId, array $parentMap, bool $hasChildren): string
|
||||
{
|
||||
if (! $hasChildren) {
|
||||
return $rootId;
|
||||
}
|
||||
|
||||
if ($categoryId === null || $categoryId === $rootId) {
|
||||
return 'direct';
|
||||
}
|
||||
|
||||
$current = $categoryId;
|
||||
$guard = 0;
|
||||
|
||||
while ($current !== null && $guard++ <= Category::MAX_DEPTH) {
|
||||
$parent = $parentMap[$current] ?? null;
|
||||
|
||||
if ($parent === $rootId) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
$current = $parent;
|
||||
}
|
||||
|
||||
return 'direct';
|
||||
}
|
||||
|
||||
/**
|
||||
* Order the segments richest-first, cap the children at the top N, and fold
|
||||
* the remainder into an "Other" segment. A leaf category yields a single
|
||||
* segment named after the category; a parent yields its children plus the
|
||||
* optional "Other" and "Direct" segments.
|
||||
*
|
||||
* @param Collection<string, Category> $children
|
||||
* @param array<string, int> $childTotals
|
||||
* @return array<int, array{key: string, label: string}>
|
||||
*/
|
||||
private function buildSeries(Category $category, Collection $children, array $childTotals, int $directTotal, bool $hasChildren): array
|
||||
{
|
||||
if (! $hasChildren) {
|
||||
return [['key' => $category->id, 'label' => $category->name]];
|
||||
}
|
||||
|
||||
$ranked = collect($childTotals)
|
||||
->sortByDesc(fn (int $total): int => $total)
|
||||
->keys()
|
||||
->all();
|
||||
|
||||
$series = [];
|
||||
|
||||
foreach (array_slice($ranked, 0, self::TOP_CHILDREN) as $childId) {
|
||||
$series[] = ['key' => $childId, 'label' => $children->get($childId)->name];
|
||||
}
|
||||
|
||||
if (count($ranked) > self::TOP_CHILDREN) {
|
||||
$series[] = ['key' => 'other', 'label' => __('Other')];
|
||||
}
|
||||
|
||||
if ($directTotal !== 0) {
|
||||
$series[] = ['key' => 'direct', 'label' => __('Direct')];
|
||||
}
|
||||
|
||||
return $series;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a point per month across the whole window, filling gaps with zero so
|
||||
* the trend is never broken. Segments outside the kept set (overflow
|
||||
* children) are summed into the "Other" segment.
|
||||
*
|
||||
* @param array<string, array<string, int>> $buckets
|
||||
* @param array<int, array{key: string, label: string}> $series
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
private function buildMonths(Carbon $start, array $buckets, array $series): array
|
||||
{
|
||||
$seriesKeys = array_column($series, 'key');
|
||||
$seriesKeySet = array_flip($seriesKeys);
|
||||
$hasOther = isset($seriesKeySet['other']);
|
||||
|
||||
$months = [];
|
||||
$cursor = $start->copy();
|
||||
|
||||
for ($index = 0; $index < self::MONTHS; $index++) {
|
||||
$monthKey = $cursor->format('Y-m');
|
||||
$point = ['key' => $monthKey];
|
||||
|
||||
foreach ($seriesKeys as $seriesKey) {
|
||||
$point[$seriesKey] = 0;
|
||||
}
|
||||
|
||||
foreach (($buckets[$monthKey] ?? []) as $segment => $amount) {
|
||||
$target = isset($seriesKeySet[$segment]) ? $segment : ($hasOther ? 'other' : null);
|
||||
|
||||
if ($target !== null) {
|
||||
$point[$target] += $amount;
|
||||
}
|
||||
}
|
||||
|
||||
$months[] = $point;
|
||||
$cursor->addMonth();
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
private function convertTransactionAmount(Transaction $transaction, string $currency): int
|
||||
{
|
||||
return $this->exchangeRateService->convert(
|
||||
$transaction->currency_code ?: $transaction->account?->currency_code ?: $currency,
|
||||
$currency,
|
||||
$transaction->amount,
|
||||
$transaction->transaction_date->toDateString(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Transaction> $transactions
|
||||
*/
|
||||
private function preloadExchangeRates(Collection $transactions, string $currency): void
|
||||
{
|
||||
$dates = $transactions
|
||||
->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, $currency) !== 0)
|
||||
->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString())
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($dates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->exchangeRateService->preloadRates($currency, $dates);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,13 @@
|
|||
{
|
||||
"Analyze": "Analizar",
|
||||
"Monthly average": "Media mensual",
|
||||
"Trend": "Tendencia",
|
||||
"Not enough history": "Historial insuficiente",
|
||||
"vs. previous 6 months": "vs. 6 meses anteriores",
|
||||
"Category analysis": "Análisis de categoría",
|
||||
"How much you spent on this category each month over the last 12 months.": "Cuánto gastaste en esta categoría cada mes durante los últimos 12 meses.",
|
||||
"Pick a category to see its monthly spending.": "Elige una categoría para ver su gasto mensual.",
|
||||
"No spending in the last 12 months.": "Sin gastos en los últimos 12 meses.",
|
||||
"Analysis": "Análisis",
|
||||
"Apply a filter to enable this button": "Aplica un filtro para activar este botón",
|
||||
"A breakdown of the transactions matching your current filters.": "Un desglose de las transacciones que coinciden con tus filtros actuales.",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import { CategoryAnalysisButton } from '@/components/categories/category-analysis-button';
|
||||
import {
|
||||
CategoryBreakdownRow,
|
||||
type CategoryBreakdownAdapter,
|
||||
|
|
@ -176,18 +177,25 @@ export function BreakdownCard({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-1 pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<AmountDisplay
|
||||
amountInCents={data.total}
|
||||
currencyCode={currency}
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
weight="semibold"
|
||||
highlightPositive
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
<AmountDisplay
|
||||
amountInCents={data.total}
|
||||
currencyCode={currency}
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
weight="semibold"
|
||||
highlightPositive
|
||||
className="text-lg"
|
||||
/>
|
||||
</div>
|
||||
<CategoryAnalysisButton
|
||||
widgetKey={`cashflow-${type}`}
|
||||
firstCategoryId={data.data[0]?.category_id ?? null}
|
||||
/>
|
||||
</div>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2.5">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { CategoryAnalysisDrawer } from '@/components/categories/category-analysis-drawer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { ChartColumnBig } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CategoryAnalysisButtonProps {
|
||||
/** Distinct localStorage slot so each widget remembers its own category. */
|
||||
widgetKey: string;
|
||||
/** Category prefilled the first time this widget opens the drawer. */
|
||||
firstCategoryId?: string | null;
|
||||
}
|
||||
|
||||
export function CategoryAnalysisButton({
|
||||
widgetKey,
|
||||
firstCategoryId,
|
||||
}: CategoryAnalysisButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<ChartColumnBig className="size-4" />
|
||||
{__('Analyze')}
|
||||
</Button>
|
||||
<CategoryAnalysisDrawer
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
widgetKey={widgetKey}
|
||||
firstCategoryId={firstCategoryId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import {
|
||||
CATEGORY_ANALYSIS_STORAGE_PREFIX,
|
||||
resolveInitialCategory,
|
||||
} from '@/components/categories/category-analysis-drawer';
|
||||
import { type Category } from '@/types/category';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
function category(id: string): Category {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
icon: 'HelpCircle',
|
||||
color: 'gray',
|
||||
type: 'expense',
|
||||
cashflow_direction: 'outflow',
|
||||
parent_id: null,
|
||||
} as Category;
|
||||
}
|
||||
|
||||
const categories = [category('a'), category('b'), category('c')];
|
||||
|
||||
function installMemoryStorage(): void {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveInitialCategory', () => {
|
||||
beforeEach(() => {
|
||||
installMemoryStorage();
|
||||
});
|
||||
|
||||
it('prefers a stored category that still exists', () => {
|
||||
localStorage.setItem(`${CATEGORY_ANALYSIS_STORAGE_PREFIX}widget`, 'b');
|
||||
|
||||
expect(resolveInitialCategory('widget', 'a', categories)).toBe('b');
|
||||
});
|
||||
|
||||
it('falls back to the first category when nothing is stored', () => {
|
||||
expect(resolveInitialCategory('widget', 'a', categories)).toBe('a');
|
||||
});
|
||||
|
||||
it('ignores a stored category that no longer exists', () => {
|
||||
localStorage.setItem(
|
||||
`${CATEGORY_ANALYSIS_STORAGE_PREFIX}widget`,
|
||||
'deleted',
|
||||
);
|
||||
|
||||
expect(resolveInitialCategory('widget', 'c', categories)).toBe('c');
|
||||
});
|
||||
|
||||
it('remembers each widget independently', () => {
|
||||
localStorage.setItem(`${CATEGORY_ANALYSIS_STORAGE_PREFIX}left`, 'a');
|
||||
localStorage.setItem(`${CATEGORY_ANALYSIS_STORAGE_PREFIX}right`, 'c');
|
||||
|
||||
expect(resolveInitialCategory('left', 'b', categories)).toBe('a');
|
||||
expect(resolveInitialCategory('right', 'b', categories)).toBe('c');
|
||||
});
|
||||
|
||||
it('returns null when neither a stored nor a first category is valid', () => {
|
||||
expect(resolveInitialCategory('widget', null, categories)).toBeNull();
|
||||
expect(
|
||||
resolveInitialCategory('widget', 'missing', categories),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
import { CategoryCombobox } from '@/components/shared/category-combobox';
|
||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ChartConfig } from '@/components/ui/chart';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/drawer';
|
||||
import { StackedBarChart } from '@/components/ui/stacked-bar-chart';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedData } from '@/types';
|
||||
import { type Category } from '@/types/category';
|
||||
import { formatCurrency } from '@/utils/currency';
|
||||
import { formatMonthFromYearMonth } from '@/utils/date';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { Minus, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export const CATEGORY_ANALYSIS_STORAGE_PREFIX = 'wm.category-analysis.';
|
||||
|
||||
interface BreakdownSeries {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MonthPoint {
|
||||
key: string;
|
||||
[seriesKey: string]: number | string;
|
||||
}
|
||||
|
||||
interface BreakdownSummary {
|
||||
average_per_month: number;
|
||||
trend_percentage: number | null;
|
||||
}
|
||||
|
||||
interface BreakdownData {
|
||||
currency: string;
|
||||
category: { id: string; name: string };
|
||||
series: BreakdownSeries[];
|
||||
months: MonthPoint[];
|
||||
summary: BreakdownSummary;
|
||||
}
|
||||
|
||||
interface CategoryAnalysisDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Distinct localStorage slot so each widget remembers its own category. */
|
||||
widgetKey: string;
|
||||
/** Category prefilled the first time the widget is opened. */
|
||||
firstCategoryId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the category to show when the drawer opens: the one this widget
|
||||
* remembered last, falling back to the widget's first category when nothing
|
||||
* valid is stored (a remembered category that was since deleted is ignored).
|
||||
*/
|
||||
export function resolveInitialCategory(
|
||||
widgetKey: string,
|
||||
firstCategoryId: string | null | undefined,
|
||||
categories: Category[],
|
||||
): string | null {
|
||||
const stored = localStorage.getItem(
|
||||
`${CATEGORY_ANALYSIS_STORAGE_PREFIX}${widgetKey}`,
|
||||
);
|
||||
|
||||
if (stored && categories.some((category) => category.id === stored)) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
if (firstCategoryId && categories.some((c) => c.id === firstCategoryId)) {
|
||||
return firstCategoryId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CategoryAnalysisDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
widgetKey,
|
||||
firstCategoryId,
|
||||
}: CategoryAnalysisDrawerProps) {
|
||||
const locale = useLocale();
|
||||
const { categories = [] } = usePage<
|
||||
SharedData & { categories: Category[] }
|
||||
>().props;
|
||||
|
||||
const [categoryId, setCategoryId] = useState<string | null>(null);
|
||||
const [data, setData] = useState<BreakdownData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCategoryId(
|
||||
resolveInitialCategory(widgetKey, firstCategoryId, categories),
|
||||
);
|
||||
}
|
||||
// Re-resolve only when the drawer is (re)opened.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, widgetKey, firstCategoryId]);
|
||||
|
||||
const selectCategory = useCallback(
|
||||
(next: string) => {
|
||||
if (next === 'null') {
|
||||
return;
|
||||
}
|
||||
|
||||
setCategoryId(next);
|
||||
localStorage.setItem(
|
||||
`${CATEGORY_ANALYSIS_STORAGE_PREFIX}${widgetKey}`,
|
||||
next,
|
||||
);
|
||||
},
|
||||
[widgetKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !categoryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetch(`/api/categories/${categoryId}/monthly-breakdown`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Request failed');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json: BreakdownData) => {
|
||||
if (active) {
|
||||
setData(json);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setError(
|
||||
__('Could not load the analysis. Please try again.'),
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, categoryId]);
|
||||
|
||||
const currency = data?.currency ?? '';
|
||||
const dataKeys = useMemo(
|
||||
() => data?.series.map((series) => series.key) ?? [],
|
||||
[data],
|
||||
);
|
||||
const config = useMemo<ChartConfig>(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
(data?.series ?? []).map((series) => [
|
||||
series.key,
|
||||
{ label: series.label },
|
||||
]),
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
const valueFormatter = useCallback(
|
||||
(value: number) => formatCurrency(value, currency, locale, 0, 0),
|
||||
[currency, locale],
|
||||
);
|
||||
|
||||
const xAxisFormatter = useCallback(
|
||||
(value: string) => formatMonthFromYearMonth(value, locale),
|
||||
[locale],
|
||||
);
|
||||
|
||||
const hasData = data !== null && data.series.length > 0;
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="h-[90vh] data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 overflow-y-auto p-6">
|
||||
<DrawerHeader className="gap-2 px-0 text-center">
|
||||
<DrawerTitle className="text-xl">
|
||||
{__('Category analysis')}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="text-sm text-pretty">
|
||||
{__(
|
||||
'How much you spent on this category each month over the last 12 months.',
|
||||
)}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<CategoryCombobox
|
||||
value={categoryId}
|
||||
onValueChange={selectCategory}
|
||||
categories={categories}
|
||||
showUncategorized={false}
|
||||
placeholder={__('Select a category')}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className="h-[320px] w-full animate-pulse rounded-lg bg-gray-200 dark:bg-gray-700" />
|
||||
)}
|
||||
|
||||
{!isLoading && error && (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && !categoryId && (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{__('Pick a category to see its monthly spending.')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && categoryId && !hasData && (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{__('No spending in the last 12 months.')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && hasData && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SummaryCards
|
||||
summary={data.summary}
|
||||
currency={currency}
|
||||
/>
|
||||
|
||||
<Card className="py-4">
|
||||
<CardContent>
|
||||
<StackedBarChart
|
||||
data={data.months}
|
||||
dataKeys={dataKeys}
|
||||
config={config}
|
||||
xAxisKey="key"
|
||||
xAxisFormatter={xAxisFormatter}
|
||||
valueFormatter={valueFormatter}
|
||||
displayCurrency={currency}
|
||||
className="h-[320px] w-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCards({
|
||||
summary,
|
||||
currency,
|
||||
}: {
|
||||
summary: BreakdownSummary;
|
||||
currency: string;
|
||||
}) {
|
||||
const trend = summary.trend_percentage;
|
||||
const TrendIcon =
|
||||
trend === null || trend === 0
|
||||
? Minus
|
||||
: trend > 0
|
||||
? TrendingUp
|
||||
: TrendingDown;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Card className="gap-0 py-4">
|
||||
<CardContent className="flex flex-col gap-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Monthly average')}
|
||||
</p>
|
||||
<AmountDisplay
|
||||
amountInCents={summary.average_per_month}
|
||||
currencyCode={currency}
|
||||
className="text-lg font-semibold tabular-nums"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="gap-0 py-4">
|
||||
<CardContent className="flex flex-col gap-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Trend')}
|
||||
</p>
|
||||
{trend === null ? (
|
||||
<p className="text-lg font-semibold text-muted-foreground">
|
||||
{__('Not enough history')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TrendIcon
|
||||
className={cn(
|
||||
'size-4',
|
||||
trend > 0 && 'text-amber-500',
|
||||
trend < 0 && 'text-emerald-500',
|
||||
trend === 0 && 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{trend > 0 ? '+' : ''}
|
||||
{trend}%
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{__('vs. previous 6 months')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import { CategoryAnalysisButton } from '@/components/categories/category-analysis-button';
|
||||
import {
|
||||
CategoryBreakdownRow,
|
||||
type CategoryBreakdownAdapter,
|
||||
|
|
@ -165,7 +166,17 @@ export function TopCategoriesCard({
|
|||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="gap-2">
|
||||
<CardTitle>{__('Top spending categories')}</CardTitle>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle>{__('Top spending categories')}</CardTitle>
|
||||
<CategoryAnalysisButton
|
||||
widgetKey="dashboard-top-categories"
|
||||
firstCategoryId={
|
||||
categories[0]?.category?.id ??
|
||||
categories[0]?.category_id ??
|
||||
null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<CardDescription>{__('on the last 30 days')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Http\Controllers\AccountBalanceController;
|
||||
use App\Http\Controllers\Api\AccountController;
|
||||
use App\Http\Controllers\Api\CashflowAnalyticsController;
|
||||
use App\Http\Controllers\Api\CategoryMonthlyBreakdownController;
|
||||
use App\Http\Controllers\Api\DashboardAnalyticsController;
|
||||
use App\Http\Controllers\Api\ImportDataController;
|
||||
use App\Http\Controllers\Api\SavedFilterController;
|
||||
|
|
@ -30,6 +31,9 @@ Route::middleware(['web', 'auth'])->group(function () {
|
|||
Route::get('transactions/analysis', [TransactionAnalysisController::class, 'summary'])->name('api.transactions.analysis');
|
||||
Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('api.transactions.bulk-update');
|
||||
|
||||
// Category analysis
|
||||
Route::get('categories/{category}/monthly-breakdown', CategoryMonthlyBreakdownController::class)->name('api.categories.monthly-breakdown');
|
||||
|
||||
// Accounts
|
||||
Route::get('accounts', [AccountController::class, 'index'])->name('api.accounts.index');
|
||||
Route::put('accounts/{account}', [AccountController::class, 'update'])->name('api.accounts.update');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\ExchangeRate;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
|
||||
beforeEach(function () {
|
||||
Http::fake();
|
||||
Carbon::setTestNow('2026-06-15');
|
||||
|
||||
$this->user = User::factory()->create(['currency_code' => 'USD']);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$this->account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
function makeBreakdownTransaction(array $attributes = []): Transaction
|
||||
{
|
||||
return Transaction::factory()->create([
|
||||
'user_id' => test()->user->id,
|
||||
'account_id' => test()->account->id,
|
||||
'currency_code' => 'USD',
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
function monthlyBreakdown(Category $category): TestResponse
|
||||
{
|
||||
return test()->getJson("/api/categories/{$category->id}/monthly-breakdown");
|
||||
}
|
||||
|
||||
test('it forbids analysing a category owned by another user', function () {
|
||||
$other = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $other->id, 'type' => CategoryType::Expense]);
|
||||
|
||||
monthlyBreakdown($category)->assertForbidden();
|
||||
});
|
||||
|
||||
test('the response is private and not cached between users', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
|
||||
|
||||
monthlyBreakdown($category)
|
||||
->assertOk()
|
||||
->assertHeader('Cache-Control', 'no-store, private');
|
||||
});
|
||||
|
||||
test('a leaf category returns a single series named after the category across twelve months', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food Delivery']);
|
||||
|
||||
makeBreakdownTransaction(['amount' => -4000, 'category_id' => $category->id, 'transaction_date' => '2026-06-04']);
|
||||
makeBreakdownTransaction(['amount' => -1000, 'category_id' => $category->id, 'transaction_date' => '2026-05-09']);
|
||||
|
||||
$response = monthlyBreakdown($category)->assertOk();
|
||||
|
||||
expect($response->json('series'))->toBe([['key' => $category->id, 'label' => 'Food Delivery']]);
|
||||
expect($response->json('months'))->toHaveCount(12);
|
||||
expect($response->json('months.0.key'))->toBe('2025-07');
|
||||
expect($response->json('months.11.key'))->toBe('2026-06');
|
||||
expect($response->json('months.11.'.$category->id))->toBe(4000);
|
||||
expect($response->json('months.10.'.$category->id))->toBe(1000);
|
||||
expect($response->json('months.0.'.$category->id))->toBe(0);
|
||||
});
|
||||
|
||||
test('spend nets within a month and a refund-dominant month dips below zero', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food Delivery']);
|
||||
|
||||
// -40, -40, +20 -> nets to 60 of spend.
|
||||
makeBreakdownTransaction(['amount' => -4000, 'category_id' => $category->id, 'transaction_date' => '2026-06-04']);
|
||||
makeBreakdownTransaction(['amount' => -4000, 'category_id' => $category->id, 'transaction_date' => '2026-06-05']);
|
||||
makeBreakdownTransaction(['amount' => 2000, 'category_id' => $category->id, 'transaction_date' => '2026-06-06']);
|
||||
|
||||
// Refunds exceed spend this month -> the bar dips below zero.
|
||||
makeBreakdownTransaction(['amount' => -1000, 'category_id' => $category->id, 'transaction_date' => '2026-05-09']);
|
||||
makeBreakdownTransaction(['amount' => 3000, 'category_id' => $category->id, 'transaction_date' => '2026-05-10']);
|
||||
|
||||
$response = monthlyBreakdown($category)->assertOk();
|
||||
|
||||
expect($response->json('months.11.'.$category->id))->toBe(6000);
|
||||
expect($response->json('months.10.'.$category->id))->toBe(-2000);
|
||||
});
|
||||
|
||||
test('a parent rolls grandchildren into their immediate child and surfaces direct spend', function () {
|
||||
$parent = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
|
||||
$delivery = Category::factory()->childOf($parent)->create(['name' => 'Delivery']);
|
||||
$grocery = Category::factory()->childOf($parent)->create(['name' => 'Grocery']);
|
||||
$uberEats = Category::factory()->childOf($delivery)->create(['name' => 'Uber Eats']);
|
||||
|
||||
makeBreakdownTransaction(['amount' => -1000, 'category_id' => $delivery->id, 'transaction_date' => '2026-06-04']);
|
||||
makeBreakdownTransaction(['amount' => -500, 'category_id' => $uberEats->id, 'transaction_date' => '2026-06-05']);
|
||||
makeBreakdownTransaction(['amount' => -2000, 'category_id' => $grocery->id, 'transaction_date' => '2026-06-06']);
|
||||
makeBreakdownTransaction(['amount' => -300, 'category_id' => $parent->id, 'transaction_date' => '2026-06-07']);
|
||||
|
||||
$response = monthlyBreakdown($parent)->assertOk();
|
||||
|
||||
// Richest child first (Grocery 2000, then Delivery 1500), then Direct.
|
||||
expect($response->json('series'))->toBe([
|
||||
['key' => $grocery->id, 'label' => 'Grocery'],
|
||||
['key' => $delivery->id, 'label' => 'Delivery'],
|
||||
['key' => 'direct', 'label' => 'Direct'],
|
||||
]);
|
||||
expect($response->json('months.11.'.$grocery->id))->toBe(2000);
|
||||
expect($response->json('months.11.'.$delivery->id))->toBe(1500);
|
||||
expect($response->json('months.11.direct'))->toBe(300);
|
||||
});
|
||||
|
||||
test('children beyond the top six fold into an Other segment', function () {
|
||||
$parent = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Shopping']);
|
||||
|
||||
foreach (range(1, 8) as $rank) {
|
||||
$child = Category::factory()->childOf($parent)->create(['name' => "Child {$rank}"]);
|
||||
makeBreakdownTransaction(['amount' => -1000 * $rank, 'category_id' => $child->id, 'transaction_date' => '2026-06-10']);
|
||||
}
|
||||
|
||||
$response = monthlyBreakdown($parent)->assertOk();
|
||||
|
||||
$series = $response->json('series');
|
||||
expect($series)->toHaveCount(7);
|
||||
expect(collect($series)->pluck('key')->last())->toBe('other');
|
||||
// The two smallest (1000 + 2000) are folded into Other.
|
||||
expect($response->json('months.11.other'))->toBe(3000);
|
||||
});
|
||||
|
||||
test('only the trailing twelve months are included', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
|
||||
|
||||
makeBreakdownTransaction(['amount' => -5000, 'category_id' => $category->id, 'transaction_date' => '2026-06-01']);
|
||||
// Older than the window start (2025-07-01) -> excluded.
|
||||
makeBreakdownTransaction(['amount' => -9999, 'category_id' => $category->id, 'transaction_date' => '2025-06-30']);
|
||||
|
||||
$response = monthlyBreakdown($category)->assertOk();
|
||||
|
||||
$total = collect($response->json('months'))->sum($category->id);
|
||||
expect($total)->toBe(5000);
|
||||
});
|
||||
|
||||
test('income categories keep inflows positive and net expenses against them', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Income, 'name' => 'Salary']);
|
||||
|
||||
makeBreakdownTransaction(['amount' => 500000, 'category_id' => $category->id, 'transaction_date' => '2026-06-01']);
|
||||
makeBreakdownTransaction(['amount' => -100000, 'category_id' => $category->id, 'transaction_date' => '2026-06-02']);
|
||||
|
||||
$response = monthlyBreakdown($category)->assertOk();
|
||||
|
||||
expect($response->json('months.11.'.$category->id))->toBe(400000);
|
||||
});
|
||||
|
||||
test('summary reports the monthly average and the half-over-half trend', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
|
||||
|
||||
// Earlier half (2025-07..2025-12): one month of 6000 -> average 1000.
|
||||
makeBreakdownTransaction(['amount' => -6000, 'category_id' => $category->id, 'transaction_date' => '2025-09-15']);
|
||||
// Recent half (2026-01..2026-06): one month of 9000 -> average 1500.
|
||||
makeBreakdownTransaction(['amount' => -9000, 'category_id' => $category->id, 'transaction_date' => '2026-02-15']);
|
||||
|
||||
$response = monthlyBreakdown($category)->assertOk();
|
||||
|
||||
expect($response->json('summary.average_per_month'))->toBe(1250);
|
||||
expect($response->json('summary.trend_percentage'))->toEqual(50);
|
||||
});
|
||||
|
||||
test('summary trend is null when the earlier half has no spending', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
|
||||
|
||||
makeBreakdownTransaction(['amount' => -6000, 'category_id' => $category->id, 'transaction_date' => '2026-06-10']);
|
||||
|
||||
$response = monthlyBreakdown($category)->assertOk();
|
||||
|
||||
expect($response->json('summary.average_per_month'))->toBe(500);
|
||||
expect($response->json('summary.trend_percentage'))->toBeNull();
|
||||
});
|
||||
|
||||
test('foreign-currency transactions are converted to the user currency', function () {
|
||||
ExchangeRate::factory()->create([
|
||||
'base_currency' => 'usd',
|
||||
'date' => '2026-06-01',
|
||||
'rates' => ['eur' => 0.5],
|
||||
]);
|
||||
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
|
||||
|
||||
makeBreakdownTransaction(['amount' => -1000, 'currency_code' => 'EUR', 'category_id' => $category->id, 'transaction_date' => '2026-06-01']);
|
||||
|
||||
$response = monthlyBreakdown($category)->assertOk();
|
||||
|
||||
// 1000 EUR cents / 0.5 = 2000 USD cents.
|
||||
expect($response->json('months.11.'.$category->id))->toBe(2000);
|
||||
});
|
||||
Loading…
Reference in New Issue