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:
parent
126f7f7e72
commit
900cf41e31
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue