'use client'; import { useState, useEffect } from 'react'; import { useSearchParams } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { extractNumericSearchParam } from '@/lib/retire-at'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig, } from '@/components/ui/chart'; import { Area, AreaChart, CartesianGrid, Line, XAxis, YAxis, ReferenceLine, type TooltipProps, } from 'recharts'; import { Slider } from '@/components/ui/slider'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import type { NameType, Payload, ValueType } from 'recharts/types/component/DefaultTooltipContent'; import { Calculator, Info, Share2, Check } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import BlurThing from './blur-thing'; import Link from 'next/link'; import type { FireCalculatorFormValues } from '@/lib/calculator-schema'; import { fireCalculatorDefaultValues, fireCalculatorFormSchema } from '@/lib/calculator-schema'; // Helper component for info tooltips next to form labels function InfoTooltip({ content }: Readonly<{ content: string }>) { return ( {content} ); } const formSchema = fireCalculatorFormSchema; type FormValues = FireCalculatorFormValues; interface YearlyData { age: number; year: number; balance: number; untouchedBalance: number; phase: 'accumulation' | 'retirement'; monthlyAllowance: number; untouchedMonthlyAllowance: number; // Monte Carlo percentiles balanceP10?: number; balanceP50?: number; balanceP90?: number; } interface CalculationResult { fireNumber: number | null; yearlyData: YearlyData[]; error?: string; successRate?: number; // For Monte Carlo } // Box-Muller transform for normal distribution function randomNormal(mean: number, stdDev: number): number { const u = 1 - Math.random(); // Converting [0,1) to (0,1] const v = Math.random(); const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); return z * stdDev + mean; } // Helper function to format currency without specific symbols const formatNumber = (value: number | null) => { if (!value) return 'N/A'; return new Intl.NumberFormat('en', { maximumFractionDigits: 0, }).format(value); }; const formatNumberShort = (value: number) => { if (value >= 1000000) { return `${(value / 1000000).toPrecision(3)}M`; } else if (value >= 1000) { return `${(value / 1000).toPrecision(3)}K`; } else if (value <= -1000000) { return `${(value / 1000000).toPrecision(3)}M`; } else if (value <= -1000) { return `${(value / 1000).toPrecision(3)}K`; } return value.toString(); }; // Chart tooltip with the same styling as ChartTooltipContent, but with our custom label info const tooltipRenderer = ({ active, payload, label }: TooltipProps) => { const allowedKeys = new Set(['balance', 'monthlyAllowance']); const filteredPayload: Payload[] = (payload ?? []) .filter( (item): item is Payload => typeof item.dataKey === 'string' && allowedKeys.has(item.dataKey), ) .map((item) => ({ ...item, value: formatNumberShort(item.value as number), })); const safeLabel = typeof label === 'string' || typeof label === 'number' ? label : undefined; return ( []) => { const point = items.length > 0 ? (items[0]?.payload as YearlyData | undefined) : undefined; if (!point) { return null; } const phaseLabel = point.phase === 'retirement' ? 'Retirement phase' : 'Accumulation phase'; return ( {`Year ${String(point.year)} (Age ${String(point.age)})`} {phaseLabel} ); }} /> ); }; export default function FireCalculatorForm({ initialValues, autoCalculate = false, }: Readonly<{ initialValues?: Partial; autoCalculate?: boolean; }>) { const [result, setResult] = useState(null); const irlYear = new Date().getFullYear(); const [copied, setCopied] = useState(false); // Initialize form with default values const form = useForm, undefined, FormValues>({ resolver: zodResolver(formSchema), defaultValues: initialValues ?? fireCalculatorDefaultValues, }); // Hydrate from URL search params const searchParams = useSearchParams(); const [hasHydrated, setHasHydrated] = useState(false); useEffect(() => { if (hasHydrated) return; if (searchParams.size === 0) { setHasHydrated(true); return; } const newValues: Partial = {}; const getParam = (key: string) => searchParams.get(key) ?? undefined; const getNum = (key: string, bounds: { min?: number; max?: number } = {}) => extractNumericSearchParam(getParam(key), bounds); const startingCapital = getNum('startingCapital', { min: 0 }); if (startingCapital !== undefined) newValues.startingCapital = startingCapital; const monthlySavings = getNum('monthlySavings', { min: 0, max: 50000 }); if (monthlySavings !== undefined) newValues.monthlySavings = monthlySavings; const currentAge = getNum('currentAge', { min: 1, max: 100 }); if (currentAge !== undefined) newValues.currentAge = currentAge; const cagr = getNum('cagr') ?? getNum('growthRate', { min: 0, max: 30 }); if (cagr !== undefined) newValues.cagr = cagr; const desiredMonthlyAllowance = getNum('monthlySpend', { min: 0, max: 20000 }) ?? getNum('monthlyAllowance', { min: 0, max: 20000 }); if (desiredMonthlyAllowance !== undefined) newValues.desiredMonthlyAllowance = desiredMonthlyAllowance; const inflationRate = getNum('inflationRate', { min: 0, max: 20 }); if (inflationRate !== undefined) newValues.inflationRate = inflationRate; const lifeExpectancy = getNum('lifeExpectancy', { min: 40, max: 110 }); if (lifeExpectancy !== undefined) newValues.lifeExpectancy = lifeExpectancy; const retirementAge = getNum('retirementAge', { min: 18, max: 100 }); if (retirementAge !== undefined) newValues.retirementAge = retirementAge; const coastFireAge = getNum('coastFireAge', { min: 18, max: 100 }); if (coastFireAge !== undefined) newValues.coastFireAge = coastFireAge; const baristaIncome = getNum('baristaIncome', { min: 0 }); if (baristaIncome !== undefined) newValues.baristaIncome = baristaIncome; const volatility = getNum('volatility', { min: 0 }); if (volatility !== undefined) newValues.volatility = volatility; const withdrawalPercentage = getNum('withdrawalPercentage', { min: 0, max: 100 }); if (withdrawalPercentage !== undefined) newValues.withdrawalPercentage = withdrawalPercentage; const simMode = searchParams.get('simulationMode'); if (simMode === 'deterministic' || simMode === 'monte-carlo') { newValues.simulationMode = simMode; } const wStrategy = searchParams.get('withdrawalStrategy'); if (wStrategy === 'fixed' || wStrategy === 'percentage') { newValues.withdrawalStrategy = wStrategy; } if (Object.keys(newValues).length > 0) { // We merge with current values (which are defaults initially) const merged = { ...form.getValues(), ...newValues }; form.reset(merged); // Trigger calculation // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); } setHasHydrated(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams, hasHydrated]); // form is stable, but adding it causes no harm, excluding for cleaner hook deps function onSubmit(values: FormValues) { setResult(null); // Reset previous results const startingCapital = values.startingCapital; const monthlySavings = values.monthlySavings; const age = values.currentAge; const cagr = values.cagr; const initialMonthlyAllowance = values.desiredMonthlyAllowance; const annualInflation = 1 + values.inflationRate / 100; const ageOfDeath = values.lifeExpectancy; const retirementAge = values.retirementAge; const coastFireAge = values.coastFireAge ?? retirementAge; const initialBaristaIncome = values.baristaIncome ?? 0; const simulationMode = values.simulationMode; const volatility = values.volatility; const numSimulations = simulationMode === 'monte-carlo' ? 2000 : 1; const simulationResults: number[][] = []; // [yearIndex][simulationIndex] -> balance // Prepare simulation runs for (let sim = 0; sim < numSimulations; sim++) { let currentBalance = startingCapital; const runBalances: number[] = []; for (let year = irlYear + 1; year <= irlYear + (ageOfDeath - age); year++) { const currentAge = age + (year - irlYear); // Determine growth rate for this year let annualGrowthRate: number; if (simulationMode === 'monte-carlo') { // Random walk const randomReturn = randomNormal(cagr, volatility) / 100; annualGrowthRate = 1 + randomReturn; } else { // Deterministic annualGrowthRate = 1 + cagr / 100; } const inflatedAllowance = initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear); const inflatedBaristaIncome = initialBaristaIncome * Math.pow(annualInflation, year - irlYear); const isRetirementYear = currentAge >= retirementAge; const phase = isRetirementYear ? 'retirement' : 'accumulation'; const isContributing = currentAge < coastFireAge; let newBalance; if (phase === 'accumulation') { newBalance = currentBalance * annualGrowthRate + (isContributing ? monthlySavings * 12 : 0); } else { const netAnnualWithdrawal = (inflatedAllowance - inflatedBaristaIncome) * 12; newBalance = currentBalance * annualGrowthRate - netAnnualWithdrawal; } // Prevent negative balance from recovering (once you're broke, you're broke) // Although debt is possible, for FIRE calc usually 0 is the floor. // But strictly speaking, if you have income, you might recover? // Let's allow negative for calculation but maybe clamp for success rate? // Standard practice: if balance < 0, it stays < 0 or goes deeper. // Let's just let the math run. runBalances.push(newBalance); currentBalance = newBalance; } simulationResults.push(runBalances); } // Aggregate results const yearlyData: YearlyData[] = []; let successCount = 0; // Initial year yearlyData.push({ age: age, year: irlYear, balance: startingCapital, untouchedBalance: startingCapital, phase: 'accumulation', monthlyAllowance: 0, untouchedMonthlyAllowance: initialMonthlyAllowance, balanceP10: startingCapital, balanceP50: startingCapital, balanceP90: startingCapital, }); const numYears = ageOfDeath - age; for (let i = 0; i < numYears; i++) { const year = irlYear + 1 + i; const currentAge = age + 1 + i; // Collect all balances for this year across simulations const balancesForYear = simulationResults.map((run) => run[i]); // Sort to find percentiles balancesForYear.sort((a, b) => a - b); const pickPercentile = (fraction: number) => { const clampedIndex = Math.min( balancesForYear.length - 1, Math.max(0, Math.floor((balancesForYear.length - 1) * fraction)), ); return balancesForYear[clampedIndex]; }; // For Monte Carlo, we present a narrow middle band (40th-60th) to show typical outcomes const p10 = pickPercentile(0.4); const p50 = pickPercentile(0.5); const p90 = pickPercentile(0.6); // Calculate other metrics (using deterministic logic for "untouched" etc for simplicity, or p50) // We need to reconstruct the "standard" fields for compatibility with the chart // Let's use p50 (Median) as the "main" line const inflatedAllowance = initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear); const isRetirementYear = currentAge >= retirementAge; const phase = isRetirementYear ? 'retirement' : 'accumulation'; // Reconstruct untouched balance for deterministic mode (for 4% rule) let untouchedBalance = 0; if (simulationMode === 'deterministic') { // We can just use the single run we have // In deterministic mode, there's only 1 simulation, so balancesForYear[0] is it. // But wait, `simulationResults` stores the *actual* balance (with withdrawals). // We need a separate tracker for "untouched" (never withdrawing) if we want accurate 4% rule. // Let's just re-calculate it simply here since it's deterministic. const prevUntouched = yearlyData[yearlyData.length - 1].untouchedBalance; const growth = 1 + cagr / 100; untouchedBalance = prevUntouched * growth + monthlySavings * 12; } yearlyData.push({ age: currentAge, year: year, balance: p50, // Use Median for the main line untouchedBalance: untouchedBalance, phase: phase, monthlyAllowance: phase === 'retirement' ? inflatedAllowance : 0, untouchedMonthlyAllowance: inflatedAllowance, balanceP10: p10, balanceP50: p50, balanceP90: p90, }); } // Calculate Success Rate (only for Monte Carlo) if (simulationMode === 'monte-carlo') { const finalBalances = simulationResults.map((run) => run[run.length - 1]); successCount = finalBalances.filter((b) => b > 0).length; } // Calculate FIRE number (using Median/Deterministic run) const retirementYear = irlYear + (retirementAge - age); const retirementIndex = yearlyData.findIndex((data) => data.year === retirementYear); const retirementData = yearlyData[retirementIndex]; if (retirementIndex === -1) { setResult({ fireNumber: null, yearlyData: yearlyData, error: 'Could not calculate retirement data', }); } else { // Set the result setResult({ fireNumber: retirementData.balance, yearlyData: yearlyData, successRate: simulationMode === 'monte-carlo' ? (successCount / numSimulations) * 100 : undefined, }); } } // Use effect for auto-calculation useEffect(() => { if (autoCalculate && !result) { // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoCalculate]); const handleShare = () => { const values = form.getValues() as FireCalculatorFormValues; const params = new URLSearchParams(); params.set('startingCapital', String(values.startingCapital)); params.set('monthlySavings', String(values.monthlySavings)); params.set('currentAge', String(values.currentAge)); params.set('cagr', String(values.cagr)); params.set('monthlySpend', String(values.desiredMonthlyAllowance)); params.set('inflationRate', String(values.inflationRate)); params.set('lifeExpectancy', String(values.lifeExpectancy)); params.set('retirementAge', String(values.retirementAge)); params.set('coastFireAge', String(values.coastFireAge)); params.set('baristaIncome', String(values.baristaIncome)); params.set('simulationMode', values.simulationMode); params.set('volatility', String(values.volatility)); params.set('withdrawalStrategy', values.withdrawalStrategy); params.set('withdrawalPercentage', String(values.withdrawalPercentage)); const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`; // eslint-disable-next-line @typescript-eslint/no-floating-promises navigator.clipboard.writeText(url).then(() => { setCopied(true); setTimeout(() => { setCopied(false); }, 4000); }); }; const simulationModeValue = form.watch('simulationMode'); const isMonteCarlo = simulationModeValue === 'monte-carlo'; const chartData = result?.yearlyData.map((row) => ({ ...row, mcRange: (row.balanceP90 ?? 0) - (row.balanceP10 ?? 0), })) ?? []; // Ensure we always have a fresh calculation when switching simulation modes (or on first render) useEffect(() => { if (!result) { // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [simulationModeValue]); const projectionChartConfig: ChartConfig = { year: { label: 'Year', }, balance: { label: 'Balance', color: 'var(--color-orange-500)', }, balanceP10: { label: 'P10 balance', color: 'var(--color-orange-500)', }, balanceP90: { label: 'P90 balance', color: 'var(--color-orange-500)', }, monthlyAllowance: { label: 'Monthly allowance', color: 'var(--color-secondary)', }, }; return ( <> FIRE Calculator Calculate your path to financial independence and retirement. { e.preventDefault(); void form.handleSubmit(onSubmit)(e); }} className="space-y-8" > ( Starting Capital { field.onChange(e.target.value === '' ? undefined : Number(e.target.value)); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} onBlur={field.onBlur} name={field.name} ref={field.ref} /> )} /> ( Monthly Savings { field.onChange(e.target.value === '' ? undefined : Number(e.target.value)); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} onBlur={field.onBlur} name={field.name} ref={field.ref} /> )} /> ( Current Age { field.onChange(e.target.value === '' ? undefined : Number(e.target.value)); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} onBlur={field.onBlur} name={field.name} ref={field.ref} /> )} /> ( Life Expectancy (Age) { field.onChange(e.target.value === '' ? undefined : Number(e.target.value)); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} onBlur={field.onBlur} name={field.name} ref={field.ref} /> )} /> ( Expected Annual Growth Rate (%) { field.onChange(e.target.value === '' ? undefined : Number(e.target.value)); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} onBlur={field.onBlur} name={field.name} ref={field.ref} /> )} /> ( Annual Inflation Rate (%) { field.onChange(e.target.value === '' ? undefined : Number(e.target.value)); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} onBlur={field.onBlur} name={field.name} ref={field.ref} /> )} /> ( Monthly Allowance (Today's Value) { field.onChange(e.target.value === '' ? undefined : Number(e.target.value)); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} onBlur={field.onBlur} name={field.name} ref={field.ref} /> )} /> {/* Retirement Age Slider */} ( Retirement Age: {field.value as number} { field.onChange(value[0]); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} className="py-4" /> )} /> ( Coast FIRE {' '} Age (Optional): { field.onChange(e.target.value === '' ? undefined : Number(e.target.value)); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} onBlur={field.onBlur} name={field.name} ref={field.ref} /> )} /> ( Barista FIRE {' '} Monthly Income { field.onChange(e.target.value === '' ? undefined : Number(e.target.value)); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} onBlur={field.onBlur} name={field.name} ref={field.ref} /> )} /> ( Simulation Mode { field.onChange(val); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} defaultValue={field.value} > Deterministic (Linear) Monte Carlo (Probabilistic) )} /> {form.watch('simulationMode') === 'monte-carlo' && ( ( Market Volatility (Std Dev %) { field.onChange(e.target.value === '' ? undefined : Number(e.target.value)); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} onBlur={field.onBlur} name={field.name} ref={field.ref} /> )} /> )} ( Withdrawal Strategy { field.onChange(val); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} defaultValue={field.value} > Fixed Inflation-Adjusted Percentage of Portfolio )} /> {form.watch('withdrawalStrategy') === 'percentage' && ( ( Withdrawal Percentage (%) { field.onChange(e.target.value === '' ? undefined : Number(e.target.value)); // eslint-disable-next-line @typescript-eslint/no-floating-promises form.handleSubmit(onSubmit)(); }} onBlur={field.onBlur} name={field.name} ref={field.ref} /> )} /> )} {!result && ( Calculate )} {result?.yearlyData && ( Financial Projection Projected balance growth with your selected retirement age {isMonteCarlo && ( Shaded band shows 40th-60th percentile outcomes across 2000 simulations. )} {/* Right Y axis */} {/* Left Y axis */} data.mcRange} stackId="mc-range" stroke="none" fill="url(#fillMonteCarloBand)" fillOpacity={0.5} yAxisId={'right'} activeDot={false} connectNulls isAnimationActive={false} className="mc-bound-band" data-testid="mc-bound-band" /> {result.fireNumber && ( )} )} {result && ( {copied ? : } {copied ? 'Sharable Link Copied!' : 'Share Calculation'} )} {result && ( {result.error ? ( {result.error} ) : ( <> FIRE Number Capital at retirement {formatNumber(result.fireNumber)} Retirement Duration Years to enjoy your financial independence {Number(form.getValues('lifeExpectancy')) - Number(form.getValues('retirementAge'))} > )} )} > ); }
Shaded band shows 40th-60th percentile outcomes across 2000 simulations.
{result.error}
{formatNumber(result.fireNumber)}
{Number(form.getValues('lifeExpectancy')) - Number(form.getValues('retirementAge'))}