From d32d1ff40b67df45ac6a1fe3c749e2ccb7233c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sun, 26 Jul 2026 17:03:52 +0200 Subject: [PATCH] feat(transactions): add a monthly trend view to the analysis drawer (#736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The analysis drawer was built for **bounded** filters — a trip, a project — where "total spent" and "avg / day" are the answer. Filter by something open-ended instead, like a single category, and it surfaces a year or two of expenses. There both numbers stop meaning anything: the cumulative line only ever climbs, `€12.32 / day` is not a figure anyone decides on, and the largest-expenses table fills with the same monthly transfer repeated five times. ## What A fourth analysis view that answers the open-ended question instead: **the monthly rate, and where the recent months sit against it**. Automatic picks it past a 120-day span; the existing per-filter override now carries four options, each named after the number it produces. `Automatic` · `Total spent` · `Income & expenses` · **`Monthly trend`** | Panel | Bounded view | Monthly trend | |---|---|---| | Rate | Total spent + avg / day | **Monthly average** and **last 3 months** with the change between them | | Chart | Bars + cumulative line | **Monthly bars**, dashed line at the average, no cumulative | | Largest expenses | top 10 | **not rendered** — across an open-ended span the biggest single transaction is trivia, and the payee and category breakdowns already answer where the money went | | Category · payee · account · label | unchanged | unchanged | Footer names the months covered rather than counting days. When income is a meaningful share the cards report net figures and the chart adds income bars. ### Only whole months count Every monthly figure covers calendar months the series spans end to end. Both edges would otherwise drag the average down, in the same direction: - The **trailing** month is still in progress — on the 2nd it holds two days of spending. - The **leading** month is partial whenever the series does not start on its 1st, whether a filter clipped it or the first transaction simply landed on the 18th. Skipping only the trailing one manufactured a rise: flat €300/month over a 120-day span starting 18 March reported **+16%** in amber on spending that never changed. Both are excluded now, which is what the one new backend field (`summary.first_date`) is for. The part-month bars are faded rather than dropped, so the solid bars are exactly the months behind the average. The average card states how many whole months it spans and the percentage says it is against the average — two figures that otherwise silently covered less than they appeared to. ### Edge cases - Forcing the view onto a span with no whole month falls back to the bounded shape, toggle label included, and the popover says why. - The recent card is hidden when whole months do not outnumber its own window, rather than repeating the overall average. - A **day override** is the user stating the real duration, so it decides the automatic view too: a trip whose transactions posted over eight months stays bounded instead of jumping to trend and leaving that override inert. ## Cost **Backend: one enum case and one summary field.** No migration — `analysis_mode` is already a nullable string behind `Rule::enum`. Every other figure is derived in the browser from the `over_time` series the endpoint already returned. ## Tests `resolveAnalysisView`, `monthlyRates` and `isAdverseChange` are pure and unit-tested directly — the part-month cutoff, the change against a negative average, the zero-average guard, and the adverse-direction rule that inverts for net. 32 component tests, plus feature coverage for the new field and the persisted mode. ## QA Browser QA against a year of grocery spending on the demo account (352 transactions, July 2025 – July 2026): the trend view resolves automatically, all four options switch correctly, and a 30-day date filter still lands on the bounded view. 22 assertions, no console errors. ## Demo https://github.com/user-attachments/assets/f4a14349-3d58-4da4-b85c-9a4ca076551f --- app/Enums/AnalysisMode.php | 1 + .../Api/TransactionAnalysisController.php | 10 +- lang/es.json | 11 +- lang/fr.json | 11 +- .../transaction-analysis-drawer.test.tsx | 509 +++++++++++- .../transaction-analysis-drawer.tsx | 772 +++++++++++++++--- tests/Feature/SavedFilterTest.php | 10 + tests/Feature/TransactionAnalysisTest.php | 15 + 8 files changed, 1236 insertions(+), 103 deletions(-) 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']);