Compare commits

...

43 Commits

Author SHA1 Message Date
3ec25c6f3f fix(deps): update nextjs monorepo to v16.1.0
Some checks are pending
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Typecheck (pull_request) Successful in 39s
Lint / Lint and Typecheck (push) Successful in 48s
2025-12-21 08:46:23 +00:00
fd97dd54a6 fix(deps): update dependency lucide-react to ^0.562.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 43s
2025-12-20 13:36:33 +01:00
b4ac2356cd chore(deps): update dependency react-hook-form to v7.69.0
Some checks failed
Lint / Lint and Typecheck (push) Has been cancelled
2025-12-20 13:34:57 +01:00
5dc185266c chore(deps): update pnpm to v10.26.1
Some checks failed
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Typecheck (pull_request) Successful in 40s
Lint / Lint and Typecheck (push) Has been cancelled
2025-12-20 12:14:17 +00:00
98090c8a8c chore(deps): update pnpm to v10.26.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 47s
2025-12-20 07:02:01 +00:00
c424fad4fc chore(deps): update dependency zod to v4.2.1
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 46s
2025-12-20 06:01:57 +00:00
d90ab94ac1 chore(deps): update dependency typescript-eslint to v8.50.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 43s
2025-12-20 05:02:17 +00:00
619d46ebb7 chore(deps): update dependency vitest to v4.0.16
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 42s
2025-12-20 04:02:25 +00:00
2b2c05c88a chore(deps): update dependency @types/node to v24.10.4
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 46s
2025-12-20 03:03:01 +00:00
84e6d5bba7 chore(deps): update dependency @testing-library/react to v16.3.1
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 45s
2025-12-20 02:03:00 +00:00
761574a972 chore(deps): update dependency @t3-oss/env-nextjs to v0.13.10
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 37s
2025-12-20 01:03:18 +00:00
f82ebc2792 chore(deps): update node.js to c921b97
All checks were successful
Lint / Lint and Typecheck (push) Successful in 42s
2025-12-20 00:02:29 +00:00
dda8cedd9a fix(deps): update nextjs monorepo to v16.0.10
Some checks are pending
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Typecheck (pull_request) Successful in 38s
Lint / Lint and Typecheck (push) Successful in 44s
2025-12-13 22:22:57 +00:00
30e5198b14 fix(deps): update dependency lucide-react to ^0.561.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 41s
2025-12-13 18:21:35 +01:00
8f2443572f chore(deps): update dependency eslint to v9.39.2
Some checks failed
Lint / Lint and Typecheck (push) Has been cancelled
2025-12-13 18:18:34 +01:00
28cb553ed0 chore(deps): update dependency @types/node to v24.10.3
Some checks failed
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Typecheck (push) Has been cancelled
Lint / Lint and Typecheck (pull_request) Successful in 40s
2025-12-13 17:04:14 +00:00
013aa41965 fix(deps): update dependency lucide-react to ^0.559.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 41s
2025-12-13 14:03:19 +00:00
6bb37bd6c3 fix(deps): update dependency lucide-react to ^0.557.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (pull_request) Successful in 38s
Lint / Lint and Typecheck (push) Successful in 44s
2025-12-13 13:03:25 +00:00
b9b16bcbf3 chore(deps): pin dependencies
All checks were successful
Lint / Lint and Typecheck (push) Successful in 43s
2025-12-13 11:27:56 +01:00
bb9d09c653 chore(deps): update pnpm to v10.25.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 38s
2025-12-13 00:02:24 +00:00
bf2c8b4e1a chore(deps): update dependency typescript-eslint to v8.49.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 36s
2025-12-12 23:02:18 +00:00
001a7b5c35 chore(deps): update dependency jsdom to v27.3.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 41s
2025-12-12 22:02:49 +00:00
85089cd187 fix(deps): update nextjs monorepo to v16.0.8
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 40s
2025-12-12 21:02:38 +00:00
4b67c1ab2c chore(deps): update dependency @vitejs/plugin-react to v5.1.2
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 39s
2025-12-12 20:03:39 +00:00
b9a2228422 chore(deps): update dependency @types/node to v24.10.2
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 37s
2025-12-12 19:29:54 +00:00
dda92f3f80 fix(deps): update react monorepo to v19.2.3
All checks were successful
Lint / Lint and Typecheck (push) Successful in 39s
2025-12-12 19:50:14 +01:00
ec52cbc116 chore(deps): update tailwindcss monorepo to v4.1.18
Some checks failed
Lint / Lint and Typecheck (push) Has been cancelled
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Typecheck (pull_request) Successful in 40s
2025-12-12 18:24:48 +00:00
17a694d4b5 Adds Docker support for Next.js standalone
All checks were successful
Lint / Lint and Typecheck (push) Successful in 48s
Adds a production-ready Dockerfile and .dockerignore, and updates Next.js config to produce a standalone output.

Provides a multi-stage build that installs dependencies (yarn/npm/pnpm supported), runs the Next.js build, and assembles a slim runtime image on Node Alpine. Configures a non-root runtime user, exposes PORT 3000, and includes runtime utilities and compatibility packages to ensure reliable container execution. These changes enable consistent, smaller production container images and simplified deployment.
2025-12-09 13:46:54 +01:00
dc9cf1c1f2 openGraph/Metadata completion
All checks were successful
Lint / Lint and Typecheck (push) Successful in 43s
2025-12-08 09:34:35 +01:00
cb4a4e2f06 chore(deps): update dependency vitest to v4.0.15
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 43s
2025-12-07 00:53:00 +00:00
6c09c22656 chore(deps): update dependency react-hook-form to v7.68.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 45s
2025-12-07 01:48:41 +01:00
a7a2fe39ca chore(deps): pin dependencies
Some checks failed
Lint / Lint and Typecheck (pull_request) Successful in 37s
Lint / Lint and Typecheck (push) Has been cancelled
2025-12-07 01:45:50 +01:00
3dc79aa425 fix MC test
All checks were successful
Lint / Lint and Typecheck (push) Successful in 41s
2025-12-07 01:43:26 +01:00
35bc31fb3d tootip and graph style fixes
Some checks failed
Lint / Lint and Typecheck (push) Failing after 45s
2025-12-06 22:58:10 +01:00
4aa961fc1c monte-carlo improvements 2025-12-06 21:42:00 +01:00
7fcb2c9a0f minor fix 2025-12-06 20:58:42 +01:00
6a13860a80 Improves input test reliability and restores setup mocks
Switches input change test to use async wait for reliable value assertion.
Restores and enhances test setup with matchMedia mock to support media query-dependent components in jsdom.
2025-12-06 20:46:01 +01:00
0a5d691d04 fix tooltips 2025-12-06 20:45:54 +01:00
9ec1a4ab79 run unit tests as part of lint job 2025-12-06 20:34:33 +01:00
b2c07ba8a3 shadcn popover 2025-12-06 20:27:08 +01:00
0030f91bb2 Removes 4% rule overlays and adds URL hydration to form
Eliminates all 4%-rule related overlays, buttons, and UI elements from the calculator for a simpler experience. Introduces hydration of calculator inputs from URL search params, enabling sharing of form state via URLs and restoring state on page reload. Updates the form's share button styling and ensures all necessary URL parameters are set for sharing.

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

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

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git

View File

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

69
Dockerfile Normal file
View 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"]

View File

@@ -5,6 +5,8 @@
import './src/env.ts';
/** @type {import("next").NextConfig} */
const config = {};
const config = {
output: 'standalone',
};
export default config;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,27 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { extractNumericSearchParam } from '@/lib/retire-at';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ChartContainer, ChartTooltip } from '@/components/ui/chart';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import {
Area,
AreaChart,
CartesianGrid,
Line,
XAxis,
YAxis,
ReferenceLine,
@@ -21,12 +29,15 @@ import {
} from 'recharts';
import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
import { Calculator, Info, Percent } from 'lucide-react';
import type { NameType, Payload, ValueType } from 'recharts/types/component/DefaultTooltipContent';
import { Calculator, Info, Share2, Check } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import BlurThing from './blur-thing';
import Link from 'next/link';
import type { FireCalculatorFormValues } from '@/lib/calculator-schema';
import { fireCalculatorDefaultValues, fireCalculatorFormSchema } from '@/lib/calculator-schema';
// Helper component for info tooltips next to form labels
function InfoTooltip({ content }: Readonly<{ content: string }>) {
return (
@@ -41,39 +52,8 @@ function InfoTooltip({ content }: Readonly<{ content: string }>) {
);
}
// Schema for form validation
const formSchema = z.object({
startingCapital: z.coerce.number(),
monthlySavings: z.coerce.number().min(0, 'Monthly savings must be a non-negative number'),
currentAge: z.coerce
.number()
.min(1, 'Age must be at least 1')
.max(100, 'No point in starting this late'),
cagr: z.coerce.number().min(0, 'Growth rate must be a non-negative number'),
desiredMonthlyAllowance: z.coerce.number().min(0, 'Monthly allowance must be a non-negative number'),
inflationRate: z.coerce.number().min(0, 'Inflation rate must be a non-negative number'),
lifeExpectancy: z.coerce
.number()
.min(40, 'Be a bit more optimistic buddy :(')
.max(100, 'You should be more realistic...'),
retirementAge: z.coerce
.number()
.min(18, 'Retirement age must be at least 18')
.max(100, 'Retirement age must be at most 100'),
coastFireAge: z.coerce
.number()
.min(18, 'Coast FIRE age must be at least 18')
.max(100, 'Coast FIRE age must be at most 100')
.optional(),
baristaIncome: z.coerce.number().min(0, 'Barista income must be a non-negative number').optional(),
simulationMode: z.enum(['deterministic', 'monte-carlo']).default('deterministic'),
volatility: z.coerce.number().min(0).default(15),
withdrawalStrategy: z.enum(['fixed', 'percentage']).default('fixed'),
withdrawalPercentage: z.coerce.number().min(0).max(100).default(4),
});
// Type for form values
type FormValues = z.infer<typeof formSchema>;
const formSchema = fireCalculatorFormSchema;
type FormValues = FireCalculatorFormValues;
interface YearlyData {
age: number;
@@ -91,8 +71,6 @@ interface YearlyData {
interface CalculationResult {
fireNumber: number | null;
fireNumber4percent: number | null;
retirementAge4percent: number | null;
yearlyData: YearlyData[];
error?: string;
successRate?: number; // For Monte Carlo
@@ -114,56 +92,153 @@ const formatNumber = (value: number | null) => {
}).format(value);
};
// Helper function to render tooltip for chart
const tooltipRenderer = ({ active, payload }: TooltipProps<ValueType, NameType>) => {
if (active && payload?.[0]?.payload) {
const data = payload[0].payload as YearlyData;
return (
<div className="bg-background border p-2 shadow-sm">
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
{data.balanceP50 !== undefined ? (
<>
<p className="text-orange-500">{`Median Balance: ${formatNumber(data.balanceP50)}`}</p>
<p className="text-xs text-orange-300">{`10th %: ${formatNumber(data.balanceP10 ?? 0)}`}</p>
<p className="text-xs text-orange-300">{`90th %: ${formatNumber(data.balanceP90 ?? 0)}`}</p>
</>
) : (
<p className="text-orange-500">{`Balance: ${formatNumber(data.balance)}`}</p>
)}
<p className="text-red-600">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
<p>{`Phase: ${data.phase === 'accumulation' ? 'Accumulation' : 'Retirement'}`}</p>
</div>
);
const formatNumberShort = (value: number) => {
if (value >= 1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toPrecision(3)}K`;
} else if (value <= -1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value <= -1000) {
return `${(value / 1000).toPrecision(3)}K`;
}
return null;
return value.toString();
};
export default function FireCalculatorForm() {
// Chart tooltip with the same styling as ChartTooltipContent, but with our custom label info
const tooltipRenderer = ({ active, payload, label }: TooltipProps<ValueType, NameType>) => {
const allowedKeys = new Set(['balance', 'monthlyAllowance']);
const filteredPayload: Payload<ValueType, NameType>[] = (payload ?? [])
.filter(
(item): item is Payload<ValueType, NameType> =>
typeof item.dataKey === 'string' && allowedKeys.has(item.dataKey),
)
.map((item) => ({
...item,
value: formatNumberShort(item.value as number),
}));
const safeLabel = typeof label === 'string' || typeof label === 'number' ? label : undefined;
return (
<ChartTooltipContent
active={active}
payload={filteredPayload}
label={safeLabel}
indicator="line"
className="min-w-48"
labelFormatter={(_, items: Payload<ValueType, NameType>[]) => {
const point = items.length > 0 ? (items[0]?.payload as YearlyData | undefined) : undefined;
if (!point) {
return null;
}
const phaseLabel = point.phase === 'retirement' ? 'Retirement phase' : 'Accumulation phase';
return (
<div className="flex flex-col gap-0.5">
<span>{`Year ${String(point.year)} (Age ${String(point.age)})`}</span>
<span className="text-muted-foreground">{phaseLabel}</span>
</div>
);
}}
/>
);
};
export default function FireCalculatorForm({
initialValues,
autoCalculate = false,
}: Readonly<{
initialValues?: Partial<FireCalculatorFormValues>;
autoCalculate?: boolean;
}>) {
const [result, setResult] = useState<CalculationResult | null>(null);
const irlYear = new Date().getFullYear();
const [showing4percent, setShowing4percent] = useState(false);
const [copied, setCopied] = useState(false);
// Initialize form with default values
const form = useForm<z.input<typeof formSchema>, undefined, FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
startingCapital: 50000,
monthlySavings: 1500,
currentAge: 25,
cagr: 7,
desiredMonthlyAllowance: 3000,
inflationRate: 2.3,
lifeExpectancy: 84,
retirementAge: 55,
coastFireAge: undefined,
baristaIncome: 0,
simulationMode: 'deterministic',
volatility: 15,
withdrawalStrategy: 'fixed',
withdrawalPercentage: 4,
},
defaultValues: initialValues ?? fireCalculatorDefaultValues,
});
// Hydrate from URL search params
const searchParams = useSearchParams();
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
if (hasHydrated) return;
if (searchParams.size === 0) {
setHasHydrated(true);
return;
}
const newValues: Partial<FormValues> = {};
const getParam = (key: string) => searchParams.get(key) ?? undefined;
const getNum = (key: string, bounds: { min?: number; max?: number } = {}) =>
extractNumericSearchParam(getParam(key), bounds);
const startingCapital = getNum('startingCapital', { min: 0 });
if (startingCapital !== undefined) newValues.startingCapital = startingCapital;
const monthlySavings = getNum('monthlySavings', { min: 0, max: 50000 });
if (monthlySavings !== undefined) newValues.monthlySavings = monthlySavings;
const currentAge = getNum('currentAge', { min: 1, max: 100 });
if (currentAge !== undefined) newValues.currentAge = currentAge;
const cagr = getNum('cagr') ?? getNum('growthRate', { min: 0, max: 30 });
if (cagr !== undefined) newValues.cagr = cagr;
const desiredMonthlyAllowance =
getNum('monthlySpend', { min: 0, max: 20000 }) ??
getNum('monthlyAllowance', { min: 0, max: 20000 });
if (desiredMonthlyAllowance !== undefined)
newValues.desiredMonthlyAllowance = desiredMonthlyAllowance;
const inflationRate = getNum('inflationRate', { min: 0, max: 20 });
if (inflationRate !== undefined) newValues.inflationRate = inflationRate;
const lifeExpectancy = getNum('lifeExpectancy', { min: 40, max: 110 });
if (lifeExpectancy !== undefined) newValues.lifeExpectancy = lifeExpectancy;
const retirementAge = getNum('retirementAge', { min: 18, max: 100 });
if (retirementAge !== undefined) newValues.retirementAge = retirementAge;
const coastFireAge = getNum('coastFireAge', { min: 18, max: 100 });
if (coastFireAge !== undefined) newValues.coastFireAge = coastFireAge;
const baristaIncome = getNum('baristaIncome', { min: 0 });
if (baristaIncome !== undefined) newValues.baristaIncome = baristaIncome;
const volatility = getNum('volatility', { min: 0 });
if (volatility !== undefined) newValues.volatility = volatility;
const withdrawalPercentage = getNum('withdrawalPercentage', { min: 0, max: 100 });
if (withdrawalPercentage !== undefined) newValues.withdrawalPercentage = withdrawalPercentage;
const simMode = searchParams.get('simulationMode');
if (simMode === 'deterministic' || simMode === 'monte-carlo') {
newValues.simulationMode = simMode;
}
const wStrategy = searchParams.get('withdrawalStrategy');
if (wStrategy === 'fixed' || wStrategy === 'percentage') {
newValues.withdrawalStrategy = wStrategy;
}
if (Object.keys(newValues).length > 0) {
// We merge with current values (which are defaults initially)
const merged = { ...form.getValues(), ...newValues };
form.reset(merged);
// Trigger calculation
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}
setHasHydrated(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, hasHydrated]); // form is stable, but adding it causes no harm, excluding for cleaner hook deps
function onSubmit(values: FormValues) {
setResult(null); // Reset previous results
@@ -180,7 +255,7 @@ export default function FireCalculatorForm() {
const simulationMode = values.simulationMode;
const volatility = values.volatility;
const numSimulations = simulationMode === 'monte-carlo' ? 500 : 1;
const numSimulations = simulationMode === 'monte-carlo' ? 2000 : 1;
const simulationResults: number[][] = []; // [yearIndex][simulationIndex] -> balance
// Prepare simulation runs
@@ -258,9 +333,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>

View File

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

View File

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

View File

@@ -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',
},
],
},
};

View File

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

View File

@@ -1,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&apos;re in the US, EU, UK, Canada, Australia, or elsewhere.
Build a world-allocation portfolio, avoid home bias, and choose the right accounts
whether you&apos;re in the US, EU, UK, Canada, Australia, or elsewhere.
</p>
</CardContent>
</Card>
@@ -128,14 +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">

View 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 4060% savings rate if you want to retire in 1015 years. The exact rate depends on your starting capital, investment returns, and spending goal. Slide the monthly savings input to see how it moves your FIRE number and timeline.',
},
{
question: 'Is the 4% rule safe for this timeline?',
answer:
'The 4% rule is a starting point, not a guarantee. Consider 3.54% for longer retirements or higher inflation periods. The calculator supports both fixed and percentage-based withdrawals so you can stress-test more conservative plans.',
},
{
question: 'What if markets underperform?',
answer:
'Use a lower CAGR (e.g., 56%) and a higher inflation rate (e.g., 3%) in the calculator. Switch to Monte Carlo mode to see success probabilities with volatility. Also build flexibility into spending: trimming costs in bad years greatly improves durability.',
},
];
};
export const generateStaticParams = () =>
RETIRE_AT_AGE_PRESETS.map((age) => ({
age: age.toString(),
}));
export const generateMetadata = async ({ params }: RetireAtPageProps): Promise<Metadata> => {
const { age: slugAge } = await params;
const age = parseAgeParam(slugAge);
const ageLabel = age.toString();
const title = `How Much Do You Need to Retire at ${ageLabel}? | InvestingFIRE`;
const description = `Instant answer plus calculator: see how much you need saved to retire at ${ageLabel}, modeled with your spending, returns, and inflation assumptions.`;
const canonical = `${BASE_URL.replace(/\/$/, '')}/learn/retire-at/${ageLabel}`;
return {
title,
description,
alternates: {
canonical,
},
openGraph: {
title,
description,
url: canonical,
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&quot;Rule of 25&quot; (annual spend ÷ {withdrawalRate * 100}%)</li>
<li>Assumes inflation-adjusted withdrawals and a diversified portfolio</li>
<li>Refine the projection below with your exact savings, age, and market assumptions</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>At-a-Glance</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<div className="flex items-center justify-between">
<span>Target age</span>
<span className="text-foreground font-semibold">{age}</span>
</div>
<div className="flex items-center justify-between">
<span>Monthly spend (today)</span>
<span className="text-foreground font-semibold">
{currencyFormatter.format(monthlySpend)}
</span>
</div>
<div className="flex items-center justify-between">
<span>Withdrawal rate</span>
<span className="text-foreground font-semibold">{(withdrawalRate * 100).toFixed(1)}%</span>
</div>
<div className="flex items-center justify-between">
<span>Rule-of-25 nest egg</span>
<span className="text-foreground font-semibold">
{currencyFormatter.format(quickNestEgg)}
</span>
</div>
</CardContent>
</Card>
</div>
<section className="mt-12 space-y-6">
<div className="flex items-baseline justify-between gap-3">
<div>
<h2 className="text-2xl font-bold">Spend Scenarios</h2>
<p className="text-muted-foreground">
Lean, classic, and comfortable budgets with required nest eggs.
</p>
</div>
<Link
href="/learn/safe-withdrawal-rate-4-percent-rule"
className="text-primary text-sm hover:underline"
>
Why the {Math.round(withdrawalRate * 100)}% rule?
</Link>
</div>
<div className="grid gap-4 md:grid-cols-3">
{scenarios.map((scenario) => (
<Card key={scenario.key} className="h-full">
<CardHeader>
<CardTitle>{scenario.label}</CardTitle>
<CardDescription>
{currencyFormatter.format(scenario.monthlySpend)} / month
</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<div className="flex items-center justify-between">
<span>Annual spend</span>
<span className="text-foreground font-semibold">
{currencyFormatter.format(scenario.annualSpend)}
</span>
</div>
<div className="flex items-center justify-between">
<span>Needed to retire</span>
<span className="text-foreground font-semibold">
{currencyFormatter.format(scenario.nestEgg)}
</span>
</div>
</CardContent>
</Card>
))}
</div>
</section>
<section className="mt-14 space-y-6">
<div className="bg-primary/5 rounded-xl border p-8 text-center">
<h2 className="mb-4 text-3xl font-bold">Ready to Plan Your Details?</h2>
<p className="text-muted-foreground mx-auto mb-8 max-w-2xl text-lg">
This page gives you a ballpark estimate. Use our full-featured calculator to customize
inflation, market returns, simulation modes (Monte Carlo), and more for your specific
situation.
</p>
<Button size="lg" className="h-auto px-8 py-6 text-lg" asChild>
<Link href={`/?${queryParams.toString()}`}>Open Full Calculator for Age {age}</Link>
</Button>
</div>
</section>
<section className="mt-12 grid gap-6 md:grid-cols-2">
<Card className="col-span-full">
<CardHeader>
<CardTitle>Key Levers to Watch</CardTitle>
<CardDescription>Improve success odds for age {age}</CardDescription>
</CardHeader>
<CardContent>
<ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li>Boost savings rate in the final 510 years before {age}</li>
<li>Lower planned spending or add part-time income (Barista/Coast FIRE)</li>
<li>Use conservative returns (57%) and realistic inflation (23%)</li>
<li>Consider longer life expectancy (age {age + 30}+)</li>
</ul>
</CardContent>
</Card>
</section>
<FaqSection faqs={faqForAge(age)} className="my-12" />
</article>
);
}

View File

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

View File

@@ -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',
},
],
},
};

View File

@@ -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',
},
],
},
};

View File

@@ -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,&quot;Global equity investing: The benefits of diversification&quot; {' '}
<Link
href="https://corporate.vanguard.com/content/dam/corp/research/pdf/global-equity-investing-benefits-diversification.pdf"
className="text-primary hover:underline"
@@ -373,7 +386,7 @@ export default function ParkYourMoneyPage() {
</Link>
</li>
<li>
MSCI, The Home Bias Effect in Global Portfolios {' '}
MSCI,&quot;The Home Bias Effect in Global Portfolios&quot; {' '}
<Link
href="https://www.msci.com/research-and-insights/quick-take/did-home-bias-help"
className="text-primary hover:underline"
@@ -395,7 +408,7 @@ export default function ParkYourMoneyPage() {
</Link>
</li>
<li>
Bogleheads Three-Fund Portfolio {' '}
Bogleheads&quot;Three-Fund Portfolio&quot; {' '}
<Link
href="https://www.bogleheads.org/wiki/Three-fund_portfolio"
className="text-primary hover:underline"

View File

@@ -1,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&quot;25x annual spending&quot; rule. It runs a{' '}
<strong>year-by-year simulation</strong> of your portfolio, combining:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
@@ -277,7 +306,7 @@ export default function HomePage() {
>
Coast FIRE Calculator
</a>{' '}
- When you max out early contributions but let compounding do the rest.
- When you&quot;max out&quot; early contributions but let compounding do the rest.
</li>
<li>
<a

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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