Dashboard: Net worth Chart (#3)
* Improve net worth chart * feat: Add current net worth display to dashboard chart * Handle different currencies * Add account balance sync service and update SyncProvider * Remove redundant transaction filters in DashboardAnalyticsController
This commit is contained in:
parent
7492b2e736
commit
a0f44a9144
|
|
@ -73,21 +73,45 @@ class DashboardAnalyticsController extends Controller
|
|||
$start = Carbon::parse($validated['from']);
|
||||
$end = Carbon::parse($validated['to']);
|
||||
|
||||
$accounts = Account::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->get();
|
||||
|
||||
$points = [];
|
||||
$current = $start->copy()->startOfMonth();
|
||||
$endMonth = $end->copy()->startOfMonth();
|
||||
|
||||
while ($current->lte($endMonth)) {
|
||||
$date = $current->copy()->endOfMonth();
|
||||
$points[] = [
|
||||
'date' => $date->format('M Y'),
|
||||
'value' => $this->calculateNetWorthAt($date),
|
||||
$point = [
|
||||
'month' => $date->format('Y-m'),
|
||||
'timestamp' => $date->timestamp,
|
||||
];
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$point[$account->id] = $this->getBalanceAt($account->id, $date);
|
||||
}
|
||||
|
||||
$points[] = $point;
|
||||
$current->addMonth();
|
||||
}
|
||||
|
||||
return response()->json($points);
|
||||
$accountsConfig = $accounts->mapWithKeys(function ($account) {
|
||||
return [
|
||||
$account->id => [
|
||||
'id' => $account->id,
|
||||
'name' => $account->name,
|
||||
'name_iv' => $account->name_iv,
|
||||
'type' => $account->type,
|
||||
'currency_code' => $account->currency_code,
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $points,
|
||||
'accounts' => $accountsConfig,
|
||||
]);
|
||||
}
|
||||
|
||||
public function accountBalances(Request $request)
|
||||
|
|
@ -235,8 +259,6 @@ class DashboardAnalyticsController extends Controller
|
|||
$spending = Transaction::query()
|
||||
->where('user_id', request()->user()->id)
|
||||
->whereBetween('transaction_date', [$from, $to])
|
||||
->where('amount', '<', 0) // Expenses are negative
|
||||
// Optional: exclude transfers?
|
||||
->whereHas('category', function ($q) {
|
||||
$q->where('type', CategoryType::Expense);
|
||||
})
|
||||
|
|
@ -250,7 +272,6 @@ class DashboardAnalyticsController extends Controller
|
|||
$income = Transaction::query()
|
||||
->where('user_id', request()->user()->id)
|
||||
->whereBetween('transaction_date', [$from, $to])
|
||||
->where('amount', '>', 0)
|
||||
->whereHas('category', function ($q) {
|
||||
$q->where('type', CategoryType::Income);
|
||||
})
|
||||
|
|
@ -259,7 +280,6 @@ class DashboardAnalyticsController extends Controller
|
|||
$expense = Transaction::query()
|
||||
->where('user_id', request()->user()->id)
|
||||
->whereBetween('transaction_date', [$from, $to])
|
||||
->where('amount', '<', 0)
|
||||
->whereHas('category', function ($q) {
|
||||
$q->where('type', CategoryType::Expense);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,25 +1,234 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { EncryptedText } from '@/components/encrypted-text';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { ChartConfig } from '@/components/ui/chart';
|
||||
import {
|
||||
ColorPalette,
|
||||
StackedBarChart,
|
||||
} from '@/components/ui/stacked-bar-chart';
|
||||
import { NetWorthEvolutionData } from '@/hooks/use-dashboard-data';
|
||||
import { TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface NetWorthChartProps {
|
||||
data: Array<{ date: string; value: number }>;
|
||||
data: NetWorthEvolutionData;
|
||||
loading?: boolean;
|
||||
color?: ColorPalette;
|
||||
showLegend?: boolean;
|
||||
}
|
||||
|
||||
export function NetWorthChart({ data, loading }: NetWorthChartProps) {
|
||||
function formatXAxisLabel(value: string): string {
|
||||
const [year, month] = value.split('-');
|
||||
const date = new Date(parseInt(year), parseInt(month) - 1);
|
||||
const monthName = date.toLocaleString('en-US', { month: 'short' });
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
if (parseInt(year) === currentYear) {
|
||||
return monthName;
|
||||
}
|
||||
|
||||
return `${monthName} ${year.slice(-2)}`;
|
||||
}
|
||||
|
||||
function formatCurrencyWithCode(value: number, currencyCode: string): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value / 100);
|
||||
}
|
||||
|
||||
function calculateTrend(
|
||||
data: Array<Record<string, string | number>>,
|
||||
accountIds: string[],
|
||||
monthsBack: number,
|
||||
): number | null {
|
||||
if (data.length < 2) return null;
|
||||
|
||||
const currentIndex = data.length - 1;
|
||||
const previousIndex = Math.max(0, data.length - 1 - monthsBack);
|
||||
|
||||
if (currentIndex === previousIndex) return null;
|
||||
|
||||
const currentTotal = accountIds.reduce((sum, id) => {
|
||||
const value = data[currentIndex][id];
|
||||
return sum + (typeof value === 'number' ? value : 0);
|
||||
}, 0);
|
||||
|
||||
const previousTotal = accountIds.reduce((sum, id) => {
|
||||
const value = data[previousIndex][id];
|
||||
return sum + (typeof value === 'number' ? value : 0);
|
||||
}, 0);
|
||||
|
||||
if (previousTotal === 0) return null;
|
||||
|
||||
return ((currentTotal - previousTotal) / Math.abs(previousTotal)) * 100;
|
||||
}
|
||||
|
||||
function TrendIndicator({
|
||||
trend,
|
||||
label,
|
||||
}: {
|
||||
trend: number | null;
|
||||
label: string;
|
||||
}) {
|
||||
if (trend === null) return null;
|
||||
|
||||
const isPositive = trend >= 0;
|
||||
const Icon = isPositive ? TrendingUp : TrendingDown;
|
||||
const iconColorClass = isPositive
|
||||
? 'text-green-600/70 dark:text-green-400/70'
|
||||
: 'text-red-600/70 dark:text-red-400/70';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={
|
||||
isPositive ? 'bg-green-100/25 dark:bg-green-900/25' : ''
|
||||
}
|
||||
>
|
||||
{isPositive ? '+' : ''}
|
||||
{trend.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<Icon className={`h-4 w-4 ${iconColorClass}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EncryptedLabelProps {
|
||||
account: { name: string; name_iv: string };
|
||||
}
|
||||
|
||||
function EncryptedLabel({ account }: EncryptedLabelProps) {
|
||||
return (
|
||||
<EncryptedText
|
||||
encryptedText={account.name}
|
||||
iv={account.name_iv}
|
||||
length={{ min: 5, max: 20 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface CurrencyTotal {
|
||||
currency: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function TotalDisplay({ totals }: { totals: CurrencyTotal[] }) {
|
||||
if (totals.length === 0) return null;
|
||||
|
||||
if (totals.length === 1) {
|
||||
return (
|
||||
<span className="text-4xl font-semibold tabular-nums">
|
||||
{formatCurrencyWithCode(totals[0].total, totals[0].currency)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-baseline gap-1">
|
||||
{totals.map((item, index) => (
|
||||
<span key={item.currency} className="flex items-baseline">
|
||||
{index > 0 && (
|
||||
<span className="mx-1 text-2xl opacity-50">+</span>
|
||||
)}
|
||||
<span className="text-4xl font-semibold tabular-nums">
|
||||
{formatCurrencyWithCode(item.total, item.currency)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NetWorthChart({
|
||||
data,
|
||||
loading,
|
||||
color = 'zinc',
|
||||
showLegend = false,
|
||||
}: NetWorthChartProps) {
|
||||
const {
|
||||
chartData,
|
||||
dataKeys,
|
||||
chartConfig,
|
||||
monthlyTrend,
|
||||
yearlyTrend,
|
||||
currencyTotals,
|
||||
accountCurrencies,
|
||||
} = useMemo(() => {
|
||||
const accounts = data.accounts || {};
|
||||
const accountIds = Object.keys(accounts);
|
||||
const chartDataArray = data.data || [];
|
||||
|
||||
const config: ChartConfig = {};
|
||||
const currencies: Record<string, string> = {};
|
||||
|
||||
accountIds.forEach((id) => {
|
||||
const account = accounts[id];
|
||||
config[id] = {
|
||||
label: account ? <EncryptedLabel account={account} /> : id,
|
||||
};
|
||||
if (account?.currency_code) {
|
||||
currencies[id] = account.currency_code;
|
||||
}
|
||||
});
|
||||
|
||||
const totals: Record<string, number> = {};
|
||||
if (chartDataArray.length > 0) {
|
||||
const lastDataPoint = chartDataArray[chartDataArray.length - 1];
|
||||
accountIds.forEach((id) => {
|
||||
const value = lastDataPoint[id];
|
||||
const currency = currencies[id] || 'USD';
|
||||
if (typeof value === 'number') {
|
||||
totals[currency] = (totals[currency] || 0) + value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const currencyTotalsList: CurrencyTotal[] = Object.entries(totals)
|
||||
.map(([currency, total]) => ({ currency, total }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
return {
|
||||
chartData: chartDataArray,
|
||||
dataKeys: accountIds,
|
||||
chartConfig: config,
|
||||
monthlyTrend: calculateTrend(chartDataArray, accountIds, 1),
|
||||
yearlyTrend: calculateTrend(
|
||||
chartDataArray,
|
||||
accountIds,
|
||||
chartDataArray.length - 1,
|
||||
),
|
||||
currencyTotals: currencyTotalsList,
|
||||
accountCurrencies: currencies,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const valueFormatter = useMemo(() => {
|
||||
return (value: number, accountId?: string): string => {
|
||||
const currency =
|
||||
accountId && accountCurrencies[accountId]
|
||||
? accountCurrencies[accountId]
|
||||
: 'USD';
|
||||
return formatCurrencyWithCode(value, currency);
|
||||
};
|
||||
}, [accountCurrencies]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Net Worth Evolution</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] w-full animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
|
|
@ -28,77 +237,59 @@ export function NetWorthChart({ data, loading }: NetWorthChartProps) {
|
|||
);
|
||||
}
|
||||
|
||||
if (dataKeys.length === 0) {
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Net Worth Evolution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||
No account data available
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Net Worth Evolution</CardTitle>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle>Net Worth Evolution</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="flex flex-col gap-1 text-sm">
|
||||
<TrendIndicator
|
||||
trend={monthlyTrend}
|
||||
label="this month"
|
||||
/>
|
||||
<TrendIndicator
|
||||
trend={yearlyTrend}
|
||||
label="for the last 12 months"
|
||||
/>
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TotalDisplay totals={currencyTotals} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="colorValue"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="#2563eb"
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="#2563eb"
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(value) => `$${value}`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(value),
|
||||
'Net Worth',
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#2563eb"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorValue)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<StackedBarChart
|
||||
data={chartData}
|
||||
dataKeys={dataKeys}
|
||||
config={chartConfig}
|
||||
color={color}
|
||||
xAxisKey="month"
|
||||
xAxisFormatter={formatXAxisLabel}
|
||||
valueFormatter={valueFormatter}
|
||||
accountCurrencies={accountCurrencies}
|
||||
className="h-[300px] w-full"
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,66 +3,66 @@ import * as React from "react"
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn("flex flex-col gap-1.5 px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn("flex flex-col gap-1.5 px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,495 @@
|
|||
import * as React from 'react';
|
||||
import * as RechartsPrimitive from 'recharts';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
color?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>['children'];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = 'Chart';
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, itemConfig]) => itemConfig.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
return itemConfig.color
|
||||
? ` --color-${key}: ${itemConfig.color};`
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join('\n'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
interface TooltipPayloadItem {
|
||||
dataKey?: string | number;
|
||||
name?: string;
|
||||
value?: number | string;
|
||||
color?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ChartTooltipContentProps {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
className?: string;
|
||||
indicator?: 'line' | 'dot' | 'dashed';
|
||||
hideLabel?: boolean;
|
||||
label?: string;
|
||||
labelFormatter?: (
|
||||
value: unknown,
|
||||
payload: TooltipPayloadItem[],
|
||||
) => React.ReactNode;
|
||||
labelClassName?: string;
|
||||
formatter?: (
|
||||
value: unknown,
|
||||
name: string,
|
||||
item: TooltipPayloadItem,
|
||||
index: number,
|
||||
payload: Record<string, unknown>,
|
||||
) => React.ReactNode;
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
valueFormatter?: (value: number, accountId?: string) => string;
|
||||
accountCurrencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
function formatCurrencyWithCode(value: number, currencyCode: string): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value / 100);
|
||||
}
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChartTooltipContentProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
hideLabel = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
nameKey,
|
||||
labelKey,
|
||||
valueFormatter,
|
||||
accountCurrencies,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn('font-medium', labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('font-medium', labelClassName)}>{value}</div>
|
||||
);
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
const currencyTotals = React.useMemo(() => {
|
||||
if (!payload?.length || !accountCurrencies) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totals: Record<string, number> = {};
|
||||
payload.forEach((item) => {
|
||||
const accountId = String(item.dataKey || item.name || '');
|
||||
const currency = accountCurrencies[accountId] || 'USD';
|
||||
const value =
|
||||
typeof item.value === 'number' ? item.value : 0;
|
||||
totals[currency] = (totals[currency] || 0) + value;
|
||||
});
|
||||
|
||||
return Object.entries(totals).sort((a, b) => b[1] - a[1]);
|
||||
}, [payload, accountCurrencies]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||
const hasMultipleCurrencies =
|
||||
currencyTotals && currencyTotals.length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map(
|
||||
(item: TooltipPayloadItem, index: number) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(
|
||||
config,
|
||||
item,
|
||||
key,
|
||||
);
|
||||
const accountId = String(
|
||||
item.dataKey || item.name || '',
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(item.dataKey)}
|
||||
className={cn(
|
||||
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
|
||||
indicator === 'dot' && 'items-center',
|
||||
)}
|
||||
>
|
||||
{formatter &&
|
||||
item?.value !== undefined &&
|
||||
item.name ? (
|
||||
formatter(
|
||||
item.value,
|
||||
item.name,
|
||||
item,
|
||||
index,
|
||||
item.payload || {},
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : '',
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel
|
||||
? tooltipLabel
|
||||
: null}
|
||||
<span className="text-muted-foreground ml-0">
|
||||
{itemConfig?.label ||
|
||||
item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value !== undefined && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{valueFormatter
|
||||
? valueFormatter(
|
||||
item.value as number,
|
||||
accountId,
|
||||
)
|
||||
: typeof item.value ===
|
||||
'number'
|
||||
? item.value.toLocaleString()
|
||||
: item.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{payload.length > 1 && (
|
||||
<div className="border-border/50 flex flex-col gap-1 border-t pt-1.5">
|
||||
{hasMultipleCurrencies ? (
|
||||
currencyTotals.map(([currency, total]) => (
|
||||
<div
|
||||
key={currency}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Total {currency}
|
||||
</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{formatCurrencyWithCode(
|
||||
total,
|
||||
currency,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Total
|
||||
</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{currencyTotals && currencyTotals[0]
|
||||
? formatCurrencyWithCode(
|
||||
currencyTotals[0][1],
|
||||
currencyTotals[0][0],
|
||||
)
|
||||
: payload
|
||||
.reduce(
|
||||
(
|
||||
sum: number,
|
||||
item: TooltipPayloadItem,
|
||||
) => {
|
||||
const value =
|
||||
item.value;
|
||||
return (
|
||||
sum +
|
||||
(typeof value ===
|
||||
'number'
|
||||
? value
|
||||
: 0)
|
||||
);
|
||||
},
|
||||
0,
|
||||
)
|
||||
.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = 'ChartTooltip';
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
interface LegendPayloadItem {
|
||||
value?: string;
|
||||
dataKey?: string | number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface ChartLegendContentProps {
|
||||
className?: string;
|
||||
hideIcon?: boolean;
|
||||
payload?: LegendPayloadItem[];
|
||||
verticalAlign?: 'top' | 'bottom';
|
||||
nameKey?: string;
|
||||
}
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChartLegendContentProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = 'bottom',
|
||||
nameKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-4',
|
||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item: LegendPayloadItem) => {
|
||||
const key = `${nameKey || item.dataKey || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(
|
||||
config,
|
||||
item,
|
||||
key,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartLegendContent.displayName = 'ChartLegend';
|
||||
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload &&
|
||||
typeof payload.payload === 'object' &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof (payload as Record<string, unknown>)[key] === 'string'
|
||||
) {
|
||||
configLabelKey = (payload as Record<string, unknown>)[key] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof (payloadPayload as Record<string, unknown>)[key] === 'string'
|
||||
) {
|
||||
configLabelKey = (payloadPayload as Record<string, unknown>)[
|
||||
key
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { Bar, BarChart, XAxis } from 'recharts';
|
||||
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart';
|
||||
|
||||
export type ColorPalette =
|
||||
| 'amber'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'emerald'
|
||||
| 'fuchsia'
|
||||
| 'gray'
|
||||
| 'green'
|
||||
| 'indigo'
|
||||
| 'lime'
|
||||
| 'neutral'
|
||||
| 'orange'
|
||||
| 'pink'
|
||||
| 'purple'
|
||||
| 'red'
|
||||
| 'rose'
|
||||
| 'slate'
|
||||
| 'stone'
|
||||
| 'teal'
|
||||
| 'violet'
|
||||
| 'yellow'
|
||||
| 'zinc';
|
||||
|
||||
const COLOR_SHADES: number[] = [700, 500, 300, 100, 600, 400, 800, 200, 900, 50];
|
||||
|
||||
export interface StackedBarChartProps<T extends Record<string, unknown>> {
|
||||
data: T[];
|
||||
dataKeys: string[];
|
||||
config: ChartConfig;
|
||||
color?: ColorPalette;
|
||||
xAxisKey: string;
|
||||
xAxisFormatter?: (value: string) => string;
|
||||
valueFormatter?: (value: number, accountId?: string) => string;
|
||||
accountCurrencies?: Record<string, string>;
|
||||
className?: string;
|
||||
showLegend?: boolean;
|
||||
}
|
||||
|
||||
export function StackedBarChart<T extends Record<string, unknown>>({
|
||||
data,
|
||||
dataKeys,
|
||||
config,
|
||||
color = 'zinc',
|
||||
xAxisKey,
|
||||
xAxisFormatter,
|
||||
valueFormatter,
|
||||
accountCurrencies,
|
||||
className,
|
||||
showLegend = true,
|
||||
}: StackedBarChartProps<T>) {
|
||||
const shades = COLOR_SHADES.map(
|
||||
(shade) => `var(--color-${color}-${shade})`,
|
||||
);
|
||||
|
||||
const configWithColors: ChartConfig = Object.fromEntries(
|
||||
Object.entries(config).map(([key, value], index) => [
|
||||
key,
|
||||
{
|
||||
...value,
|
||||
color: shades[index % shades.length],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartContainer config={configWithColors} className={className}>
|
||||
<BarChart accessibilityLayer data={data}>
|
||||
<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 isFirst = index === 0;
|
||||
const isLast = index === dataKeys.length - 1;
|
||||
const radius: [number, number, number, number] = [
|
||||
isLast ? 4 : 0,
|
||||
isLast ? 4 : 0,
|
||||
isFirst ? 4 : 0,
|
||||
isFirst ? 4 : 0,
|
||||
];
|
||||
|
||||
return (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
stackId="stack"
|
||||
fill={shades[index % shades.length]}
|
||||
radius={radius}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useOnlineStatus } from '@/hooks/use-online-status';
|
||||
import { checkDatabaseVersion } from '@/lib/db-migration-helper';
|
||||
import { accountBalanceSyncService } from '@/services/account-balance-sync';
|
||||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { bankSyncService } from '@/services/bank-sync';
|
||||
|
|
@ -124,12 +125,14 @@ export function SyncProvider({
|
|||
const [
|
||||
categoriesResult,
|
||||
accountsResult,
|
||||
accountBalancesResult,
|
||||
banksResult,
|
||||
automationRulesResult,
|
||||
transactionsResult,
|
||||
] = await Promise.all([
|
||||
categorySyncService.sync(),
|
||||
accountSyncService.sync(),
|
||||
accountBalanceSyncService.sync(),
|
||||
bankSyncService.sync(),
|
||||
automationRuleSyncService.sync(),
|
||||
transactionSyncService.sync(),
|
||||
|
|
@ -138,6 +141,7 @@ export function SyncProvider({
|
|||
const allErrors = [
|
||||
...categoriesResult.errors,
|
||||
...accountsResult.errors,
|
||||
...accountBalancesResult.errors,
|
||||
...banksResult.errors,
|
||||
...automationRulesResult.errors,
|
||||
...transactionsResult.errors,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,21 @@
|
|||
import { Account } from '@/types/account';
|
||||
import { Account, AccountType } from '@/types/account';
|
||||
import { Category } from '@/types/category';
|
||||
import { format, subDays, subMonths } from 'date-fns';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface NetWorthEvolutionAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
name_iv: string;
|
||||
type: AccountType;
|
||||
currency_code: string;
|
||||
}
|
||||
|
||||
export interface NetWorthEvolutionData {
|
||||
data: Array<Record<string, string | number>>;
|
||||
accounts: Record<string, NetWorthEvolutionAccount>;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
netWorth: {
|
||||
current: number;
|
||||
|
|
@ -22,7 +35,7 @@ export interface DashboardData {
|
|||
expense: number;
|
||||
};
|
||||
};
|
||||
netWorthHistory: Array<{ date: string; value: number }>;
|
||||
netWorthEvolution: NetWorthEvolutionData;
|
||||
accounts: Array<
|
||||
Account & {
|
||||
current_balance: number;
|
||||
|
|
@ -46,7 +59,7 @@ export function useDashboardData(): DashboardData {
|
|||
expense: 0,
|
||||
previous: { income: 0, expense: 0 },
|
||||
},
|
||||
netWorthHistory: [],
|
||||
netWorthEvolution: { data: [], accounts: {} },
|
||||
accounts: [],
|
||||
topCategories: [],
|
||||
});
|
||||
|
|
@ -119,7 +132,8 @@ export function useDashboardData(): DashboardData {
|
|||
expense: cashFlow.current.expense,
|
||||
previous: cashFlow.previous,
|
||||
},
|
||||
netWorthHistory: netWorthEvolution,
|
||||
netWorthEvolution:
|
||||
netWorthEvolution as NetWorthEvolutionData,
|
||||
accounts: accountBalances.map(
|
||||
(acc: {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default function Dashboard() {
|
|||
netWorth,
|
||||
monthlySpending,
|
||||
cashFlow,
|
||||
netWorthHistory,
|
||||
netWorthEvolution,
|
||||
accounts: accountMetrics,
|
||||
topCategories,
|
||||
isLoading,
|
||||
|
|
@ -60,7 +60,7 @@ export default function Dashboard() {
|
|||
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-3">
|
||||
<NetWorthChartComponent
|
||||
data={netWorthHistory}
|
||||
data={netWorthEvolution}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class AccountBalanceSyncService {
|
|||
});
|
||||
}
|
||||
|
||||
async sync(): Promise<void> {
|
||||
async sync() {
|
||||
return await this.syncManager.sync();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -176,8 +176,40 @@ test('top categories returns highest spending categories', function () {
|
|||
expect($data[1]['amount'])->toBe(3000);
|
||||
});
|
||||
|
||||
test('net worth evolution returns monthly data points', function () {
|
||||
Account::factory()->create(['user_id' => $this->user->id]);
|
||||
test('net worth evolution returns monthly data points with per-account balances', function () {
|
||||
$account1 = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Checking,
|
||||
'name' => 'Checking Account',
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
$account2 = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Savings,
|
||||
'name' => 'Savings Account',
|
||||
'currency_code' => 'EUR',
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account1->id,
|
||||
'balance_date' => now()->subMonth()->endOfMonth(),
|
||||
'balance' => 100000,
|
||||
]);
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account2->id,
|
||||
'balance_date' => now()->subMonth()->endOfMonth(),
|
||||
'balance' => 200000,
|
||||
]);
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account1->id,
|
||||
'balance_date' => now()->endOfMonth(),
|
||||
'balance' => 150000,
|
||||
]);
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account2->id,
|
||||
'balance_date' => now()->endOfMonth(),
|
||||
'balance' => 250000,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/net-worth-evolution?'.http_build_query([
|
||||
'from' => now()->subMonths(2)->startOfMonth()->toDateString(),
|
||||
|
|
@ -187,10 +219,72 @@ test('net worth evolution returns monthly data points', function () {
|
|||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
// Debug output
|
||||
// dump($data);
|
||||
|
||||
// Should have points for current month + 2 previous months = 3 points
|
||||
expect($data)->toHaveCount(3);
|
||||
expect($data[0])->toHaveKeys(['date', 'value', 'timestamp']);
|
||||
expect($data)->toHaveKeys(['data', 'accounts']);
|
||||
expect($data['data'])->toHaveCount(3);
|
||||
expect($data['data'][0])->toHaveKeys(['month', 'timestamp', $account1->id, $account2->id]);
|
||||
expect($data['accounts'])->toHaveKey($account1->id);
|
||||
expect($data['accounts'])->toHaveKey($account2->id);
|
||||
expect($data['accounts'][$account1->id]['name'])->toBe('Checking Account');
|
||||
expect($data['accounts'][$account1->id]['currency_code'])->toBe('USD');
|
||||
expect($data['accounts'][$account2->id]['name'])->toBe('Savings Account');
|
||||
expect($data['accounts'][$account2->id]['currency_code'])->toBe('EUR');
|
||||
});
|
||||
|
||||
test('net worth evolution uses last balance of each month per account', function () {
|
||||
$account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Checking,
|
||||
]);
|
||||
|
||||
$lastMonth = now()->subMonth();
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $lastMonth->copy()->startOfMonth()->addDays(5),
|
||||
'balance' => 100000,
|
||||
]);
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $lastMonth->copy()->endOfMonth()->subDays(5),
|
||||
'balance' => 150000,
|
||||
]);
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $lastMonth->copy()->endOfMonth(),
|
||||
'balance' => 200000,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/net-worth-evolution?'.http_build_query([
|
||||
'from' => $lastMonth->copy()->startOfMonth()->toDateString(),
|
||||
'to' => $lastMonth->copy()->endOfMonth()->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
expect($data['data'][0][$account->id])->toBe(200000);
|
||||
});
|
||||
|
||||
test('net worth evolution returns account metadata', function () {
|
||||
$account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::CreditCard,
|
||||
'name' => 'My Credit Card',
|
||||
'name_iv' => 'test_iv_1234567',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/net-worth-evolution?'.http_build_query([
|
||||
'from' => now()->startOfMonth()->toDateString(),
|
||||
'to' => now()->endOfMonth()->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
expect($data['accounts'][$account->id])->toMatchArray([
|
||||
'id' => $account->id,
|
||||
'name' => 'My Credit Card',
|
||||
'name_iv' => 'test_iv_1234567',
|
||||
'type' => 'credit_card',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue