337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import {
|
|
ChartViewToggle,
|
|
MoMChart,
|
|
MoMPercentChart,
|
|
} from '@/components/charts';
|
|
import { EncryptedText } from '@/components/encrypted-text';
|
|
import { AmountDisplay } from '@/components/ui/amount-display';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { ChartConfig } from '@/components/ui/chart';
|
|
import { StackedBarChart } from '@/components/ui/stacked-bar-chart';
|
|
import { useChartViews } from '@/hooks/use-chart-views';
|
|
import { NetWorthEvolutionData } from '@/hooks/use-dashboard-data';
|
|
import { AccountInfo } from '@/lib/chart-calculations';
|
|
import { useMemo } from 'react';
|
|
import { PercentageTrendIndicator } from './percentage-trend-indicator';
|
|
|
|
interface NetWorthChartProps {
|
|
data: NetWorthEvolutionData;
|
|
loading?: boolean;
|
|
showLegend?: boolean;
|
|
}
|
|
|
|
interface TrendData {
|
|
percentage: number;
|
|
previousAmount: number;
|
|
currentAmount: number;
|
|
}
|
|
|
|
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 calculateTrend(
|
|
data: Array<Record<string, string | number>>,
|
|
accountIds: string[],
|
|
monthsBack: number,
|
|
): TrendData | 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 {
|
|
percentage:
|
|
((currentTotal - previousTotal) / Math.abs(previousTotal)) * 100,
|
|
previousAmount: previousTotal,
|
|
currentAmount: currentTotal,
|
|
};
|
|
}
|
|
|
|
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 (
|
|
<AmountDisplay
|
|
amountInCents={totals[0].total}
|
|
currencyCode={totals[0].currency}
|
|
variant="large"
|
|
minimumFractionDigits={0}
|
|
maximumFractionDigits={0}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-baseline justify-end gap-1">
|
|
{totals.map((item, index) => (
|
|
<span key={item.currency} className="flex items-baseline">
|
|
{index > 0 && (
|
|
<span className="mx-1 text-lg opacity-50 sm:text-2xl">
|
|
+
|
|
</span>
|
|
)}
|
|
<AmountDisplay
|
|
amountInCents={item.total}
|
|
currencyCode={item.currency}
|
|
variant="large"
|
|
minimumFractionDigits={0}
|
|
maximumFractionDigits={0}
|
|
/>
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function NetWorthChart({
|
|
data,
|
|
loading,
|
|
showLegend = false,
|
|
}: NetWorthChartProps) {
|
|
const {
|
|
chartData,
|
|
dataKeys,
|
|
chartConfig,
|
|
monthlyTrend,
|
|
yearlyTrend,
|
|
currencyTotals,
|
|
accountCurrencies,
|
|
primaryCurrency,
|
|
accountsForHook,
|
|
} = useMemo(() => {
|
|
const accounts = data.accounts || {};
|
|
const accountIds = Object.keys(accounts);
|
|
const chartDataArray = data.data || [];
|
|
|
|
const config: ChartConfig = {};
|
|
const currencies: Record<string, string> = {};
|
|
const hookAccounts: Record<string, AccountInfo> = {};
|
|
|
|
accountIds.forEach((id) => {
|
|
const account = accounts[id];
|
|
config[id] = {
|
|
label: account ? <EncryptedLabel account={account} /> : id,
|
|
};
|
|
if (account?.currency_code) {
|
|
currencies[id] = account.currency_code;
|
|
}
|
|
if (account) {
|
|
hookAccounts[id] = {
|
|
id: account.id,
|
|
type: account.type,
|
|
currency_code: 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);
|
|
|
|
const primary =
|
|
currencyTotalsList.length > 0
|
|
? currencyTotalsList[0].currency
|
|
: 'USD';
|
|
|
|
return {
|
|
chartData: chartDataArray,
|
|
dataKeys: accountIds,
|
|
chartConfig: config,
|
|
monthlyTrend: calculateTrend(chartDataArray, accountIds, 1),
|
|
yearlyTrend: calculateTrend(
|
|
chartDataArray,
|
|
accountIds,
|
|
chartDataArray.length - 1,
|
|
),
|
|
currencyTotals: currencyTotalsList,
|
|
accountCurrencies: currencies,
|
|
primaryCurrency: primary,
|
|
accountsForHook: hookAccounts,
|
|
};
|
|
}, [data]);
|
|
|
|
const chartViews = useChartViews({
|
|
data: chartData,
|
|
accounts: accountsForHook,
|
|
initialView: 'stacked',
|
|
hasStackedView: true,
|
|
});
|
|
|
|
const valueFormatter = useMemo(() => {
|
|
return (value: number, accountId?: string): React.ReactNode => {
|
|
const currency =
|
|
accountId && accountCurrencies[accountId]
|
|
? accountCurrencies[accountId]
|
|
: 'USD';
|
|
return (
|
|
<AmountDisplay
|
|
amountInCents={value}
|
|
currencyCode={currency}
|
|
minimumFractionDigits={0}
|
|
maximumFractionDigits={0}
|
|
/>
|
|
);
|
|
};
|
|
}, [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" />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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="group overflow-hidden">
|
|
<CardHeader>
|
|
<div className="flex flex-row items-start justify-between gap-4">
|
|
<div className="flex min-w-0 flex-col gap-2">
|
|
<CardTitle>Net Worth Evolution</CardTitle>
|
|
<CardDescription className="flex flex-col gap-1 text-sm">
|
|
<div className="text-foreground">
|
|
<TotalDisplay totals={currencyTotals} />
|
|
</div>
|
|
<PercentageTrendIndicator
|
|
trend={monthlyTrend?.percentage ?? null}
|
|
label="this month"
|
|
previousAmount={monthlyTrend?.previousAmount}
|
|
currentAmount={monthlyTrend?.currentAmount}
|
|
currencyCode={primaryCurrency}
|
|
/>
|
|
<PercentageTrendIndicator
|
|
trend={yearlyTrend?.percentage ?? null}
|
|
label="for the last 12 months"
|
|
previousAmount={yearlyTrend?.previousAmount}
|
|
currentAmount={yearlyTrend?.currentAmount}
|
|
currencyCode={primaryCurrency}
|
|
/>
|
|
</CardDescription>
|
|
</div>
|
|
|
|
<ChartViewToggle
|
|
value={chartViews.currentView}
|
|
onValueChange={chartViews.setCurrentView}
|
|
availableViews={chartViews.availableViews}
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="relative min-w-0">
|
|
{chartViews.currentView === 'stacked' && (
|
|
<StackedBarChart
|
|
data={chartData.slice(1)}
|
|
dataKeys={dataKeys}
|
|
config={chartConfig}
|
|
xAxisKey="month"
|
|
xAxisFormatter={formatXAxisLabel}
|
|
valueFormatter={valueFormatter}
|
|
accountCurrencies={accountCurrencies}
|
|
className="h-[300px] w-full"
|
|
showLegend={showLegend}
|
|
/>
|
|
)}
|
|
{chartViews.currentView === 'mom' && (
|
|
<MoMChart
|
|
data={chartViews.deltaSeries}
|
|
currencyCode={primaryCurrency}
|
|
xAxisFormatter={formatXAxisLabel}
|
|
className="h-[300px] w-full"
|
|
/>
|
|
)}
|
|
{chartViews.currentView === 'mom_percent' && (
|
|
<MoMPercentChart
|
|
data={chartViews.momPercentSeries}
|
|
xAxisFormatter={formatXAxisLabel}
|
|
className="h-[300px] w-full"
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|