diff --git a/app/Enums/AnalysisMode.php b/app/Enums/AnalysisMode.php index a4b37044..cc43a0c4 100644 --- a/app/Enums/AnalysisMode.php +++ b/app/Enums/AnalysisMode.php @@ -6,4 +6,5 @@ enum AnalysisMode: string { case Expense = 'expense'; case Income = 'income'; + case Trend = 'trend'; } diff --git a/app/Http/Controllers/Api/TransactionAnalysisController.php b/app/Http/Controllers/Api/TransactionAnalysisController.php index ed325060..488f395a 100644 --- a/app/Http/Controllers/Api/TransactionAnalysisController.php +++ b/app/Http/Controllers/Api/TransactionAnalysisController.php @@ -86,7 +86,7 @@ class TransactionAnalysisController extends Controller } /** - * @return array{income: int, expense: int, net: int, count: int, days: int, average_expense_per_day: int} + * @return array{income: int, expense: int, net: int, count: int, days: int, average_expense_per_day: int, first_date: ?string} */ private function summaryTotals(Collection $transactions, string $currency): array { @@ -116,6 +116,14 @@ class TransactionAnalysisController extends Controller 'count' => $transactions->count(), 'days' => $days, 'average_expense_per_day' => $days > 0 ? intdiv($expense, $days) : $expense, + // The day the series starts, so a monthly average can tell a whole + // calendar month from one the filter only clips a few days out of. + 'first_date' => $transactions->isEmpty() + ? null + : $transactions + ->map(fn (Transaction $transaction): Carbon => $transaction->transaction_date) + ->min() + ->toDateString(), ]; } diff --git a/lang/es.json b/lang/es.json index 572bf8a4..6ab25cf5 100644 --- a/lang/es.json +++ b/lang/es.json @@ -117,7 +117,6 @@ "Show less": "Ver menos", "Analysis view": "Vista de análisis", "Automatic": "Automático", - "Expenses only": "Solo gastos", "Income & expenses": "Ingresos y gastos", "adjusted": "ajustado", "Adjust number of days": "Ajustar número de días", @@ -2314,5 +2313,13 @@ "Over by": "Excedido en", "Available": "Disponible", "View Budget": "Ver presupuesto", - "Don't want these emails? Manage notifications in [notification settings](:url).": "¿No quieres estos emails? Gestiona las notificaciones en los [ajustes de notificaciones](:url)." + "Don't want these emails? Manage notifications in [notification settings](:url).": "¿No quieres estos emails? Gestiona las notificaciones en los [ajustes de notificaciones](:url).", + "Monthly trend": "Tendencia mensual", + "Monthly net average": "Media mensual neta", + "Last :count months": "Últimos :count meses", + "Spending per month": "Gasto por mes", + "over :count whole months": "sobre :count meses completos", + "vs. average": "vs. media", + "Income & expenses per month": "Ingresos y gastos por mes", + "Monthly trend needs at least one whole calendar month.": "La tendencia mensual necesita al menos un mes natural completo." } diff --git a/lang/fr.json b/lang/fr.json index dd34f9d3..51500b02 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -97,7 +97,6 @@ "Show less": "voir moins", "Analysis view": "Vue d'analyse", "Automatic": "Automatique", - "Expenses only": "seulement les dépenses", "Income & expenses": "Recettes et dépenses", "adjusted": "serré", "Adjust number of days": "Définir le nombre de jours", @@ -2159,5 +2158,13 @@ "Catch-all budget": "Budget général", "All untracked expenses": "Toutes les dépenses non suivies", "Automatically track every expense that no other budget covers. You can only have one.": "Suit automatiquement chaque dépense qu'aucun autre budget ne couvre. Vous ne pouvez en avoir qu'un seul.", - "This catch-all budget tracks every expense that no other budget covers.": "Ce budget général suit chaque dépense qu'aucun autre budget ne couvre." + "This catch-all budget tracks every expense that no other budget covers.": "Ce budget général suit chaque dépense qu'aucun autre budget ne couvre.", + "Monthly trend": "Tendance mensuelle", + "Monthly net average": "Moyenne mensuelle nette", + "Last :count months": ":count derniers mois", + "Spending per month": "Dépenses par mois", + "over :count whole months": "sur :count mois complets", + "vs. average": "vs. moyenne", + "Income & expenses per month": "Revenus et dépenses par mois", + "Monthly trend needs at least one whole calendar month.": "La tendance mensuelle nécessite au moins un mois calendaire complet." } diff --git a/resources/js/components/transactions/transaction-analysis-drawer.test.tsx b/resources/js/components/transactions/transaction-analysis-drawer.test.tsx index b1f4367a..b0acef55 100644 --- a/resources/js/components/transactions/transaction-analysis-drawer.test.tsx +++ b/resources/js/components/transactions/transaction-analysis-drawer.test.tsx @@ -8,7 +8,12 @@ import { } from '@testing-library/react'; import type React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { TransactionAnalysisDrawer } from './transaction-analysis-drawer'; +import { + isAdverseChange, + monthlyRates, + resolveAnalysisView, + TransactionAnalysisDrawer, +} from './transaction-analysis-drawer'; const axiosGet = vi.fn(); const axiosPatch = vi.fn(); @@ -48,6 +53,7 @@ const filters: TransactionFilters = { creditorName: '', debtorName: '', searchText: '', + aiCategorizedOnly: false, }; // expense 90000 cents over a 90-day span → auto avg = 1000/day. @@ -60,6 +66,7 @@ const analysisResponse = { count: 5, days: 90, average_expense_per_day: 1000, + first_date: null, }, by_category: [], distinct_category_count: 0, @@ -80,14 +87,18 @@ function mockAnalysisFetch(response: unknown = analysisResponse) { }) as unknown as typeof fetch; } -// In expense-only mode the Avg/day amount lives in the card labelled "Avg / day". -function avgPerDay(): number { +// Every summary card renders its label and its mocked amount in one rounded box. +function cardAmount(label: string): number { const card = screen - .getByText('Avg / day') + .getByText(label) .closest('div.rounded-lg') as HTMLElement; return Number(within(card).getByTestId('amount').textContent); } +function avgPerDay(): number { + return cardAmount('Avg / day'); +} + function stubLocalStorage() { const store = new Map(); vi.stubGlobal('localStorage', { @@ -109,6 +120,188 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe('monthly rates', () => { + function month(key: string, expense: number, income = 0) { + return { key, label: key, income, expense, net: income - expense }; + } + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + // Mid-July, so 2026-07 is the calendar month still in progress. + vi.setSystemTime(new Date('2026-07-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('leaves the month in progress out of the average', () => { + const rates = monthlyRates( + [ + month('2026-04', 1000), + month('2026-05', 4000), + month('2026-06', 4000), + month('2026-07', 999900), + ], + false, + ); + + // (1000 + 4000 + 4000) / 3, with July's part-month figure discarded. + expect(rates?.average).toBe(3000); + }); + + it('compares the recent window against the whole span', () => { + const rates = monthlyRates( + [ + month('2026-02', 1000), + month('2026-03', 1000), + month('2026-04', 1000), + month('2026-05', 4000), + month('2026-06', 4000), + ], + false, + ); + + expect(rates?.average).toBe(2200); + expect(rates?.recentAverage).toBe(3000); + expect(rates?.changePercentage).toBe(36); + }); + + it('withholds the recent window when it covers the whole span', () => { + const rates = monthlyRates( + [ + month('2026-04', 1000), + month('2026-05', 1000), + month('2026-06', 1000), + ], + false, + ); + + expect(rates?.average).toBe(1000); + expect(rates?.recentAverage).toBeNull(); + expect(rates?.changePercentage).toBeNull(); + }); + + it('reports nothing without a single completed month', () => { + expect(monthlyRates([month('2026-07', 5000)], false)).toBeNull(); + expect(monthlyRates([], false)).toBeNull(); + }); + + it('sizes the change against a negative net average', () => { + const rates = monthlyRates( + [ + month('2026-02', 200), + month('2026-03', 200), + month('2026-04', 600), + month('2026-05', 600), + month('2026-06', 600), + ], + true, + ); + + // Net runs at −440/month overall and −600 recently: 36% further under. + expect(rates?.average).toBe(-440); + expect(rates?.recentAverage).toBe(-600); + expect(rates?.changePercentage).toBe(-36); + expect(isAdverseChange(rates?.changePercentage ?? null, true)).toBe( + true, + ); + }); + + it('has no change to report against an average of zero', () => { + const rates = monthlyRates( + [ + month('2026-03', 1000, 1000), + month('2026-04', 1000, 1000), + month('2026-05', 1000, 1000), + month('2026-06', 1000, 1000), + ], + true, + ); + + expect(rates?.average).toBe(0); + expect(rates?.changePercentage).toBeNull(); + }); + + it('leaves out a first month the series only clips a few days out of', () => { + // Flat €300 a month, but the span starts on the 18th of February and + // July is still running. Counting either edge would invent a rise. + const rates = monthlyRates( + [ + month('2026-02', 15000), + month('2026-03', 30000), + month('2026-04', 30000), + month('2026-05', 30000), + month('2026-06', 30000), + month('2026-07', 999900), + ], + false, + '2026-02-18', + ); + + expect(rates?.months).toBe(4); + expect(rates?.average).toBe(30000); + expect(rates?.recentAverage).toBe(30000); + expect(rates?.changePercentage).toBe(0); + }); + + it('keeps a first month the series covers from its 1st', () => { + const rates = monthlyRates( + [ + month('2026-02', 30000), + month('2026-03', 30000), + month('2026-04', 30000), + month('2026-05', 30000), + month('2026-06', 30000), + ], + false, + '2026-02-01', + ); + + expect(rates?.months).toBe(5); + }); + + it('reads a rise as bad for spending and good for a net result', () => { + expect(isAdverseChange(20, false)).toBe(true); + expect(isAdverseChange(-20, false)).toBe(false); + expect(isAdverseChange(20, true)).toBe(false); + expect(isAdverseChange(-20, true)).toBe(true); + expect(isAdverseChange(0, false)).toBe(false); + expect(isAdverseChange(null, false)).toBe(false); + }); + + it('falls back to the bounded shape with nothing to average', () => { + const view = resolveAnalysisView('trend', 0, 5000, [ + month('2026-07', 5000), + ]); + + expect(view.resolvedMode).toBe('expense'); + expect(view.trendRates).toBeNull(); + }); + + it('switches the trend view to net once income is a real share', () => { + const view = resolveAnalysisView('trend', 100000, 40000, [ + month('2026-04', 1000, 5000), + month('2026-05', 1000, 5000), + month('2026-06', 1000, 5000), + ]); + + expect(view.showsIncome).toBe(true); + expect(view.resolvedMode).toBe('trend'); + expect(view.trendRates?.average).toBe(4000); + }); + + it('leaves a bounded request alone', () => { + const view = resolveAnalysisView('expense', 100000, 40000, [ + month('2026-04', 1000), + ]); + + expect(view.resolvedMode).toBe('expense'); + expect(view.boundedMode).toBe('expense'); + expect(view.trendRates).toBeNull(); + }); +}); + describe('TransactionAnalysisDrawer day override', () => { it('averages over the auto date span when there is no override', async () => { axiosGet.mockResolvedValue({ data: { data: [] } }); @@ -313,7 +506,7 @@ describe('TransactionAnalysisDrawer view mode', () => { fireEvent.click( screen.getByRole('button', { name: /Income & expenses/i }), ); - fireEvent.click(screen.getByText('Expenses only')); + fireEvent.click(screen.getByText('Total spent')); await waitFor(() => expect(axiosPatch).toHaveBeenCalledWith( @@ -327,6 +520,312 @@ describe('TransactionAnalysisDrawer view mode', () => { }); }); +describe('TransactionAnalysisDrawer monthly trend view', () => { + // Feb–Jun are complete; Jul is the calendar month in progress and its + // deliberately huge figure must not reach either average. + const monthlyPoints = [ + ['2026-02', 1000], + ['2026-03', 1000], + ['2026-04', 1000], + ['2026-05', 4000], + ['2026-06', 4000], + ['2026-07', 999900], + ].map(([date, expense]) => ({ + date, + label: date as string, + income: 0, + expense: expense as number, + cumulative_expense: 0, + cumulative_net: 0, + })); + + function trendResponse(overrides: Record = {}) { + return { + ...analysisResponse, + summary: { + ...analysisResponse.summary, + count: 40, + // Long enough for the automatic view to pick the trend shape. + days: 150, + }, + over_time: { bucket: 'month', points: monthlyPoints }, + largest_expenses: [ + { + id: 'tx-1', + date: '2026-05-10', + description: 'Grand Hotel', + amount: 50000, + category: null, + account: { name: 'Visa', bank: null }, + labels: [], + }, + ], + ...overrides, + }; + } + + function renderDrawer() { + render( + , + ); + } + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date('2026-07-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('averages completed months only, ignoring the month in progress', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + mockAnalysisFetch(trendResponse()); + + renderDrawer(); + + await waitFor(() => + expect(screen.getByText('Monthly average')).toBeInTheDocument(), + ); + + // (1000 + 1000 + 1000 + 4000 + 4000) / 5 completed months. + expect(cardAmount('Monthly average')).toBe(2200); + // The last three completed months: (1000 + 4000 + 4000) / 3. + expect(cardAmount('Last 3 months')).toBe(3000); + // 3000 against 2200. + expect(screen.getByText('+36%')).toBeInTheDocument(); + }); + + it('replaces the bounded widgets with the monthly ones', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + mockAnalysisFetch(trendResponse()); + + renderDrawer(); + + await waitFor(() => + expect(screen.getByText('Spending per month')).toBeInTheDocument(), + ); + + expect(screen.queryByText('Avg / day')).not.toBeInTheDocument(); + expect(screen.queryByText('Total spent')).not.toBeInTheDocument(); + expect( + screen.queryByText('Spending over time'), + ).not.toBeInTheDocument(); + // A single largest expense says nothing across an open-ended span. + expect(screen.queryByText('Largest expenses')).not.toBeInTheDocument(); + // The footer names the months behind the average instead of a day count. + expect( + screen.getByText(/Feb 2026 – Jul 2026/, { exact: false }), + ).toBeInTheDocument(); + }); + + it('keeps the bounded shape below the trend span', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + mockAnalysisFetch( + trendResponse({ + summary: { + ...analysisResponse.summary, + count: 40, + days: 119, + }, + }), + ); + + renderDrawer(); + + await waitFor(() => + expect(screen.getByText('Avg / day')).toBeInTheDocument(), + ); + expect(screen.queryByText('Monthly average')).not.toBeInTheDocument(); + expect(screen.getByText('Largest expenses')).toBeInTheDocument(); + }); + + it('drops the recent card when it would repeat the overall average', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + mockAnalysisFetch( + trendResponse({ + // Three completed months plus the one in progress: the recent + // window would cover every month it is compared against. + over_time: { bucket: 'month', points: monthlyPoints.slice(-4) }, + }), + ); + + renderDrawer(); + + await waitFor(() => + expect(screen.getByText('Monthly average')).toBeInTheDocument(), + ); + + // (1000 + 4000 + 4000) / 3. + expect(cardAmount('Monthly average')).toBe(3000); + expect(screen.queryByText('Last 3 months')).not.toBeInTheDocument(); + }); + + it('falls back to the bounded view without a completed month', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + mockAnalysisFetch( + trendResponse({ + over_time: { bucket: 'month', points: monthlyPoints.slice(-1) }, + }), + ); + + renderDrawer(); + + await waitFor(() => + expect(screen.getByText('Avg / day')).toBeInTheDocument(), + ); + expect(screen.queryByText('Monthly average')).not.toBeInTheDocument(); + // The trigger names what is on screen, not the view that was unavailable. + expect( + screen.getByRole('button', { name: /Total spent/i }), + ).toBeInTheDocument(); + }); + + it('reports a net rate when income is a meaningful share', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + mockAnalysisFetch( + trendResponse({ + summary: { + income: 100000, + expense: 40000, + net: 60000, + count: 40, + days: 150, + average_expense_per_day: 266, + }, + over_time: { + bucket: 'month', + points: monthlyPoints + .slice(0, 5) + .map((point) => ({ ...point, income: 5000 })), + }, + }), + ); + + renderDrawer(); + + await waitFor(() => + expect(screen.getByText('Monthly net average')).toBeInTheDocument(), + ); + + // Income 5000 less expense, averaged over the five completed months: + // (4000 + 4000 + 4000 + 1000 + 1000) / 5. + expect(cardAmount('Monthly net average')).toBe(2800); + }); + + it('restores a forced trend view from the browser with no saved filter', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + localStorage.setItem( + `wm.analysis-mode.${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: '', + })}`, + 'trend', + ); + // A bounded span, so only the stored override can reach the trend view. + mockAnalysisFetch( + trendResponse({ + summary: { + ...analysisResponse.summary, + count: 40, + days: 60, + }, + }), + ); + + renderDrawer(); + + await waitFor(() => + expect(screen.getByText('Monthly average')).toBeInTheDocument(), + ); + }); + + it('honours a day override that puts the span back under the threshold', async () => { + axiosGet.mockResolvedValue({ + data: { + data: [ + { + id: 'saved-1', + filters: { label_ids: ['label-1'] }, + // The user has said the real duration is 20 days, even + // though the transactions posted across five months. + analysis_days: 20, + analysis_mode: null, + }, + ], + }, + }); + mockAnalysisFetch(trendResponse()); + + renderDrawer(); + + await waitFor(() => + expect(screen.getByText('Avg / day')).toBeInTheDocument(), + ); + expect(screen.queryByText('Monthly average')).not.toBeInTheDocument(); + // 90000 / 20, the override rather than the 150-day span. + expect(avgPerDay()).toBe(4500); + }); + + it('persists a forced trend view to the matched saved filter', async () => { + axiosGet.mockResolvedValue({ + data: { + data: [ + { + id: 'saved-1', + filters: { label_ids: ['label-1'] }, + analysis_days: null, + analysis_mode: null, + }, + ], + }, + }); + axiosPatch.mockResolvedValue({ data: {} }); + // A bounded span, so the trend view only appears once forced. + mockAnalysisFetch( + trendResponse({ + summary: { + ...analysisResponse.summary, + count: 40, + days: 60, + }, + }), + ); + + renderDrawer(); + + await waitFor(() => + expect(screen.getByText('Avg / day')).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByRole('button', { name: /Total spent/i })); + fireEvent.click(screen.getByText('Monthly trend')); + + await waitFor(() => + expect(axiosPatch).toHaveBeenCalledWith( + '/api/saved-filters/saved-1/analysis-mode', + { analysis_mode: 'trend' }, + ), + ); + await waitFor(() => + expect(screen.getByText('Monthly average')).toBeInTheDocument(), + ); + }); +}); + describe('TransactionAnalysisDrawer largest expenses columns', () => { function largestExpense(overrides: Record = {}) { return { diff --git a/resources/js/components/transactions/transaction-analysis-drawer.tsx b/resources/js/components/transactions/transaction-analysis-drawer.tsx index 9bb17c75..5870e6f2 100644 --- a/resources/js/components/transactions/transaction-analysis-drawer.tsx +++ b/resources/js/components/transactions/transaction-analysis-drawer.tsx @@ -39,16 +39,19 @@ import { import { type Label } from '@/types/label'; import { type TransactionFilters } from '@/types/transaction'; import { type UUID } from '@/types/uuid'; -import { formatDate } from '@/utils/date'; +import { formatDate, formatMonthFromYearMonth } from '@/utils/date'; import { __ } from '@/utils/i18n'; import axios from 'axios'; -import { parseISO } from 'date-fns'; +import { format, parseISO } from 'date-fns'; import * as Icons from 'lucide-react'; import { Check, HelpCircle, + Minus, Settings2, SlidersHorizontal, + TrendingDown, + TrendingUp, type LucideIcon, } from 'lucide-react'; import { @@ -60,15 +63,24 @@ import { } from 'react'; import { Bar, + Cell, ComposedChart, Line, + ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts'; -type AnalysisMode = 'expense' | 'income'; +type AnalysisMode = 'expense' | 'income' | 'trend'; + +/** + * The two bounded shapes, where a total and a daily average are the answer. + * The trend view is never one of them, and typing the bounded widgets against + * this keeps that guarantee at the compiler instead of in a fallthrough. + */ +type BoundedMode = Exclude; /** * Income only changes the analysis into its income-and-expense shape once it @@ -77,10 +89,51 @@ type AnalysisMode = 'expense' | 'income'; */ const INCOME_MODE_THRESHOLD = 0.15; -function detectMode(income: number, expense: number): AnalysisMode { - return income > 0 && income >= expense * INCOME_MODE_THRESHOLD - ? 'income' - : 'expense'; +/** + * Past this span a filter has stopped describing a bounded thing like a trip + * or a project: the total and the daily average answer nothing, so the + * analysis switches to a monthly rate and its direction. Four months is the + * shortest span where the recent-months average covers less than the whole + * set, which is what makes the comparison between the two mean anything. + */ +const TREND_MIN_DAYS = 120; + +/** How many completed months the recent-rate card averages over. */ +const RECENT_MONTHS = 3; + +function hasSignificantIncome(income: number, expense: number): boolean { + return income > 0 && income >= expense * INCOME_MODE_THRESHOLD; +} + +function detectMode( + income: number, + expense: number, + days: number, +): AnalysisMode { + if (days >= TREND_MIN_DAYS) { + return 'trend'; + } + + return hasSignificantIncome(income, expense) ? 'income' : 'expense'; +} + +/** Compact axis ticks: an amount in cents in, "1.2K" out. */ +function compactAmount(value: number, locale: string): string { + return new Intl.NumberFormat(locale, { + notation: 'compact', + compactDisplay: 'short', + }).format(value / 100); +} + +function modeLabel(mode: AnalysisMode): string { + switch (mode) { + case 'expense': + return __('Total spent'); + case 'income': + return __('Income & expenses'); + case 'trend': + return __('Monthly trend'); + } } interface AnalysisSummary { @@ -90,6 +143,8 @@ interface AnalysisSummary { count: number; days: number; average_expense_per_day: number; + /** The day the series starts on, or null when nothing matched. */ + first_date: string | null; } interface CategorySlice { @@ -137,6 +192,12 @@ interface LargestExpense { labels: { id: string; name: string; color: string }[]; } +/** + * One bucket of the over-time series. The endpoint walks a cursor from the + * first transaction to the last and fills empty buckets with zeroes, so the + * series is chronological and gapless — which is what lets the monthly + * derivations below average by position and read the span off the ends. + */ interface OverTimePoint { date: string; label: string; @@ -161,6 +222,192 @@ interface AnalysisData { over_time: { bucket: 'day' | 'month'; points: OverTimePoint[] }; } +interface MonthlyPoint { + key: string; + label: string; + income: number; + expense: number; + net: number; +} + +interface MonthlyRates { + average: number; + /** + * Null while the whole months do not outnumber the recent window, when the + * two averages would be the same number shown twice. + */ + recentAverage: number | null; + changePercentage: number | null; + /** How many whole months the average covers, for the card to own up to. */ + months: number; +} + +/** + * Folds the over-time series into calendar months. The series arrives bucketed + * by day for short spans and by month for long ones, and the trend view needs + * months either way — including when the user forces it onto a span the + * backend bucketed by day. + */ +function toMonthlyPoints( + points: OverTimePoint[], + locale: string, +): MonthlyPoint[] { + const months = new Map(); + + for (const point of points) { + const key = point.date.slice(0, 7); + const month = months.get(key); + + if (month) { + month.income += point.income; + month.expense += point.expense; + month.net += point.income - point.expense; + continue; + } + + months.set(key, { + key, + label: formatMonthFromYearMonth(key, locale), + income: point.income, + expense: point.expense, + net: point.income - point.expense, + }); + } + + return [...months.values()]; +} + +/** + * Whether the series only covers part of this month, which is what keeps it out + * of every monthly figure. + * + * Either edge of the span can be a fraction of a month and both would drag an + * average down. The trailing one is the calendar month still in progress — on + * the 2nd it holds two days of spending, which would read as a drop that has + * not happened. The leading one is the month the series starts in, whenever + * that is not its 1st: a filter clipping a few days out of March, or simply the + * first transaction landing on the 18th, leaves a month that never had a chance + * to spend a full month's worth. + */ +export function isPartialMonth(key: string, firstDate: string | null): boolean { + if (key >= format(new Date(), 'yyyy-MM')) { + return true; + } + + return ( + firstDate !== null && + !firstDate.endsWith('-01') && + key === firstDate.slice(0, 7) + ); +} + +/** + * The monthly rate and how the recent months compare against it. + */ +export function monthlyRates( + months: MonthlyPoint[], + useNet: boolean, + firstDate: string | null = null, +): MonthlyRates | null { + const whole = months.filter( + (month) => !isPartialMonth(month.key, firstDate), + ); + + if (whole.length === 0) { + return null; + } + + const value = (month: MonthlyPoint) => (useNet ? month.net : month.expense); + const mean = (subset: MonthlyPoint[]) => + Math.round( + subset.reduce((sum, month) => sum + value(month), 0) / + subset.length, + ); + + const average = mean(whole); + + if (whole.length <= RECENT_MONTHS) { + return { + average, + recentAverage: null, + changePercentage: null, + months: whole.length, + }; + } + + const recentAverage = mean(whole.slice(-RECENT_MONTHS)); + + return { + average, + recentAverage, + changePercentage: + average === 0 + ? null + : Math.round( + ((recentAverage - average) / Math.abs(average)) * 100, + ), + months: whole.length, + }; +} + +interface AnalysisView { + /** The shape actually on screen, which the view toggle's trigger names. */ + resolvedMode: AnalysisMode; + /** The bounded shape to render whenever the trend one is not on screen. */ + boundedMode: BoundedMode; + /** Non-null exactly when the trend view has something to average. */ + trendRates: MonthlyRates | null; + /** Whether income is a big enough share to report net figures. */ + showsIncome: boolean; +} + +/** + * Settles which analysis shape is on screen. Trend is a request rather than a + * guarantee: it needs at least one completed calendar month to average, so a + * span without one falls back to the bounded shape, toggle label included. + */ +export function resolveAnalysisView( + effectiveMode: AnalysisMode, + income: number, + expense: number, + months: MonthlyPoint[], + firstDate: string | null = null, +): AnalysisView { + const showsIncome = hasSignificantIncome(income, expense); + const isTrend = effectiveMode === 'trend'; + + const boundedMode: BoundedMode = isTrend + ? showsIncome + ? 'income' + : 'expense' + : effectiveMode; + const trendRates = isTrend + ? monthlyRates(months, showsIncome, firstDate) + : null; + + return { + resolvedMode: trendRates ? 'trend' : boundedMode, + boundedMode, + trendRates, + showsIncome, + }; +} + +/** + * Whether a change in the monthly rate is bad news: a rising monthly spend is, + * while a rising net result is the opposite. + */ +export function isAdverseChange( + change: number | null, + showsIncome: boolean, +): boolean { + if (change === null || change === 0) { + return false; + } + + return showsIncome ? change < 0 : change > 0; +} + interface TransactionAnalysisDrawerProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -202,25 +449,28 @@ function readStoredDays(key: string): number | null { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } +const ANALYSIS_MODES: AnalysisMode[] = ['expense', 'income', 'trend']; + function readStoredMode(key: string): AnalysisMode | null { const raw = localStorage.getItem(key); - return raw === 'expense' || raw === 'income' ? raw : null; + + return ANALYSIS_MODES.find((mode) => mode === raw) ?? null; } /** - * Resolves the day span and view mode used for a filter set. + * Carries the two overrides a filter set can hold: the day span behind the + * daily average, and the analysis view. Both are remembered per filter + * fingerprint in the browser and, when the current filters match a saved + * filter, synced to the backend. A single lookup of the saved filters backs + * both, so the drawer hits the API once per open. * - * Both follow the same rule: an automatic value (the transaction span; the - * income-share detection) unless the user overrides it. Overrides are - * remembered per filter fingerprint in the browser and, when the current - * filters match a saved filter, synced to the backend. A single lookup of the - * saved filters backs both, so the drawer hits the API once per open. + * Which view an absent override resolves to is the caller's business — it + * depends on the effective day span, which this hook is what supplies. */ function useAnalysisPreferences( open: boolean, filters: TransactionFilters, autoDays: number, - autoMode: AnalysisMode, ) { const fingerprint = useMemo( () => filtersFingerprint(serializeFilters(filters)), @@ -314,7 +564,6 @@ function useAnalysisPreferences( return { effectiveDays: dayOverride ?? autoDays, isDaysOverridden: dayOverride !== null, - effectiveMode: modeOverride ?? autoMode, modeOverride, isSaved: savedFilterId !== null, applyDays, @@ -368,26 +617,42 @@ export function TransactionAnalysisDrawer({ const income = data?.summary.income ?? 0; const expense = data?.summary.expense ?? 0; const net = data?.summary.net ?? 0; - const autoMode = detectMode(income, expense); const { effectiveDays, isDaysOverridden, - effectiveMode, modeOverride, isSaved, applyDays, applyMode, - } = useAnalysisPreferences( - open, - filters, - data?.summary.days ?? 0, - autoMode, - ); + } = useAnalysisPreferences(open, filters, data?.summary.days ?? 0); const averagePerDay = effectiveDays > 0 ? Math.round(expense / effectiveDays) : expense; + // The day override is the user telling us the real duration, so it decides + // the automatic view too: a trip whose transactions posted over eight + // months is still a trip, and stays on the bounded shape. + const effectiveMode = + modeOverride ?? detectMode(income, expense, effectiveDays); + + const monthlyPoints = useMemo( + () => toMonthlyPoints(data?.over_time.points ?? [], locale), + [data, locale], + ); + + const { resolvedMode, boundedMode, trendRates, showsIncome } = useMemo( + () => + resolveAnalysisView( + effectiveMode, + income, + expense, + monthlyPoints, + data?.summary.first_date ?? null, + ), + [effectiveMode, income, expense, monthlyPoints, data], + ); + return ( @@ -397,7 +662,7 @@ export function TransactionAnalysisDrawer({ {hasTransactions && ( @@ -431,33 +696,57 @@ export function TransactionAnalysisDrawer({ {!isLoading && !error && data && hasTransactions && (
- + {trendRates ? ( + + ) : ( + + )} - + {trendRates ? ( + + ) : ( + + )} - + {!trendRates && ( + + )} {data.distinct_category_count > 1 && (

{label}

- +
+ + {badge} +
); } +/** + * Renders the span as the months it covers, so the denominator behind a + * monthly average is legible without counting days. + */ +function monthRange(months: MonthlyPoint[], locale: string): string { + const first = months.at(0); + const last = months.at(-1); + + if (!first || !last) { + return ''; + } + + const label = (key: string) => + formatDate(parseISO(`${key}-01`), 'MMM yyyy', locale); + + return first.key === last.key + ? label(first.key) + : `${label(first.key)} – ${label(last.key)}`; +} + +/** + * The pair of numbers that answer "how am I doing" over an open-ended span: + * the monthly rate, and where the recent months sit against it. + */ +function TrendCards({ + rates, + months, + count, + currency, + locale, + showsIncome, +}: { + rates: MonthlyRates; + months: MonthlyPoint[]; + count: number; + currency: string; + locale: string; + showsIncome: boolean; +}) { + const change = rates.changePercentage; + const isAdverse = isAdverseChange(change, showsIncome); + const ChangeIcon = + change === null || change === 0 + ? Minus + : change > 0 + ? TrendingUp + : TrendingDown; + + // Only a net figure can be good news; spending is always in the red. + const tone = (amount: number): 'income' | 'expense' => + showsIncome && amount >= 0 ? 'income' : 'expense'; + + return ( + +
+ + {__('over :count whole months', { + count: rates.months, + })} + + } + /> + + {rates.recentAverage !== null && ( + + + + {change > 0 ? '+' : ''} + {change}% + + + {__('vs. average')} + + + ) + } + /> + )} +
+ +

+ {count} {__('transactions')} · {monthRange(months, locale)} +

+
+ ); +} + function ModeToggle({ override, - effectiveMode, + resolvedMode, isSaved, onApply, }: { override: AnalysisMode | null; - effectiveMode: AnalysisMode; + resolvedMode: AnalysisMode; isSaved: boolean; onApply: (value: AnalysisMode | null) => void; }) { + // Picking a view that cannot be built would otherwise change nothing on + // screen and say nothing about why. + const unavailable = override === 'trend' && resolvedMode !== 'trend'; const [open, setOpen] = useState(false); const options: { value: AnalysisMode | null; label: string }[] = [ { value: null, label: __('Automatic') }, - { value: 'expense', label: __('Expenses only') }, - { value: 'income', label: __('Income & expenses') }, + { value: 'expense', label: modeLabel('expense') }, + { value: 'income', label: modeLabel('income') }, + { value: 'trend', label: modeLabel('trend') }, ]; - const triggerLabel = - override === null - ? effectiveMode === 'income' - ? __('Income & expenses') - : __('Expenses only') - : override === 'income' - ? __('Income & expenses') - : __('Expenses only'); + // The trigger names what is on screen, not what was asked for: a forced + // trend view that could not be built falls back, and so does the label. + const triggerLabel = modeLabel(resolvedMode); const choose = (value: AnalysisMode | null) => { onApply(value); @@ -719,6 +1136,13 @@ function ModeToggle({ ); })} + {unavailable && ( +

+ {__( + 'Monthly trend needs at least one whole calendar month.', + )} +

+ )} {isSaved && (

{__('Saved with this filter.')} @@ -831,7 +1255,7 @@ function OverTimeChart({ points: OverTimePoint[]; currency: string; locale: string; - mode: AnalysisMode; + mode: BoundedMode; }) { const cumulativeKey = mode === 'income' ? 'cumulative_net' : 'cumulative_expense'; @@ -847,12 +1271,6 @@ function OverTimeChart({ }, }; - const compact = (value: number) => - new Intl.NumberFormat(locale, { - notation: 'compact', - compactDisplay: 'short', - }).format(value / 100); - return ( @@ -868,7 +1286,7 @@ function OverTimeChart({ tickLine={false} axisLine={false} width={48} - tickFormatter={compact} + tickFormatter={(value) => compactAmount(value, locale)} /> ({ + label: row.label, + amount: point ? point[row.key] : 0, + }))} + /> + ); +} + +/** + * The shared body of the chart tooltips: a title over labelled amounts, all + * formatted the same way whichever chart raised it. + */ +function AmountRowsTooltip({ + title, + rows, + currency, +}: { + title?: string; + rows: { label: string; amount: number }[]; + currency: string; +}) { return (

-
{point?.label}
+
{title}
{rows.map((row) => (
{row.label} @@ -968,7 +1411,150 @@ function OverTimeTooltip({ * A sentinel that keeps an absent category/account from colliding with a real * one named with an empty string when counting distinct values. */ -const MISSING = ''; +const MISSING = '\0'; + +/** + * Monthly bars without a cumulative line: over an open-ended span the running + * total only ever climbs, while the month-to-month shape is the whole story. + */ +function MonthlyTrendChart({ + months, + average, + currency, + locale, + showsIncome, + firstDate, +}: { + months: MonthlyPoint[]; + average: number; + currency: string; + locale: string; + showsIncome: boolean; + firstDate: string | null; +}) { + const config: ChartConfig = { + expense: { label: __('Expenses'), color: 'var(--color-chart-5)' }, + ...(showsIncome + ? { income: { label: __('Income'), color: 'var(--color-chart-2)' } } + : {}), + }; + + return ( + + + + + compactAmount(value, locale)} + /> + + } + cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }} + /> + {/* + * The rate the cards report, so the recent bars can be read + * against it. Drawn for spending only: with income in play + * the cards report a net figure, and no line over + * income-and-expense bars would sit on it meaningfully. + */} + {!showsIncome && ( + + )} + {showsIncome && ( + + )} + {/* + * A part-month bar is faded rather than dropped: it is real + * spending, but at full strength it reads as a fall against + * the average line when the month simply has not finished. + */} + + {months.map((month) => ( + + ))} + + + + + ); +} + +function MonthlyTrendTooltip({ + active, + payload, + currency, + showsIncome, +}: { + active?: boolean; + payload?: { payload?: MonthlyPoint }[]; + currency: string; + showsIncome: boolean; +}) { + if (!active || !payload?.length) { + return null; + } + + const month = payload[0]?.payload; + const keys: { label: string; key: 'income' | 'expense' | 'net' }[] = + showsIncome + ? [ + { label: __('Income'), key: 'income' }, + { label: __('Expenses'), key: 'expense' }, + { label: __('Net result'), key: 'net' }, + ] + : [{ label: __('Expenses'), key: 'expense' }]; + + return ( + ({ + label: row.label, + amount: month ? month[row.key] : 0, + }))} + /> + ); +} function LargestTransactions({ items, @@ -1245,12 +1831,6 @@ function HorizontalBarBreakdown({ amount: { label: __('Spent'), color }, }; - const compact = (value: number) => - new Intl.NumberFormat(locale, { - notation: 'compact', - compactDisplay: 'short', - }).format(value / 100); - return ( - + + compactAmount(value, locale) + } + /> fresh()->analysis_mode)->toBe(AnalysisMode::Income); }); +test('stores the monthly trend analysis view mode', function () { + $savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]); + + $this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-mode", ['analysis_mode' => 'trend']) + ->assertOk() + ->assertJsonPath('data.analysis_mode', 'trend'); + + expect($savedFilter->fresh()->analysis_mode)->toBe(AnalysisMode::Trend); +}); + test('clears the analysis view mode when sent null', function () { $savedFilter = SavedFilter::factory()->create([ 'user_id' => $this->user->id, diff --git a/tests/Feature/TransactionAnalysisTest.php b/tests/Feature/TransactionAnalysisTest.php index 044050f2..cad20946 100644 --- a/tests/Feature/TransactionAnalysisTest.php +++ b/tests/Feature/TransactionAnalysisTest.php @@ -67,6 +67,21 @@ test('summary totals income, expense, net and count from the filtered set', func ]); }); +test('summary reports the day the series starts on', function () { + makeTransaction(['amount' => -40000, 'transaction_date' => '2026-03-18']); + makeTransaction(['amount' => -10000, 'transaction_date' => '2026-06-02']); + + $this->getJson('/api/transactions/analysis') + ->assertOk() + ->assertJsonPath('summary.first_date', '2026-03-18'); +}); + +test('summary reports no start day when nothing matches', function () { + $this->getJson('/api/transactions/analysis?'.http_build_query(['search' => 'nothing matches this'])) + ->assertOk() + ->assertJsonPath('summary.first_date', null); +}); + test('category breakdown groups expenses by top-level category', function () { $hotel = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Hotel', 'color' => 'blue', 'icon' => 'Building']); $meals = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Meals']);