diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php
index e1996418..9089de32 100644
--- a/app/Http/Controllers/BudgetController.php
+++ b/app/Http/Controllers/BudgetController.php
@@ -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,
diff --git a/resources/css/app.css b/resources/css/app.css
index 09b26d36..7d0d1fb9 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -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);
diff --git a/resources/js/components/budgets/budget-spending-chart.tsx b/resources/js/components/budgets/budget-spending-chart.tsx
index 02d59944..aeabd393 100644
--- a/resources/js/components/budgets/budget-spending-chart.tsx
+++ b/resources/js/components/budgets/budget-spending-chart.tsx
@@ -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 (
- {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',
+ })}
@@ -74,6 +83,16 @@ function CustomTooltip({
{formatCurrency(spent, currencyCode)}
+ {hasPreviousPeriod && data.prevSpent !== undefined && (
+
+
+ Last period:
+
+
+ {formatCurrency(data.prevSpent, currencyCode)}
+
+
+ )}
Available:
@@ -92,51 +111,88 @@ function CustomTooltip({
);
}
+function buildCumulativeSpending(period: BudgetPeriod): Map
{
+ const transactions = period.budget_transactions || [];
+ const transactionsByDate = new Map();
+
+ 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();
- 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({
{
+ if (hasPreviousPeriod) {
+ return `Day ${value}`;
+ }
const date = new Date(value);
return date.toLocaleDateString('en-US', {
month: 'short',
@@ -242,7 +307,10 @@ export function BudgetSpendingChart({
/>
+
}
/>
+ {hasPreviousPeriod && (
+
+ )}
diff --git a/resources/js/pages/budgets/show.tsx b/resources/js/pages/budgets/show.tsx
index 35f1d61c..c67f3134 100644
--- a/resources/js/pages/budgets/show.tsx
+++ b/resources/js/pages/budgets/show.tsx
@@ -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({
diff --git a/tests/Feature/BudgetTest.php b/tests/Feature/BudgetTest.php
index 1e5ac37d..e7eb6159 100644
--- a/tests/Feature/BudgetTest.php
+++ b/tests/Feature/BudgetTest.php
@@ -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');