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