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  ### Dark mode  ## 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:
parent
ab160ae489
commit
9bbd91ac12
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue