whisper-money/resources/js/components/accounts/account-balance-chart.tsx

1235 lines
51 KiB
TypeScript

import { AccountName } from '@/components/accounts/account-name';
import {
type ChartCurrencyMode,
type ChartGranularity,
ChartCurrencyToggle,
ChartGranularityToggle,
ChartSettingsPopover,
ChartViewToggle,
MoMChart,
MoMPercentChart,
} from '@/components/charts';
import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator';
import { AmountDisplay } from '@/components/ui/amount-display';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import {
convertSingleAccountData,
useChartViews,
} from '@/hooks/use-chart-views';
import { useLocale } from '@/hooks/use-locale';
import { useIsMobile } from '@/hooks/use-mobile';
import {
Account,
isRealEstateAccount,
supportsInvestedAmount,
} from '@/types/account';
import { formatCurrency } from '@/utils/currency';
import { formatDayFromDate, formatMonthFromYearMonth } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { format, subDays, subMonths } from 'date-fns';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Area,
AreaChart,
Bar,
BarChart,
ComposedChart,
Line,
ReferenceLine,
XAxis,
} from 'recharts';
const DAILY_DAYS = 30;
function InvestmentTooltipContent({
active,
payload,
valueFormatter,
}: {
active?: boolean;
payload?: Array<{
dataKey?: string | number;
name?: string;
value?: number | string;
color?: string;
payload?: Record<string, unknown>;
}>;
valueFormatter: (value: number) => string;
}) {
if (!active || !payload?.length) return null;
const balanceItem = payload.find((p) => p.dataKey === 'value');
const investedItem = payload.find((p) => p.dataKey === 'invested_amount');
const balance =
typeof balanceItem?.value === 'number' ? balanceItem.value : null;
const invested =
typeof investedItem?.value === 'number' ? investedItem.value : null;
const gain =
balance !== null && invested !== null ? balance - invested : null;
return (
<div className="grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<div className="grid gap-1.5">
{payload.map((item) => (
<div
key={String(item.dataKey)}
className="flex w-full items-center gap-2"
>
<div
className="size-2.5 rounded-xs"
style={{ backgroundColor: item.color }}
/>
<div className="flex flex-1 justify-between gap-4">
<span className="text-muted-foreground">
{item.dataKey === 'value'
? __('Balance')
: __('Invested')}
</span>
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === 'number'
? valueFormatter(item.value)
: item.value}
</span>
</div>
</div>
))}
{gain !== null && (
<div className="flex w-full items-center gap-2 border-t border-border/50 pt-1.5">
<div className="size-2.5" />
<div className="flex flex-1 justify-between gap-4">
<span className="text-muted-foreground">
{__('Gain/loss')}
</span>
<span
className={`font-mono font-medium whitespace-nowrap tabular-nums ${gain >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
{gain >= 0 ? '+' : ''}
{valueFormatter(gain)}
</span>
</div>
</div>
)}
</div>
</div>
);
}
function RealEstateTooltipContent({
active,
payload,
valueFormatter,
}: {
active?: boolean;
payload?: Array<{
dataKey?: string | number;
name?: string;
value?: number | string;
color?: string;
payload?: Record<string, unknown>;
}>;
valueFormatter: (value: number) => string;
}) {
const { accountMainLineColor, mortgageLineColor } = useChartColors();
if (!active || !payload?.length) return null;
const marketValueItem = payload.find((p) => p.dataKey === 'value');
const mortgageItem = payload.find((p) => p.dataKey === 'mortgage_balance');
const marketValue =
typeof marketValueItem?.value === 'number'
? marketValueItem.value
: null;
const mortgageOwed =
typeof mortgageItem?.value === 'number' ? mortgageItem.value : null;
const equity =
marketValue !== null && mortgageOwed !== null
? marketValue - mortgageOwed
: null;
return (
<div className="grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<div className="grid gap-1.5">
{marketValue !== null && (
<div className="flex w-full items-center gap-2">
<div
className="size-2.5 rounded-xs"
style={{
backgroundColor:
marketValueItem?.color ??
accountMainLineColor,
}}
/>
<div className="flex flex-1 justify-between gap-4">
<span className="text-muted-foreground">
{__('Market value')}
</span>
<span className="font-mono font-medium text-foreground tabular-nums">
{valueFormatter(marketValue)}
</span>
</div>
</div>
)}
{mortgageOwed !== null && (
<div className="flex w-full items-center gap-2">
<div
className="size-2.5 rounded-xs"
style={{
backgroundColor:
mortgageItem?.color ?? mortgageLineColor,
}}
/>
<div className="flex flex-1 justify-between gap-4">
<span className="text-muted-foreground">
{__('Mortgage owed')}
</span>
<span className="font-mono font-medium text-foreground tabular-nums">
{valueFormatter(mortgageOwed)}
</span>
</div>
</div>
)}
{equity !== null && (
<div className="flex w-full items-center gap-2 border-t border-border/50 pt-1.5">
<div className="size-2.5" />
<div className="flex flex-1 justify-between gap-4">
<span className="text-muted-foreground">
{__('Equity')}
</span>
<span
className={`font-mono font-medium whitespace-nowrap tabular-nums ${equity >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
{valueFormatter(equity)}
</span>
</div>
</div>
)}
</div>
</div>
);
}
export interface BalanceDataPoint {
month: string;
timestamp: number;
value: number;
invested_amount?: number | null;
mortgage_balance?: number | null;
projected?: boolean;
projected_value?: number;
projected_mortgage_balance?: number;
display_value?: number;
display_invested_amount?: number | null;
display_mortgage_balance?: number;
}
interface AccountBalanceData {
data: BalanceDataPoint[];
account: {
id: string;
name: string;
name_iv: string;
type: string;
currency_code: string;
};
display_currency_code?: string;
}
interface DailyBalanceDataPoint {
date: string;
timestamp: number;
value: number;
invested_amount?: number | null;
mortgage_balance?: number | null;
display_value?: number;
display_invested_amount?: number | null;
display_mortgage_balance?: number;
}
interface AccountDailyBalanceData {
data: DailyBalanceDataPoint[];
account: {
id: string;
name: string;
name_iv: string;
type: string;
currency_code: string;
};
display_currency_code?: string;
}
export interface ChartComputedData {
chartData: BalanceDataPoint[];
currentBalance: number;
currentMortgageBalance: number | null;
currencyCode: string;
hasMortgageData: boolean;
shortTrend: ReturnType<typeof calculateTrend>;
longTrend: ReturnType<typeof calculateTrend>;
}
interface AccountBalanceChartProps {
account: Account;
loading?: boolean;
refreshKey?: number;
onBalanceClick?: () => void;
onDataLoaded?: (data: ChartComputedData) => void;
}
function createXAxisFormatter(locale: string, granularity: ChartGranularity) {
if (granularity === 'daily') {
return function formatXAxisLabel(value: string): string {
return formatDayFromDate(value, locale);
};
}
return function formatXAxisLabel(value: string): string {
return formatMonthFromYearMonth(value, locale);
};
}
function formatChartCurrency(
value: number,
currencyCode: string,
locale: string,
): string {
return formatCurrency(value, currencyCode, locale);
}
function calculateTrend(
data: Array<{ value: number }>,
periodsBack: number,
): { percentage: number; previousValue: number; currentValue: number } | null {
if (data.length < 2) return null;
const currentIndex = data.length - 1;
const previousIndex = Math.max(0, data.length - 1 - periodsBack);
if (currentIndex === previousIndex) return null;
const currentValue = data[currentIndex].value;
const previousValue = data[previousIndex].value;
if (previousValue === 0) return null;
return {
percentage:
((currentValue - previousValue) / Math.abs(previousValue)) * 100,
previousValue,
currentValue,
};
}
/**
* Normalize daily data points to use "month" key so they work with
* convertSingleAccountData and useChartViews without changes.
*/
function normalizeDailyData(data: DailyBalanceDataPoint[]): BalanceDataPoint[] {
return data.map((point) => ({
month: point.date,
timestamp: point.timestamp,
value: point.value,
invested_amount: point.invested_amount,
mortgage_balance: point.mortgage_balance,
display_value: point.display_value,
display_invested_amount: point.display_invested_amount,
display_mortgage_balance: point.display_mortgage_balance,
}));
}
export function AccountBalanceChart({
account,
loading: initialLoading,
refreshKey,
onBalanceClick,
onDataLoaded,
}: AccountBalanceChartProps) {
const locale = useLocale();
const isMobile = useIsMobile();
const isLoan = account.type === 'loan';
const isRealEstate = isRealEstateAccount(account);
const [granularity, setGranularity] = useState<ChartGranularity>('monthly');
const [currencyMode, setCurrencyMode] = useState<ChartCurrencyMode>('user');
const [balanceData, setBalanceData] = useState<AccountBalanceData | null>(
null,
);
const [isLoading, setIsLoading] = useState(true);
const fetchBalanceData = useCallback(
async (currentGranularity: ChartGranularity) => {
setIsLoading(true);
try {
const now = new Date();
const to = format(now, 'yyyy-MM-dd');
if (currentGranularity === 'daily') {
// Fetch DAILY_DAYS + 1 days (extra day for DoD baseline)
const from = format(subDays(now, DAILY_DAYS), 'yyyy-MM-dd');
const params = new URLSearchParams({ from, to });
const response = await fetch(
`/api/dashboard/account/${account.id}/daily-balance-evolution?${params.toString()}`,
);
const data: AccountDailyBalanceData = await response.json();
// Normalize daily data so the rest of the component works uniformly
setBalanceData({
data: normalizeDailyData(data.data),
account: data.account,
display_currency_code: data.display_currency_code,
});
} else {
const from = format(subMonths(now, 12), 'yyyy-MM-dd');
const params = new URLSearchParams({ from, to });
const response = await fetch(
`/api/dashboard/account/${account.id}/balance-evolution?${params.toString()}`,
);
const data = await response.json();
setBalanceData(data);
}
} catch (error) {
console.error('Failed to fetch balance data:', error);
} finally {
setIsLoading(false);
}
},
[account.id],
);
useEffect(() => {
fetchBalanceData(granularity);
}, [fetchBalanceData, granularity, refreshKey]);
const showInvestmentBenefits = supportsInvestedAmount(account);
const {
chartData,
currentBalance,
currentInvestedAmount,
currentMortgageBalance,
hasMortgageData,
hasProjectedData,
shortTrend,
longTrend,
} = useMemo(() => {
if (!balanceData?.data?.length) {
return {
chartData: [],
currentBalance: 0,
currentInvestedAmount: null as number | null,
currentMortgageBalance: null as number | null,
hasMortgageData: false,
hasProjectedData: false,
shortTrend: null,
longTrend: null,
};
}
const data = balanceData.data;
// For trend calculations, use only non-projected data points
const historicalData = data.filter((d) => !d.projected);
const current = historicalData[historicalData.length - 1]?.value ?? 0;
// Find the most recent non-null invested_amount
let invested: number | null = null;
for (let i = data.length - 1; i >= 0; i--) {
if (
data[i].invested_amount !== null &&
data[i].invested_amount !== undefined
) {
invested = data[i].invested_amount!;
break;
}
}
// Check if any data point has mortgage data
const hasMortgage = data.some(
(d) =>
d.mortgage_balance !== null && d.mortgage_balance !== undefined,
);
// Find the most recent mortgage balance
let mortgage: number | null = null;
if (hasMortgage) {
for (let i = data.length - 1; i >= 0; i--) {
if (
data[i].mortgage_balance !== null &&
data[i].mortgage_balance !== undefined
) {
mortgage = data[i].mortgage_balance!;
break;
}
}
}
const hasProjection = data.some((d) => d.projected);
// Add projected_value / projected_mortgage_balance fields for chart
// rendering:
// - Historical points: value/mortgage_balance only
// - Last historical point: also gets projected_* to connect the lines
// - Projected points: both value/mortgage_balance and projected_*
let augmentedData = data;
if (hasProjection) {
const lastHistoricalIndex = data.findIndex((d) => d.projected) - 1;
augmentedData = data.map((d, i) => ({
...d,
projected_value:
d.projected || i === lastHistoricalIndex
? d.value
: undefined,
projected_mortgage_balance:
(d.projected || i === lastHistoricalIndex) &&
d.mortgage_balance !== null &&
d.mortgage_balance !== undefined
? d.mortgage_balance
: undefined,
}));
}
return {
chartData: augmentedData,
currentBalance: current,
currentInvestedAmount: invested,
currentMortgageBalance: mortgage,
hasMortgageData: hasMortgage,
hasProjectedData: hasProjection,
shortTrend: calculateTrend(historicalData, 1),
longTrend: calculateTrend(
historicalData,
historicalData.length - 1,
),
};
}, [balanceData]);
// Determine if currency toggle is available
const displayCurrencyCode = balanceData?.display_currency_code ?? null;
const hasCurrencyToggle = displayCurrencyCode !== null;
// When in user-currency mode, swap display_* values into the primary fields
const activeCurrencyCode =
currencyMode === 'user' && displayCurrencyCode
? displayCurrencyCode
: account.currency_code;
const activeChartData = useMemo(() => {
if (currencyMode === 'user' && hasCurrencyToggle) {
// User currency mode: swap balance to display_value (account→user),
// but invested_amount is already in user currency — keep as-is
return chartData.map((point) => ({
...point,
value: point.display_value ?? point.value,
mortgage_balance:
point.display_mortgage_balance !== undefined
? point.display_mortgage_balance
: point.mortgage_balance,
projected_value:
'projected_value' in point &&
point.display_value !== undefined
? point.display_value
: point.projected_value,
projected_mortgage_balance:
'projected_mortgage_balance' in point &&
point.display_mortgage_balance !== undefined
? point.display_mortgage_balance
: point.projected_mortgage_balance,
}));
}
if (currencyMode === 'account' && hasCurrencyToggle) {
// Account currency mode: balance is already in account currency,
// but invested_amount is in user currency — swap to display_invested_amount (user→account)
return chartData.map((point) => ({
...point,
invested_amount:
point.display_invested_amount !== undefined
? point.display_invested_amount
: point.invested_amount,
}));
}
return chartData;
}, [chartData, currencyMode, hasCurrencyToggle]);
const activeCurrentBalance = useMemo(() => {
if (currencyMode !== 'user' || !hasCurrencyToggle) {
return currentBalance;
}
const historicalData = activeChartData.filter((d) => !d.projected);
return historicalData[historicalData.length - 1]?.value ?? 0;
}, [activeChartData, currentBalance, currencyMode, hasCurrencyToggle]);
const activeCurrentMortgageBalance = useMemo(() => {
if (currencyMode !== 'user' || !hasCurrencyToggle) {
return currentMortgageBalance;
}
if (!hasMortgageData) return null;
for (let i = activeChartData.length - 1; i >= 0; i--) {
const mb = activeChartData[i].mortgage_balance;
if (mb !== null && mb !== undefined) return mb;
}
return null;
}, [
activeChartData,
currentMortgageBalance,
currencyMode,
hasCurrencyToggle,
hasMortgageData,
]);
const activeCurrentInvestedAmount = useMemo(() => {
if (!hasCurrencyToggle) {
return currentInvestedAmount;
}
// In both currency modes, activeChartData has the correctly-swapped invested_amount
for (let i = activeChartData.length - 1; i >= 0; i--) {
const ia = activeChartData[i].invested_amount;
if (ia !== null && ia !== undefined) return ia;
}
return null;
}, [activeChartData, currentInvestedAmount, hasCurrencyToggle]);
// A lone invested point (e.g. a freshly connected account whose history was
// backfilled with balances but cost basis only for the latest date) renders
// as a single dot with nothing to connect to. Only draw the invested
// overlay once there are at least two points to plot.
const hasInvestedSeries = useMemo(
() =>
activeChartData.filter(
(point) =>
point.invested_amount !== null &&
point.invested_amount !== undefined,
).length >= 2,
[activeChartData],
);
const showInvestedOverlay =
showInvestmentBenefits &&
activeCurrentInvestedAmount !== null &&
hasInvestedSeries;
const activeShortTrend = useMemo(() => {
if (currencyMode !== 'user' || !hasCurrencyToggle) return shortTrend;
const historicalData = activeChartData.filter((d) => !d.projected);
return calculateTrend(historicalData, 1);
}, [activeChartData, currencyMode, hasCurrencyToggle, shortTrend]);
const activeLongTrend = useMemo(() => {
if (currencyMode !== 'user' || !hasCurrencyToggle) return longTrend;
const historicalData = activeChartData.filter((d) => !d.projected);
return calculateTrend(historicalData, historicalData.length - 1);
}, [activeChartData, currencyMode, hasCurrencyToggle, longTrend]);
useEffect(() => {
if (onDataLoaded && activeChartData.length > 0) {
onDataLoaded({
chartData: activeChartData,
currentBalance: activeCurrentBalance,
currentMortgageBalance: activeCurrentMortgageBalance,
currencyCode: activeCurrencyCode,
hasMortgageData,
shortTrend: activeShortTrend,
longTrend: activeLongTrend,
});
}
}, [
activeChartData,
activeCurrentBalance,
activeCurrentMortgageBalance,
activeCurrencyCode,
hasMortgageData,
activeShortTrend,
activeLongTrend,
onDataLoaded,
]);
// Convert data for useChartViews hook
const { data: hookData, accounts: hookAccounts } = useMemo(() => {
return convertSingleAccountData(
activeChartData,
account.id,
account.type,
activeCurrencyCode,
);
}, [activeChartData, account.id, account.type, activeCurrencyCode]);
const chartViews = useChartViews({
data: hookData,
accounts: hookAccounts,
initialView: 'stacked',
hasStackedView: true,
});
const { accountMainLineColor, mortgageLineColor } = useChartColors();
const chartConfig: ChartConfig = {
value: {
label: (
<AccountName account={account} length={{ min: 5, max: 20 }} />
),
color: accountMainLineColor,
},
...(showInvestmentBenefits
? {
invested_amount: {
label: __('Invested'),
color: 'var(--color-chart-4)',
},
}
: {}),
...(hasMortgageData
? {
mortgage_balance: {
label: __('Mortgage owed'),
color: mortgageLineColor,
},
}
: {}),
...(hasProjectedData
? {
projected_value: {
label: __('Projected'),
color: hasMortgageData
? accountMainLineColor
: 'var(--color-chart-2)',
},
...(hasMortgageData
? {
projected_mortgage_balance: {
label: __('Projected mortgage'),
color: mortgageLineColor,
},
}
: {}),
}
: {}),
};
const formatXAxisLabel = useMemo(
() => createXAxisFormatter(locale, granularity),
[locale, granularity],
);
const todayMarker = useMemo(() => {
const now = new Date();
return granularity === 'daily'
? format(now, 'yyyy-MM-dd')
: format(now, 'yyyy-MM');
}, [granularity]);
const valueFormatter = (value: number): string => {
return formatChartCurrency(value, activeCurrencyCode, locale);
};
const scrollContainerRef = useRef<HTMLDivElement>(null);
const minBarWidth = granularity === 'daily' ? 20 : 50;
const minChartWidth = activeChartData.length * minBarWidth;
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollLeft =
scrollContainerRef.current.scrollWidth;
}
}, [activeChartData]);
const shortTrendLabel =
granularity === 'daily' ? __('today') : __('this month');
const longTrendLabel =
granularity === 'daily'
? __('for the last 30 days')
: __('for the last 12 months');
const chartTitle = isRealEstate
? __('Market value evolution')
: isLoan
? __('Owed amount evolution')
: __('Balance evolution');
const emptyMessage = isRealEstate
? __('No market value data available')
: isLoan
? __('No owed amount data available')
: __('No balance data available');
const currentEquity =
hasMortgageData && activeCurrentMortgageBalance !== null
? activeCurrentBalance - activeCurrentMortgageBalance
: null;
if (initialLoading || isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>{chartTitle}</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 (chartData.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>{chartTitle}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
{emptyMessage}
</div>
</CardContent>
</Card>
);
}
return (
<Card className="group">
<CardHeader>
<div className="flex flex-row items-start justify-between">
<div className="flex flex-col gap-1 sm:gap-2">
<CardTitle>{chartTitle}</CardTitle>
<button
type="button"
onClick={onBalanceClick}
className="-ml-3 cursor-pointer rounded-md px-2 py-1 text-left text-4xl font-semibold tabular-nums transition-colors hover:bg-muted"
>
<AmountDisplay
amountInCents={activeCurrentBalance}
currencyCode={activeCurrencyCode}
/>
</button>
{currentEquity !== null && (
<div className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground">
{__('Equity')}
</span>
<span
className={`text-lg font-semibold tabular-nums ${currentEquity >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
<AmountDisplay
amountInCents={currentEquity}
currencyCode={activeCurrencyCode}
/>
</span>
</div>
)}
<CardDescription className="flex flex-col gap-1 text-sm">
<PercentageTrendIndicator
trend={activeShortTrend?.percentage ?? null}
label={shortTrendLabel}
previousAmount={activeShortTrend?.previousValue}
currentAmount={activeShortTrend?.currentValue}
currencyCode={activeCurrencyCode}
/>
<PercentageTrendIndicator
trend={activeLongTrend?.percentage ?? null}
label={longTrendLabel}
previousAmount={activeLongTrend?.previousValue}
currentAmount={activeLongTrend?.currentValue}
currencyCode={activeCurrencyCode}
/>
</CardDescription>
</div>
<div className="flex items-center gap-2">
{isMobile ? (
<ChartSettingsPopover
granularity={granularity}
onGranularityChange={setGranularity}
currentView={chartViews.currentView}
onViewChange={chartViews.setCurrentView}
availableViews={chartViews.availableViews}
currencyToggle={
hasCurrencyToggle
? {
value: currencyMode,
onValueChange: setCurrencyMode,
accountCurrencyCode:
account.currency_code,
userCurrencyCode:
displayCurrencyCode!,
}
: undefined
}
/>
) : (
<>
{hasCurrencyToggle && (
<ChartCurrencyToggle
value={currencyMode}
onValueChange={setCurrencyMode}
accountCurrencyCode={
account.currency_code
}
userCurrencyCode={displayCurrencyCode!}
/>
)}
<ChartGranularityToggle
value={granularity}
onValueChange={setGranularity}
/>
<ChartViewToggle
value={chartViews.currentView}
onValueChange={chartViews.setCurrentView}
availableViews={chartViews.availableViews}
granularity={granularity}
/>
</>
)}
</div>
</div>
</CardHeader>
<CardContent className="relative">
{chartViews.currentView === 'stacked' && (
<div
ref={scrollContainerRef}
className="h-[300px] w-full overflow-x-auto"
>
<ChartContainer
config={chartConfig}
className="h-full w-full"
style={{ minWidth: `${minChartWidth}px` }}
>
{hasMortgageData ? (
<ComposedChart
accessibilityLayer
data={activeChartData.slice(1)}
>
<defs>
<linearGradient
id="fillBalance"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={accountMainLineColor}
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor={accountMainLineColor}
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
<ChartTooltip
content={
<RealEstateTooltipContent
valueFormatter={valueFormatter}
/>
}
/>
<Area
dataKey="value"
type="monotone"
fill="url(#fillBalance)"
stroke={accountMainLineColor}
strokeWidth={2}
dot={false}
activeDot={{ r: 5 }}
fillOpacity={1}
/>
<Line
dataKey="mortgage_balance"
type="monotone"
stroke={mortgageLineColor}
strokeWidth={1.5}
strokeDasharray="4 3"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
{hasProjectedData && (
<>
<Line
dataKey="projected_value"
type="monotone"
stroke={accountMainLineColor}
strokeWidth={2}
strokeDasharray="6 4"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
<Line
dataKey="projected_mortgage_balance"
type="monotone"
stroke={mortgageLineColor}
strokeWidth={1.5}
strokeDasharray="6 4"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
<ReferenceLine
x={todayMarker}
stroke="var(--color-foreground)"
strokeWidth={1}
strokeDasharray="4 4"
label={{
value: __('Today'),
position: 'top',
fontSize: 12,
fill: 'var(--color-muted-foreground)',
}}
/>
</>
)}
</ComposedChart>
) : granularity === 'daily' ? (
<AreaChart
accessibilityLayer
data={activeChartData.slice(1)}
>
<defs>
<linearGradient
id="fillBalance"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--color-chart-2)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="var(--color-chart-2)"
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
<ChartTooltip
content={
showInvestmentBenefits ? (
<InvestmentTooltipContent
valueFormatter={
valueFormatter
}
/>
) : (
<ChartTooltipContent
hideLabel
valueFormatter={
valueFormatter
}
/>
)
}
/>
<Area
dataKey="value"
type="monotone"
fill="url(#fillBalance)"
stroke="var(--color-chart-2)"
strokeWidth={2}
dot={false}
activeDot={{ r: 5 }}
fillOpacity={1}
/>
{showInvestedOverlay && (
<Line
dataKey="invested_amount"
type="monotone"
stroke="var(--color-chart-6)"
strokeWidth={1.5}
strokeDasharray="2 2"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
)}
</AreaChart>
) : showInvestedOverlay ? (
<ComposedChart
accessibilityLayer
data={activeChartData.slice(1)}
>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
<ChartTooltip
content={
<InvestmentTooltipContent
valueFormatter={valueFormatter}
/>
}
/>
<Bar
dataKey="value"
fill="var(--color-chart-2)"
radius={[4, 4, 0, 0]}
/>
<Line
dataKey="invested_amount"
type="monotone"
stroke="var(--color-chart-6)"
strokeWidth={1.5}
strokeDasharray="2 2"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
</ComposedChart>
) : hasProjectedData ? (
<ComposedChart
accessibilityLayer
data={activeChartData.slice(1)}
>
<defs>
<linearGradient
id="fillBalance"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--color-chart-2)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="var(--color-chart-2)"
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
<ChartTooltip
content={
<ChartTooltipContent
hideLabel
valueFormatter={valueFormatter}
/>
}
/>
<Area
dataKey="value"
type="monotone"
fill="url(#fillBalance)"
stroke="var(--color-chart-2)"
strokeWidth={2}
dot={false}
activeDot={{ r: 5 }}
fillOpacity={1}
/>
<Line
dataKey="projected_value"
type="monotone"
stroke="var(--color-chart-2)"
strokeWidth={2}
strokeDasharray="6 4"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
<ReferenceLine
x={todayMarker}
stroke="var(--color-foreground)"
strokeWidth={1}
strokeDasharray="4 4"
label={{
value: __('Today'),
position: 'top',
fontSize: 12,
fill: 'var(--color-muted-foreground)',
}}
/>
</ComposedChart>
) : (
<BarChart
accessibilityLayer
data={activeChartData.slice(1)}
>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
<ChartTooltip
content={
<ChartTooltipContent
hideLabel
valueFormatter={valueFormatter}
/>
}
/>
<Bar
dataKey="value"
fill="var(--color-chart-2)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
)}
</ChartContainer>
</div>
)}
{chartViews.currentView === 'mom' && (
<MoMChart
data={chartViews.deltaSeries}
currencyCode={activeCurrencyCode}
xAxisFormatter={formatXAxisLabel}
className="h-[300px] w-full"
/>
)}
{chartViews.currentView === 'mom_percent' && (
<MoMPercentChart
data={chartViews.momPercentSeries}
xAxisFormatter={formatXAxisLabel}
className="h-[300px] w-full"
/>
)}
</CardContent>
</Card>
);
}