diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php index 280e7d8d..d2d56942 100644 --- a/app/Http/Controllers/Api/DashboardAnalyticsController.php +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -198,6 +198,59 @@ class DashboardAnalyticsController extends Controller ]); } + public function netWorthDailyEvolution(Request $request) + { + $validated = $request->validate([ + 'from' => 'required|date', + 'to' => 'required|date', + ]); + + $start = Carbon::parse($validated['from']); + $end = Carbon::parse($validated['to']); + + $accounts = Account::query() + ->where('user_id', $request->user()->id) + ->with(['bank:id,name,logo']) + ->get(); + + $points = []; + $current = $start->copy(); + + while ($current->lte($end)) { + $date = $current->copy(); + $point = [ + 'date' => $date->format('Y-m-d'), + 'timestamp' => $date->endOfDay()->timestamp, + ]; + + foreach ($accounts as $account) { + $point[$account->id] = $this->getBalanceAt($account->id, $date); + } + + $points[] = $point; + $current->addDay(); + } + + $accountsConfig = $accounts->mapWithKeys(function ($account) { + return [ + $account->id => [ + 'id' => $account->id, + 'name' => $account->name, + 'name_iv' => $account->name_iv, + 'encrypted' => $account->encrypted, + 'type' => $account->type, + 'currency_code' => $account->currency_code, + 'bank' => $account->bank, + ], + ]; + }); + + return response()->json([ + 'data' => $points, + 'accounts' => $accountsConfig, + ]); + } + public function topCategories(Request $request) { $validated = $request->validate([ diff --git a/resources/js/components/dashboard/net-worth-chart.tsx b/resources/js/components/dashboard/net-worth-chart.tsx index 8d934997..d3b293cf 100644 --- a/resources/js/components/dashboard/net-worth-chart.tsx +++ b/resources/js/components/dashboard/net-worth-chart.tsx @@ -1,5 +1,7 @@ import { AccountName } from '@/components/accounts/account-name'; import { + type ChartGranularity, + ChartGranularityToggle, ChartViewToggle, MoMChart, MoMPercentChart, @@ -13,13 +15,16 @@ import { CardTitle, } from '@/components/ui/card'; import { ChartConfig } from '@/components/ui/chart'; +import { StackedAreaChart } from '@/components/ui/stacked-area-chart'; import { StackedBarChart } from '@/components/ui/stacked-bar-chart'; import { useChartViews } from '@/hooks/use-chart-views'; import { NetWorthEvolutionData } from '@/hooks/use-dashboard-data'; import { useLocale } from '@/hooks/use-locale'; import { AccountInfo } from '@/lib/chart-calculations'; +import { formatDayFromDate } from '@/utils/date'; import { __ } from '@/utils/i18n'; -import { useMemo } from 'react'; +import { format, subDays } from 'date-fns'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { PercentageTrendIndicator } from './percentage-trend-indicator'; interface NetWorthChartProps { @@ -28,13 +33,23 @@ interface NetWorthChartProps { showLegend?: boolean; } +const DAILY_DAYS = 30; + interface TrendData { percentage: number; previousAmount: number; currentAmount: number; } -function formatXAxisLabel(value: string, locale: string = 'en'): string { +function formatXAxisLabel( + value: string, + locale: string = 'en', + granularity: ChartGranularity = 'monthly', +): string { + if (granularity === 'daily') { + return formatDayFromDate(value, locale); + } + const [year, month] = value.split('-'); const date = new Date(parseInt(year), parseInt(month) - 1); const monthName = date.toLocaleString(locale, { month: 'short' }); @@ -50,12 +65,12 @@ function formatXAxisLabel(value: string, locale: string = 'en'): string { function calculateTrend( data: Array>, accountIds: string[], - monthsBack: number, + periodsBack: number, ): TrendData | null { if (data.length < 2) return null; const currentIndex = data.length - 1; - const previousIndex = Math.max(0, data.length - 1 - monthsBack); + const previousIndex = Math.max(0, data.length - 1 - periodsBack); if (currentIndex === previousIndex) return null; @@ -130,25 +145,69 @@ function TotalDisplay({ totals }: { totals: CurrencyTotal[] }) { } export function NetWorthChart({ - data, + data: monthlyData, loading, showLegend = false, }: NetWorthChartProps) { const locale = useLocale(); + const [granularity, setGranularity] = useState('monthly'); + const [dailyData, setDailyData] = useState( + null, + ); + const [isDailyLoading, setIsDailyLoading] = useState(false); + + const fetchDailyData = useCallback(async () => { + setIsDailyLoading(true); + try { + const now = new Date(); + const to = format(now, 'yyyy-MM-dd'); + const from = format(subDays(now, DAILY_DAYS), 'yyyy-MM-dd'); + const params = new URLSearchParams({ from, to }); + const response = await fetch( + `/api/dashboard/net-worth-daily-evolution?${params.toString()}`, + ); + const data: NetWorthEvolutionData = await response.json(); + + // Normalize daily data: rename "date" key to "month" so it works + // uniformly with useChartViews and the rest of the component. + const normalizedData = data.data.map((point) => { + const { date, ...rest } = point as Record & { + date: string; + }; + return { ...rest, month: date }; + }) as Array>; + + setDailyData({ data: normalizedData, accounts: data.accounts }); + } catch (error) { + console.error('Failed to fetch daily net worth data:', error); + } finally { + setIsDailyLoading(false); + } + }, []); + + useEffect(() => { + if (granularity === 'daily' && !dailyData) { + fetchDailyData(); + } + }, [granularity, dailyData, fetchDailyData]); + + const activeData = + granularity === 'daily' && dailyData ? dailyData : monthlyData; + const { chartData, dataKeys, chartConfig, - monthlyTrend, - yearlyTrend, + shortTrend, + longTrend, currencyTotals, accountCurrencies, primaryCurrency, accountsForHook, } = useMemo(() => { - const accounts = data.accounts || {}; + const accounts = activeData.accounts || {}; const accountIds = Object.keys(accounts); - const chartDataArray = data.data || []; + const chartDataArray = activeData.data || []; const config: ChartConfig = {}; const currencies: Record = {}; @@ -196,8 +255,8 @@ export function NetWorthChart({ chartData: chartDataArray, dataKeys: accountIds, chartConfig: config, - monthlyTrend: calculateTrend(chartDataArray, accountIds, 1), - yearlyTrend: calculateTrend( + shortTrend: calculateTrend(chartDataArray, accountIds, 1), + longTrend: calculateTrend( chartDataArray, accountIds, chartDataArray.length - 1, @@ -207,7 +266,7 @@ export function NetWorthChart({ primaryCurrency: primary, accountsForHook: hookAccounts, }; - }, [data]); + }, [activeData]); const chartViews = useChartViews({ data: chartData, @@ -233,7 +292,14 @@ export function NetWorthChart({ }; }, [accountCurrencies]); - if (loading) { + const shortTrendLabel = + granularity === 'daily' ? __('today') : __('this month'); + const longTrendLabel = + granularity === 'daily' + ? __('for the last 30 days') + : __('for the last 12 months'); + + if (loading || (granularity === 'daily' && isDailyLoading)) { return ( @@ -264,6 +330,9 @@ export function NetWorthChart({ ); } + const xAxisFormatter = (value: string) => + formatXAxisLabel(value, locale, granularity); + return ( @@ -275,62 +344,76 @@ export function NetWorthChart({ - +
+ + +
- {chartViews.currentView === 'stacked' && ( - - formatXAxisLabel(value, locale) - } - valueFormatter={valueFormatter} - accountCurrencies={accountCurrencies} - className="h-[300px] w-full" - showLegend={showLegend} - /> - )} + {chartViews.currentView === 'stacked' && + (granularity === 'daily' ? ( + + ) : ( + + ))} {chartViews.currentView === 'mom' && ( - formatXAxisLabel(value, locale) - } + xAxisFormatter={xAxisFormatter} className="h-[300px] w-full" /> )} {chartViews.currentView === 'mom_percent' && ( - formatXAxisLabel(value, locale) - } + xAxisFormatter={xAxisFormatter} className="h-[300px] w-full" /> )} diff --git a/resources/js/components/ui/stacked-area-chart.tsx b/resources/js/components/ui/stacked-area-chart.tsx new file mode 100644 index 00000000..e395ebf5 --- /dev/null +++ b/resources/js/components/ui/stacked-area-chart.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef } from 'react'; +import { Area, AreaChart, XAxis } from 'recharts'; + +import { + ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart'; +import { cn } from '@/lib/utils'; + +const COLOR_SHADES: string[] = [ + 'var(--color-chart-2)', + 'var(--color-chart-3)', + 'var(--color-chart-4)', + 'var(--color-chart-5)', + 'var(--color-chart-6)', + 'var(--color-chart-7)', + 'var(--color-chart-8)', + 'var(--color-chart-9)', + 'var(--color-chart-10)', + 'var(--color-chart-1)', +]; + +export interface StackedAreaChartProps> { + data: T[]; + dataKeys: string[]; + config: ChartConfig; + xAxisKey: string; + xAxisFormatter?: (value: string) => string; + valueFormatter?: (value: number, accountId?: string) => string; + accountCurrencies?: Record; + className?: string; + showLegend?: boolean; + minBarWidth?: number; +} + +export function StackedAreaChart>({ + data, + dataKeys, + config, + xAxisKey, + xAxisFormatter, + valueFormatter, + accountCurrencies, + className, + showLegend = true, + minBarWidth = 20, +}: StackedAreaChartProps) { + const scrollContainerRef = useRef(null); + + const configWithColors: ChartConfig = Object.fromEntries( + Object.entries(config).map(([key, value], index) => [ + key, + { + ...value, + color: COLOR_SHADES[index % COLOR_SHADES.length], + }, + ]), + ); + + const minChartWidth = data.length * minBarWidth; + + useEffect(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollLeft = + scrollContainerRef.current.scrollWidth; + } + }, [data]); + + return ( +
+ + + + {dataKeys.map((key, index) => { + const color = + COLOR_SHADES[index % COLOR_SHADES.length]; + return ( + + + + + ); + })} + + + + } + /> + {showLegend && ( + } /> + )} + {dataKeys.map((key, index) => { + const color = + COLOR_SHADES[index % COLOR_SHADES.length]; + return ( + + ); + })} + + +
+ ); +} diff --git a/routes/api.php b/routes/api.php index 071e42fc..81476136 100644 --- a/routes/api.php +++ b/routes/api.php @@ -43,6 +43,7 @@ Route::middleware(['web', 'auth'])->group(function () { Route::get('monthly-spending', [DashboardAnalyticsController::class, 'monthlySpending']); Route::get('cash-flow', [DashboardAnalyticsController::class, 'cashFlow']); Route::get('net-worth-evolution', [DashboardAnalyticsController::class, 'netWorthEvolution']); + Route::get('net-worth-daily-evolution', [DashboardAnalyticsController::class, 'netWorthDailyEvolution']); Route::get('top-categories', [DashboardAnalyticsController::class, 'topCategories']); Route::get('account/{account}/balance-evolution', [DashboardAnalyticsController::class, 'accountBalanceEvolution']); Route::get('account/{account}/daily-balance-evolution', [DashboardAnalyticsController::class, 'accountDailyBalanceEvolution']); diff --git a/tests/Feature/DashboardAnalyticsTest.php b/tests/Feature/DashboardAnalyticsTest.php index ba1a1657..ec34e228 100644 --- a/tests/Feature/DashboardAnalyticsTest.php +++ b/tests/Feature/DashboardAnalyticsTest.php @@ -384,3 +384,123 @@ test('account daily balance evolution forbids access to other users accounts', f $response->assertForbidden(); }); + +test('net worth daily evolution returns daily data points with per-account balances', function () { + $account1 = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Checking, + 'name' => 'Daily Checking', + 'currency_code' => 'USD', + ]); + $account2 = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Savings, + 'name' => 'Daily Savings', + 'currency_code' => 'EUR', + ]); + + AccountBalance::factory()->create([ + 'account_id' => $account1->id, + 'balance_date' => now()->subDays(2), + 'balance' => 100000, + ]); + AccountBalance::factory()->create([ + 'account_id' => $account2->id, + 'balance_date' => now()->subDays(2), + 'balance' => 200000, + ]); + AccountBalance::factory()->create([ + 'account_id' => $account1->id, + 'balance_date' => now(), + 'balance' => 150000, + ]); + AccountBalance::factory()->create([ + 'account_id' => $account2->id, + 'balance_date' => now(), + 'balance' => 250000, + ]); + + $response = $this->getJson('/api/dashboard/net-worth-daily-evolution?'.http_build_query([ + 'from' => now()->subDays(2)->toDateString(), + 'to' => now()->toDateString(), + ])); + + $response->assertOk(); + $data = $response->json(); + + expect($data)->toHaveKeys(['data', 'accounts']); + expect($data['data'])->toHaveCount(3); + expect($data['data'][0])->toHaveKeys(['date', 'timestamp', $account1->id, $account2->id]); + expect($data['data'][0]['date'])->toBe(now()->subDays(2)->format('Y-m-d')); + expect($data['data'][0][$account1->id])->toBe(100000); + expect($data['data'][0][$account2->id])->toBe(200000); + expect($data['data'][2][$account1->id])->toBe(150000); + expect($data['data'][2][$account2->id])->toBe(250000); + expect($data['accounts'])->toHaveKey($account1->id); + expect($data['accounts'])->toHaveKey($account2->id); + expect($data['accounts'][$account1->id]['currency_code'])->toBe('USD'); + expect($data['accounts'][$account2->id]['currency_code'])->toBe('EUR'); +}); + +test('net worth daily evolution fills gaps with last known balance', function () { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Checking, + ]); + + // Balance before the range — carried forward into gap days + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->subDays(10), + 'balance' => 50000, + ]); + + // Balance on the last day of the range + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now(), + 'balance' => 80000, + ]); + + $response = $this->getJson('/api/dashboard/net-worth-daily-evolution?'.http_build_query([ + 'from' => now()->subDays(2)->toDateString(), + 'to' => now()->toDateString(), + ])); + + $response->assertOk(); + $data = $response->json(); + + // 3 days in range: -2, -1, today + expect($data['data'])->toHaveCount(3); + // Days -2 and -1 carry forward the 50000 balance + expect($data['data'][0][$account->id])->toBe(50000); + expect($data['data'][1][$account->id])->toBe(50000); + // Today has the actual 80000 entry + expect($data['data'][2][$account->id])->toBe(80000); +}); + +test('net worth daily evolution returns account metadata including bank', function () { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::CreditCard, + 'name' => 'My Daily CC', + 'name_iv' => 'test_iv_daily', + ]); + + $response = $this->getJson('/api/dashboard/net-worth-daily-evolution?'.http_build_query([ + 'from' => now()->subDays(1)->toDateString(), + 'to' => now()->toDateString(), + ])); + + $response->assertOk(); + $data = $response->json(); + + expect($data['accounts'][$account->id])->toMatchArray([ + 'id' => $account->id, + 'name' => 'My Daily CC', + 'name_iv' => 'test_iv_daily', + 'type' => 'credit_card', + ]); + expect($data['accounts'][$account->id])->toHaveKey('bank'); + expect($data['accounts'][$account->id]['bank'])->toHaveKeys(['id', 'name', 'logo']); +});