Compare commits
43 Commits
bfac54a194
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ec25c6f3f | |||
| fd97dd54a6 | |||
| b4ac2356cd | |||
| 5dc185266c | |||
| 98090c8a8c | |||
| c424fad4fc | |||
| d90ab94ac1 | |||
| 619d46ebb7 | |||
| 2b2c05c88a | |||
| 84e6d5bba7 | |||
| 761574a972 | |||
| f82ebc2792 | |||
| dda8cedd9a | |||
| 30e5198b14 | |||
| 8f2443572f | |||
| 28cb553ed0 | |||
| 013aa41965 | |||
| 6bb37bd6c3 | |||
| b9b16bcbf3 | |||
| bb9d09c653 | |||
| bf2c8b4e1a | |||
| 001a7b5c35 | |||
| 85089cd187 | |||
| 4b67c1ab2c | |||
| b9a2228422 | |||
| dda92f3f80 | |||
| ec52cbc116 | |||
| 17a694d4b5 | |||
| dc9cf1c1f2 | |||
| cb4a4e2f06 | |||
| 6c09c22656 | |||
| a7a2fe39ca | |||
| 3dc79aa425 | |||
| 35bc31fb3d | |||
| 4aa961fc1c | |||
| 7fcb2c9a0f | |||
| 6a13860a80 | |||
| 0a5d691d04 | |||
| 9ec1a4ab79 | |||
| b2c07ba8a3 | |||
| 0030f91bb2 | |||
| 2b0df3d100 | |||
| 15a32dc467 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
@@ -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
|
||||
|
||||
69
Dockerfile
Normal file
69
Dockerfile
Normal file
@@ -0,0 +1,69 @@
|
||||
# syntax=docker.io/docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
|
||||
|
||||
FROM node:24-alpine@sha256:c921b97d4b74f51744057454b306b418cf693865e73b8100559189605f6955b8 AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
# ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
# wget needed for healthcheck
|
||||
RUN apk add --no-cache wget
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy public files. "[c]" as workaround for conditional matching since the folder might not exist.
|
||||
COPY --from=builder /app/publi[c] ./public
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
||||
@@ -5,6 +5,8 @@
|
||||
import './src/env.ts';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {};
|
||||
const config = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
39
package.json
39
package.json
@@ -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",
|
||||
@@ -32,44 +33,44 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cssnano": "^7.1.2",
|
||||
"lucide-react": "^0.556.0",
|
||||
"next": "16.0.7",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.0",
|
||||
"next-plausible": "^3.12.4",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.56.1",
|
||||
"recharts": "^2.15.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "4.1.17",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "24.10.1",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.1",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/node": "24.10.4",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-next": "16.0.7",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-next": "16.1.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"jsdom": "^27.2.0",
|
||||
"jsdom": "27.3.0",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.7.4",
|
||||
"prettier-plugin-tailwindcss": "0.7.2",
|
||||
"tailwindcss": "4.1.17",
|
||||
"tailwindcss": "4.1.18",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.48.1",
|
||||
"vitest": "^4.0.13"
|
||||
"typescript-eslint": "8.50.0",
|
||||
"vitest": "4.0.16"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"packageManager": "pnpm@10.26.1",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "19.2.7",
|
||||
|
||||
1471
pnpm-lock.yaml
generated
1471
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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,18 @@ export default function FireCalculatorForm() {
|
||||
// Sort to find percentiles
|
||||
balancesForYear.sort((a, b) => a - b);
|
||||
|
||||
const p10 = balancesForYear[Math.floor(numSimulations * 0.1)];
|
||||
const p50 = balancesForYear[Math.floor(numSimulations * 0.5)];
|
||||
const p90 = balancesForYear[Math.floor(numSimulations * 0.9)];
|
||||
const pickPercentile = (fraction: number) => {
|
||||
const clampedIndex = Math.min(
|
||||
balancesForYear.length - 1,
|
||||
Math.max(0, Math.floor((balancesForYear.length - 1) * fraction)),
|
||||
);
|
||||
return balancesForYear[clampedIndex];
|
||||
};
|
||||
|
||||
// For Monte Carlo, we present a narrow middle band (40th-60th) to show typical outcomes
|
||||
const p10 = pickPercentile(0.4);
|
||||
const p50 = pickPercentile(0.5);
|
||||
const p90 = pickPercentile(0.6);
|
||||
|
||||
// Calculate other metrics (using deterministic logic for "untouched" etc for simplicity, or p50)
|
||||
// We need to reconstruct the "standard" fields for compatibility with the chart
|
||||
@@ -307,42 +391,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 +408,83 @@ 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 simulationModeValue = form.watch('simulationMode');
|
||||
const isMonteCarlo = simulationModeValue === 'monte-carlo';
|
||||
const chartData =
|
||||
result?.yearlyData.map((row) => ({
|
||||
...row,
|
||||
mcRange: (row.balanceP90 ?? 0) - (row.balanceP10 ?? 0),
|
||||
})) ?? [];
|
||||
|
||||
// Ensure we always have a fresh calculation when switching simulation modes (or on first render)
|
||||
useEffect(() => {
|
||||
if (!result) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [simulationModeValue]);
|
||||
|
||||
const projectionChartConfig: ChartConfig = {
|
||||
year: {
|
||||
label: 'Year',
|
||||
},
|
||||
balance: {
|
||||
label: 'Balance',
|
||||
color: 'var(--color-orange-500)',
|
||||
},
|
||||
balanceP10: {
|
||||
label: 'P10 balance',
|
||||
color: 'var(--color-orange-500)',
|
||||
},
|
||||
balanceP90: {
|
||||
label: 'P90 balance',
|
||||
color: 'var(--color-orange-500)',
|
||||
},
|
||||
monthlyAllowance: {
|
||||
label: 'Monthly allowance',
|
||||
color: 'var(--color-secondary)',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="border-primary/15 bg-background/90 shadow-primary/10 mb-6 border shadow-lg backdrop-blur">
|
||||
@@ -667,7 +802,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 +935,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 +955,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 +964,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 +990,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 +1052,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 +1062,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 +1133,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,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('40th-60th 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"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CoastFireChart } from '@/app/components/charts/CoastFireChart';
|
||||
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
@@ -39,16 +40,28 @@ const faqs: FaqItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: `Coast FIRE vs. Lean FIRE: Which Strategy Is Right For You? (${new Date().getFullYear().toString()})`,
|
||||
description:
|
||||
'Compare Coast FIRE (front-loading savings) with Lean FIRE (minimalist living). See the math, pros, cons, and find your path to freedom.',
|
||||
alternates: {
|
||||
canonical: 'https://investingfire.com/learn/coast-fire-vs-lean-fire',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Coast FIRE vs. Lean FIRE: The Ultimate Comparison',
|
||||
description:
|
||||
"Don't just retire early—retire smarter. We break down the two most popular alternative FIRE strategies.",
|
||||
type: 'article',
|
||||
siteName: 'InvestingFIRE',
|
||||
url: 'https://investingfire.com/learn/coast-fire-vs-lean-fire',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Info } from 'lucide-react';
|
||||
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
@@ -33,15 +34,27 @@ const faqs: FaqItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: 'Home Bias in Investing: Why It Matters and How to Fix It',
|
||||
description:
|
||||
'Home bias concentrates risk in one country. Learn why it happens, how it hurts returns, and simple steps to global diversification.',
|
||||
alternates: {
|
||||
canonical: 'https://investingfire.com/learn/home-bias-in-investing',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Home Bias in Investing: Why It Matters and How to Fix It',
|
||||
description: 'Reduce country concentration, improve diversification, and stay tax aware.',
|
||||
type: 'article',
|
||||
siteName: 'InvestingFIRE',
|
||||
url: 'https://investingfire.com/learn/home-bias-in-investing',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -175,7 +188,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 +199,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 +210,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 +221,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 +233,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 +244,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 +255,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 +267,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 +278,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 +287,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 +298,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 +320,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,13 +1,35 @@
|
||||
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';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: 'Learn FIRE | Financial Independence Guides & Resources',
|
||||
description:
|
||||
'Master the art of Financial Independence and Early Retirement. Deep dives into safe withdrawal rates, asset allocation, and FIRE strategies.',
|
||||
alternates: {
|
||||
canonical: 'https://investingfire.com/learn',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Learn FIRE | Financial Independence Guides & Resources',
|
||||
description:
|
||||
'Master the art of Financial Independence and Early Retirement. Deep dives into safe withdrawal rates, asset allocation, and FIRE strategies.',
|
||||
siteName: 'InvestingFIRE',
|
||||
url: 'https://investingfire.com/learn',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
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 +129,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 +150,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">
|
||||
|
||||
288
src/app/learn/retire-at/[age]/page.tsx
Normal file
288
src/app/learn/retire-at/[age]/page.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
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,
|
||||
siteName: 'InvestingFIRE',
|
||||
type: 'article',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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()));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Info } from 'lucide-react';
|
||||
import { FourPercentRuleChart } from '@/app/components/charts/FourPercentRuleChart';
|
||||
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
@@ -39,14 +40,26 @@ const faqs: FaqItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: 'Safe Withdrawal Rates & The 4% Rule Explained (2025 Update)',
|
||||
description: `Is the 4% rule safe in ${new Date().getFullYear().toString()}? We analyze the Trinity Study, sequence of returns risk, and variable withdrawal strategies for a bulletproof retirement.`,
|
||||
alternates: {
|
||||
canonical: 'https://investingfire.com/learn/safe-withdrawal-rate-4-percent-rule',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Safe Withdrawal Rates & The 4% Rule Explained',
|
||||
description: "Don't run out of money. Understanding the math behind safe retirement withdrawals.",
|
||||
type: 'article',
|
||||
siteName: 'InvestingFIRE',
|
||||
url: 'https://investingfire.com/learn/safe-withdrawal-rate-4-percent-rule',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { FireFlowchart } from '@/app/components/charts/FireFlowchart';
|
||||
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
@@ -37,15 +38,27 @@ const faqs: FaqItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: `What is FIRE? The Ultimate Guide to Financial Independence (${new Date().getFullYear().toString()})`,
|
||||
description:
|
||||
'Discover the FIRE movement (Financial Independence, Retire Early). Learn how to calculate your FIRE number, savings rate, and retire decades ahead of schedule.',
|
||||
alternates: {
|
||||
canonical: 'https://investingfire.com/learn/what-is-fire',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'What is FIRE? The Ultimate Guide to Financial Independence',
|
||||
description: 'Stop trading time for money. The comprehensive guide to regaining your freedom.',
|
||||
type: 'article',
|
||||
siteName: 'InvestingFIRE',
|
||||
url: 'https://investingfire.com/learn/what-is-fire',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Info } from 'lucide-react';
|
||||
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
@@ -34,15 +35,27 @@ const faqs: FaqItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: `Where to Park Your Money for FIRE (${new Date().getFullYear().toString()})`,
|
||||
description:
|
||||
'Build a globally diversified, low-cost index portfolio, avoid home bias, and use the right tax wrappers—wherever you live. A practical guide for FIRE investors.',
|
||||
alternates: {
|
||||
canonical: 'https://investingfire.com/learn/where-to-park-your-money',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Where to Park Your Money for FIRE',
|
||||
description: 'Global index investing playbook: avoid home bias, cut fees, optimize taxes.',
|
||||
type: 'article',
|
||||
siteName: 'InvestingFIRE',
|
||||
url: 'https://investingfire.com/learn/where-to-park-your-money',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -362,7 +375,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 +386,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 +408,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,8 +1,10 @@
|
||||
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';
|
||||
import { Testimonials } from './components/Testimonials';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
@@ -37,6 +39,31 @@ const faqs: FaqItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `InvestingFIRE | Finance and Retirement Calculator ${new Date().getFullYear().toString()}`,
|
||||
description:
|
||||
'Achieve Financial Independence & Early Retirement! Plan your FIRE journey with the InvestingFIRE calculator and get personal projections in gorgeous graphs..',
|
||||
alternates: {
|
||||
canonical: 'https://investingfire.com',
|
||||
},
|
||||
openGraph: {
|
||||
title: `InvestingFIRE | Finance and Retirement Calculator ${new Date().getFullYear().toString()}`,
|
||||
description:
|
||||
'Achieve Financial Independence & Early Retirement! Plan your FIRE journey with the InvestingFIRE calculator and get personal projections in gorgeous graphs.',
|
||||
type: 'website',
|
||||
url: 'https://investingfire.com',
|
||||
siteName: 'InvestingFIRE',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="from-background via-primary/10 to-secondary/10 text-foreground relative flex min-h-screen w-full flex-col items-center overflow-hidden bg-gradient-to-b px-4 pt-6 pb-16">
|
||||
@@ -64,7 +91,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 +135,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 +306,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({
|
||||
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',
|
||||
|
||||
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