feat: Add daily granularity toggle with area visualization to net worth chart (#136)

## Summary

Adds daily granularity support to the dashboard Net Worth Evolution
chart, mirroring the daily balance feature added for individual account
pages in #135.

## Why

The net worth chart only supported monthly granularity, making it
impossible to see short-term net worth fluctuations. Users who track
daily balances need a way to visualize day-over-day net worth changes
across all accounts.

## Screenshots
<img width="1263" height="714" alt="image"
src="https://github.com/user-attachments/assets/646e7e21-5c1b-42dc-9a20-5e9b6d5d72b4"
/>
<img width="1261" height="715" alt="image"
src="https://github.com/user-attachments/assets/b9fe5080-55f3-485a-a566-1d25d83518c7"
/>
This commit is contained in:
Víctor Falcón 2026-02-19 15:35:22 +01:00 committed by GitHub
parent 126f7f7e72
commit 900cf41e31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 456 additions and 47 deletions

View File

@ -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([

View File

@ -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<Record<string, string | number>>,
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<ChartGranularity>('monthly');
const [dailyData, setDailyData] = useState<NetWorthEvolutionData | null>(
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<string, unknown> & {
date: string;
};
return { ...rest, month: date };
}) as Array<Record<string, string | number>>;
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<string, string> = {};
@ -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 (
<Card className="col-span-3">
<CardHeader>
@ -264,6 +330,9 @@ export function NetWorthChart({
);
}
const xAxisFormatter = (value: string) =>
formatXAxisLabel(value, locale, granularity);
return (
<Card className="group overflow-hidden">
<CardHeader>
@ -275,62 +344,76 @@ export function NetWorthChart({
<TotalDisplay totals={currencyTotals} />
</div>
<PercentageTrendIndicator
trend={monthlyTrend?.percentage ?? null}
label={__('this month')}
previousAmount={monthlyTrend?.previousAmount}
currentAmount={monthlyTrend?.currentAmount}
trend={shortTrend?.percentage ?? null}
label={shortTrendLabel}
previousAmount={shortTrend?.previousAmount}
currentAmount={shortTrend?.currentAmount}
currencyCode={primaryCurrency}
/>
<PercentageTrendIndicator
trend={yearlyTrend?.percentage ?? null}
label={__('for the last 12 months')}
previousAmount={yearlyTrend?.previousAmount}
currentAmount={yearlyTrend?.currentAmount}
trend={longTrend?.percentage ?? null}
label={longTrendLabel}
previousAmount={longTrend?.previousAmount}
currentAmount={longTrend?.currentAmount}
currencyCode={primaryCurrency}
/>
</CardDescription>
</div>
<ChartViewToggle
value={chartViews.currentView}
onValueChange={chartViews.setCurrentView}
availableViews={chartViews.availableViews}
/>
<div className="flex items-center gap-2">
<ChartGranularityToggle
value={granularity}
onValueChange={setGranularity}
/>
<ChartViewToggle
value={chartViews.currentView}
onValueChange={chartViews.setCurrentView}
availableViews={chartViews.availableViews}
granularity={granularity}
/>
</div>
</div>
</CardHeader>
<CardContent className="relative min-w-0">
{chartViews.currentView === 'stacked' && (
<StackedBarChart
data={chartData.slice(1)}
dataKeys={dataKeys}
config={chartConfig}
xAxisKey="month"
xAxisFormatter={(value) =>
formatXAxisLabel(value, locale)
}
valueFormatter={valueFormatter}
accountCurrencies={accountCurrencies}
className="h-[300px] w-full"
showLegend={showLegend}
/>
)}
{chartViews.currentView === 'stacked' &&
(granularity === 'daily' ? (
<StackedAreaChart
data={chartData.slice(1)}
dataKeys={dataKeys}
config={chartConfig}
xAxisKey="month"
xAxisFormatter={xAxisFormatter}
valueFormatter={valueFormatter}
accountCurrencies={accountCurrencies}
className="h-[300px] w-full"
showLegend={showLegend}
/>
) : (
<StackedBarChart
data={chartData.slice(1)}
dataKeys={dataKeys}
config={chartConfig}
xAxisKey="month"
xAxisFormatter={xAxisFormatter}
valueFormatter={valueFormatter}
accountCurrencies={accountCurrencies}
className="h-[300px] w-full"
showLegend={showLegend}
/>
))}
{chartViews.currentView === 'mom' && (
<MoMChart
data={chartViews.deltaSeries}
currencyCode={primaryCurrency}
xAxisFormatter={(value) =>
formatXAxisLabel(value, locale)
}
xAxisFormatter={xAxisFormatter}
className="h-[300px] w-full"
/>
)}
{chartViews.currentView === 'mom_percent' && (
<MoMPercentChart
data={chartViews.momPercentSeries}
xAxisFormatter={(value) =>
formatXAxisLabel(value, locale)
}
xAxisFormatter={xAxisFormatter}
className="h-[300px] w-full"
/>
)}

View File

@ -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<T extends Record<string, unknown>> {
data: T[];
dataKeys: string[];
config: ChartConfig;
xAxisKey: string;
xAxisFormatter?: (value: string) => string;
valueFormatter?: (value: number, accountId?: string) => string;
accountCurrencies?: Record<string, string>;
className?: string;
showLegend?: boolean;
minBarWidth?: number;
}
export function StackedAreaChart<T extends Record<string, unknown>>({
data,
dataKeys,
config,
xAxisKey,
xAxisFormatter,
valueFormatter,
accountCurrencies,
className,
showLegend = true,
minBarWidth = 20,
}: StackedAreaChartProps<T>) {
const scrollContainerRef = useRef<HTMLDivElement>(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 (
<div
ref={scrollContainerRef}
className={cn('overflow-x-auto', className)}
>
<ChartContainer
config={configWithColors}
className="h-full w-full"
style={{ minWidth: `${minChartWidth}px` }}
>
<AreaChart accessibilityLayer data={data}>
<defs>
{dataKeys.map((key, index) => {
const color =
COLOR_SHADES[index % COLOR_SHADES.length];
return (
<linearGradient
key={key}
id={`fill-${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={color}
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor={color}
stopOpacity={0.05}
/>
</linearGradient>
);
})}
</defs>
<XAxis
dataKey={xAxisKey}
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={xAxisFormatter}
/>
<ChartTooltip
content={
<ChartTooltipContent
hideLabel
valueFormatter={valueFormatter}
accountCurrencies={accountCurrencies}
/>
}
/>
{showLegend && (
<ChartLegend content={<ChartLegendContent />} />
)}
{dataKeys.map((key, index) => {
const color =
COLOR_SHADES[index % COLOR_SHADES.length];
return (
<Area
key={key}
dataKey={key}
stackId="stack"
type="monotone"
fill={`url(#fill-${key})`}
stroke={color}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
fillOpacity={1}
/>
);
})}
</AreaChart>
</ChartContainer>
</div>
);
}

View File

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

View File

@ -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']);
});