import { isSameDay } from "date-fns"; import { BarChart as BarChartIcon, LineChart as LineChartIcon, Loader2, X } from "lucide-react"; import { useCallback, useState } from "react"; import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { usePortfolioSelector } from "../../hooks/usePortfolio"; import { calculateFutureProjection } from "../../utils/calculations/futureProjection"; import { formatCurrency } from "../../utils/formatters"; import { Tooltip as InfoTooltip } from "../utils/ToolTip"; import type { ProjectionData, SustainabilityAnalysis, WithdrawalPlan } from "../../types"; interface FutureProjectionModalProps { performancePerAnno: number; bestPerformancePerAnno: { percentage: number, year: number }[]; worstPerformancePerAnno: { percentage: number, year: number }[]; onClose: () => void; } export type ChartType = 'line' | 'bar'; type ScenarioCalc = { projection: ProjectionData[], sustainability: SustainabilityAnalysis | null, avaragedAmount: number, percentage: number, percentageAveraged: number }; export const FutureProjectionModal = ({ performancePerAnno, bestPerformancePerAnno, worstPerformancePerAnno, onClose }: FutureProjectionModalProps) => { const [years, setYears] = useState('10'); const [isCalculating, setIsCalculating] = useState(false); const [chartType, setChartType] = useState('line'); const [projectionData, setProjectionData] = useState([]); const [scenarios, setScenarios] = useState<{ best: ScenarioCalc, worst: ScenarioCalc }>({ best: { projection: [], sustainability: null, avaragedAmount: 0, percentage: 0, percentageAveraged: 0 }, worst: { projection: [], sustainability: null, avaragedAmount: 0, percentage: 0, percentageAveraged: 0 }, }); const [withdrawalPlan, setWithdrawalPlan] = useState({ amount: 0, interval: 'monthly', startTrigger: 'auto', startDate: new Date(), startPortfolioValue: 0, enabled: false, autoStrategy: { type: 'maintain', targetYears: 30, targetGrowth: 2, }, }); const [sustainabilityAnalysis, setSustainabilityAnalysis] = useState(null); const [startFromZero, setStartFromZero] = useState(false); const { assets } = usePortfolioSelector((state) => ({ assets: state.assets, })); const calculateProjection = useCallback(async () => { setIsCalculating(true); try { const { projection, sustainability } = await calculateFutureProjection( assets, parseInt(years), performancePerAnno, withdrawalPlan.enabled ? withdrawalPlan : undefined, startFromZero ); setProjectionData(projection); setSustainabilityAnalysis(sustainability); const slicedBestCase = bestPerformancePerAnno.slice(0, bestPerformancePerAnno.length > 1 ? Math.floor(bestPerformancePerAnno.length / 2) : 1); const slicedWorstCase = worstPerformancePerAnno.slice(0, worstPerformancePerAnno.length > 1 ? Math.floor(worstPerformancePerAnno.length / 2) : 1); const bestCase = slicedBestCase.reduce((acc, curr) => acc + curr.percentage, 0) / slicedBestCase.length || 0; const worstCase = slicedWorstCase.reduce((acc, curr) => acc + curr.percentage, 0) / slicedWorstCase.length || 0; const bestCaseAvaraged = (bestCase + performancePerAnno) / 2; const worstCaseAvaraged = (worstCase + performancePerAnno) / 2; setScenarios({ best: { ...await calculateFutureProjection( assets, parseInt(years), bestCaseAvaraged, withdrawalPlan.enabled ? withdrawalPlan : undefined ), avaragedAmount: slicedBestCase.length, percentageAveraged: bestCaseAvaraged, percentage: bestCase }, worst: { ...await calculateFutureProjection( assets, parseInt(years), worstCaseAvaraged, withdrawalPlan.enabled ? withdrawalPlan : undefined ), avaragedAmount: slicedWorstCase.length, percentage: worstCase, percentageAveraged: worstCaseAvaraged } }); } catch (error) { console.error('Error calculating projection:', error); } finally { setIsCalculating(false); } }, [assets, years, withdrawalPlan, performancePerAnno, bestPerformancePerAnno, worstPerformancePerAnno, startFromZero]); const CustomTooltip = ({ active, payload, label }: any) => { if (active && payload && payload.length) { const value = payload[0].value; const invested = payload[1].value; const withdrawn = payload[2]?.value || 0; const totalWithdrawn = payload[3]?.value || 0; const percentageGain = ((value - invested) / invested) * 100; return (

{new Date(label).toLocaleDateString('de-DE')}

Value: {formatCurrency(value)}

Invested: {formatCurrency(invested)}

{withdrawn > 0 && ( <>

Monthly Withdrawal: {formatCurrency(withdrawn)}

Total Withdrawn: {formatCurrency(totalWithdrawn)}

)}

= 0 ? 'text-green-500' : 'text-red-500'}`}> Return: {percentageGain.toFixed(2)}%

); } return null; }; const CustomScenarioTooltip = ({ active, payload, label }: any) => { if (active && payload && payload.length) { const bestCase = payload.find((p: any) => p.dataKey === 'bestCase')?.value || 0; const baseCase = payload.find((p: any) => p.dataKey === 'baseCase')?.value || 0; const worstCase = payload.find((p: any) => p.dataKey === 'worstCase')?.value || 0; const invested = payload.find((p: any) => p.dataKey === 'invested')?.value || 0; return (

{new Date(label).toLocaleDateString('de-DE')}

Best-Case: {formatCurrency(bestCase)} {((bestCase - invested) / invested * 100).toFixed(2)}%

Avg. Base-Case: {formatCurrency(baseCase)} {((baseCase - invested) / invested * 100).toFixed(2)}%

Worst-Case: {formatCurrency(worstCase)} {((worstCase - invested) / invested * 100).toFixed(2)}%

); } return null; }; const renderChart = () => { if (isCalculating) { return (
); } if (!projectionData.length) { return (
Click calculate to see the projection
); } return ( {chartType === 'line' ? ( new Date(date).toLocaleDateString('de-DE', { year: 'numeric', month: 'numeric' })} /> } /> {withdrawalPlan.enabled && ( <> )} ) : ( new Date(date).toLocaleDateString('de-DE', { year: 'numeric', month: 'numeric' })} /> } /> {withdrawalPlan.enabled && ( <> )} )} ); }; const renderScenarioDescription = () => { if (!scenarios.best.projection.length) return null; const getLastValue = (projection: ProjectionData[]) => { const lastPoint = projection[projection.length - 1]; return { value: lastPoint.value, invested: lastPoint.invested, returnPercentage: ((lastPoint.value - lastPoint.invested) / lastPoint.invested) * 100 }; }; const baseCase = getLastValue(projectionData); const bestCase = getLastValue(scenarios.best.projection); const worstCase = getLastValue(scenarios.worst.projection); return (

Scenario Calculations

  • Avg. Base Case: Using historical average return of{' '} {performancePerAnno.toFixed(2)}% After {years} years you'd have{' '} {formatCurrency(baseCase.value)} from {formatCurrency(baseCase.invested)} invested,{' '} that's a total return of {baseCase.returnPercentage.toFixed(2)}%
  • Best Case: Average of top 50% performing years ({scenarios.best.avaragedAmount} years) at {scenarios.best.percentage.toFixed(2)}%, averaged with base case to {scenarios.best.percentageAveraged.toFixed(2)}%.{' '} After {years} years you'd have {formatCurrency(bestCase.value)} from {formatCurrency(bestCase.invested)} invested,{' '} that's a total return of {bestCase.returnPercentage.toFixed(2)}%
  • Worst Case: Average of bottom 50% performing years ({scenarios.worst.avaragedAmount} years) at {scenarios.worst.percentage.toFixed(2)}%, averaged with base case to {scenarios.worst.percentageAveraged.toFixed(2)}%.{' '} After {years} years you'd have {formatCurrency(worstCase.value)} from {formatCurrency(worstCase.invested)} invested,{' '} that's a total return of {worstCase.returnPercentage.toFixed(2)}%
); }; const renderScenarioChart = () => { if (!scenarios.best.projection.length) return null; // Create a merged and sorted dataset for consistent x-axis const mergedData = projectionData.map(basePoint => { const date = basePoint.date; const bestPoint = scenarios.best.projection.find(p => isSameDay(p.date, date)); const worstPoint = scenarios.worst.projection.find(p => isSameDay(p.date, date)); return { date, bestCase: bestPoint?.value || 0, baseCase: basePoint.value, worstCase: worstPoint?.value || 0, invested: basePoint.invested }; }).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); return (

Scenario Comparison

new Date(date).toLocaleDateString('de-DE', { year: 'numeric', month: 'numeric' })} /> }/>
); }; return (

Future Portfolio Projection

Projection Settings

setYears(e.target.value)} min="1" max="50" className="w-24 p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200" />

Future projections are calculated with your portfolio's average annual return rate of{' '} {performancePerAnno.toFixed(2)}%.

Strategy explanations:
  • Maintain: Portfolio value stays constant, withdrawing only the returns
  • Deplete: Portfolio depletes to zero over specified years
  • Grow: Portfolio continues to grow at target rate while withdrawing
{renderScenarioDescription()}

Withdrawal Plan

setWithdrawalPlan(prev => ({ ...prev, amount: parseFloat(e.target.value) }))} min="0" step="100" className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200" />
{withdrawalPlan.startTrigger === 'date' ? (
setWithdrawalPlan(prev => ({ ...prev, startDate: new Date(e.target.value) }))} min={new Date().toISOString().split('T')[0]} className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200" />
) : withdrawalPlan.startTrigger === 'portfolioValue' ? (
setWithdrawalPlan(prev => ({ ...prev, startPortfolioValue: parseFloat(e.target.value) }))} min="0" step="1000" className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200" />
) : null} {withdrawalPlan.startTrigger === 'auto' && (
setWithdrawalPlan(prev => ({ ...prev, amount: parseFloat(e.target.value) }))} min="0" step="100" className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200" />
{withdrawalPlan.autoStrategy?.type === 'deplete' && (
setWithdrawalPlan(prev => ({ ...prev, autoStrategy: { ...prev.autoStrategy!, targetYears: parseInt(e.target.value) } }))} min="1" max="100" className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200" />
)} {withdrawalPlan.autoStrategy?.type === 'grow' && (
setWithdrawalPlan(prev => ({ ...prev, autoStrategy: { ...prev.autoStrategy!, targetGrowth: parseFloat(e.target.value) } }))} min="0.1" max="10" step="0.1" className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200" />
)}

{withdrawalPlan.autoStrategy?.type === 'maintain' && ( "The calculator will determine when your portfolio can sustain this withdrawal amount while maintaining its value." )} {withdrawalPlan.autoStrategy?.type === 'deplete' && ( "The calculator will determine when you can start withdrawing this amount to deplete the portfolio over your specified timeframe." )} {withdrawalPlan.autoStrategy?.type === 'grow' && ( "The calculator will determine when you can start withdrawing this amount while maintaining the target growth rate." )}

)}
{sustainabilityAnalysis && withdrawalPlan.enabled && (

Withdrawal Analysis

To withdraw {formatCurrency(withdrawalPlan.amount)} {withdrawalPlan.interval}, you need to invest for{' '} {sustainabilityAnalysis.yearsToReachTarget} years until your portfolio reaches{' '} {formatCurrency(sustainabilityAnalysis.targetValue)}.

With this withdrawal plan, your portfolio will{' '} {sustainabilityAnalysis.sustainableYears === 'infinite' ? ( remain sustainable indefinitely ) : ( <> last for{' '} {sustainabilityAnalysis.sustainableYears} years {' '} {sustainabilityAnalysis.sustainableYears > parseInt(years) && ( (extends beyond the current chart view of {years} years) )} )} .

)}
{renderChart()}
{renderScenarioChart()}
); };