feat(transactions): filtered analysis dashboard (#507)
## Summary
Adds an **Analysis** button to the transactions toolbar (left of
Categorize) that opens a bottom-sheet dashboard summarizing the
transactions matching the current filters. Built around the Miami-trip
use case: tag a trip, filter to it, and see where the money went.
Everything is behind the existing `TransactionAnalysis` feature flag
(button hidden + endpoint returns 403 when off).
## Behavior
- **Button**: hidden unless `features.transactionAnalysis`. Disabled
until at least one filter is applied — desktop shows a hover tooltip,
mobile a tap dialog, both reading *"Apply a filter to enable this
button."*
- **Drawer** (vaul bottom-sheet, like the importer): fetches
`/api/transactions/analysis` with the current filters on open. Skeleton
+ empty/error states.
- **Charts**: KPI cards · spending over time (income/expense bars +
cumulative line, auto daily/monthly bucketing) · category donut + ranked
list (only when >1 category) · per-tag bars (only when >1 label).
- **Manual day override**: the *Avg / day* card has an editor to
override the date span used for the daily average (tickets bought months
ahead skew the span). Resolution precedence: matching saved filter's
`analysis_days` → `localStorage[fingerprint]` → auto span. Edits persist
to localStorage always, and sync to the saved filter when the current
filters match one.
## Backend
- `Api/TransactionAnalysisController@summary` — reuses
`Transaction::applyFilters()` + `IndexTransactionRequest`, converts to
the user's base currency via `ExchangeRateService` (rates preloaded),
rolls categories up through `CategoryTree`.
- `GET /api/transactions/analysis`.
- `analysis_days` (nullable) on `saved_filters` + `PATCH
/api/saved-filters/{id}/analysis-days`.
## Tests
- Pest: analysis endpoint (flag gating, totals, category/tag breakdown,
day/month bucketing, cumulative) + saved-filter day override
(set/clear/forbidden/invalid).
- Vitest: button gating (hidden/disabled/opens) + day-override
resolution (auto / saved-precedence / localStorage fallback / persists
to saved filter).
- New `lang/es.json` keys for all added strings.
## Notes
- Data is always sourced from the backend.
- The flag is still off by default — enable `TransactionAnalysis`
per-user to try it.
This commit is contained in:
parent
c53c40cf0b
commit
8375fd490e
|
|
@ -16,7 +16,7 @@ class SavedFilterController extends Controller
|
|||
$savedFilters = SavedFilter::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'filters']);
|
||||
->get();
|
||||
|
||||
return response()->json(['data' => $savedFilters]);
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ class SavedFilterController extends Controller
|
|||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => $savedFilter->only(['id', 'name', 'filters']),
|
||||
'data' => $savedFilter,
|
||||
], 201);
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ class SavedFilterController extends Controller
|
|||
$savedFilter->update(['filters' => $request->validated('filters')]);
|
||||
|
||||
return response()->json([
|
||||
'data' => $savedFilter->only(['id', 'name', 'filters']),
|
||||
'data' => $savedFilter,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -53,4 +53,19 @@ class SavedFilterController extends Controller
|
|||
|
||||
return response()->json(['message' => 'Saved filter deleted']);
|
||||
}
|
||||
|
||||
public function updateAnalysisDays(Request $request, SavedFilter $savedFilter): JsonResponse
|
||||
{
|
||||
abort_unless($savedFilter->user_id === $request->user()->id, 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'analysis_days' => ['nullable', 'integer', 'min:1', 'max:36500'],
|
||||
]);
|
||||
|
||||
$savedFilter->update(['analysis_days' => $validated['analysis_days'] ?? null]);
|
||||
|
||||
return response()->json([
|
||||
'data' => $savedFilter,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Features\TransactionAnalysis;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\IndexTransactionRequest;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\CategoryTree;
|
||||
use App\Services\ExchangeRateService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class TransactionAnalysisController extends Controller
|
||||
{
|
||||
/**
|
||||
* A daily breakdown is used while the filtered set spans this many days or
|
||||
* fewer; beyond that the chart switches to monthly buckets.
|
||||
*/
|
||||
private const DAILY_BUCKET_MAX_DAYS = 62;
|
||||
|
||||
public function __construct(
|
||||
private ExchangeRateService $exchangeRateService,
|
||||
private CategoryTree $tree,
|
||||
) {}
|
||||
|
||||
public function summary(IndexTransactionRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless(Feature::for($user)->active(TransactionAnalysis::class), 403);
|
||||
|
||||
$validated = $request->validated();
|
||||
$currency = $user->currency_code;
|
||||
|
||||
$filters = array_filter([
|
||||
'date_from' => $validated['date_from'] ?? null,
|
||||
'date_to' => $validated['date_to'] ?? null,
|
||||
'amount_min' => $validated['amount_min'] ?? null,
|
||||
'amount_max' => $validated['amount_max'] ?? null,
|
||||
'category_ids' => $validated['category_ids'] ?? null,
|
||||
'account_ids' => $validated['account_ids'] ?? null,
|
||||
'label_ids' => $validated['label_ids'] ?? null,
|
||||
'creditor_name' => $validated['creditor_name'] ?? null,
|
||||
'debtor_name' => $validated['debtor_name'] ?? null,
|
||||
'search' => $validated['search'] ?? null,
|
||||
], fn ($value) => $value !== null);
|
||||
|
||||
$transactions = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->with(['account', 'category', 'labels'])
|
||||
->applyFilters($filters)
|
||||
->get();
|
||||
|
||||
$this->preloadExchangeRates($transactions, $currency);
|
||||
|
||||
$byCategory = $this->categoryBreakdown($transactions, $currency, $user->id);
|
||||
$byTag = $this->tagBreakdown($transactions, $currency);
|
||||
|
||||
return response()
|
||||
->json([
|
||||
'currency' => $currency,
|
||||
'summary' => $this->summaryTotals($transactions, $currency),
|
||||
'by_category' => $byCategory->values(),
|
||||
'distinct_category_count' => $byCategory->count(),
|
||||
'by_tag' => $byTag->values(),
|
||||
'distinct_label_count' => $byTag->count(),
|
||||
'over_time' => $this->overTime($transactions, $currency),
|
||||
])
|
||||
->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{income: int, expense: int, net: int, count: int, days: int, average_expense_per_day: int}
|
||||
*/
|
||||
private function summaryTotals(Collection $transactions, string $currency): array
|
||||
{
|
||||
$income = 0;
|
||||
$expense = 0;
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
if ($amount > 0) {
|
||||
$income += $amount;
|
||||
} else {
|
||||
$expense += abs($amount);
|
||||
}
|
||||
}
|
||||
|
||||
$days = $this->spanInDays($transactions);
|
||||
|
||||
return [
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'net' => $income - $expense,
|
||||
'count' => $transactions->count(),
|
||||
'days' => $days,
|
||||
'average_expense_per_day' => $days > 0 ? intdiv($expense, $days) : $expense,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expenses grouped by their top-level category, rolled up through the
|
||||
* category tree so parents absorb their children's spending.
|
||||
*/
|
||||
private function categoryBreakdown(Collection $transactions, string $currency, string $userId): Collection
|
||||
{
|
||||
$expenses = $transactions->filter(
|
||||
fn (Transaction $transaction): bool => $this->convertTransactionAmount($transaction, $currency) < 0,
|
||||
);
|
||||
|
||||
$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))),
|
||||
];
|
||||
})
|
||||
->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'],
|
||||
]);
|
||||
|
||||
$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([
|
||||
'category_id' => null,
|
||||
'name' => __('Uncategorized'),
|
||||
'color' => 'gray',
|
||||
'amount' => $uncategorized,
|
||||
]);
|
||||
}
|
||||
|
||||
return $breakdown
|
||||
->filter(fn (array $node): bool => $node['amount'] > 0)
|
||||
->sortByDesc('amount')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spending grouped by label. A transaction contributes to every label
|
||||
* attached to it, so the totals can exceed overall expenses.
|
||||
*/
|
||||
private function tagBreakdown(Collection $transactions, string $currency): Collection
|
||||
{
|
||||
$totals = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
if ($amount >= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($transaction->labels as $label) {
|
||||
$totals[$label->id] ??= ['id' => $label->id, 'name' => $label->name, 'color' => $label->color, 'amount' => 0];
|
||||
$totals[$label->id]['amount'] += abs($amount);
|
||||
}
|
||||
}
|
||||
|
||||
return collect($totals)
|
||||
->filter(fn (array $tag): bool => $tag['amount'] > 0)
|
||||
->sortByDesc('amount')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Income and expense bucketed over the filtered span, plus a running
|
||||
* expense total so the pace of spending is visible.
|
||||
*
|
||||
* @return array{bucket: string, points: array<int, array{date: string, label: string, income: int, expense: int, cumulative_expense: int}>}
|
||||
*/
|
||||
private function overTime(Collection $transactions, string $currency): array
|
||||
{
|
||||
if ($transactions->isEmpty()) {
|
||||
return ['bucket' => 'day', 'points' => []];
|
||||
}
|
||||
|
||||
$dates = $transactions->map(fn (Transaction $transaction): Carbon => $transaction->transaction_date->copy());
|
||||
$start = $dates->min();
|
||||
$end = $dates->max();
|
||||
|
||||
$daily = $start->diffInDays($end) <= self::DAILY_BUCKET_MAX_DAYS;
|
||||
$keyFormat = $daily ? 'Y-m-d' : 'Y-m';
|
||||
|
||||
$buckets = [];
|
||||
foreach ($transactions as $transaction) {
|
||||
$key = $transaction->transaction_date->format($keyFormat);
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
$buckets[$key] ??= ['income' => 0, 'expense' => 0];
|
||||
|
||||
if ($amount > 0) {
|
||||
$buckets[$key]['income'] += $amount;
|
||||
} else {
|
||||
$buckets[$key]['expense'] += abs($amount);
|
||||
}
|
||||
}
|
||||
|
||||
$points = [];
|
||||
$cumulative = 0;
|
||||
$cursor = $daily ? $start->copy()->startOfDay() : $start->copy()->startOfMonth();
|
||||
$last = $daily ? $end->copy()->startOfDay() : $end->copy()->startOfMonth();
|
||||
|
||||
while ($cursor->lte($last)) {
|
||||
$key = $cursor->format($keyFormat);
|
||||
$income = $buckets[$key]['income'] ?? 0;
|
||||
$expense = $buckets[$key]['expense'] ?? 0;
|
||||
$cumulative += $expense;
|
||||
|
||||
$points[] = [
|
||||
'date' => $key,
|
||||
'label' => $daily ? $cursor->format('M j') : $cursor->format('M Y'),
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'cumulative_expense' => $cumulative,
|
||||
];
|
||||
|
||||
$daily ? $cursor->addDay() : $cursor->addMonth();
|
||||
}
|
||||
|
||||
return ['bucket' => $daily ? 'day' : 'month', 'points' => $points];
|
||||
}
|
||||
|
||||
private function spanInDays(Collection $transactions): int
|
||||
{
|
||||
if ($transactions->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$dates = $transactions->map(fn (Transaction $transaction): Carbon => $transaction->transaction_date);
|
||||
|
||||
return (int) $dates->min()->diffInDays($dates->max()) + 1;
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,12 +17,21 @@ class SavedFilter extends Model
|
|||
'user_id',
|
||||
'name',
|
||||
'filters',
|
||||
'analysis_days',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected $hidden = [
|
||||
'user_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'filters' => 'array',
|
||||
'analysis_days' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('saved_filters', function (Blueprint $table) {
|
||||
$table->unsignedInteger('analysis_days')->nullable()->after('filters');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('saved_filters', function (Blueprint $table) {
|
||||
$table->dropColumn('analysis_days');
|
||||
});
|
||||
}
|
||||
};
|
||||
18
lang/es.json
18
lang/es.json
|
|
@ -1,4 +1,22 @@
|
|||
{
|
||||
"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.",
|
||||
"Could not load the analysis. Please try again.": "No se pudo cargar el análisis. Inténtalo de nuevo.",
|
||||
"No transactions match the current filters.": "Ninguna transacción coincide con los filtros actuales.",
|
||||
"Avg / day": "Media / día",
|
||||
"transactions": "transacciones",
|
||||
"days": "días",
|
||||
"Spending over time": "Gasto a lo largo del tiempo",
|
||||
"Cumulative spend": "Gasto acumulado",
|
||||
"Spending by category": "Gasto por categoría",
|
||||
"Spending by tag": "Gasto por etiqueta",
|
||||
"adjusted": "ajustado",
|
||||
"Adjust number of days": "Ajustar número de días",
|
||||
"Days for daily average": "Días para la media diaria",
|
||||
"Override the date span when it does not match the real duration.": "Anula el rango de fechas cuando no coincide con la duración real.",
|
||||
"Saved with this filter.": "Guardado con este filtro.",
|
||||
"Reset to auto": "Restablecer a automático",
|
||||
"Bank account connected": "Cuenta bancaria conectada",
|
||||
"Connection unsuccessful": "Conexión fallida",
|
||||
"You can close this window and go back to the app to continue.": "Puedes cerrar esta ventana y volver a la app para continuar.",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import { type TransactionFilters } from '@/types/transaction';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TransactionActionsMenu } from './transaction-actions-menu';
|
||||
|
||||
const features = { transactionAnalysis: true };
|
||||
|
||||
vi.mock('@/actions/App/Http/Controllers/TransactionController', () => ({
|
||||
categorize: { url: () => '/transactions/categorize' },
|
||||
}));
|
||||
|
||||
vi.mock('@/contexts/encryption-key-context', () => ({
|
||||
useEncryptionKey: () => ({ isKeySet: true }),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-mobile', () => ({
|
||||
useIsMobile: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-re-evaluate-all-transactions', () => ({
|
||||
useReEvaluateAllTransactions: () => ({ reEvaluateAll: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@inertiajs/react', () => ({
|
||||
Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
usePage: () => ({ props: { features } }),
|
||||
}));
|
||||
|
||||
vi.mock('./import-transactions-drawer', () => ({
|
||||
ImportTransactionsDrawer: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./transaction-analysis-drawer', () => ({
|
||||
TransactionAnalysisDrawer: ({ open }: { open: boolean }) =>
|
||||
open ? <div data-testid="analysis-drawer" /> : null,
|
||||
}));
|
||||
|
||||
const emptyFilters: TransactionFilters = {
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
amountMin: null,
|
||||
amountMax: null,
|
||||
categoryIds: [],
|
||||
accountIds: [],
|
||||
labelIds: [],
|
||||
creditorName: '',
|
||||
debtorName: '',
|
||||
searchText: '',
|
||||
};
|
||||
|
||||
function renderMenu(filters: TransactionFilters) {
|
||||
return render(
|
||||
<TransactionActionsMenu
|
||||
categories={[]}
|
||||
accounts={[]}
|
||||
banks={[]}
|
||||
onAddTransaction={vi.fn()}
|
||||
transactions={[]}
|
||||
filters={filters}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('TransactionActionsMenu analysis button', () => {
|
||||
beforeEach(() => {
|
||||
features.transactionAnalysis = true;
|
||||
});
|
||||
|
||||
it('is hidden when the TransactionAnalysis feature flag is off', () => {
|
||||
features.transactionAnalysis = false;
|
||||
renderMenu(emptyFilters);
|
||||
|
||||
expect(screen.queryByText('Analysis')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is disabled when no filter is applied', () => {
|
||||
renderMenu(emptyFilters);
|
||||
|
||||
expect(screen.getByText('Analysis').closest('button')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the analysis drawer when a filter is applied and clicked', () => {
|
||||
renderMenu({ ...emptyFilters, labelIds: ['label-1'] });
|
||||
|
||||
const button = screen.getByText('Analysis').closest('button')!;
|
||||
expect(button).toHaveAttribute('aria-disabled', 'false');
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByTestId('analysis-drawer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
import { categorize } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonGroup } from '@/components/ui/button-group';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -14,18 +21,31 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { useReEvaluateAllTransactions } from '@/hooks/use-re-evaluate-all-transactions';
|
||||
import { hasActiveFilters } from '@/lib/transaction-filter-serialization';
|
||||
|
||||
import { type SharedData } from '@/types';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import {
|
||||
type DecryptedTransaction,
|
||||
type TransactionFilters,
|
||||
} from '@/types/transaction';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { ChevronDown, Plus, Upload, WandSparkles } from 'lucide-react';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
BarChart3,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ImportTransactionsDrawer } from './import-transactions-drawer';
|
||||
import { TransactionAnalysisDrawer } from './transaction-analysis-drawer';
|
||||
|
||||
interface TransactionActionsMenuProps {
|
||||
categories: Category[];
|
||||
|
|
@ -36,6 +56,7 @@ interface TransactionActionsMenuProps {
|
|||
transactions: DecryptedTransaction[];
|
||||
onReEvaluateComplete?: () => void;
|
||||
onImportComplete?: () => void;
|
||||
filters: TransactionFilters;
|
||||
}
|
||||
|
||||
export function TransactionActionsMenu({
|
||||
|
|
@ -47,12 +68,29 @@ export function TransactionActionsMenu({
|
|||
transactions,
|
||||
onReEvaluateComplete,
|
||||
onImportComplete,
|
||||
filters,
|
||||
}: TransactionActionsMenuProps) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const { features } = usePage<SharedData>().props;
|
||||
const isMobile = useIsMobile();
|
||||
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
|
||||
const [analysisDrawerOpen, setAnalysisDrawerOpen] = useState(false);
|
||||
const [analysisHintOpen, setAnalysisHintOpen] = useState(false);
|
||||
const [isReEvaluating, setIsReEvaluating] = useState(false);
|
||||
const { reEvaluateAll } = useReEvaluateAllTransactions();
|
||||
|
||||
const canAnalyze = hasActiveFilters(filters);
|
||||
|
||||
const handleAnalysisClick = () => {
|
||||
if (!canAnalyze) {
|
||||
if (isMobile) {
|
||||
setAnalysisHintOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setAnalysisDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleAddTransaction = () => {
|
||||
if (!isKeySet) {
|
||||
toast.error(
|
||||
|
|
@ -90,6 +128,50 @@ export function TransactionActionsMenu({
|
|||
return (
|
||||
<>
|
||||
<ButtonGroup>
|
||||
{features.transactionAnalysis &&
|
||||
(isMobile ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={
|
||||
!canAnalyze
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: ''
|
||||
}
|
||||
aria-disabled={!canAnalyze}
|
||||
onClick={handleAnalysisClick}
|
||||
>
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
{__('Analysis')}
|
||||
</Button>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={
|
||||
!canAnalyze
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: ''
|
||||
}
|
||||
aria-disabled={!canAnalyze}
|
||||
onClick={handleAnalysisClick}
|
||||
>
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
{__('Analysis')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{!canAnalyze && (
|
||||
<TooltipContent>
|
||||
{__(
|
||||
'Apply a filter to enable this button',
|
||||
)}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -200,6 +282,25 @@ export function TransactionActionsMenu({
|
|||
automationRules={automationRules}
|
||||
onImportComplete={onImportComplete}
|
||||
/>
|
||||
|
||||
{features.transactionAnalysis && (
|
||||
<TransactionAnalysisDrawer
|
||||
open={analysisDrawerOpen}
|
||||
onOpenChange={setAnalysisDrawerOpen}
|
||||
filters={filters}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={analysisHintOpen} onOpenChange={setAnalysisHintOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Analysis')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__('Apply a filter to enable this button')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
import { type TransactionFilters } from '@/types/transaction';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TransactionAnalysisDrawer } from './transaction-analysis-drawer';
|
||||
|
||||
const axiosGet = vi.fn();
|
||||
const axiosPatch = vi.fn();
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => axiosGet(...args),
|
||||
patch: (...args: unknown[]) => axiosPatch(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-locale', () => ({
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/amount-display', () => ({
|
||||
AmountDisplay: ({ amountInCents }: { amountInCents: number }) => (
|
||||
<span data-testid="amount">{amountInCents}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
const filters: TransactionFilters = {
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
amountMin: null,
|
||||
amountMax: null,
|
||||
categoryIds: [],
|
||||
accountIds: [],
|
||||
labelIds: ['label-1'],
|
||||
creditorName: '',
|
||||
debtorName: '',
|
||||
searchText: '',
|
||||
};
|
||||
|
||||
// expense 90000 cents over a 90-day span → auto avg = 1000/day.
|
||||
const analysisResponse = {
|
||||
currency: 'USD',
|
||||
summary: {
|
||||
income: 0,
|
||||
expense: 90000,
|
||||
net: -90000,
|
||||
count: 5,
|
||||
days: 90,
|
||||
average_expense_per_day: 1000,
|
||||
},
|
||||
by_category: [],
|
||||
distinct_category_count: 0,
|
||||
by_tag: [],
|
||||
distinct_label_count: 0,
|
||||
over_time: { bucket: 'day', points: [] },
|
||||
};
|
||||
|
||||
function mockAnalysisFetch() {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => analysisResponse,
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
// The Avg/day card is the 4th amount rendered (Income, Expenses, Net, Avg).
|
||||
function avgPerDay(): number {
|
||||
return Number(screen.getAllByTestId('amount')[3].textContent);
|
||||
}
|
||||
|
||||
function stubLocalStorage() {
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubLocalStorage();
|
||||
axiosGet.mockReset();
|
||||
axiosPatch.mockReset();
|
||||
mockAnalysisFetch();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('TransactionAnalysisDrawer day override', () => {
|
||||
it('averages over the auto date span when there is no override', async () => {
|
||||
axiosGet.mockResolvedValue({ data: { data: [] } });
|
||||
|
||||
render(
|
||||
<TransactionAnalysisDrawer
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
filters={filters}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(avgPerDay()).toBe(1000));
|
||||
});
|
||||
|
||||
it('prefers a matching saved filter day override over the span', async () => {
|
||||
axiosGet.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'saved-1',
|
||||
filters: { label_ids: ['label-1'] },
|
||||
analysis_days: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<TransactionAnalysisDrawer
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
filters={filters}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 90000 / 3 = 30000.
|
||||
await waitFor(() => expect(avgPerDay()).toBe(30000));
|
||||
});
|
||||
|
||||
it('falls back to a browser override when no saved filter matches', async () => {
|
||||
axiosGet.mockResolvedValue({ data: { data: [] } });
|
||||
localStorage.setItem(
|
||||
`wm.analysis-days.${JSON.stringify({
|
||||
date_from: null,
|
||||
date_to: null,
|
||||
amount_min: null,
|
||||
amount_max: null,
|
||||
category_ids: [],
|
||||
account_ids: [],
|
||||
label_ids: ['label-1'],
|
||||
creditor_name: '',
|
||||
debtor_name: '',
|
||||
search: '',
|
||||
})}`,
|
||||
'5',
|
||||
);
|
||||
|
||||
render(
|
||||
<TransactionAnalysisDrawer
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
filters={filters}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 90000 / 5 = 18000.
|
||||
await waitFor(() => expect(avgPerDay()).toBe(18000));
|
||||
});
|
||||
|
||||
it('persists a new override to the matched saved filter', async () => {
|
||||
axiosGet.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'saved-1',
|
||||
filters: { label_ids: ['label-1'] },
|
||||
analysis_days: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
axiosPatch.mockResolvedValue({ data: {} });
|
||||
|
||||
render(
|
||||
<TransactionAnalysisDrawer
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
filters={filters}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(avgPerDay()).toBe(1000));
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Adjust number of days'));
|
||||
fireEvent.change(screen.getByRole('spinbutton'), {
|
||||
target: { value: '6' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Apply'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(axiosPatch).toHaveBeenCalledWith(
|
||||
'/api/saved-filters/saved-1/analysis-days',
|
||||
{ analysis_days: 6 },
|
||||
),
|
||||
);
|
||||
// 90000 / 6 = 15000.
|
||||
expect(avgPerDay()).toBe(15000);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,803 @@
|
|||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/drawer';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import {
|
||||
filtersFingerprint,
|
||||
serializeFilters,
|
||||
type SerializedFilters,
|
||||
} from '@/lib/transaction-filter-serialization';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type TransactionFilters } from '@/types/transaction';
|
||||
import { type UUID } from '@/types/uuid';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import axios from 'axios';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
Line,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
interface AnalysisSummary {
|
||||
income: number;
|
||||
expense: number;
|
||||
net: number;
|
||||
count: number;
|
||||
days: number;
|
||||
average_expense_per_day: number;
|
||||
}
|
||||
|
||||
interface CategorySlice {
|
||||
category_id: string | null;
|
||||
name: string;
|
||||
color: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface TagSlice {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface OverTimePoint {
|
||||
date: string;
|
||||
label: string;
|
||||
income: number;
|
||||
expense: number;
|
||||
cumulative_expense: number;
|
||||
}
|
||||
|
||||
interface AnalysisData {
|
||||
currency: string;
|
||||
summary: AnalysisSummary;
|
||||
by_category: CategorySlice[];
|
||||
distinct_category_count: number;
|
||||
by_tag: TagSlice[];
|
||||
distinct_label_count: number;
|
||||
over_time: { bucket: 'day' | 'month'; points: OverTimePoint[] };
|
||||
}
|
||||
|
||||
interface TransactionAnalysisDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
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)',
|
||||
];
|
||||
|
||||
function buildQueryString(filters: SerializedFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
params.set(key, value.join(','));
|
||||
}
|
||||
} else if (value !== undefined && value !== null && value !== '') {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
interface SavedFilterSummary {
|
||||
id: UUID;
|
||||
filters: SerializedFilters;
|
||||
analysis_days: number | null;
|
||||
}
|
||||
|
||||
const DAY_OVERRIDE_STORAGE_PREFIX = 'wm.analysis-days.';
|
||||
|
||||
function readStoredDays(key: string): number | null {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the number of days used to average daily spending for a filter set.
|
||||
*
|
||||
* The date span between the first and last transaction is the default, but a
|
||||
* user can override it (e.g. tickets bought months ahead skew the span). The
|
||||
* override is remembered per filter fingerprint in the browser, and also
|
||||
* synced to the backend when the current filters match a saved filter.
|
||||
*/
|
||||
function useAnalysisDays(
|
||||
open: boolean,
|
||||
filters: TransactionFilters,
|
||||
autoDays: number,
|
||||
) {
|
||||
const fingerprint = useMemo(
|
||||
() => filtersFingerprint(serializeFilters(filters)),
|
||||
[filters],
|
||||
);
|
||||
const storageKey = `${DAY_OVERRIDE_STORAGE_PREFIX}${fingerprint}`;
|
||||
|
||||
const [override, setOverride] = useState<number | null>(null);
|
||||
const [savedFilterId, setSavedFilterId] = useState<UUID | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const local = readStoredDays(storageKey);
|
||||
let active = true;
|
||||
|
||||
axios
|
||||
.get<{ data: SavedFilterSummary[] }>('/api/saved-filters')
|
||||
.then((response) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const match =
|
||||
response.data.data.find(
|
||||
(saved) =>
|
||||
filtersFingerprint(saved.filters) === fingerprint,
|
||||
) ?? null;
|
||||
setSavedFilterId(match?.id ?? null);
|
||||
setOverride(match?.analysis_days ?? local);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setSavedFilterId(null);
|
||||
setOverride(local);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, fingerprint, storageKey]);
|
||||
|
||||
const applyDays = useCallback(
|
||||
(value: number | null) => {
|
||||
setOverride(value);
|
||||
|
||||
if (value === null) {
|
||||
localStorage.removeItem(storageKey);
|
||||
} else {
|
||||
localStorage.setItem(storageKey, String(value));
|
||||
}
|
||||
|
||||
if (savedFilterId) {
|
||||
void axios.patch(
|
||||
`/api/saved-filters/${savedFilterId}/analysis-days`,
|
||||
{ analysis_days: value },
|
||||
);
|
||||
}
|
||||
},
|
||||
[storageKey, savedFilterId],
|
||||
);
|
||||
|
||||
return {
|
||||
effectiveDays: override ?? autoDays,
|
||||
isOverridden: override !== null,
|
||||
isSaved: savedFilterId !== null,
|
||||
applyDays,
|
||||
};
|
||||
}
|
||||
|
||||
export function TransactionAnalysisDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
filters,
|
||||
}: TransactionAnalysisDrawerProps) {
|
||||
const locale = useLocale();
|
||||
const [data, setData] = useState<AnalysisData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAnalysis = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const query = buildQueryString(serializeFilters(filters));
|
||||
const response = await fetch(
|
||||
`/api/transactions/analysis?${query}`,
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Request failed');
|
||||
}
|
||||
|
||||
setData((await response.json()) as AnalysisData);
|
||||
} catch {
|
||||
setError(__('Could not load the analysis. Please try again.'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
void loadAnalysis();
|
||||
}
|
||||
}, [open, loadAnalysis]);
|
||||
|
||||
const currency = data?.currency ?? '';
|
||||
const hasTransactions = (data?.summary.count ?? 0) > 0;
|
||||
|
||||
const { effectiveDays, isOverridden, isSaved, applyDays } = useAnalysisDays(
|
||||
open,
|
||||
filters,
|
||||
data?.summary.days ?? 0,
|
||||
);
|
||||
const expense = data?.summary.expense ?? 0;
|
||||
const averagePerDay =
|
||||
effectiveDays > 0 ? Math.round(expense / effectiveDays) : expense;
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="h-[90vh] data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
|
||||
<div className="mx-auto w-full max-w-5xl overflow-y-auto p-6">
|
||||
<DrawerHeader className="px-0">
|
||||
<DrawerTitle>{__('Analysis')}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{__(
|
||||
'A breakdown of the transactions matching your current filters.',
|
||||
)}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
{isLoading && <AnalysisSkeleton />}
|
||||
|
||||
{!isLoading && error && (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && !hasTransactions && (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{__('No transactions match the current filters.')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && data && hasTransactions && (
|
||||
<div className="flex flex-col gap-8">
|
||||
<SummaryCards
|
||||
summary={data.summary}
|
||||
currency={currency}
|
||||
days={effectiveDays}
|
||||
averagePerDay={averagePerDay}
|
||||
isOverridden={isOverridden}
|
||||
isSaved={isSaved}
|
||||
onApplyDays={applyDays}
|
||||
/>
|
||||
|
||||
<OverTimeChart
|
||||
points={data.over_time.points}
|
||||
currency={currency}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
{data.distinct_category_count > 1 && (
|
||||
<CategoryBreakdown
|
||||
slices={data.by_category}
|
||||
currency={currency}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.distinct_label_count > 1 && (
|
||||
<TagBreakdown
|
||||
slices={data.by_tag}
|
||||
currency={currency}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCards({
|
||||
summary,
|
||||
currency,
|
||||
days,
|
||||
averagePerDay,
|
||||
isOverridden,
|
||||
isSaved,
|
||||
onApplyDays,
|
||||
}: {
|
||||
summary: AnalysisSummary;
|
||||
currency: string;
|
||||
days: number;
|
||||
averagePerDay: number;
|
||||
isOverridden: boolean;
|
||||
isSaved: boolean;
|
||||
onApplyDays: (value: number | null) => void;
|
||||
}) {
|
||||
const cards = [
|
||||
{ label: __('Income'), amount: summary.income, tone: 'income' },
|
||||
{ label: __('Expenses'), amount: summary.expense, tone: 'expense' },
|
||||
{ label: __('Net'), amount: summary.net, tone: 'net' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
<div key={card.label} className="rounded-lg border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{card.label}
|
||||
</p>
|
||||
<AmountDisplay
|
||||
amountInCents={card.amount}
|
||||
currencyCode={currency}
|
||||
className={cn(
|
||||
'mt-1 text-lg font-semibold tabular-nums',
|
||||
card.tone === 'income' && 'text-emerald-600',
|
||||
card.tone === 'expense' && 'text-red-600',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Avg / day')}
|
||||
</p>
|
||||
<DayEditorPopover
|
||||
days={days}
|
||||
isOverridden={isOverridden}
|
||||
isSaved={isSaved}
|
||||
onApply={onApplyDays}
|
||||
/>
|
||||
</div>
|
||||
<AmountDisplay
|
||||
amountInCents={averagePerDay}
|
||||
currencyCode={currency}
|
||||
className="mt-1 text-lg font-semibold text-red-600 tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="col-span-2 text-xs text-muted-foreground sm:col-span-4">
|
||||
{summary.count} {__('transactions')} · {days} {__('days')}
|
||||
{isOverridden && ` (${__('adjusted')})`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayEditorPopover({
|
||||
days,
|
||||
isOverridden,
|
||||
isSaved,
|
||||
onApply,
|
||||
}: {
|
||||
days: number;
|
||||
isOverridden: boolean;
|
||||
isSaved: boolean;
|
||||
onApply: (value: number | null) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState(String(days));
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(String(days));
|
||||
}
|
||||
}, [open, days]);
|
||||
|
||||
const save = () => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
onApply(parsed);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
onApply(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
aria-label={__('Adjust number of days')}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">
|
||||
{__('Days for daily average')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Override the date span when it does not match the real duration.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
save();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isSaved && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Saved with this filter.')}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={reset}
|
||||
disabled={!isOverridden}
|
||||
>
|
||||
{__('Reset to auto')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={save}>
|
||||
{__('Apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function OverTimeChart({
|
||||
points,
|
||||
currency,
|
||||
locale,
|
||||
}: {
|
||||
points: OverTimePoint[];
|
||||
currency: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const config: ChartConfig = {
|
||||
income: { label: __('Income'), color: 'var(--color-chart-2)' },
|
||||
expense: { label: __('Expenses'), color: 'var(--color-chart-5)' },
|
||||
cumulative_expense: {
|
||||
label: __('Cumulative spend'),
|
||||
color: 'var(--color-chart-1)',
|
||||
},
|
||||
};
|
||||
|
||||
const compact = (value: number) =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(value / 100);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">{__('Spending over time')}</h3>
|
||||
<ChartContainer config={config} className="h-64 w-full">
|
||||
<ComposedChart data={points}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={16}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={compact}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<OverTimeTooltip currency={currency} />}
|
||||
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="income"
|
||||
fill="var(--color-chart-2)"
|
||||
radius={[3, 3, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="expense"
|
||||
fill="var(--color-chart-5)"
|
||||
radius={[3, 3, 0, 0]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cumulative_expense"
|
||||
stroke="var(--color-chart-1)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ChartContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface TooltipPayloadItem {
|
||||
name?: string;
|
||||
dataKey?: string;
|
||||
value?: number;
|
||||
payload?: OverTimePoint;
|
||||
}
|
||||
|
||||
function OverTimeTooltip({
|
||||
active,
|
||||
payload,
|
||||
currency,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
currency: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const point = payload[0]?.payload;
|
||||
const rows: {
|
||||
label: string;
|
||||
key: 'income' | 'expense' | 'cumulative_expense';
|
||||
}[] = [
|
||||
{ label: __('Income'), key: 'income' },
|
||||
{ label: __('Expenses'), key: 'expense' },
|
||||
{ label: __('Cumulative spend'), key: 'cumulative_expense' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<div className="font-medium">{point?.label}</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className="mt-1 flex items-center justify-between gap-4"
|
||||
>
|
||||
<span className="text-muted-foreground">{row.label}</span>
|
||||
<AmountDisplay
|
||||
amountInCents={point ? point[row.key] : 0}
|
||||
currencyCode={currency}
|
||||
className="font-mono tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryBreakdown({
|
||||
slices,
|
||||
currency,
|
||||
}: {
|
||||
slices: CategorySlice[];
|
||||
currency: string;
|
||||
}) {
|
||||
const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
|
||||
const config: ChartConfig = { amount: { label: __('Spent') } };
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
{__('Spending by category')}
|
||||
</h3>
|
||||
<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_PALETTE[
|
||||
index % CHART_PALETTE.length
|
||||
]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TagBreakdown({
|
||||
slices,
|
||||
currency,
|
||||
locale,
|
||||
}: {
|
||||
slices: TagSlice[];
|
||||
currency: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const config: ChartConfig = {
|
||||
amount: { label: __('Spent'), color: 'var(--color-chart-1)' },
|
||||
};
|
||||
|
||||
const compact = (value: number) =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(value / 100);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">{__('Spending by tag')}</h3>
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="w-full"
|
||||
style={{ height: `${Math.max(slices.length * 44, 88)}px` }}
|
||||
>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart
|
||||
layout="vertical"
|
||||
data={slices}
|
||||
margin={{ left: 8, right: 16 }}
|
||||
>
|
||||
<XAxis type="number" hide tickFormatter={compact} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={96}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{
|
||||
fill: 'var(--color-muted)',
|
||||
opacity: 0.3,
|
||||
}}
|
||||
content={<TagTooltip currency={currency} />}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="amount"
|
||||
fill="var(--color-chart-1)"
|
||||
radius={[0, 3, 3, 0]}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TagTooltip({
|
||||
active,
|
||||
payload,
|
||||
currency,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: { payload?: TagSlice }[];
|
||||
currency: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slice = payload[0]?.payload;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<div className="font-medium">{slice?.name}</div>
|
||||
<AmountDisplay
|
||||
amountInCents={slice?.amount ?? 0}
|
||||
currencyCode={currency}
|
||||
className="mt-1 font-mono tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisSkeleton() {
|
||||
return (
|
||||
<div className="flex animate-pulse flex-col gap-8">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-20 rounded-lg border bg-muted/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-64 rounded-lg border bg-muted/50" />
|
||||
<div className="h-52 rounded-lg border bg-muted/50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1184,6 +1184,7 @@ export default function Transactions({
|
|||
onImportComplete={() =>
|
||||
refreshTransactions()
|
||||
}
|
||||
filters={filters}
|
||||
/>
|
||||
|
||||
<DataTableViewOptions table={table} />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\Api\CashflowAnalyticsController;
|
|||
use App\Http\Controllers\Api\DashboardAnalyticsController;
|
||||
use App\Http\Controllers\Api\ImportDataController;
|
||||
use App\Http\Controllers\Api\SavedFilterController;
|
||||
use App\Http\Controllers\Api\TransactionAnalysisController;
|
||||
use App\Http\Controllers\Api\TransactionController;
|
||||
use App\Http\Controllers\EncryptionController;
|
||||
use App\Http\Controllers\Sync\TransactionSyncController;
|
||||
|
|
@ -26,6 +27,7 @@ Route::middleware(['web', 'auth'])->group(function () {
|
|||
|
||||
// Transactions
|
||||
Route::get('transactions', [TransactionController::class, 'index'])->name('api.transactions.index');
|
||||
Route::get('transactions/analysis', [TransactionAnalysisController::class, 'summary'])->name('api.transactions.analysis');
|
||||
Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('api.transactions.bulk-update');
|
||||
|
||||
// Accounts
|
||||
|
|
@ -62,5 +64,6 @@ Route::middleware(['web', 'auth'])->group(function () {
|
|||
Route::get('saved-filters', [SavedFilterController::class, 'index'])->name('api.saved-filters.index');
|
||||
Route::post('saved-filters', [SavedFilterController::class, 'store'])->name('api.saved-filters.store');
|
||||
Route::patch('saved-filters/{savedFilter}', [SavedFilterController::class, 'update'])->name('api.saved-filters.update');
|
||||
Route::patch('saved-filters/{savedFilter}/analysis-days', [SavedFilterController::class, 'updateAnalysisDays'])->name('api.saved-filters.analysis-days');
|
||||
Route::delete('saved-filters/{savedFilter}', [SavedFilterController::class, 'destroy'])->name('api.saved-filters.destroy');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,43 @@ test('lists only the current user saved filters ordered by name', function () {
|
|||
expect($response->json('data.1.name'))->toBe('Zurich trip');
|
||||
});
|
||||
|
||||
test('updates the analysis day override on a saved filter', function () {
|
||||
$savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-days", ['analysis_days' => 21])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.analysis_days', 21);
|
||||
|
||||
expect($savedFilter->fresh()->analysis_days)->toBe(21);
|
||||
});
|
||||
|
||||
test('clears the analysis day override when sent null', function () {
|
||||
$savedFilter = SavedFilter::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'analysis_days' => 14,
|
||||
]);
|
||||
|
||||
$this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-days", ['analysis_days' => null])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.analysis_days', null);
|
||||
|
||||
expect($savedFilter->fresh()->analysis_days)->toBeNull();
|
||||
});
|
||||
|
||||
test('cannot update the analysis days of another user saved filter', function () {
|
||||
$savedFilter = SavedFilter::factory()->create(['name' => 'Not mine']);
|
||||
|
||||
$this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-days", ['analysis_days' => 5])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('rejects an invalid analysis day override', function () {
|
||||
$savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-days", ['analysis_days' => 0])
|
||||
->assertJsonValidationErrors('analysis_days');
|
||||
});
|
||||
|
||||
test('stores a saved filter for the current user', function () {
|
||||
$payload = [
|
||||
'name' => 'Trip to Japan',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Features\TransactionAnalysis;
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
beforeEach(function () {
|
||||
Http::fake();
|
||||
|
||||
$this->user = User::factory()->create(['currency_code' => 'USD']);
|
||||
$this->actingAs($this->user);
|
||||
Feature::for($this->user)->activate(TransactionAnalysis::class);
|
||||
|
||||
$this->account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
});
|
||||
|
||||
function makeTransaction(array $attributes = []): Transaction
|
||||
{
|
||||
return Transaction::factory()->create([
|
||||
'user_id' => test()->user->id,
|
||||
'account_id' => test()->account->id,
|
||||
'currency_code' => 'USD',
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
test('analysis endpoint is gated behind the TransactionAnalysis feature flag', function () {
|
||||
Feature::for($this->user)->deactivate(TransactionAnalysis::class);
|
||||
|
||||
$this->getJson('/api/transactions/analysis')->assertForbidden();
|
||||
});
|
||||
|
||||
test('analysis response is not cached between users', function () {
|
||||
$this->getJson('/api/transactions/analysis')
|
||||
->assertOk()
|
||||
->assertHeader('Cache-Control', 'no-store, private');
|
||||
});
|
||||
|
||||
test('summary totals income, expense, net and count from the filtered set', function () {
|
||||
$label = Label::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$income = makeTransaction(['amount' => 100000, 'transaction_date' => '2026-01-10']);
|
||||
$income->labels()->attach($label);
|
||||
|
||||
$expense = makeTransaction(['amount' => -40000, 'transaction_date' => '2026-01-12']);
|
||||
$expense->labels()->attach($label);
|
||||
|
||||
// Outside the label filter, must be excluded.
|
||||
makeTransaction(['amount' => -99999, 'transaction_date' => '2026-01-12']);
|
||||
|
||||
$response = $this->getJson('/api/transactions/analysis?'.http_build_query([
|
||||
'label_ids' => $label->id,
|
||||
]));
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson([
|
||||
'currency' => 'USD',
|
||||
'summary' => [
|
||||
'income' => 100000,
|
||||
'expense' => 40000,
|
||||
'net' => 60000,
|
||||
'count' => 2,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('category breakdown groups expenses by top-level category', function () {
|
||||
$hotel = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Hotel']);
|
||||
$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']);
|
||||
makeTransaction(['amount' => -20000, 'category_id' => $meals->id, 'transaction_date' => '2026-01-11']);
|
||||
|
||||
$response = $this->getJson('/api/transactions/analysis');
|
||||
|
||||
$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]);
|
||||
});
|
||||
|
||||
test('tag breakdown sums expenses per label', function () {
|
||||
$trip = Label::factory()->create(['user_id' => $this->user->id, 'name' => 'Miami']);
|
||||
$food = Label::factory()->create(['user_id' => $this->user->id, 'name' => 'Food']);
|
||||
|
||||
$meal = makeTransaction(['amount' => -3000, 'transaction_date' => '2026-01-10']);
|
||||
$meal->labels()->attach([$trip->id, $food->id]);
|
||||
|
||||
$hotel = makeTransaction(['amount' => -7000, 'transaction_date' => '2026-01-11']);
|
||||
$hotel->labels()->attach($trip->id);
|
||||
|
||||
$response = $this->getJson('/api/transactions/analysis');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('distinct_label_count'))->toBe(2);
|
||||
expect($response->json('by_tag.0'))->toMatchArray(['name' => 'Miami', 'amount' => 10000]);
|
||||
expect($response->json('by_tag.1'))->toMatchArray(['name' => 'Food', 'amount' => 3000]);
|
||||
});
|
||||
|
||||
test('over time uses daily buckets for short spans and carries a cumulative expense', function () {
|
||||
makeTransaction(['amount' => -1000, 'transaction_date' => '2026-01-10']);
|
||||
makeTransaction(['amount' => -2000, 'transaction_date' => '2026-01-12']);
|
||||
|
||||
$response = $this->getJson('/api/transactions/analysis');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('over_time.bucket'))->toBe('day');
|
||||
|
||||
$points = $response->json('over_time.points');
|
||||
expect($points)->toHaveCount(3); // Jan 10, 11 (gap filled), 12
|
||||
expect($points[0])->toMatchArray(['date' => '2026-01-10', 'expense' => 1000, 'cumulative_expense' => 1000]);
|
||||
expect($points[1])->toMatchArray(['date' => '2026-01-11', 'expense' => 0, 'cumulative_expense' => 1000]);
|
||||
expect($points[2])->toMatchArray(['date' => '2026-01-12', 'expense' => 2000, 'cumulative_expense' => 3000]);
|
||||
});
|
||||
|
||||
test('over time switches to monthly buckets for long spans', function () {
|
||||
makeTransaction(['amount' => -1000, 'transaction_date' => '2026-01-10']);
|
||||
makeTransaction(['amount' => -2000, 'transaction_date' => '2026-06-10']);
|
||||
|
||||
$response = $this->getJson('/api/transactions/analysis');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('over_time.bucket'))->toBe('month');
|
||||
expect($response->json('over_time.points'))->toHaveCount(6); // Jan..Jun
|
||||
});
|
||||
Loading…
Reference in New Issue