whisper-money/resources/js/components/dashboard/net-worth-chart.tsx

347 lines
12 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 { useLocale } from '@/hooks/use-locale';
import { AccountInfo } from '@/lib/chart-calculations';
import { __ } from '@/utils/i18n';
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, locale: string = 'en'): string {
const [year, month] = value.split('-');
const date = new Date(parseInt(year), parseInt(month) - 1);
const monthName = date.toLocaleString(locale, { 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 locale = useLocale();
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={(value) =>
formatXAxisLabel(value, locale)
}
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)
}
className="h-[300px] w-full"
/>
)}
{chartViews.currentView === 'mom_percent' && (
<MoMPercentChart
data={chartViews.momPercentSeries}
xAxisFormatter={(value) =>
formatXAxisLabel(value, locale)
}
className="h-[300px] w-full"
/>
)}
</CardContent>
</Card>
);
}