Compare commits

...

10 Commits

Author SHA1 Message Date
35bc31fb3d tootip and graph style fixes
Some checks failed
Lint / Lint and Typecheck (push) Failing after 45s
2025-12-06 22:58:10 +01:00
4aa961fc1c monte-carlo improvements 2025-12-06 21:42:00 +01:00
7fcb2c9a0f minor fix 2025-12-06 20:58:42 +01:00
6a13860a80 Improves input test reliability and restores setup mocks
Switches input change test to use async wait for reliable value assertion.
Restores and enhances test setup with matchMedia mock to support media query-dependent components in jsdom.
2025-12-06 20:46:01 +01:00
0a5d691d04 fix tooltips 2025-12-06 20:45:54 +01:00
9ec1a4ab79 run unit tests as part of lint job 2025-12-06 20:34:33 +01:00
b2c07ba8a3 shadcn popover 2025-12-06 20:27:08 +01:00
0030f91bb2 Removes 4% rule overlays and adds URL hydration to form
Eliminates all 4%-rule related overlays, buttons, and UI elements from the calculator for a simpler experience. Introduces hydration of calculator inputs from URL search params, enabling sharing of form state via URLs and restoring state on page reload. Updates the form's share button styling and ensures all necessary URL parameters are set for sharing.

Also refactors tests to remove 4%-rule tests and adds mocks for next/navigation.

Simplifies calculator behavior and improves accessibility for stateful URLs.
2025-12-06 20:25:04 +01:00
2b0df3d100 quotes 2025-12-06 20:04:08 +01:00
15a32dc467 sharable calc, retire at pages 2025-12-06 20:04:08 +01:00
19 changed files with 1375 additions and 288 deletions

View File

@@ -4,7 +4,7 @@ on:
pull_request:
push:
branches:
- "**" # matches every branch
- '**' # matches every branch
jobs:
lint_and_typecheck:
@@ -22,10 +22,13 @@ jobs:
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
node-version: 24
cache: "pnpm"
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run lint
run: pnpm run lint
- name: Run unit tests
run: pnpm test

View File

@@ -23,6 +23,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.2",

39
pnpm-lock.yaml generated
View File

@@ -33,6 +33,9 @@ importers:
'@radix-ui/react-navigation-menu':
specifier: ^1.2.14
version: 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-select':
specifier: ^2.2.2
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@@ -985,6 +988,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@@ -4612,6 +4628,29 @@ snapshots:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1)
aria-hidden: 1.2.6
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
react-remove-scroll: 2.7.1(@types/react@19.2.7)(react@19.2.1)
optionalDependencies:
'@types/react': 19.2.7
'@types/react-dom': 19.2.3(@types/react@19.2.7)
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
dependencies:
'@floating-ui/react-dom': 2.1.5(react-dom@19.2.1(react@19.2.1))(react@19.2.1)

View File

@@ -1,19 +1,27 @@
'use client';
import { useState } from 'react';
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 } from '@/components/ui/chart';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import {
Area,
AreaChart,
CartesianGrid,
Line,
XAxis,
YAxis,
ReferenceLine,
@@ -21,12 +29,15 @@ import {
} from 'recharts';
import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
import { Calculator, Info, Percent } from 'lucide-react';
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 (
@@ -41,39 +52,8 @@ function InfoTooltip({ content }: Readonly<{ content: string }>) {
);
}
// Schema for form validation
const formSchema = z.object({
startingCapital: z.coerce.number(),
monthlySavings: z.coerce.number().min(0, 'Monthly savings must be a non-negative number'),
currentAge: z.coerce
.number()
.min(1, 'Age must be at least 1')
.max(100, 'No point in starting this late'),
cagr: z.coerce.number().min(0, 'Growth rate must be a non-negative number'),
desiredMonthlyAllowance: z.coerce.number().min(0, 'Monthly allowance must be a non-negative number'),
inflationRate: z.coerce.number().min(0, 'Inflation rate must be a non-negative number'),
lifeExpectancy: z.coerce
.number()
.min(40, 'Be a bit more optimistic buddy :(')
.max(100, 'You should be more realistic...'),
retirementAge: z.coerce
.number()
.min(18, 'Retirement age must be at least 18')
.max(100, 'Retirement age must be at most 100'),
coastFireAge: z.coerce
.number()
.min(18, 'Coast FIRE age must be at least 18')
.max(100, 'Coast FIRE age must be at most 100')
.optional(),
baristaIncome: z.coerce.number().min(0, 'Barista income must be a non-negative number').optional(),
simulationMode: z.enum(['deterministic', 'monte-carlo']).default('deterministic'),
volatility: z.coerce.number().min(0).default(15),
withdrawalStrategy: z.enum(['fixed', 'percentage']).default('fixed'),
withdrawalPercentage: z.coerce.number().min(0).max(100).default(4),
});
// Type for form values
type FormValues = z.infer<typeof formSchema>;
const formSchema = fireCalculatorFormSchema;
type FormValues = FireCalculatorFormValues;
interface YearlyData {
age: number;
@@ -91,8 +71,6 @@ interface YearlyData {
interface CalculationResult {
fireNumber: number | null;
fireNumber4percent: number | null;
retirementAge4percent: number | null;
yearlyData: YearlyData[];
error?: string;
successRate?: number; // For Monte Carlo
@@ -114,56 +92,153 @@ const formatNumber = (value: number | null) => {
}).format(value);
};
// Helper function to render tooltip for chart
const tooltipRenderer = ({ active, payload }: TooltipProps<ValueType, NameType>) => {
if (active && payload?.[0]?.payload) {
const data = payload[0].payload as YearlyData;
return (
<div className="bg-background border p-2 shadow-sm">
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
{data.balanceP50 !== undefined ? (
<>
<p className="text-orange-500">{`Median Balance: ${formatNumber(data.balanceP50)}`}</p>
<p className="text-xs text-orange-300">{`10th %: ${formatNumber(data.balanceP10 ?? 0)}`}</p>
<p className="text-xs text-orange-300">{`90th %: ${formatNumber(data.balanceP90 ?? 0)}`}</p>
</>
) : (
<p className="text-orange-500">{`Balance: ${formatNumber(data.balance)}`}</p>
)}
<p className="text-red-600">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
<p>{`Phase: ${data.phase === 'accumulation' ? 'Accumulation' : 'Retirement'}`}</p>
</div>
);
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 null;
return value.toString();
};
export default function FireCalculatorForm() {
// 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 [showing4percent, setShowing4percent] = useState(false);
const [copied, setCopied] = useState(false);
// Initialize form with default values
const form = useForm<z.input<typeof formSchema>, undefined, FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
startingCapital: 50000,
monthlySavings: 1500,
currentAge: 25,
cagr: 7,
desiredMonthlyAllowance: 3000,
inflationRate: 2.3,
lifeExpectancy: 84,
retirementAge: 55,
coastFireAge: undefined,
baristaIncome: 0,
simulationMode: 'deterministic',
volatility: 15,
withdrawalStrategy: 'fixed',
withdrawalPercentage: 4,
},
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
@@ -180,7 +255,7 @@ export default function FireCalculatorForm() {
const simulationMode = values.simulationMode;
const volatility = values.volatility;
const numSimulations = simulationMode === 'monte-carlo' ? 500 : 1;
const numSimulations = simulationMode === 'monte-carlo' ? 2000 : 1;
const simulationResults: number[][] = []; // [yearIndex][simulationIndex] -> balance
// Prepare simulation runs
@@ -258,9 +333,9 @@ export default function FireCalculatorForm() {
// Sort to find percentiles
balancesForYear.sort((a, b) => a - b);
const p10 = balancesForYear[Math.floor(numSimulations * 0.1)];
const p10 = balancesForYear[Math.floor(numSimulations * 0.4)];
const p50 = balancesForYear[Math.floor(numSimulations * 0.5)];
const p90 = balancesForYear[Math.floor(numSimulations * 0.9)];
const p90 = balancesForYear[Math.floor(numSimulations * 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
@@ -307,42 +382,16 @@ export default function FireCalculatorForm() {
const retirementIndex = yearlyData.findIndex((data) => data.year === retirementYear);
const retirementData = yearlyData[retirementIndex];
const [fireNumber4percent, retirementAge4percent] = (() => {
// Re-enable 4% rule for deterministic mode or use p50 for MC
// For MC, "untouchedBalance" isn't tracked per run in aggregate, but we can use balanceP50 roughly
// or just disable it as it's a different philosophy.
// For now, let's calculate it based on the main "balance" field (which is p50 in MC)
for (const yearData of yearlyData) {
// Note: This is imperfect for MC because 'balance' includes withdrawals in retirement
// whereas 4% rule check usually looks at "if I retired now with this balance".
// The original code had `untouchedBalance` which grew without withdrawals.
// Since we removed `untouchedBalance` calculation in the aggregate loop, let's skip 4% for MC for now.
if (
simulationMode === 'deterministic' &&
yearData.untouchedBalance &&
yearData.untouchedBalance > (yearData.untouchedMonthlyAllowance * 12) / 0.04
) {
return [yearData.untouchedBalance, yearData.age];
}
}
return [null, null];
})();
if (retirementIndex === -1) {
setResult({
fireNumber: null,
fireNumber4percent: null,
retirementAge4percent: null,
error: 'Could not calculate retirement data',
yearlyData: yearlyData,
error: 'Could not calculate retirement data',
});
} else {
// Set the result
setResult({
fireNumber: retirementData.balance,
fireNumber4percent: fireNumber4percent,
retirementAge4percent: retirementAge4percent,
yearlyData: yearlyData,
successRate:
simulationMode === 'monte-carlo' ? (successCount / numSimulations) * 100 : undefined,
@@ -350,6 +399,73 @@ export default function FireCalculatorForm() {
}
}
// 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 isMonteCarlo = form.watch('simulationMode') === 'monte-carlo';
const chartData =
result?.yearlyData.map((row) => ({
...row,
mcRange: (row.balanceP90 ?? 0) - (row.balanceP10 ?? 0),
})) ?? [];
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">
@@ -667,7 +783,7 @@ export default function FireCalculatorForm() {
<FormItem>
<FormLabel>
Simulation Mode
<InfoTooltip content="Deterministic uses fixed yearly returns. Monte Carlo simulates market randomness with 500 runs to show probability ranges." />
<InfoTooltip content="Monte Carlo simulates market randomness with 2000 runs to show probability ranges. Deterministic uses fixed yearly returns." />
</FormLabel>
<Select
onValueChange={(val) => {
@@ -800,11 +916,13 @@ export default function FireCalculatorForm() {
</CardDescription>
</CardHeader>
<CardContent className="px-2">
<ChartContainer className="aspect-auto h-80 w-full" config={{}}>
<AreaChart
data={result.yearlyData}
margin={{ top: 10, right: 20, left: 20, bottom: 10 }}
>
{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"
@@ -818,18 +936,7 @@ export default function FireCalculatorForm() {
<YAxis
yAxisId={'right'}
orientation="right"
tickFormatter={(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();
}}
tickFormatter={formatNumberShort}
width={30}
stroke="var(--color-orange-500)"
tick={{}}
@@ -838,23 +945,20 @@ export default function FireCalculatorForm() {
<YAxis
yAxisId="left"
orientation="left"
tickFormatter={(value: number) => {
if (value >= 1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toPrecision(3)}K`;
}
return value.toString();
}}
tickFormatter={formatNumberShort}
width={30}
stroke="var(--color-red-600)"
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.8} />
<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"
@@ -867,33 +971,61 @@ export default function FireCalculatorForm() {
yAxisId={'right'}
stackId={'a'}
/>
{form.getValues('simulationMode') === 'monte-carlo' && (
<>
<Area
type="monotone"
dataKey="balanceP10"
stroke="none"
fill="var(--color-orange-500)"
fillOpacity={0.1}
yAxisId={'right'}
connectNulls
/>
<Area
type="monotone"
dataKey="balanceP90"
stroke="none"
fill="var(--color-orange-500)"
fillOpacity={0.1}
yAxisId={'right'}
connectNulls
/>
</>
)}
<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(--color-red-600)"
stroke="var(--primary)"
fill="none"
activeDot={{ r: 6 }}
yAxisId="left"
@@ -901,8 +1033,8 @@ export default function FireCalculatorForm() {
{result.fireNumber && (
<ReferenceLine
y={result.fireNumber}
stroke="var(--primary)"
strokeWidth={2}
stroke="var(--secondary)"
strokeWidth={1}
strokeDasharray="2 1"
label={{
value: 'FIRE Number',
@@ -911,65 +1043,38 @@ export default function FireCalculatorForm() {
yAxisId={'right'}
/>
)}
{result.fireNumber4percent && showing4percent && (
<ReferenceLine
y={result.fireNumber4percent}
stroke="var(--secondary)"
strokeWidth={1}
strokeDasharray="1 1"
label={{
value: '4%-Rule FIRE Number',
position: 'insideBottomLeft',
}}
yAxisId={'right'}
/>
)}
<ReferenceLine
x={
irlYear +
(Number(form.getValues('retirementAge')) -
Number(form.getValues('currentAge')))
}
stroke="var(--primary)"
strokeWidth={2}
stroke="var(--secondary)"
strokeWidth={1}
label={{
value: 'Retirement',
position: 'insideTopRight',
}}
yAxisId={'left'}
/>
{result.retirementAge4percent && showing4percent && (
<ReferenceLine
x={
irlYear +
(result.retirementAge4percent - Number(form.getValues('currentAge')))
}
stroke="var(--secondary)"
strokeWidth={1}
label={{
value: '4%-Rule Retirement',
position: 'insideBottomLeft',
}}
yAxisId={'left'}
/>
)}
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)}
{result && (
<Button
onClick={() => {
setShowing4percent(!showing4percent);
}}
variant={showing4percent ? 'secondary' : 'default'}
size={'sm'}
className="mt-2 gap-2 self-start"
>
<Percent className="h-4 w-4" />
{showing4percent ? 'Hide' : 'Show'} 4%-Rule
</Button>
<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>
@@ -1009,35 +1114,6 @@ export default function FireCalculatorForm() {
</p>
</CardContent>
</Card>
{showing4percent && (
<>
<Card>
<CardHeader>
<CardTitle>4%-Rule FIRE Number</CardTitle>
<CardDescription className="text-xs">
Capital needed for 4% of it to be greater than your yearly allowance
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">{formatNumber(result.fireNumber4percent)}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>4%-Rule Retirement Duration</CardTitle>
<CardDescription className="text-xs">
Years to enjoy your financial independence if you follow the 4% rule
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{Number(form.getValues('lifeExpectancy')) - (result.retirementAge4percent ?? 0)}
</p>
</CardContent>
</Card>
</>
)}
</>
)}
</div>

View File

@@ -36,6 +36,17 @@ vi.mock('recharts', async () => {
};
});
// Mock next/navigation
vi.mock('next/navigation', () => ({
useSearchParams: () => new URLSearchParams(),
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
}),
usePathname: () => '/',
}));
describe('FireCalculatorForm', () => {
it('renders the form with default values', () => {
render(<FireCalculatorForm />);
@@ -59,15 +70,16 @@ describe('FireCalculatorForm', () => {
});
});
it('allows changing inputs', () => {
// using fireEvent for reliability with number inputs in jsdom
it('allows changing inputs', async () => {
render(<FireCalculatorForm />);
const savingsInput = screen.getByRole('spinbutton', { name: /Monthly Savings/i });
fireEvent.change(savingsInput, { target: { value: '2000' } });
expect(savingsInput).toHaveValue(2000);
await waitFor(() => {
expect(savingsInput).toHaveValue(2000);
});
});
it('validates inputs', async () => {
@@ -101,28 +113,20 @@ describe('FireCalculatorForm', () => {
expect(await screen.findByRole('spinbutton', { name: /Market Volatility/i })).toBeInTheDocument();
});
it('toggles 4% Rule overlay', async () => {
it('shows Monte Carlo percentile bounds on the chart', async () => {
const user = userEvent.setup();
render(<FireCalculatorForm />);
// Calculate first to show results
const calculateButton = screen.getByRole('button', { name: /Calculate/i });
await user.click(calculateButton);
const modeTrigger = screen.getByRole('combobox', { name: /Simulation Mode/i });
await user.click(modeTrigger);
// Wait for results
await waitFor(() => {
expect(screen.getByText('Financial Projection')).toBeInTheDocument();
});
const monteCarloOption = await screen.findByRole('option', { name: /Monte Carlo/i });
await user.click(monteCarloOption);
// Find the Show 4%-Rule button
const showButton = screen.getByRole('button', { name: /Show 4%-Rule/i });
await user.click(showButton);
await screen.findByText('Financial Projection');
const bandLegend = await screen.findByTestId('mc-band-legend');
// Should now see 4%-Rule stats
expect(await screen.findByText('4%-Rule FIRE Number')).toBeInTheDocument();
// Button text should change
expect(screen.getByRole('button', { name: /Hide 4%-Rule/i })).toBeInTheDocument();
expect(bandLegend).toHaveTextContent('10th-90th percentile');
});
it('handles withdrawal strategy selection', async () => {

View File

@@ -92,7 +92,7 @@ export function FourPercentRuleChart() {
content={
<ChartTooltipContent
labelFormatter={(value) => `Year ${String(value)}`}
indicator="dot"
indicator="line"
/>
}
/>

View File

@@ -175,7 +175,7 @@ export default function HomeBiasPage() {
<h2 className="mt-16">Evidence & Further Reading</h2>
<ul className="mb-6 list-disc space-y-2 pl-5">
<li>
MSCI, The Home Bias Effect in Global Portfolios {' '}
MSCI,&quot;The Home Bias Effect in Global Portfolios&quot; {' '}
<Link
href="https://www.msci.com/research-and-insights/quick-take/did-home-bias-help"
className="text-primary hover:underline"
@@ -186,7 +186,7 @@ export default function HomeBiasPage() {
</Link>
</li>
<li>
Vanguard Research, Global equity investing: The benefits of diversification {' '}
Vanguard Research,&quot;Global equity investing: The benefits of diversification&quot; {' '}
<Link
href="https://www.vanguardmexico.com/content/dam/intl/americas/documents/mexico/en/global-equity-investing-diversification-sizing.pdf"
className="text-primary hover:underline"
@@ -197,7 +197,7 @@ export default function HomeBiasPage() {
</Link>
</li>
<li>
Sercu &amp; Vanpee (2012), The home bias puzzle in equity portfolios {' '}
Sercu &amp; Vanpee (2012),&quot;The home bias puzzle in equity portfolios&quot; {' '}
<Link
href="https://doi.org/10.1093/acprof:oso/9780199754656.003.0015"
className="text-primary hover:underline"
@@ -208,8 +208,8 @@ export default function HomeBiasPage() {
</Link>
</li>
<li>
Fisher, Shah &amp; Titman (2017), Should you tilt your equity portfolio to smaller
countries? {' '}
Fisher, Shah &amp; Titman (2017),&quot;Should you tilt your equity portfolio to smaller
countries?&quot; {' '}
<Link
href="https://doi.org/10.3905/jpm.2017.44.1.127"
className="text-primary hover:underline"
@@ -220,7 +220,7 @@ export default function HomeBiasPage() {
</Link>
</li>
<li>
Attig &amp; Sy (2023), Diversification during hard times {' '}
Attig &amp; Sy (2023), &quot;Diversification during hard times&quot; {' '}
<Link
href="https://doi.org/10.1080/0015198X.2022.2160620"
className="text-primary hover:underline"
@@ -231,7 +231,7 @@ export default function HomeBiasPage() {
</Link>
</li>
<li>
Blanchett (2021), Foreign revenue: A new world of risk exposures {' '}
Blanchett (2021),&quot;Foreign revenue: A new world of risk exposures&quot; {' '}
<Link
href="https://doi.org/10.3905/jpm.2021.1.237"
className="text-primary hover:underline"
@@ -242,8 +242,8 @@ export default function HomeBiasPage() {
</Link>
</li>
<li>
Anarkulova, Cederburg &amp; ODoherty (2023), Beyond the status quo: A critical assessment
of lifecycle investment advice {' '}
Anarkulova, Cederburg &amp; ODoherty (2023),&quot;Beyond the status quo: A critical
assessment of lifecycle investment advice&quot; {' '}
<Link
href="https://doi.org/10.2139/ssrn.4590406"
className="text-primary hover:underline"
@@ -254,7 +254,7 @@ export default function HomeBiasPage() {
</Link>
</li>
<li>
Goetzmann (2004), Will history rhyme? {' '}
Goetzmann (2004),&quot;Will history rhyme?&quot; {' '}
<Link
href="https://doi.org/10.3905/jpm.2004.442619"
className="text-primary hover:underline"
@@ -265,7 +265,7 @@ export default function HomeBiasPage() {
</Link>
</li>
<li>
Ritter (2012), Is economic growth good for investors? {' '}
Ritter (2012),&quot;Is economic growth good for investors?&quot; {' '}
<Link
href="https://doi.org/10.1111/j.1745-6622.2012.00385.x"
className="text-primary hover:underline"
@@ -274,7 +274,7 @@ export default function HomeBiasPage() {
</Link>
</li>
<li>
French (2022), Five things I know about investing {' '}
French (2022),&quot;Five things I know about investing&quot; {' '}
<Link
href="https://www.dimensional.com/us-en/insights/five-things-i-know-about-investing"
className="text-primary hover:underline"
@@ -285,7 +285,7 @@ export default function HomeBiasPage() {
</Link>
</li>
<li>
Bryan (2018), World War 1 and global stock markets {' '}
Bryan (2018),&quot;World War 1 and global stock markets&quot; {' '}
<Link
href="https://globalfinancialdata.com/world-war-1-and-global-stock-markets"
className="text-primary hover:underline"
@@ -307,7 +307,7 @@ export default function HomeBiasPage() {
</Link>
</li>
<li>
Merton (1973), An intertemporal capital asset pricing model {' '}
Merton (1973),&quot;An intertemporal capital asset pricing model&quot; {' '}
<Link
href="https://doi.org/10.2307/1913811"
className="text-primary hover:underline"

View File

@@ -1,6 +1,7 @@
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import BlurThing from '../components/blur-thing';
import { RETIRE_AT_AGE_PRESETS } from '@/lib/retire-at';
export const metadata = {
title: 'Learn FIRE | Financial Independence Guides & Resources',
@@ -8,6 +9,8 @@ export const metadata = {
'Master the art of Financial Independence and Early Retirement. Deep dives into safe withdrawal rates, asset allocation, and FIRE strategies.',
};
const retireAgeLinks = RETIRE_AT_AGE_PRESETS;
export default function LearnHubPage() {
return (
<div className="container mx-auto max-w-4xl px-4 py-12">
@@ -107,8 +110,8 @@ export default function LearnHubPage() {
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
Build a world-allocation portfolio, avoid home bias, and choose the right accounts whether
you&apos;re in the US, EU, UK, Canada, Australia, or elsewhere.
Build a world-allocation portfolio, avoid home bias, and choose the right accounts
whether you&apos;re in the US, EU, UK, Canada, Australia, or elsewhere.
</p>
</CardContent>
</Card>
@@ -128,14 +131,41 @@ export default function LearnHubPage() {
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
Understand the hidden risks of overweighting your domestic market and learn practical steps
to diversify globally without creating tax headaches.
Understand the hidden risks of overweighting your domestic market and learn practical
steps to diversify globally without creating tax headaches.
</p>
</CardContent>
</Card>
</Link>
</div>
<div className="mt-14 space-y-4">
<div className="text-center">
<h2 className="text-3xl font-bold">Retire By Age</h2>
<p className="text-muted-foreground">
See exactly how much you need to retire at different ages, backed by the calculator.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
{retireAgeLinks.map((age) => (
<Link
key={age}
href={`/learn/retire-at/${age.toString()}`}
className="transition-transform hover:scale-[1.02]"
>
<Card className="hover:border-primary/50 h-full cursor-pointer border-2">
<CardHeader>
<CardTitle className="text-xl">Retire at {age}</CardTitle>
<CardDescription className="text-muted-foreground text-xs">
How much to save, what to invest, and what to tweak for age {age}.
</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
</div>
<div className="bg-muted mt-16 rounded-xl p-8 text-center">
<h2 className="mb-4 text-2xl font-bold">Ready to see the numbers?</h2>
<p className="text-muted-foreground mb-6">

View File

@@ -0,0 +1,279 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
import {
RETIRE_AT_AGE_PRESETS,
buildSpendScenarios,
calculateNestEggFromSpend,
extractCalculatorValuesFromSearch,
parseAgeParam,
} from '@/lib/retire-at';
import { BASE_URL } from '@/lib/constants';
export const dynamic = 'force-static';
export const dynamicParams = false;
interface RetireAtPageProps {
params: Promise<{ age: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}
const currencyFormatter = new Intl.NumberFormat('en', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
});
const faqForAge = (age: number): FaqItem[] => {
const ageLabel = age.toString();
return [
{
question: `How much do I need to retire at ${ageLabel}?`,
answer:
'A quick rule is your desired annual spending divided by a safe withdrawal rate. Using 4%, multiply your yearly spend by 25. Spending $60k/year means roughly $1.5M. Use the calculator below to tailor the projection to your own savings, growth, and inflation assumptions.',
},
{
question: `What savings rate should I target to retire at ${ageLabel}?`,
answer:
'Aim for a 4060% savings rate if you want to retire in 1015 years. The exact rate depends on your starting capital, investment returns, and spending goal. Slide the monthly savings input to see how it moves your FIRE number and timeline.',
},
{
question: 'Is the 4% rule safe for this timeline?',
answer:
'The 4% rule is a starting point, not a guarantee. Consider 3.54% for longer retirements or higher inflation periods. The calculator supports both fixed and percentage-based withdrawals so you can stress-test more conservative plans.',
},
{
question: 'What if markets underperform?',
answer:
'Use a lower CAGR (e.g., 56%) and a higher inflation rate (e.g., 3%) in the calculator. Switch to Monte Carlo mode to see success probabilities with volatility. Also build flexibility into spending: trimming costs in bad years greatly improves durability.',
},
];
};
export const generateStaticParams = () =>
RETIRE_AT_AGE_PRESETS.map((age) => ({
age: age.toString(),
}));
export const generateMetadata = async ({ params }: RetireAtPageProps): Promise<Metadata> => {
const { age: slugAge } = await params;
const age = parseAgeParam(slugAge);
const ageLabel = age.toString();
const title = `How Much Do You Need to Retire at ${ageLabel}? | InvestingFIRE`;
const description = `Instant answer plus calculator: see how much you need saved to retire at ${ageLabel}, modeled with your spending, returns, and inflation assumptions.`;
const canonical = `${BASE_URL.replace(/\/$/, '')}/learn/retire-at/${ageLabel}`;
return {
title,
description,
alternates: {
canonical,
},
openGraph: {
title,
description,
url: canonical,
type: 'article',
},
};
};
export default async function RetireAtPage({ params, searchParams }: RetireAtPageProps) {
const { age: slugAge } = await params;
const resolvedSearch = (await searchParams) ?? {};
const age = parseAgeParam(slugAge);
const ageLabel = age.toString();
const initialValues = extractCalculatorValuesFromSearch(resolvedSearch, age);
const monthlySpend = initialValues.desiredMonthlyAllowance ?? 4000;
const withdrawalRate = 0.04;
const quickNestEgg = calculateNestEggFromSpend(monthlySpend, withdrawalRate);
const scenarios = buildSpendScenarios(monthlySpend, withdrawalRate);
const canonical = `${BASE_URL.replace(/\/$/, '')}/learn/retire-at/${ageLabel}`;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: `How Much Do You Need to Retire at ${ageLabel}?`,
description:
'Detailed guidance plus an interactive calculator showing exactly how much you need saved to retire at your target age.',
mainEntityOfPage: canonical,
datePublished: '2025-01-25',
dateModified: new Date().toISOString().split('T')[0],
publisher: {
'@type': 'Organization',
name: 'InvestingFIRE',
logo: {
'@type': 'ImageObject',
url: `${BASE_URL}apple-icon.png`,
},
},
};
const queryParams = new URLSearchParams();
if (initialValues.currentAge) queryParams.set('currentAge', initialValues.currentAge.toString());
queryParams.set('retirementAge', age.toString());
queryParams.set('monthlySpend', monthlySpend.toString());
if (initialValues.monthlySavings)
queryParams.set('monthlySavings', initialValues.monthlySavings.toString());
if (initialValues.startingCapital)
queryParams.set('startingCapital', initialValues.startingCapital.toString());
return (
<article className="container mx-auto max-w-4xl px-4 py-12">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<nav className="text-muted-foreground mb-6 text-sm">
<Link href="/" className="hover:text-primary">
Home
</Link>
<span className="mx-2">/</span>
<Link href="/learn" className="hover:text-primary">
Learn
</Link>
<span className="mx-2">/</span>
<span className="text-foreground">Retire at {age}</span>
</nav>
<header className="mb-10">
<h1 className="mb-4 text-4xl font-extrabold tracking-tight lg:text-5xl">
How Much Do I Need to Retire at {age}?
</h1>
<p className="text-muted-foreground text-xl leading-relaxed">
Get an instant rule-of-thumb number, then dial in the details with the FIRE calculator loaded
for age {age}. Adjust savings, returns, inflation, and withdrawals to stress-test your plan.
</p>
</header>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Quick Answer</CardTitle>
<CardDescription>
Based on a {Math.round(withdrawalRate * 100)}% withdrawal rate
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-lg">
With a monthly spend of <strong>{currencyFormatter.format(monthlySpend)}</strong>, you need
roughly <strong>{currencyFormatter.format(quickNestEgg)}</strong> invested to retire at{' '}
{age}.
</p>
<ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li>Uses the classic&quot;Rule of 25&quot; (annual spend ÷ {withdrawalRate * 100}%)</li>
<li>Assumes inflation-adjusted withdrawals and a diversified portfolio</li>
<li>Refine the projection below with your exact savings, age, and market assumptions</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>At-a-Glance</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<div className="flex items-center justify-between">
<span>Target age</span>
<span className="text-foreground font-semibold">{age}</span>
</div>
<div className="flex items-center justify-between">
<span>Monthly spend (today)</span>
<span className="text-foreground font-semibold">
{currencyFormatter.format(monthlySpend)}
</span>
</div>
<div className="flex items-center justify-between">
<span>Withdrawal rate</span>
<span className="text-foreground font-semibold">{(withdrawalRate * 100).toFixed(1)}%</span>
</div>
<div className="flex items-center justify-between">
<span>Rule-of-25 nest egg</span>
<span className="text-foreground font-semibold">
{currencyFormatter.format(quickNestEgg)}
</span>
</div>
</CardContent>
</Card>
</div>
<section className="mt-12 space-y-6">
<div className="flex items-baseline justify-between gap-3">
<div>
<h2 className="text-2xl font-bold">Spend Scenarios</h2>
<p className="text-muted-foreground">
Lean, classic, and comfortable budgets with required nest eggs.
</p>
</div>
<Link
href="/learn/safe-withdrawal-rate-4-percent-rule"
className="text-primary text-sm hover:underline"
>
Why the {Math.round(withdrawalRate * 100)}% rule?
</Link>
</div>
<div className="grid gap-4 md:grid-cols-3">
{scenarios.map((scenario) => (
<Card key={scenario.key} className="h-full">
<CardHeader>
<CardTitle>{scenario.label}</CardTitle>
<CardDescription>
{currencyFormatter.format(scenario.monthlySpend)} / month
</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<div className="flex items-center justify-between">
<span>Annual spend</span>
<span className="text-foreground font-semibold">
{currencyFormatter.format(scenario.annualSpend)}
</span>
</div>
<div className="flex items-center justify-between">
<span>Needed to retire</span>
<span className="text-foreground font-semibold">
{currencyFormatter.format(scenario.nestEgg)}
</span>
</div>
</CardContent>
</Card>
))}
</div>
</section>
<section className="mt-14 space-y-6">
<div className="bg-primary/5 rounded-xl border p-8 text-center">
<h2 className="mb-4 text-3xl font-bold">Ready to Plan Your Details?</h2>
<p className="text-muted-foreground mx-auto mb-8 max-w-2xl text-lg">
This page gives you a ballpark estimate. Use our full-featured calculator to customize
inflation, market returns, simulation modes (Monte Carlo), and more for your specific
situation.
</p>
<Button size="lg" className="h-auto px-8 py-6 text-lg" asChild>
<Link href={`/?${queryParams.toString()}`}>Open Full Calculator for Age {age}</Link>
</Button>
</div>
</section>
<section className="mt-12 grid gap-6 md:grid-cols-2">
<Card className="col-span-full">
<CardHeader>
<CardTitle>Key Levers to Watch</CardTitle>
<CardDescription>Improve success odds for age {age}</CardDescription>
</CardHeader>
<CardContent>
<ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li>Boost savings rate in the final 510 years before {age}</li>
<li>Lower planned spending or add part-time income (Barista/Coast FIRE)</li>
<li>Use conservative returns (57%) and realistic inflation (23%)</li>
<li>Consider longer life expectancy (age {age + 30}+)</li>
</ul>
</CardContent>
</Card>
</section>
<FaqSection faqs={faqForAge(age)} className="my-12" />
</article>
);
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { RETIRE_AT_AGE_PRESETS } from '@/lib/retire-at';
import { generateStaticParams } from '../[age]/page';
describe('retire-at generateStaticParams', () => {
it('returns all preset ages as strings with no duplicates', () => {
const params = generateStaticParams();
const ages = params.map((p) => p.age);
expect(ages).toHaveLength(RETIRE_AT_AGE_PRESETS.length);
expect(new Set(ages).size).toBe(ages.length);
expect(ages).toEqual(RETIRE_AT_AGE_PRESETS.map((age) => age.toString()));
});
});

View File

@@ -362,7 +362,7 @@ export default function ParkYourMoneyPage() {
<h2 className="mt-16">Further Reading & Evidence</h2>
<ul className="mb-6 list-disc space-y-2 pl-5">
<li>
Vanguard Research, Global equity investing: The benefits of diversification {' '}
Vanguard Research,&quot;Global equity investing: The benefits of diversification&quot; {' '}
<Link
href="https://corporate.vanguard.com/content/dam/corp/research/pdf/global-equity-investing-benefits-diversification.pdf"
className="text-primary hover:underline"
@@ -373,7 +373,7 @@ export default function ParkYourMoneyPage() {
</Link>
</li>
<li>
MSCI, The Home Bias Effect in Global Portfolios {' '}
MSCI,&quot;The Home Bias Effect in Global Portfolios&quot; {' '}
<Link
href="https://www.msci.com/research-and-insights/quick-take/did-home-bias-help"
className="text-primary hover:underline"
@@ -395,7 +395,7 @@ export default function ParkYourMoneyPage() {
</Link>
</li>
<li>
Bogleheads Three-Fund Portfolio {' '}
Bogleheads&quot;Three-Fund Portfolio&quot; {' '}
<Link
href="https://www.bogleheads.org/wiki/Three-fund_portfolio"
className="text-primary hover:underline"

View File

@@ -1,4 +1,5 @@
import Image from 'next/image';
import { Suspense } from 'react';
import FireCalculatorForm from './components/FireCalculatorForm';
import BackgroundPattern from './components/BackgroundPattern';
import { FaqSection, type FaqItem } from './components/FaqSection';
@@ -64,7 +65,9 @@ export default function HomePage() {
how FIRE works.
</p>
<div className="mt-8 w-full max-w-2xl">
<FireCalculatorForm />
<Suspense fallback={<div>Loading calculator...</div>}>
<FireCalculatorForm />
</Suspense>
</div>
</div>
@@ -106,7 +109,7 @@ export default function HomePage() {
How This FIRE Calculator Provides Investing Insights
</h2>
<p className="mb-4 text-lg leading-relaxed">
Our interactive tool goes beyond a simple 25x annual spending rule. It runs a{' '}
Our interactive tool goes beyond a simple&quot;25x annual spending&quot; rule. It runs a{' '}
<strong>year-by-year simulation</strong> of your portfolio, combining:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
@@ -277,7 +280,7 @@ export default function HomePage() {
>
Coast FIRE Calculator
</a>{' '}
- When you max out early contributions but let compounding do the rest.
- When you&quot;max out&quot; early contributions but let compounding do the rest.
</li>
<li>
<a

View File

@@ -0,0 +1,110 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
const setupMatchMedia = (matches: boolean) => {
const listeners = new Set<EventListenerOrEventListenerObject>();
const mockMatchMedia = (query: string): MediaQueryList => ({
matches,
media: query,
onchange: null,
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
if (type === 'change') {
listeners.add(listener);
}
},
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
if (type === 'change') {
listeners.delete(listener);
}
},
addListener: () => {
/* deprecated */
},
removeListener: () => {
/* deprecated */
},
dispatchEvent: (event: Event) => {
listeners.forEach((listener) => {
if (typeof listener === 'function') {
listener(event);
} else {
listener.handleEvent(event);
}
});
return true;
},
});
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(mockMatchMedia),
});
};
describe('Tooltip hybrid behaviour', () => {
beforeEach(() => {
class ResizeObserverMock {
observe() {
/* noop */
}
unobserve() {
/* noop */
}
disconnect() {
/* noop */
}
}
Object.defineProperty(window, 'ResizeObserver', {
writable: true,
value: ResizeObserverMock,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('falls back to popover interaction on touch devices', async () => {
setupMatchMedia(true);
render(
<Tooltip>
<TooltipTrigger>Trigger</TooltipTrigger>
<TooltipContent>Tooltip text</TooltipContent>
</Tooltip>,
);
const trigger = screen.getByRole('button', { name: 'Trigger' });
expect(trigger).toHaveAttribute('data-touch', 'true');
const user = userEvent.setup();
await user.click(trigger);
expect(
await screen.findByText('Tooltip text', { selector: '[data-slot="tooltip-content"]' }),
).toBeVisible();
});
it('keeps tooltip interaction on non-touch devices', async () => {
setupMatchMedia(false);
render(
<Tooltip defaultOpen>
<TooltipTrigger>Trigger</TooltipTrigger>
<TooltipContent>Tooltip text</TooltipContent>
</Tooltip>,
);
const trigger = screen.getByRole('button', { name: 'Trigger' });
expect(trigger).toHaveAttribute('data-touch', 'false');
expect(
await screen.findByText('Tooltip text', { selector: '[data-slot="tooltip-content"]' }),
).toBeVisible();
});
});

View File

@@ -0,0 +1,42 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
function Popover({ ...props }: Readonly<React.ComponentProps<typeof PopoverPrimitive.Root>>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -1,10 +1,55 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
type TooltipProps = Readonly<
React.ComponentProps<typeof TooltipPrimitive.Root> & React.ComponentProps<typeof PopoverPrimitive.Root>
>;
type TooltipTriggerProps = Readonly<
React.ComponentProps<typeof TooltipPrimitive.Trigger> &
React.ComponentProps<typeof PopoverPrimitive.Trigger>
>;
type TooltipContentProps = Readonly<
React.ComponentProps<typeof TooltipPrimitive.Content> &
React.ComponentProps<typeof PopoverPrimitive.Content>
>;
const TooltipTouchContext = React.createContext<boolean>(false);
function useIsTouchDevice() {
const [isTouch, setIsTouch] = React.useState<boolean>(() => {
if (typeof window === 'undefined') {
return false;
}
return window.matchMedia('(pointer: coarse)').matches;
});
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(pointer: coarse)');
const handleChange = (event: MediaQueryListEvent) => {
setIsTouch(event.matches);
};
setIsTouch(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
};
}, []);
return isTouch;
}
function TooltipProvider({
delayDuration = 0,
...props
@@ -14,28 +59,63 @@ function TooltipProvider({
);
}
function Tooltip({ ...props }: Readonly<React.ComponentProps<typeof TooltipPrimitive.Root>>) {
function Tooltip({ children, ...props }: TooltipProps) {
const isTouch = useIsTouchDevice();
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
<TooltipTouchContext.Provider value={isTouch}>
{isTouch ? (
<PopoverPrimitive.Root data-slot="tooltip" data-touch="true" {...props}>
{children}
</PopoverPrimitive.Root>
) : (
<TooltipPrimitive.Root data-slot="tooltip" data-touch="false" {...props}>
{children}
</TooltipPrimitive.Root>
)}
</TooltipTouchContext.Provider>
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
function TooltipTrigger({ ...props }: TooltipTriggerProps) {
const isTouch = React.useContext(TooltipTouchContext);
return isTouch ? (
<PopoverPrimitive.Trigger data-slot="tooltip-trigger" data-touch="true" {...props} />
) : (
<TooltipPrimitive.Trigger data-slot="tooltip-trigger" data-touch="false" {...props} />
);
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
function TooltipContent({ className, sideOffset = 0, children, ...props }: TooltipContentProps) {
const isTouch = React.useContext(TooltipTouchContext);
if (isTouch) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="tooltip-content"
data-touch="true"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-popover-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance shadow-md outline-hidden',
className,
)}
{...props}
>
{children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
);
}
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
data-touch="false"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import {
RETIRE_AT_AGE_PRESETS,
buildSpendScenarios,
calculateNestEggFromSpend,
deriveDefaultInputs,
extractCalculatorValuesFromSearch,
parseAgeParam,
} from '../retire-at';
describe('retire-at helpers', () => {
it('calculates a rule-of-25 style nest egg', () => {
const result = calculateNestEggFromSpend(4000, 0.04);
expect(result).toBe(1200000);
});
it('builds lean/base/comfortable spend scenarios', () => {
const scenarios = buildSpendScenarios(4000, 0.04);
expect(scenarios).toHaveLength(3);
const baseline = scenarios.find((scenario) => scenario.key === 'baseline');
expect(baseline?.monthlySpend).toBe(4000);
expect(baseline?.nestEgg).toBe(1200000);
});
it('parses and clamps age params', () => {
expect(parseAgeParam('90')).toBe(80);
expect(parseAgeParam('42')).toBe(42);
expect(parseAgeParam('not-a-number', 55)).toBe(55);
});
it('derives calculator defaults for a target age', () => {
const defaults = deriveDefaultInputs(50);
expect(defaults.retirementAge).toBe(50);
expect(defaults.currentAge).toBeLessThan(50);
expect(defaults.desiredMonthlyAllowance).toBeGreaterThanOrEqual(500);
});
it('exposes preset age list for sitemap/static params', () => {
expect(RETIRE_AT_AGE_PRESETS).toContain(50);
expect(Array.isArray(RETIRE_AT_AGE_PRESETS)).toBe(true);
});
describe('extractCalculatorValuesFromSearch', () => {
it('parses valid numeric params', () => {
const searchParams = {
currentAge: '30',
retirementAge: '55',
monthlySpend: '4000',
monthlySavings: '1500',
startingCapital: '100000',
};
const values = extractCalculatorValuesFromSearch(searchParams, 55);
expect(values.currentAge).toBe(30);
expect(values.retirementAge).toBe(55);
expect(values.desiredMonthlyAllowance).toBe(4000);
expect(values.monthlySavings).toBe(1500);
expect(values.startingCapital).toBe(100000);
});
it('handles invalid numbers by falling back to defaults', () => {
const searchParams = {
currentAge: 'not-a-number',
monthlySpend: 'invalid',
};
// targetAge 55 implies some defaults
const values = extractCalculatorValuesFromSearch(searchParams, 55);
// currentAge should default based on logic in deriveDefaultInputs
// for 55, defaultCurrentAge is around 40
expect(values.currentAge).toBeGreaterThan(18);
// desiredMonthlyAllowance has a default logic too
expect(values.desiredMonthlyAllowance).toBeDefined();
});
it('clamps values to safe bounds and business logic', () => {
const searchParams = {
currentAge: '150', // max 100, but further constrained by retirement age
monthlySpend: '-500', // min 0
};
const values = extractCalculatorValuesFromSearch(searchParams, 60);
// Clamped to retirementAge (60) - 1 = 59 by deriveDefaultInputs
expect(values.currentAge).toBe(59);
// Clamped to min 500 by deriveDefaultInputs
expect(values.desiredMonthlyAllowance).toBe(500);
});
it('supports array params (takes first)', () => {
const searchParams = {
currentAge: ['30', '40'],
};
const values = extractCalculatorValuesFromSearch(searchParams, 60);
expect(values.currentAge).toBe(30);
});
it('parses simulation mode', () => {
expect(
extractCalculatorValuesFromSearch({ simulationMode: 'monte-carlo' }, 55).simulationMode,
).toBe('monte-carlo');
expect(
extractCalculatorValuesFromSearch({ simulationMode: 'deterministic' }, 55).simulationMode,
).toBe('deterministic');
expect(
extractCalculatorValuesFromSearch({ simulationMode: 'invalid-mode' }, 55).simulationMode,
).toBeUndefined();
});
it('parses extra fields (volatility, withdrawal, barista)', () => {
const searchParams = {
volatility: '20',
withdrawalStrategy: 'percentage',
withdrawalPercentage: '3.5',
coastFireAge: '45',
baristaIncome: '1000',
};
const values = extractCalculatorValuesFromSearch(searchParams, 55);
expect(values.volatility).toBe(20);
expect(values.withdrawalStrategy).toBe('percentage');
expect(values.withdrawalPercentage).toBe(3.5);
expect(values.coastFireAge).toBe(45);
expect(values.baristaIncome).toBe(1000);
});
});
});

View File

@@ -0,0 +1,50 @@
import * as z from 'zod';
export const fireCalculatorFormSchema = z.object({
startingCapital: z.coerce.number(),
monthlySavings: z.coerce.number().min(0, 'Monthly savings must be a non-negative number'),
currentAge: z.coerce
.number()
.min(1, 'Age must be at least 1')
.max(100, 'No point in starting this late'),
cagr: z.coerce.number().min(0, 'Growth rate must be a non-negative number'),
desiredMonthlyAllowance: z.coerce.number().min(0, 'Monthly allowance must be a non-negative number'),
inflationRate: z.coerce.number().min(0, 'Inflation rate must be a non-negative number'),
lifeExpectancy: z.coerce
.number()
.min(40, 'Be a bit more optimistic buddy :(')
.max(100, 'You should be more realistic...'),
retirementAge: z.coerce
.number()
.min(20, 'Retirement age must be at least 20')
.max(100, 'Retirement age must be at most 100'),
coastFireAge: z.coerce
.number()
.min(20, 'Coast FIRE age must be at least 20')
.max(100, 'Coast FIRE age must be at most 100')
.optional(),
baristaIncome: z.coerce.number().min(0, 'Barista income must be a non-negative number').optional(),
simulationMode: z.enum(['deterministic', 'monte-carlo']).default('monte-carlo'),
volatility: z.coerce.number().min(0).default(15),
withdrawalStrategy: z.enum(['fixed', 'percentage']).default('fixed'),
withdrawalPercentage: z.coerce.number().min(0).max(100).default(4),
});
export type FireCalculatorFormValues = z.infer<typeof fireCalculatorFormSchema>;
export const fireCalculatorDefaultValues: FireCalculatorFormValues = {
startingCapital: 50000,
monthlySavings: 1500,
currentAge: 25,
cagr: 7,
desiredMonthlyAllowance: 3000,
inflationRate: 2.3,
lifeExpectancy: 84,
retirementAge: 65,
coastFireAge: undefined,
baristaIncome: 0,
simulationMode: 'monte-carlo',
volatility: 15,
withdrawalStrategy: 'fixed',
withdrawalPercentage: 4,
};

206
src/lib/retire-at.ts Normal file
View File

@@ -0,0 +1,206 @@
import type { FireCalculatorFormValues } from '@/lib/calculator-schema';
type NumericParam = string | number | null | undefined;
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
export const numericFromParam = (value: NumericParam) => {
if (value === null || value === undefined) return undefined;
const parsed = typeof value === 'string' ? Number(value) : value;
if (!Number.isFinite(parsed)) return undefined;
return parsed;
};
export const RETIRE_AT_AGE_PRESETS = [35, 40, 45, 50, 55, 60, 65, 70] as const;
export interface SpendScenario {
key: 'lean' | 'baseline' | 'comfortable';
label: string;
monthlySpend: number;
annualSpend: number;
nestEgg: number;
withdrawalRate: number;
}
export const parseAgeParam = (ageParam: NumericParam, fallback = 50) => {
const parsed = numericFromParam(ageParam);
if (parsed === undefined) return fallback;
return clamp(Math.round(parsed), 30, 80);
};
export const calculateNestEggFromSpend = (monthlySpend: number, withdrawalRate = 0.04) => {
const safeRate = withdrawalRate > 0 ? withdrawalRate : 0.0001;
const normalizedSpend = Math.max(0, monthlySpend);
return (normalizedSpend * 12) / safeRate;
};
export const buildSpendScenarios = (
baseMonthlySpend: number,
withdrawalRate = 0.04,
): SpendScenario[] => {
const normalizedSpend = Math.max(500, baseMonthlySpend);
const levels: { key: SpendScenario['key']; label: string; multiplier: number }[] = [
{ key: 'lean', label: 'Lean FIRE', multiplier: 0.8 },
{ key: 'baseline', label: 'Classic FIRE', multiplier: 1 },
{ key: 'comfortable', label: 'Fat FIRE', multiplier: 1.25 },
];
return levels.map(({ key, label, multiplier }) => {
const monthlySpend = Math.round(normalizedSpend * multiplier);
const annualSpend = monthlySpend * 12;
return {
key,
label,
monthlySpend,
annualSpend,
withdrawalRate,
nestEgg: calculateNestEggFromSpend(monthlySpend, withdrawalRate),
};
});
};
export const deriveDefaultInputs = (
targetAge: number,
opts?: {
currentAge?: number;
desiredMonthlyAllowance?: number;
monthlySavings?: number;
startingCapital?: number;
},
): Partial<FireCalculatorFormValues> => {
const retirementAge = clamp(Math.round(targetAge), 30, 80);
// Smarter defaults based on retirement age goal
// Early FIRE (30-45): Likely started early, high savings, maybe less capital if very young.
// Standard FIRE (45-55): Peak earning years, building capital.
// Late FIRE (55+): Closer to traditional age, probably higher capital.
// Default current age:
// If target < 40: assume user is 22-25 (just starting or early career)
// If target 40-50: assume user is 30
// If target 50+: assume user is 35-40
// But generally 10-15 years out is a good "planning" gap for the calculator default.
// The user asked for "good assumptions" for a "generic" number.
// Let's stick to a gap, but maybe vary savings/capital.
let defaultCurrentAge = retirementAge - 15;
if (retirementAge < 40) defaultCurrentAge = 22; // Very aggressive
if (defaultCurrentAge < 20) defaultCurrentAge = 20;
const currentAge = clamp(
Math.round(opts?.currentAge ?? defaultCurrentAge),
18,
Math.max(18, retirementAge - 1),
);
// Assumptions for "ballpark" numbers:
// Savings: increases with age usually.
// Capital: increases with age.
let defaultMonthlySavings = 1000;
let defaultStartingCapital = 20000;
if (currentAge >= 30) {
defaultMonthlySavings = 1500;
defaultStartingCapital = 50000;
}
if (currentAge >= 40) {
defaultMonthlySavings = 2000;
defaultStartingCapital = 100000;
}
if (currentAge >= 50) {
defaultMonthlySavings = 2500;
defaultStartingCapital = 250000;
}
// If aggressive early retirement is the goal (short timeline), they probably save more?
// Or maybe we just show what it TAKES.
// The calculator solves forward from inputs.
// We should provide realistic inputs for someone *trying* to retire at `targetAge`.
const monthlySavings = clamp(Math.round(opts?.monthlySavings ?? defaultMonthlySavings), 0, 50000);
const startingCapital = clamp(
Math.round(opts?.startingCapital ?? defaultStartingCapital),
0,
100000000,
);
const desiredMonthlyAllowance = clamp(
Math.round(opts?.desiredMonthlyAllowance ?? (retirementAge < 50 ? 4000 : 5000)),
500,
20000,
);
const lifeExpectancy = clamp(Math.round(retirementAge + 30), retirementAge + 10, 110);
return {
currentAge,
retirementAge,
desiredMonthlyAllowance,
monthlySavings,
startingCapital,
lifeExpectancy,
};
};
export const extractNumericSearchParam = (
value: string | string[] | undefined,
bounds?: { min?: number; max?: number },
) => {
const normalized = Array.isArray(value) ? value[0] : value;
const parsed = numericFromParam(normalized);
if (parsed === undefined) return undefined;
if (bounds && (bounds.min !== undefined || bounds.max !== undefined)) {
const min = bounds.min ?? Number.MIN_SAFE_INTEGER;
const max = bounds.max ?? Number.MAX_SAFE_INTEGER;
return clamp(parsed, min, max);
}
return parsed;
};
export const extractCalculatorValuesFromSearch = (
searchParams: Record<string, string | string[] | undefined>,
targetAge: number,
): Partial<FireCalculatorFormValues> => {
const desiredMonthlyAllowance =
extractNumericSearchParam(searchParams.monthlySpend ?? searchParams.monthlyAllowance, {
min: 0,
max: 20000,
}) ?? undefined;
const base = deriveDefaultInputs(targetAge, {
currentAge: extractNumericSearchParam(searchParams.currentAge, { min: 1, max: 100 }),
desiredMonthlyAllowance,
monthlySavings: extractNumericSearchParam(searchParams.monthlySavings, { min: 0, max: 50000 }),
startingCapital: extractNumericSearchParam(searchParams.startingCapital, { min: 0 }),
});
return {
...base,
retirementAge:
extractNumericSearchParam(searchParams.retirementAge, { min: 18, max: 100 }) ?? base.retirementAge,
cagr: extractNumericSearchParam(searchParams.cagr ?? searchParams.growthRate, {
min: 0,
max: 30,
}),
inflationRate: extractNumericSearchParam(searchParams.inflationRate, { min: 0, max: 20 }),
lifeExpectancy:
extractNumericSearchParam(searchParams.lifeExpectancy, { min: 40, max: 110 }) ??
base.lifeExpectancy,
simulationMode:
searchParams.simulationMode === 'monte-carlo' || searchParams.simulationMode === 'deterministic'
? searchParams.simulationMode
: undefined,
withdrawalStrategy:
searchParams.withdrawalStrategy === 'percentage' || searchParams.withdrawalStrategy === 'fixed'
? searchParams.withdrawalStrategy
: undefined,
withdrawalPercentage: extractNumericSearchParam(searchParams.withdrawalPercentage, {
min: 0,
max: 100,
}),
volatility: extractNumericSearchParam(searchParams.volatility, { min: 0 }),
coastFireAge: extractNumericSearchParam(searchParams.coastFireAge, { min: 18, max: 100 }),
baristaIncome: extractNumericSearchParam(searchParams.baristaIncome, { min: 0 }),
};
};

View File

@@ -1,2 +1,21 @@
import "@testing-library/jest-dom";
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Provide a basic matchMedia mock for jsdom so components using media queries
// (e.g. pointer detection in Tooltip) do not throw during tests.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated but still used in some libs
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}