feat(transactions): add a monthly trend view to the analysis drawer (#736)

## 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
This commit is contained in:
Víctor Falcón 2026-07-26 17:03:52 +02:00 committed by GitHub
parent 1bd2d80b14
commit d32d1ff40b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1236 additions and 103 deletions

View File

@ -6,4 +6,5 @@ enum AnalysisMode: string
{
case Expense = 'expense';
case Income = 'income';
case Trend = 'trend';
}

View File

@ -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(),
];
}

View File

@ -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."
}

View File

@ -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."
}

View File

@ -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<string, string>();
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', () => {
// FebJun 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<string, unknown> = {}) {
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(
<TransactionAnalysisDrawer
open
onOpenChange={vi.fn()}
filters={filters}
/>,
);
}
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<string, unknown> = {}) {
return {

View File

@ -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<AnalysisMode, 'trend'>;
/**
* 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<string, MonthlyPoint>();
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 (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[90vh] data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
@ -397,7 +662,7 @@ export function TransactionAnalysisDrawer({
{hasTransactions && (
<ModeToggle
override={modeOverride}
effectiveMode={effectiveMode}
resolvedMode={resolvedMode}
isSaved={isSaved}
onApply={applyMode}
/>
@ -431,33 +696,57 @@ export function TransactionAnalysisDrawer({
{!isLoading && !error && data && hasTransactions && (
<div className="flex flex-col gap-6">
<SummaryCards
mode={effectiveMode}
income={income}
expense={expense}
net={net}
count={data.summary.count}
currency={currency}
days={effectiveDays}
averagePerDay={averagePerDay}
isDaysOverridden={isDaysOverridden}
isSaved={isSaved}
onApplyDays={applyDays}
/>
{trendRates ? (
<TrendCards
rates={trendRates}
months={monthlyPoints}
count={data.summary.count}
currency={currency}
locale={locale}
showsIncome={showsIncome}
/>
) : (
<SummaryCards
mode={boundedMode}
income={income}
expense={expense}
net={net}
count={data.summary.count}
currency={currency}
days={effectiveDays}
averagePerDay={averagePerDay}
isDaysOverridden={isDaysOverridden}
isSaved={isSaved}
onApplyDays={applyDays}
/>
)}
<OverTimeChart
points={data.over_time.points}
currency={currency}
locale={locale}
mode={effectiveMode}
/>
{trendRates ? (
<MonthlyTrendChart
months={monthlyPoints}
average={trendRates.average}
currency={currency}
locale={locale}
showsIncome={showsIncome}
firstDate={data.summary.first_date ?? null}
/>
) : (
<OverTimeChart
points={data.over_time.points}
currency={currency}
locale={locale}
mode={boundedMode}
/>
)}
<LargestTransactions
items={data.largest_expenses ?? []}
currency={currency}
locale={locale}
filters={filters}
/>
{!trendRates && (
<LargestTransactions
items={data.largest_expenses ?? []}
currency={currency}
locale={locale}
filters={filters}
/>
)}
{data.distinct_category_count > 1 && (
<CategoryBreakdown
@ -537,7 +826,7 @@ function SummaryCards({
isSaved,
onApplyDays,
}: {
mode: AnalysisMode;
mode: BoundedMode;
income: number;
expense: number;
net: number;
@ -633,57 +922,185 @@ function SummaryCard({
amount,
currency,
tone,
badge,
}: {
label: string;
amount: number;
currency: string;
tone: 'income' | 'expense';
/** Sits beside the amount, for a card that qualifies its own figure. */
badge?: ReactNode;
}) {
return (
<div className="rounded-lg bg-muted/50 p-4">
<div className="flex h-6 items-center">
<p className="text-xs text-muted-foreground">{label}</p>
</div>
<AmountDisplay
amountInCents={amount}
currencyCode={currency}
className={cn(
'mt-1 text-lg font-semibold tabular-nums',
tone === 'income' && 'text-emerald-600',
tone === 'expense' && 'text-red-600',
)}
/>
<div className="mt-1 flex flex-wrap items-baseline gap-x-2">
<AmountDisplay
amountInCents={amount}
currencyCode={currency}
className={cn(
'text-lg font-semibold tabular-nums',
tone === 'income' && 'text-emerald-600',
tone === 'expense' && 'text-red-600',
)}
/>
{badge}
</div>
</div>
);
}
/**
* 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 (
<Panel>
<div
className={cn(
'grid gap-3',
rates.recentAverage !== null && 'grid-cols-2',
)}
>
<SummaryCard
label={
showsIncome
? __('Monthly net average')
: __('Monthly average')
}
amount={rates.average}
currency={currency}
tone={tone(rates.average)}
badge={
// Owns up to the denominator: part-months at either end
// of the span are left out of every figure here.
<span className="text-xs text-muted-foreground">
{__('over :count whole months', {
count: rates.months,
})}
</span>
}
/>
{rates.recentAverage !== null && (
<SummaryCard
label={__('Last :count months', {
count: RECENT_MONTHS,
})}
amount={rates.recentAverage}
currency={currency}
tone={tone(rates.recentAverage)}
badge={
change !== null && (
<span className="flex items-center gap-1 text-xs">
<span
className={cn(
'flex items-center gap-0.5 font-medium tabular-nums',
change === 0 &&
'text-muted-foreground',
isAdverse && 'text-amber-500',
change !== 0 &&
!isAdverse &&
'text-emerald-500',
)}
>
<ChangeIcon className="size-3.5" />
{change > 0 ? '+' : ''}
{change}%
</span>
<span className="text-muted-foreground">
{__('vs. average')}
</span>
</span>
)
}
/>
)}
</div>
<p className="text-xs text-muted-foreground">
{count} {__('transactions')} · {monthRange(months, locale)}
</p>
</Panel>
);
}
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({
</Button>
);
})}
{unavailable && (
<p className="px-2 pt-1 text-xs text-amber-600">
{__(
'Monthly trend needs at least one whole calendar month.',
)}
</p>
)}
{isSaved && (
<p className="px-2 pt-1 text-xs text-muted-foreground">
{__('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 (
<Panel title={__('Spending over time')}>
<ChartContainer config={config} className="h-64 w-full">
@ -868,7 +1286,7 @@ function OverTimeChart({
tickLine={false}
axisLine={false}
width={48}
tickFormatter={compact}
tickFormatter={(value) => compactAmount(value, locale)}
/>
<Tooltip
content={
@ -926,14 +1344,14 @@ function OverTimeTooltip({
currency: string;
cumulativeKey: 'cumulative_expense' | 'cumulative_net';
cumulativeLabel: string;
mode: AnalysisMode;
mode: BoundedMode;
}) {
if (!active || !payload?.length) {
return null;
}
const point = payload[0]?.payload;
const rows: {
const keys: {
label: string;
key: 'income' | 'expense' | 'cumulative_expense' | 'cumulative_net';
}[] = [
@ -944,17 +1362,42 @@ function OverTimeTooltip({
{ label: cumulativeLabel, key: cumulativeKey },
];
return (
<AmountRowsTooltip
title={point?.label}
currency={currency}
rows={keys.map((row) => ({
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 (
<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>
<div className="font-medium">{title}</div>
{rows.map((row) => (
<div
key={row.key}
key={row.label}
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}
amountInCents={row.amount}
currencyCode={currency}
className="font-mono tabular-nums"
/>
@ -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 (
<Panel
title={
showsIncome
? __('Income & expenses per month')
: __('Spending per month')
}
>
<ChartContainer config={config} className="h-64 w-full">
<ComposedChart data={months}>
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={16}
/>
<YAxis
tickLine={false}
axisLine={false}
width={48}
tickFormatter={(value) => compactAmount(value, locale)}
/>
<Tooltip
content={
<MonthlyTrendTooltip
currency={currency}
showsIncome={showsIncome}
/>
}
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 && (
<ReferenceLine
y={average}
stroke="var(--color-muted-foreground)"
strokeDasharray="4 4"
/>
)}
{showsIncome && (
<Bar
dataKey="income"
fill="var(--color-chart-2)"
radius={[3, 3, 0, 0]}
/>
)}
{/*
* 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.
*/}
<Bar
dataKey="expense"
fill="var(--color-chart-5)"
radius={[3, 3, 0, 0]}
>
{months.map((month) => (
<Cell
key={month.key}
fill="var(--color-chart-5)"
fillOpacity={
isPartialMonth(month.key, firstDate)
? 0.35
: 1
}
/>
))}
</Bar>
</ComposedChart>
</ChartContainer>
</Panel>
);
}
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 (
<AmountRowsTooltip
title={month?.label}
currency={currency}
rows={keys.map((row) => ({
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 (
<Panel title={title}>
<ChartContainer
@ -1264,7 +1844,13 @@ function HorizontalBarBreakdown({
data={data}
margin={{ left: 8, right: 16 }}
>
<XAxis type="number" hide tickFormatter={compact} />
<XAxis
type="number"
hide
tickFormatter={(value) =>
compactAmount(value, locale)
}
/>
<YAxis
type="category"
dataKey="name"

View File

@ -74,6 +74,16 @@ test('updates the analysis view mode on a saved filter', function () {
expect($savedFilter->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,

View File

@ -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']);