feat: Add previous period comparison to budget chart (#93)

## Summary
- Load the previous budget period (with transactions) in
`BudgetController::show()` and pass it to the frontend
- Overlay previous period's cumulative spending as a dashed line on the
budget spending chart
- Use day-of-period alignment (Day 1, Day 2...) when comparing so
periods of different lengths align
- Tooltip shows both "Spent" (current) and "Last period" values when
hovering
- Chart now shows the full period timeline (not just up to today)
- When no previous period exists, chart behavior is unchanged

## Screenshots

### Light mode
![Budget chart - Light
mode](https://raw.githubusercontent.com/whisper-money/whisper-money/1cad899e771752e46183f8bb882f6eec4e3dd5de/.github/budget-final-light.png)

### Dark mode
![Budget chart - Dark
mode](https://raw.githubusercontent.com/whisper-money/whisper-money/1cad899e771752e46183f8bb882f6eec4e3dd5de/.github/budget-final-dark.png)

## Test plan
- [x] Tests pass: `php artisan test --compact
tests/Feature/BudgetTest.php` (9 tests)
- [x] Pint formatting passes
- [x] ESLint + Prettier pass
- [ ] Manual: view a budget with a prior period — dashed line appears
- [ ] Manual: view a budget with no prior period — chart unchanged
This commit is contained in:
Víctor Falcón 2026-02-01 14:21:13 +01:00 committed by GitHub
parent ab160ae489
commit 9bbd91ac12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 192 additions and 37 deletions

View File

@ -55,6 +55,12 @@ class BudgetController extends Controller
'budgetTransactions.transaction.labels',
]);
$previousPeriod = $budget->periods()
->where('end_date', '<', $currentPeriod->start_date)
->orderBy('end_date', 'desc')
->with(['budgetTransactions.transaction'])
->first();
$budget->load(['category', 'label']);
$categories = \App\Models\Category::query()
@ -79,6 +85,7 @@ class BudgetController extends Controller
return Inertia::render('budgets/show', [
'budget' => $budget,
'currentPeriod' => $currentPeriod,
'previousPeriod' => $previousPeriod,
'categories' => $categories,
'accounts' => $accounts,
'banks' => $banks,

View File

@ -100,6 +100,7 @@
--chart-10: var(--color-zinc-50);
--spent: var(--color-zinc-800);
--allocated: var(--color-zinc-200);
--spent-prev: var(--color-zinc-400);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
@ -143,6 +144,7 @@
--chart-10: var(--color-zinc-100);
--spent: var(--color-zinc-200);
--allocated: var(--color-zinc-700);
--spent-prev: var(--color-zinc-500);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.985 0 0);

View File

@ -13,35 +13,42 @@ import {
import { BudgetPeriod } from '@/types/budget';
import { formatCurrency } from '@/utils/currency';
import { useMemo } from 'react';
import { Area, AreaChart, XAxis } from 'recharts';
import { Area, AreaChart, Line, XAxis } from 'recharts';
interface Props {
currentPeriod: BudgetPeriod;
previousPeriod?: BudgetPeriod | null;
budgetName: string;
currencyCode: string;
}
interface ChartDataPoint {
day: number;
date: string;
spent: number;
allocated: number;
remaining: number;
prevSpent?: number;
prevDate?: string;
}
interface CustomTooltipProps {
active?: boolean;
payload?: Array<{
payload: {
date: string;
spent: number;
allocated: number;
remaining: number;
};
payload: ChartDataPoint;
}>;
label?: string;
label?: string | number;
currencyCode: string;
hasPreviousPeriod: boolean;
}
function CustomTooltip({
active,
payload,
label,
currencyCode,
hasPreviousPeriod,
}: CustomTooltipProps) {
if (!active || !payload || !payload.length || !label) {
if (!active || !payload || !payload.length) {
return null;
}
@ -55,11 +62,13 @@ function CustomTooltip({
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="mb-2 text-sm font-medium">
{new Date(label).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
{hasPreviousPeriod
? `Day ${data.day}`
: new Date(data.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
<div className="space-y-1 text-sm">
<div className="flex items-center justify-between gap-8">
@ -74,6 +83,16 @@ function CustomTooltip({
{formatCurrency(spent, currencyCode)}
</span>
</div>
{hasPreviousPeriod && data.prevSpent !== undefined && (
<div className="flex items-center justify-between gap-8">
<span className="text-muted-foreground">
Last period:
</span>
<span className="font-medium text-muted-foreground">
{formatCurrency(data.prevSpent, currencyCode)}
</span>
</div>
)}
<div className="border-t pt-1">
<div className="flex items-center justify-between gap-8">
<span className="font-medium">Available:</span>
@ -92,51 +111,88 @@ function CustomTooltip({
);
}
function buildCumulativeSpending(period: BudgetPeriod): Map<string, number> {
const transactions = period.budget_transactions || [];
const transactionsByDate = new Map<string, number>();
transactions.forEach((t) => {
if (!t.transaction) return;
const date = new Date(t.transaction.transaction_date)
.toISOString()
.split('T')[0];
transactionsByDate.set(
date,
(transactionsByDate.get(date) || 0) + t.amount,
);
});
return transactionsByDate;
}
export function BudgetSpendingChart({
currentPeriod,
previousPeriod,
budgetName,
currencyCode,
}: Props) {
const hasPreviousPeriod = !!previousPeriod;
const chartData = useMemo(() => {
const transactions = currentPeriod.budget_transactions || [];
const currentByDate = buildCumulativeSpending(currentPeriod);
const prevByDate = previousPeriod
? buildCumulativeSpending(previousPeriod)
: null;
const startDate = new Date(currentPeriod.start_date);
const endDate = new Date(currentPeriod.end_date);
// Group transactions by date (using the actual transaction date, not when it was assigned)
const transactionsByDate = new Map<string, number>();
transactions.forEach((t) => {
if (!t.transaction) return;
const date = new Date(t.transaction.transaction_date)
.toISOString()
.split('T')[0];
transactionsByDate.set(
date,
(transactionsByDate.get(date) || 0) + t.amount,
);
});
const prevStartDate = previousPeriod
? new Date(previousPeriod.start_date)
: null;
const prevEndDate = previousPeriod
? new Date(previousPeriod.end_date)
: null;
// Generate daily data points
const data = [];
const data: ChartDataPoint[] = [];
let cumulativeSpent = 0;
let prevCumulativeSpent = 0;
const currentDate = new Date(startDate);
let dayIndex = 1;
while (currentDate <= endDate && currentDate <= new Date()) {
while (currentDate <= endDate) {
const dateStr = currentDate.toISOString().split('T')[0];
const dailySpent = transactionsByDate.get(dateStr) || 0;
const dailySpent = currentByDate.get(dateStr) || 0;
cumulativeSpent += dailySpent;
data.push({
const point: ChartDataPoint = {
day: dayIndex,
date: dateStr,
spent: cumulativeSpent,
allocated: currentPeriod.allocated_amount,
remaining: currentPeriod.allocated_amount - cumulativeSpent,
});
};
// Map to the same day index in the previous period
if (prevByDate && prevStartDate && prevEndDate) {
const prevDate = new Date(prevStartDate);
prevDate.setDate(prevDate.getDate() + dayIndex - 1);
if (prevDate <= prevEndDate) {
const prevDateStr = prevDate.toISOString().split('T')[0];
const prevDailySpent = prevByDate.get(prevDateStr) || 0;
prevCumulativeSpent += prevDailySpent;
point.prevSpent = prevCumulativeSpent;
point.prevDate = prevDateStr;
}
}
data.push(point);
currentDate.setDate(currentDate.getDate() + 1);
dayIndex++;
}
return data;
}, [currentPeriod]);
}, [currentPeriod, previousPeriod]);
const chartConfig = {
spent: {
@ -147,6 +203,12 @@ export function BudgetSpendingChart({
label: 'Budget',
color: 'var(--allocated)',
},
...(hasPreviousPeriod && {
prevSpent: {
label: 'Last Period',
color: 'var(--spent-prev)',
},
}),
} satisfies ChartConfig;
const periodLabel = useMemo(() => {
@ -228,11 +290,14 @@ export function BudgetSpendingChart({
</linearGradient>
</defs>
<XAxis
dataKey="date"
dataKey={hasPreviousPeriod ? 'day' : 'date'}
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => {
if (hasPreviousPeriod) {
return `Day ${value}`;
}
const date = new Date(value);
return date.toLocaleDateString('en-US', {
month: 'short',
@ -242,7 +307,10 @@ export function BudgetSpendingChart({
/>
<ChartTooltip
content={
<CustomTooltip currencyCode={currencyCode} />
<CustomTooltip
currencyCode={currencyCode}
hasPreviousPeriod={hasPreviousPeriod}
/>
}
/>
<Area
@ -265,6 +333,18 @@ export function BudgetSpendingChart({
activeDot={{ r: 6 }}
fillOpacity={1}
/>
{hasPreviousPeriod && (
<Line
dataKey="prevSpent"
type="basis"
stroke="var(--color-prevSpent)"
strokeWidth={2}
strokeDasharray="6 4"
dot={false}
activeDot={{ r: 4 }}
connectNulls={false}
/>
)}
</AreaChart>
</ChartContainer>
</CardContent>

View File

@ -25,6 +25,7 @@ import { useEffect, useMemo, useState } from 'react';
interface Props {
budget: Budget;
currentPeriod: BudgetPeriod;
previousPeriod: BudgetPeriod | null;
categories: Category[];
accounts: Account[];
banks: Bank[];
@ -34,6 +35,7 @@ interface Props {
export default function BudgetShow({
budget,
currentPeriod,
previousPeriod,
categories,
accounts,
banks,
@ -167,6 +169,7 @@ export default function BudgetShow({
<BudgetSpendingChart
currentPeriod={currentPeriod}
previousPeriod={previousPeriod}
budgetName={budget.name}
currencyCode={currencyCode}
/>

View File

@ -115,6 +115,69 @@ test('user can delete their budget', function () {
]);
});
test('budget show returns previous period when it exists', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
Feature::for($user)->activate('budgets');
$budget = Budget::factory()->monthly()->create([
'user_id' => $user->id,
'period_start_day' => 1,
]);
// Create a previous period (last month)
$budget->periods()->create([
'start_date' => now()->subMonth()->startOfMonth(),
'end_date' => now()->subMonth()->endOfMonth(),
'allocated_amount' => 30000,
'carried_over_amount' => 0,
]);
// Create the current period
$budget->periods()->create([
'start_date' => now()->startOfMonth(),
'end_date' => now()->endOfMonth(),
'allocated_amount' => 30000,
'carried_over_amount' => 0,
]);
$response = $this->actingAs($user)->get("/budgets/{$budget->id}");
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('budgets/show')
->has('currentPeriod')
->has('previousPeriod')
->where('previousPeriod.start_date', now()->subMonth()->startOfMonth()->toJSON())
);
});
test('budget show returns null previous period when it is the first period', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
Feature::for($user)->activate('budgets');
$budget = Budget::factory()->monthly()->create([
'user_id' => $user->id,
'period_start_day' => 1,
]);
// Create only the current period
$budget->periods()->create([
'start_date' => now()->startOfMonth(),
'end_date' => now()->endOfMonth(),
'allocated_amount' => 30000,
'carried_over_amount' => 0,
]);
$response = $this->actingAs($user)->get("/budgets/{$budget->id}");
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('budgets/show')
->has('currentPeriod')
->where('previousPeriod', null)
);
});
test('budget period is automatically generated', function () {
$user = User::factory()->create(['onboarded_at' => now()]);
Feature::for($user)->activate('budgets');