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:
Víctor Falcón 2025-12-03 08:53:28 +01:00 committed by GitHub
parent 7492b2e736
commit a0f44a9144
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1084 additions and 145 deletions

View File

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

View File

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

View File

@ -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 }

View File

@ -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,
};

View File

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

View File

@ -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,

View File

@ -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;

View File

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

View File

@ -12,7 +12,7 @@ class AccountBalanceSyncService {
});
}
async sync(): Promise<void> {
async sync() {
return await this.syncManager.sync();
}

View File

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