Files
fire/src/app/components/FireCalculatorForm.tsx
Felix Schulze 3dc79aa425
All checks were successful
Lint / Lint and Typecheck (push) Successful in 41s
fix MC test
2025-12-07 01:43:26 +01:00

1143 lines
47 KiB
TypeScript

'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 (
<Tooltip>
<TooltipTrigger type="button" className="ml-1 inline-flex align-middle">
<Info className="text-muted-foreground hover:text-foreground h-3.5 w-3.5 transition-colors" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{content}
</TooltipContent>
</Tooltip>
);
}
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<ValueType, NameType>) => {
const allowedKeys = new Set(['balance', 'monthlyAllowance']);
const filteredPayload: Payload<ValueType, NameType>[] = (payload ?? [])
.filter(
(item): item is Payload<ValueType, NameType> =>
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 (
<ChartTooltipContent
active={active}
payload={filteredPayload}
label={safeLabel}
indicator="line"
className="min-w-48"
labelFormatter={(_, items: Payload<ValueType, NameType>[]) => {
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 (
<div className="flex flex-col gap-0.5">
<span>{`Year ${String(point.year)} (Age ${String(point.age)})`}</span>
<span className="text-muted-foreground">{phaseLabel}</span>
</div>
);
}}
/>
);
};
export default function FireCalculatorForm({
initialValues,
autoCalculate = false,
}: Readonly<{
initialValues?: Partial<FireCalculatorFormValues>;
autoCalculate?: boolean;
}>) {
const [result, setResult] = useState<CalculationResult | null>(null);
const irlYear = new Date().getFullYear();
const [copied, setCopied] = useState(false);
// Initialize form with default values
const form = useForm<z.input<typeof formSchema>, 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<FormValues> = {};
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 (
<>
<Card className="border-primary/15 bg-background/90 shadow-primary/10 mb-6 border shadow-lg backdrop-blur">
<BlurThing />
<CardHeader>
<CardTitle className="text-2xl">FIRE Calculator</CardTitle>
<CardDescription className="text-muted-foreground text-sm">
Calculate your path to financial independence and retirement.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit(onSubmit)(e);
}}
className="space-y-8"
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField
control={form.control}
name="startingCapital"
render={({ field }) => (
<FormItem>
<FormLabel>
Starting Capital
<InfoTooltip content="Your current invested savings or nest egg. This is the amount you have already saved and invested." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 10000"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="monthlySavings"
render={({ field }) => (
<FormItem>
<FormLabel>
Monthly Savings
<InfoTooltip content="The amount you invest each month. This is added to your portfolio during the accumulation phase." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 500"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currentAge"
render={({ field }) => (
<FormItem>
<FormLabel>
Current Age
<InfoTooltip content="Your age today. This is used to calculate the timeline to retirement and beyond." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 30"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lifeExpectancy"
render={({ field }) => (
<FormItem>
<FormLabel>
Life Expectancy (Age)
<InfoTooltip content="Your estimated age of death for planning purposes. This determines how long your money needs to last." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 90"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cagr"
render={({ field }) => (
<FormItem>
<FormLabel>
Expected Annual Growth Rate (%)
<InfoTooltip content="Average yearly investment return (CAGR). The VTI has historically returned ~7%." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 7"
type="number"
step="0.1"
value={field.value as number | string | undefined}
onChange={(e) => {
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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="inflationRate"
render={({ field }) => (
<FormItem>
<FormLabel>
Annual Inflation Rate (%)
<InfoTooltip content="Expected yearly price increase. Historical average is ~2-3%. This adjusts your spending needs over time." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 2"
type="number"
step="0.1"
value={field.value as number | string | undefined}
onChange={(e) => {
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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="desiredMonthlyAllowance"
render={({ field }) => (
<FormItem>
<FormLabel>
Monthly Allowance (Today&apos;s Value)
<InfoTooltip content="Your monthly spending needs in retirement, expressed in today's dollars. This will be adjusted for inflation each year." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 2000"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Retirement Age Slider */}
<FormField
control={form.control}
name="retirementAge"
render={({ field }) => (
<FormItem>
<FormLabel>
Retirement Age: {field.value as number}
<InfoTooltip content="The age when you stop working and start withdrawing from your portfolio to cover living expenses." />
</FormLabel>
<FormControl>
<Slider
name="retirementAge"
value={[field.value as number]}
min={25}
max={75}
step={1}
onValueChange={(value: number[]) => {
field.onChange(value[0]);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
className="py-4"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="coastFireAge"
render={({ field }) => (
<FormItem>
<FormLabel>
<Button variant="link" size={'sm'} asChild>
<Link href="/learn/what-is-fire#types-of-fire">Coast FIRE</Link>
</Button>{' '}
Age (Optional):
<InfoTooltip content="The age when you stop making new contributions but keep working to cover current expenses. Your existing investments compound until retirement." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 45 (defaults to Retirement Age)"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baristaIncome"
render={({ field }) => (
<FormItem>
<FormLabel>
<Button variant="link" size={'sm'} asChild>
<Link href="/learn/what-is-fire#types-of-fire">Barista FIRE</Link>
</Button>{' '}
Monthly Income
<InfoTooltip content="Part-time income during retirement (e.g., from a low-stress job). This reduces the amount you need to withdraw from your portfolio." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 1000"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="simulationMode"
render={({ field }) => (
<FormItem>
<FormLabel>
Simulation Mode
<InfoTooltip content="Monte Carlo simulates market randomness with 2000 runs to show probability ranges. Deterministic uses fixed yearly returns." />
</FormLabel>
<Select
onValueChange={(val) => {
field.onChange(val);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select simulation mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="deterministic">Deterministic (Linear)</SelectItem>
<SelectItem value="monte-carlo">Monte Carlo (Probabilistic)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.watch('simulationMode') === 'monte-carlo' && (
<FormField
control={form.control}
name="volatility"
render={({ field }) => (
<FormItem>
<FormLabel>
Market Volatility (Std Dev %)
<InfoTooltip content="Standard deviation of annual returns. 15% is typical for stocks. Higher values mean more unpredictable year-to-year swings." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 15"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="withdrawalStrategy"
render={({ field }) => (
<FormItem>
<FormLabel>
Withdrawal Strategy
<InfoTooltip content="Fixed inflation-adjusted maintains your purchasing power yearly. Percentage of portfolio adjusts spending based on current balance." />
</FormLabel>
<Select
onValueChange={(val) => {
field.onChange(val);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select withdrawal strategy" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fixed">Fixed Inflation-Adjusted</SelectItem>
<SelectItem value="percentage">Percentage of Portfolio</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.watch('withdrawalStrategy') === 'percentage' && (
<FormField
control={form.control}
name="withdrawalPercentage"
render={({ field }) => (
<FormItem>
<FormLabel>
Withdrawal Percentage (%)
<InfoTooltip content="Annual withdrawal rate as percentage of current portfolio. 4% is the classic 'safe' rate from the Trinity Study." />
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 4.0"
type="number"
step="0.1"
value={field.value as number | string | undefined}
onChange={(e) => {
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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
{!result && (
<Button type="submit" className="mx-auto w-full max-w-md justify-center" size="lg">
<Calculator className="h-4 w-4" />
Calculate
</Button>
)}
{result?.yearlyData && (
<Card className="rounded-md shadow-none">
<CardHeader>
<CardTitle>Financial Projection</CardTitle>
<CardDescription>
Projected balance growth with your selected retirement age
</CardDescription>
</CardHeader>
<CardContent className="px-2">
{isMonteCarlo && (
<p className="text-muted-foreground px-2 text-xs" data-testid="mc-band-legend">
Shaded band shows 40th-60th percentile outcomes across 2000 simulations.
</p>
)}
<ChartContainer className="aspect-auto h-80 w-full" config={projectionChartConfig}>
<AreaChart data={chartData} margin={{ top: 10, right: 20, left: 20, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="year"
label={{
value: 'Year',
position: 'insideBottom',
offset: -10,
}}
/>
{/* Right Y axis */}
<YAxis
yAxisId={'right'}
orientation="right"
tickFormatter={formatNumberShort}
width={30}
stroke="var(--color-orange-500)"
tick={{}}
/>
{/* Left Y axis */}
<YAxis
yAxisId="left"
orientation="left"
tickFormatter={formatNumberShort}
width={30}
stroke="var(--color-primary)"
/>
<ChartTooltip content={tooltipRenderer} />
<defs>
<linearGradient id="fillBalance" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-orange-500)" stopOpacity={0.5} />
<stop offset="95%" stopColor="var(--color-orange-500)" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="fillMonteCarloBand" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-primary)" stopOpacity={0.1} />
<stop offset="100%" stopColor="var(--color-secondary)" stopOpacity={0.3} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="balance"
name="balance"
stroke="var(--color-orange-500)"
fill="url(#fillBalance)"
fillOpacity={0.9}
activeDot={{ r: 6 }}
yAxisId={'right'}
stackId={'a'}
/>
<Area
type="monotone"
dataKey="balanceP10"
stackId="mc-range"
stroke="none"
fill="none"
yAxisId={'right'}
connectNulls
isAnimationActive={false}
className="mc-bound-base"
data-testid="mc-bound-lower"
/>
<Area
type="monotone"
dataKey={(data: YearlyData & { mcRange: number }) => 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"
/>
<Line
type="monotone"
dataKey="balanceP10"
stroke="var(--color-orange-500)"
strokeDasharray="6 6"
strokeWidth={0}
dot={false}
activeDot={false}
yAxisId={'right'}
className="mc-bound-line-lower"
data-testid="mc-bound-line-lower"
/>
<Line
type="monotone"
dataKey="balanceP90"
stroke="var(--color-orange-500)"
strokeDasharray="6 6"
strokeWidth={0}
dot={false}
activeDot={false}
yAxisId={'right'}
className="mc-bound-line-upper"
data-testid="mc-bound-line-upper"
/>
<Area
type="step"
dataKey="monthlyAllowance"
name="allowance"
stroke="var(--primary)"
fill="none"
activeDot={{ r: 6 }}
yAxisId="left"
/>
{result.fireNumber && (
<ReferenceLine
y={result.fireNumber}
stroke="var(--secondary)"
strokeWidth={1}
strokeDasharray="2 1"
label={{
value: 'FIRE Number',
position: 'insideBottomRight',
}}
yAxisId={'right'}
/>
)}
<ReferenceLine
x={
irlYear +
(Number(form.getValues('retirementAge')) -
Number(form.getValues('currentAge')))
}
stroke="var(--secondary)"
strokeWidth={1}
label={{
value: 'Retirement',
position: 'insideTopRight',
}}
yAxisId={'left'}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)}
{result && (
<div className="mt-2 flex flex-wrap justify-end gap-2">
<Button
onClick={handleShare}
variant="default"
size={'lg'}
className="w-full gap-2 md:w-auto"
type="button"
>
{copied ? <Check className="h-4 w-4" /> : <Share2 className="h-4 w-4" />}
{copied ? 'Sharable Link Copied!' : 'Share Calculation'}
</Button>
</div>
)}
</form>
</Form>
</CardContent>
</Card>
{result && (
<div className="mb-4 grid grid-cols-1 gap-2 md:grid-cols-2">
{result.error ? (
<Card className="col-span-full">
<CardContent className="pt-6">
<p className="text-destructive">{result.error}</p>
</CardContent>
</Card>
) : (
<>
<Card>
<CardHeader>
<CardTitle>FIRE Number</CardTitle>
<CardDescription className="text-xs">Capital at retirement</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">{formatNumber(result.fireNumber)}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Retirement Duration</CardTitle>
<CardDescription className="text-xs">
Years to enjoy your financial independence
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{Number(form.getValues('lifeExpectancy')) - Number(form.getValues('retirementAge'))}
</p>
</CardContent>
</Card>
</>
)}
</div>
)}
</>
);
}