Compare commits
10 Commits
bfac54a194
...
35bc31fb3d
| Author | SHA1 | Date | |
|---|---|---|---|
| 35bc31fb3d | |||
| 4aa961fc1c | |||
| 7fcb2c9a0f | |||
| 6a13860a80 | |||
| 0a5d691d04 | |||
| 9ec1a4ab79 | |||
| b2c07ba8a3 | |||
| 0030f91bb2 | |||
| 2b0df3d100 | |||
| 15a32dc467 |
@@ -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
|
||||
|
||||
@@ -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
39
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
stackId="mc-range"
|
||||
stroke="none"
|
||||
fill="var(--color-orange-500)"
|
||||
fillOpacity={0.1}
|
||||
fill="none"
|
||||
yAxisId={'right'}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
className="mc-bound-base"
|
||||
data-testid="mc-bound-lower"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="balanceP90"
|
||||
dataKey={(data: YearlyData & { mcRange: number }) => data.mcRange}
|
||||
stackId="mc-range"
|
||||
stroke="none"
|
||||
fill="var(--color-orange-500)"
|
||||
fillOpacity={0.1}
|
||||
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 && (
|
||||
<div className="mt-2 flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowing4percent(!showing4percent);
|
||||
}}
|
||||
variant={showing4percent ? 'secondary' : 'default'}
|
||||
size={'sm'}
|
||||
className="mt-2 gap-2 self-start"
|
||||
onClick={handleShare}
|
||||
variant="default"
|
||||
size={'lg'}
|
||||
className="w-full gap-2 md:w-auto"
|
||||
type="button"
|
||||
>
|
||||
<Percent className="h-4 w-4" />
|
||||
{showing4percent ? 'Hide' : 'Show'} 4%-Rule
|
||||
{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>
|
||||
|
||||
@@ -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,16 +70,17 @@ 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' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(savingsInput).toHaveValue(2000);
|
||||
});
|
||||
});
|
||||
|
||||
it('validates inputs', async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -92,7 +92,7 @@ export function FourPercentRuleChart() {
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => `Year ${String(value)}`}
|
||||
indicator="dot"
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,"The Home Bias Effect in Global Portfolios" —{' '}
|
||||
<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,"Global equity investing: The benefits of diversification" —{' '}
|
||||
<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 & Vanpee (2012), “The home bias puzzle in equity portfolios” —{' '}
|
||||
Sercu & Vanpee (2012),"The home bias puzzle in equity portfolios" —{' '}
|
||||
<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 & Titman (2017), “Should you tilt your equity portfolio to smaller
|
||||
countries?” —{' '}
|
||||
Fisher, Shah & Titman (2017),"Should you tilt your equity portfolio to smaller
|
||||
countries?" —{' '}
|
||||
<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 & Sy (2023), “Diversification during hard times” —{' '}
|
||||
Attig & Sy (2023), "Diversification during hard times" —{' '}
|
||||
<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),"Foreign revenue: A new world of risk exposures" —{' '}
|
||||
<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 & O’Doherty (2023), “Beyond the status quo: A critical assessment
|
||||
of lifecycle investment advice” —{' '}
|
||||
Anarkulova, Cederburg & O’Doherty (2023),"Beyond the status quo: A critical
|
||||
assessment of lifecycle investment advice" —{' '}
|
||||
<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),"Will history rhyme?" —{' '}
|
||||
<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),"Is economic growth good for investors?" —{' '}
|
||||
<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),"Five things I know about investing" —{' '}
|
||||
<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),"World War 1 and global stock markets" —{' '}
|
||||
<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),"An intertemporal capital asset pricing model" —{' '}
|
||||
<Link
|
||||
href="https://doi.org/10.2307/1913811"
|
||||
className="text-primary hover:underline"
|
||||
|
||||
@@ -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'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'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">
|
||||
|
||||
279
src/app/learn/retire-at/[age]/page.tsx
Normal file
279
src/app/learn/retire-at/[age]/page.tsx
Normal 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 40–60% savings rate if you want to retire in 10–15 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.5–4% 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., 5–6%) 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"Rule of 25" (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 5–10 years before {age}</li>
|
||||
<li>Lower planned spending or add part-time income (Barista/Coast FIRE)</li>
|
||||
<li>Use conservative returns (5–7%) and realistic inflation (2–3%)</li>
|
||||
<li>Consider longer life expectancy (age {age + 30}+)</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<FaqSection faqs={faqForAge(age)} className="my-12" />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
16
src/app/learn/retire-at/__tests__/page.test.ts
Normal file
16
src/app/learn/retire-at/__tests__/page.test.ts
Normal 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()));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,"Global equity investing: The benefits of diversification" —{' '}
|
||||
<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,"The Home Bias Effect in Global Portfolios" —{' '}
|
||||
<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"Three-Fund Portfolio" —{' '}
|
||||
<Link
|
||||
href="https://www.bogleheads.org/wiki/Three-fund_portfolio"
|
||||
className="text-primary hover:underline"
|
||||
|
||||
@@ -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">
|
||||
<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"25x annual spending" 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"max out" early contributions but let compounding do the rest.
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
|
||||
110
src/components/ui/__tests__/tooltip.test.tsx
Normal file
110
src/components/ui/__tests__/tooltip.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
42
src/components/ui/popover.tsx
Normal file
42
src/components/ui/popover.tsx
Normal 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 };
|
||||
@@ -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({
|
||||
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,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
)}
|
||||
{...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',
|
||||
|
||||
129
src/lib/__tests__/retire-at.test.ts
Normal file
129
src/lib/__tests__/retire-at.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
50
src/lib/calculator-schema.ts
Normal file
50
src/lib/calculator-schema.ts
Normal 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
206
src/lib/retire-at.ts
Normal 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 }),
|
||||
};
|
||||
};
|
||||
@@ -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(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user