Compare commits
66 Commits
2b09cfc352
...
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 | |||
| bfac54a194 | |||
| 9d9c8b0d37 | |||
| dc74f507c3 | |||
| 80f0de3b57 | |||
| 9d66598c7e | |||
| ed31944963 | |||
| e8f0269b75 | |||
| 597b7a5883 | |||
| 14834024ec | |||
| 8ac1c1a9df | |||
| 46dd28482f | |||
| 288a9b4992 | |||
| 37d8511da7 | |||
| cd2179f7a0 | |||
| 21a8c95a2b | |||
| 1711c2d16b | |||
| 8714d3a30d | |||
| dc5f32f093 | |||
| a99f1e75cc | |||
| c4d39a8b67 | |||
| 67a68d0a72 | |||
| fd6d0b306f | |||
| b977393768 |
3
.cursor/worktrees.json
Normal file
3
.cursor/worktrees.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"setup-worktree": ["pnpm install"]
|
||||
}
|
||||
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:
|
||||
@@ -19,13 +19,16 @@ jobs:
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
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;
|
||||
|
||||
42
package.json
42
package.json
@@ -23,52 +23,54 @@
|
||||
"@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",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@t3-oss/env-nextjs": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cssnano": "^7.1.2",
|
||||
"lucide-react": "^0.555.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.56.1",
|
||||
"@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.1",
|
||||
"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",
|
||||
|
||||
1519
pnpm-lock.yaml
generated
1519
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,22 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
export function AuthorBio() {
|
||||
return (
|
||||
<Card className="mt-12 bg-muted/50">
|
||||
<Card className="bg-muted/50 mt-12">
|
||||
<CardContent className="flex items-center gap-4 p-6">
|
||||
<Avatar className="h-16 w-16 border-2 border-background">
|
||||
<Avatar className="border-background h-16 w-16 border-2">
|
||||
<AvatarImage src="/images/author-profile.jpg" alt="Author" />
|
||||
<AvatarFallback>IF</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Written by The InvestingFIRE Team</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We are a group of financial data enthusiasts and early retirees dedicated to building the most accurate FIRE tools on the web. Our goal is to replace guesswork with math.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
We are a group of financial data enthusiasts and early retirees dedicated to building the
|
||||
most accurate FIRE tools on the web. Our goal is to replace guesswork with math.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
49
src/app/components/FaqSection.tsx
Normal file
49
src/app/components/FaqSection.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
|
||||
export interface FaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
interface FaqSectionProps {
|
||||
faqs: FaqItem[];
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function FaqSection({
|
||||
faqs,
|
||||
className,
|
||||
title = 'Frequently Asked Questions',
|
||||
}: Readonly<FaqSectionProps>) {
|
||||
// JSON-LD FAQPage schema
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={className}>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
|
||||
<h2 className="mb-6 text-2xl font-bold">{title}</h2>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{faqs.map((faq) => (
|
||||
<AccordionItem key={faq.question} value={faq.question}>
|
||||
<AccordionTrigger className="text-left">{faq.question}</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground">{faq.answer}</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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,43 +29,31 @@ 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, 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';
|
||||
|
||||
// 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),
|
||||
});
|
||||
import type { FireCalculatorFormValues } from '@/lib/calculator-schema';
|
||||
import { fireCalculatorDefaultValues, fireCalculatorFormSchema } from '@/lib/calculator-schema';
|
||||
|
||||
// Type for form values
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
// Helper component for info tooltips next to form labels
|
||||
function InfoTooltip({ content }: Readonly<{ content: string }>) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button" className="ml-1 inline-flex align-middle">
|
||||
<Info className="text-muted-foreground hover:text-foreground h-3.5 w-3.5 transition-colors" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = fireCalculatorFormSchema;
|
||||
type FormValues = FireCalculatorFormValues;
|
||||
|
||||
interface YearlyData {
|
||||
age: number;
|
||||
@@ -75,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
|
||||
@@ -98,54 +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,
|
||||
},
|
||||
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
|
||||
|
||||
@@ -162,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
|
||||
@@ -240,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
|
||||
@@ -289,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,
|
||||
@@ -332,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">
|
||||
@@ -357,7 +510,10 @@ export default function FireCalculatorForm() {
|
||||
name="startingCapital"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Starting Capital</FormLabel>
|
||||
<FormLabel>
|
||||
Starting Capital
|
||||
<InfoTooltip content="Your current invested savings or nest egg. This is the amount you have already saved and invested." />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 10000"
|
||||
@@ -382,7 +538,10 @@ export default function FireCalculatorForm() {
|
||||
name="monthlySavings"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Monthly Savings</FormLabel>
|
||||
<FormLabel>
|
||||
Monthly Savings
|
||||
<InfoTooltip content="The amount you invest each month. This is added to your portfolio during the accumulation phase." />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 500"
|
||||
@@ -407,7 +566,10 @@ export default function FireCalculatorForm() {
|
||||
name="currentAge"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Age</FormLabel>
|
||||
<FormLabel>
|
||||
Current Age
|
||||
<InfoTooltip content="Your age today. This is used to calculate the timeline to retirement and beyond." />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 30"
|
||||
@@ -432,7 +594,10 @@ export default function FireCalculatorForm() {
|
||||
name="lifeExpectancy"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Life Expectancy (Age)</FormLabel>
|
||||
<FormLabel>
|
||||
Life Expectancy (Age)
|
||||
<InfoTooltip content="Your estimated age of death for planning purposes. This determines how long your money needs to last." />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 90"
|
||||
@@ -457,7 +622,10 @@ export default function FireCalculatorForm() {
|
||||
name="cagr"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Expected Annual Growth Rate (%)</FormLabel>
|
||||
<FormLabel>
|
||||
Expected Annual Growth Rate (%)
|
||||
<InfoTooltip content="Average yearly investment return (CAGR). The VTI has historically returned ~7%." />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 7"
|
||||
@@ -483,7 +651,10 @@ export default function FireCalculatorForm() {
|
||||
name="inflationRate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Annual Inflation Rate (%)</FormLabel>
|
||||
<FormLabel>
|
||||
Annual Inflation Rate (%)
|
||||
<InfoTooltip content="Expected yearly price increase. Historical average is ~2-3%. This adjusts your spending needs over time." />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 2"
|
||||
@@ -509,7 +680,10 @@ export default function FireCalculatorForm() {
|
||||
name="desiredMonthlyAllowance"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Desired Monthly Allowance (Today's Value)</FormLabel>
|
||||
<FormLabel>
|
||||
Monthly Allowance (Today's Value)
|
||||
<InfoTooltip content="Your monthly spending needs in retirement, expressed in today's dollars. This will be adjusted for inflation each year." />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 2000"
|
||||
@@ -536,7 +710,10 @@ export default function FireCalculatorForm() {
|
||||
name="retirementAge"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Retirement Age: {field.value as number}</FormLabel>
|
||||
<FormLabel>
|
||||
Retirement Age: {field.value as number}
|
||||
<InfoTooltip content="The age when you stop working and start withdrawing from your portfolio to cover living expenses." />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
name="retirementAge"
|
||||
@@ -561,7 +738,13 @@ export default function FireCalculatorForm() {
|
||||
name="coastFireAge"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Coast FIRE Age (Optional) - Stop contributing at age:</FormLabel>
|
||||
<FormLabel>
|
||||
<Button variant="link" size={'sm'} asChild>
|
||||
<Link href="/learn/what-is-fire#types-of-fire">Coast FIRE</Link>
|
||||
</Button>{' '}
|
||||
Age (Optional):
|
||||
<InfoTooltip content="The age when you stop making new contributions but keep working to cover current expenses. Your existing investments compound until retirement." />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 45 (defaults to Retirement Age)"
|
||||
@@ -586,7 +769,13 @@ export default function FireCalculatorForm() {
|
||||
name="baristaIncome"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Barista FIRE Income (Monthly during Retirement)</FormLabel>
|
||||
<FormLabel>
|
||||
<Button variant="link" size={'sm'} asChild>
|
||||
<Link href="/learn/what-is-fire#types-of-fire">Barista FIRE</Link>
|
||||
</Button>{' '}
|
||||
Monthly Income
|
||||
<InfoTooltip content="Part-time income during retirement (e.g., from a low-stress job). This reduces the amount you need to withdraw from your portfolio." />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 1000"
|
||||
@@ -611,7 +800,10 @@ export default function FireCalculatorForm() {
|
||||
name="simulationMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Simulation Mode</FormLabel>
|
||||
<FormLabel>
|
||||
Simulation Mode
|
||||
<InfoTooltip content="Monte Carlo simulates market randomness with 2000 runs to show probability ranges. Deterministic uses fixed yearly returns." />
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
@@ -640,7 +832,10 @@ export default function FireCalculatorForm() {
|
||||
name="volatility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Market Volatility (Std Dev %)</FormLabel>
|
||||
<FormLabel>
|
||||
Market Volatility (Std Dev %)
|
||||
<InfoTooltip content="Standard deviation of annual returns. 15% is typical for stocks. Higher values mean more unpredictable year-to-year swings." />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 15"
|
||||
@@ -666,7 +861,10 @@ export default function FireCalculatorForm() {
|
||||
name="withdrawalStrategy"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Withdrawal Strategy</FormLabel>
|
||||
<FormLabel>
|
||||
Withdrawal Strategy
|
||||
<InfoTooltip content="Fixed inflation-adjusted maintains your purchasing power yearly. Percentage of portfolio adjusts spending based on current balance." />
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
@@ -695,7 +893,10 @@ export default function FireCalculatorForm() {
|
||||
name="withdrawalPercentage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Withdrawal Percentage (%)</FormLabel>
|
||||
<FormLabel>
|
||||
Withdrawal Percentage (%)
|
||||
<InfoTooltip content="Annual withdrawal rate as percentage of current portfolio. 4% is the classic 'safe' rate from the Trinity Study." />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 4.0"
|
||||
@@ -734,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"
|
||||
@@ -752,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={{}}
|
||||
@@ -772,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"
|
||||
@@ -801,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"
|
||||
@@ -835,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',
|
||||
@@ -845,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>
|
||||
@@ -943,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>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import FireCalculatorForm from "../FireCalculatorForm";
|
||||
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import FireCalculatorForm from '../FireCalculatorForm';
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||
|
||||
// Mocking ResizeObserver
|
||||
class ResizeObserver {
|
||||
observe() { /* noop */ }
|
||||
unobserve() { /* noop */ }
|
||||
disconnect() { /* noop */ }
|
||||
observe() {
|
||||
/* noop */
|
||||
}
|
||||
unobserve() {
|
||||
/* noop */
|
||||
}
|
||||
disconnect() {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
global.ResizeObserver = ResizeObserver;
|
||||
|
||||
@@ -20,115 +26,121 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
// Mock Recharts ResponsiveContainer
|
||||
vi.mock("recharts", async () => {
|
||||
const originalModule = await vi.importActual("recharts");
|
||||
vi.mock('recharts', async () => {
|
||||
const originalModule = await vi.importActual('recharts');
|
||||
return {
|
||||
...originalModule,
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div style={{ width: "500px", height: "300px" }}>{children}</div>
|
||||
<div style={{ width: '500px', height: '300px' }}>{children}</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
describe("FireCalculatorForm", () => {
|
||||
it("renders the form with default values", () => {
|
||||
// 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 />);
|
||||
|
||||
expect(screen.getByText("FIRE Calculator")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Starting Capital/i)).toHaveValue(50000);
|
||||
expect(screen.getByLabelText(/Monthly Savings/i)).toHaveValue(1500);
|
||||
expect(screen.getByLabelText(/Current Age/i)).toHaveValue(25);
|
||||
|
||||
expect(screen.getByText('FIRE Calculator')).toBeInTheDocument();
|
||||
expect(screen.getByRole('spinbutton', { name: /Starting Capital/i })).toHaveValue(50000);
|
||||
expect(screen.getByRole('spinbutton', { name: /Monthly Savings/i })).toHaveValue(1500);
|
||||
expect(screen.getByRole('spinbutton', { name: /Current Age/i })).toHaveValue(25);
|
||||
});
|
||||
|
||||
it("calculates and displays results when submitted", async () => {
|
||||
it('calculates and displays results when submitted', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FireCalculatorForm />);
|
||||
|
||||
const calculateButton = screen.getByRole("button", { name: /Calculate/i });
|
||||
|
||||
const calculateButton = screen.getByRole('button', { name: /Calculate/i });
|
||||
await user.click(calculateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Financial Projection")).toBeInTheDocument();
|
||||
expect(screen.getByText("FIRE Number")).toBeInTheDocument();
|
||||
expect(screen.getByText('Financial Projection')).toBeInTheDocument();
|
||||
expect(screen.getByText('FIRE Number')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("allows changing inputs", () => {
|
||||
// using fireEvent for reliability with number inputs in jsdom
|
||||
render(<FireCalculatorForm />);
|
||||
|
||||
const savingsInput = screen.getByLabelText(/Monthly Savings/i);
|
||||
|
||||
fireEvent.change(savingsInput, { target: { value: "2000" } });
|
||||
|
||||
it('allows changing inputs', async () => {
|
||||
render(<FireCalculatorForm />);
|
||||
|
||||
const savingsInput = screen.getByRole('spinbutton', { name: /Monthly Savings/i });
|
||||
|
||||
fireEvent.change(savingsInput, { target: { value: '2000' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(savingsInput).toHaveValue(2000);
|
||||
});
|
||||
|
||||
it("validates inputs", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FireCalculatorForm />);
|
||||
|
||||
const ageInput = screen.getByLabelText(/Current Age/i);
|
||||
// Use fireEvent to set invalid value directly
|
||||
fireEvent.change(ageInput, { target: { value: "-5" } });
|
||||
|
||||
const calculateButton = screen.getByRole("button", { name: /Calculate/i });
|
||||
await user.click(calculateButton);
|
||||
|
||||
// Look for error message text
|
||||
expect(await screen.findByText(/Age must be at least 1/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles Monte Carlo simulation mode", async () => {
|
||||
it('validates inputs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FireCalculatorForm />);
|
||||
|
||||
const ageInput = screen.getByRole('spinbutton', { name: /Current Age/i });
|
||||
// Use fireEvent to set invalid value directly
|
||||
fireEvent.change(ageInput, { target: { value: '-5' } });
|
||||
|
||||
const calculateButton = screen.getByRole('button', { name: /Calculate/i });
|
||||
await user.click(calculateButton);
|
||||
|
||||
// Look for error message text
|
||||
expect(await screen.findByText(/Age must be at least 1/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles Monte Carlo simulation mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FireCalculatorForm />);
|
||||
|
||||
// Select Trigger
|
||||
const modeTrigger = screen.getByRole("combobox", { name: /Simulation Mode/i });
|
||||
const modeTrigger = screen.getByRole('combobox', { name: /Simulation Mode/i });
|
||||
await user.click(modeTrigger);
|
||||
|
||||
|
||||
// Select Monte Carlo from dropdown
|
||||
const monteCarloOption = await screen.findByRole("option", { name: /Monte Carlo/i });
|
||||
const monteCarloOption = await screen.findByRole('option', { name: /Monte Carlo/i });
|
||||
await user.click(monteCarloOption);
|
||||
|
||||
// Verify Volatility input appears
|
||||
expect(await screen.findByLabelText(/Market Volatility/i)).toBeInTheDocument();
|
||||
expect(await screen.findByRole('spinbutton', { name: /Market Volatility/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles 4% Rule overlay", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FireCalculatorForm />);
|
||||
|
||||
// Calculate first to show results
|
||||
const calculateButton = screen.getByRole("button", { name: /Calculate/i });
|
||||
await user.click(calculateButton);
|
||||
|
||||
// Wait for results
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Financial Projection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the Show 4%-Rule button
|
||||
const showButton = screen.getByRole("button", { name: /Show 4%-Rule/i });
|
||||
await user.click(showButton);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
it("handles withdrawal strategy selection", async () => {
|
||||
it('shows Monte Carlo percentile bounds on the chart', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FireCalculatorForm />);
|
||||
|
||||
const strategyTrigger = screen.getByRole("combobox", { name: /Withdrawal Strategy/i });
|
||||
const modeTrigger = screen.getByRole('combobox', { name: /Simulation Mode/i });
|
||||
await user.click(modeTrigger);
|
||||
|
||||
const monteCarloOption = await screen.findByRole('option', { name: /Monte Carlo/i });
|
||||
await user.click(monteCarloOption);
|
||||
|
||||
await screen.findByText('Financial Projection');
|
||||
const bandLegend = await screen.findByTestId('mc-band-legend');
|
||||
|
||||
expect(bandLegend).toHaveTextContent('40th-60th percentile');
|
||||
});
|
||||
|
||||
it('handles withdrawal strategy selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FireCalculatorForm />);
|
||||
|
||||
const strategyTrigger = screen.getByRole('combobox', { name: /Withdrawal Strategy/i });
|
||||
await user.click(strategyTrigger);
|
||||
|
||||
const percentageOption = await screen.findByRole("option", { name: /Percentage of Portfolio/i });
|
||||
const percentageOption = await screen.findByRole('option', { name: /Percentage of Portfolio/i });
|
||||
await user.click(percentageOption);
|
||||
|
||||
expect(await screen.findByLabelText(/Withdrawal Percentage/i)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole('spinbutton', { name: /Withdrawal Percentage/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ export default function BlurThing() {
|
||||
return (
|
||||
<>
|
||||
{/* Decorative background elements */}
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div className="from-primary/25 to-primary/15 absolute -top-24 -right-24 h-64 w-64 rounded-full bg-gradient-to-br blur-3xl" />
|
||||
<div className="absolute -bottom-24 -left-24 h-64 w-64 rounded-full bg-gradient-to-br from-orange-500/25 to-red-500/15 blur-3xl" />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Line, LineChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
@@ -45,6 +52,9 @@ const generateData = () => {
|
||||
const data = generateData();
|
||||
|
||||
const chartConfig = {
|
||||
age: {
|
||||
label: 'Age',
|
||||
},
|
||||
Standard: {
|
||||
label: 'Standard Path',
|
||||
color: 'var(--chart-4)',
|
||||
@@ -75,16 +85,22 @@ export function CoastFireChart() {
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value: number) => `$${String(value / 1000)}k`}
|
||||
tickFormatter={(value: number) => {
|
||||
if (value < 1000) {
|
||||
return `$${String(value)}`;
|
||||
}
|
||||
if (value < 1000000) {
|
||||
return `$${String(value / 1000)}k`;
|
||||
}
|
||||
if (value < 1000000000) {
|
||||
return `$${String(value / 1000000)}M`;
|
||||
}
|
||||
return `$${String(value / 1000000000)}B`;
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => `Age ${String(value)}`}
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
content={<ChartTooltipContent indicator="line" labelKey="age" />}
|
||||
/>
|
||||
<Line
|
||||
dataKey="Standard"
|
||||
@@ -103,6 +119,12 @@ export function CoastFireChart() {
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
<CardFooter>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Simulation assumes 7% returns. Standard: Save $10k/yr (age 25-65). Coast: Save $30k/yr (age
|
||||
25-35), then $0.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
@@ -85,7 +92,7 @@ export function FourPercentRuleChart() {
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => `Year ${String(value)}`}
|
||||
indicator="dot"
|
||||
indicator="line"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -96,6 +103,15 @@ export function FourPercentRuleChart() {
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<div>
|
||||
<p className="font-medium">4% balances safety and spending power</p>
|
||||
<p className="text-muted-foreground leading-none">
|
||||
A 5% withdrawal rate risks depleting your portfolio within 30 years, while 3% leaves a large
|
||||
surplus. The 4% rule is widely considered the safe "sweet spot."
|
||||
</p>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,17 +4,64 @@ import { Separator } from '@/components/ui/separator';
|
||||
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';
|
||||
|
||||
export const metadata = {
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
question: 'What is the main difference between Coast FIRE and Lean FIRE?',
|
||||
answer:
|
||||
'Coast FIRE focuses on front-loading your savings early so compound interest does the rest—you still work but only to cover current expenses. Lean FIRE means fully retiring but on a minimal budget, typically under $40,000/year.',
|
||||
},
|
||||
{
|
||||
question: 'How do I calculate my Coast FIRE number?',
|
||||
answer:
|
||||
'Your Coast FIRE number depends on your target retirement age, expected investment returns, and desired retirement spending. Use the formula: Coast Number = Target FIRE Number ÷ (1 + annual return)^(years until traditional retirement). Our calculator handles this automatically.',
|
||||
},
|
||||
{
|
||||
question: 'Is Lean FIRE sustainable long-term?',
|
||||
answer:
|
||||
'Lean FIRE can be sustainable if you genuinely enjoy a minimalist lifestyle and have low-cost hobbies. However, it has less margin for unexpected expenses like healthcare or inflation spikes. Consider building a buffer or having flexible spending categories.',
|
||||
},
|
||||
{
|
||||
question: 'Can I combine Coast FIRE and Lean FIRE strategies?',
|
||||
answer:
|
||||
'Absolutely. Many people save aggressively (Lean FIRE mindset) to hit their Coast number early, then switch to a lower-stress job while their investments compound. This hybrid approach offers flexibility and reduced burnout.',
|
||||
},
|
||||
{
|
||||
question: 'Which strategy is better for someone in their 20s?',
|
||||
answer:
|
||||
'Coast FIRE often works well for young savers because you have decades for compound growth. Save aggressively for 10-15 years, hit your Coast number, then enjoy career flexibility. Lean FIRE might suit those who want to exit the workforce entirely ASAP.',
|
||||
},
|
||||
{
|
||||
question: 'What are the biggest risks of each strategy?',
|
||||
answer:
|
||||
'Coast FIRE risks include poor market returns during your coasting years or lifestyle inflation. Lean FIRE risks include unexpected expenses, healthcare costs, or finding the frugal lifestyle unsustainable over decades.',
|
||||
},
|
||||
];
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,7 +116,7 @@ export default function CoastVsLeanPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||
<div className="max-w-none">
|
||||
<h2>The Quick Summary</h2>
|
||||
<p>Not sure which one fits you? Here is the high-level breakdown:</p>
|
||||
|
||||
@@ -147,7 +194,7 @@ export default function CoastVsLeanPage() {
|
||||
discipline.
|
||||
</p>
|
||||
|
||||
<Separator className="my-12" />
|
||||
<Separator className="my-16" />
|
||||
|
||||
<h2>Run The Numbers</h2>
|
||||
<p>The best way to decide is to see the math. Use our calculator to simulate both scenarios:</p>
|
||||
@@ -180,6 +227,8 @@ export default function CoastVsLeanPage() {
|
||||
The most important step is to just <strong>start</strong>.
|
||||
</p>
|
||||
|
||||
<FaqSection faqs={faqs} className="my-12" />
|
||||
|
||||
<AuthorBio />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
354
src/app/learn/home-bias-in-investing/page.tsx
Normal file
354
src/app/learn/home-bias-in-investing/page.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import Link from 'next/link';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
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[] = [
|
||||
{
|
||||
question: 'How much of my portfolio should be domestic?',
|
||||
answer:
|
||||
'A common approach is market-cap weighting globally (roughly 55–60% US, 40–45% international today). Some investors keep 10–30% home tilt for currency needs, but large overweights increase concentration risk.',
|
||||
},
|
||||
{
|
||||
question: 'Does currency hedging remove home bias?',
|
||||
answer:
|
||||
'No. Hedging manages currency volatility but does not reduce country/sector concentration. Home bias is about overweighting domestic equities relative to their global weight.',
|
||||
},
|
||||
{
|
||||
question: 'Are there times when a home tilt makes sense?',
|
||||
answer:
|
||||
'Yes. If you have future liabilities in local currency (housing, tuition) or you want to simplify taxes, a modest tilt can be justified. Keep it intentional and sized.',
|
||||
},
|
||||
{
|
||||
question: 'What about emerging markets?',
|
||||
answer:
|
||||
'Global indexes already include emerging markets (EM). Adding a small EM tilt is optional; avoid excluding EM entirely to prevent regional concentration.',
|
||||
},
|
||||
{
|
||||
question: 'How do I reduce home bias in practice?',
|
||||
answer:
|
||||
'Replace single-country funds with global or All-World ETFs. Set an allocation policy (e.g., 80% global cap-weight, 20% local tilt) and rebalance to it instead of reacting to headlines.',
|
||||
},
|
||||
];
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function HomeBiasPage() {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Home Bias in Investing: Why It Matters and How to Fix It',
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'InvestingFIRE Team',
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'InvestingFIRE',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
},
|
||||
},
|
||||
datePublished: '2025-01-24',
|
||||
description:
|
||||
'Understand home bias, its risks, and practical steps to diversify globally while respecting local tax rules.',
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="container mx-auto max-w-3xl 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">Home Bias</span>
|
||||
</nav>
|
||||
|
||||
<header className="mb-10">
|
||||
<h1 className="mb-6 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
Home Bias: The Hidden Risk in Your Portfolio
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xl leading-relaxed">
|
||||
Overweighting your home market feels comfortable but concentrates risk. Here’s why it happens
|
||||
and how to diversify without creating tax headaches.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="max-w-none">
|
||||
<Alert className="mb-8">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Bias Check</AlertTitle>
|
||||
<AlertDescription>
|
||||
If your country is less than 10% of global market cap but more than 50% of your portfolio,
|
||||
you’re taking concentrated country and currency risk.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<h2>What is Home Bias?</h2>
|
||||
<p>
|
||||
Home bias is the tendency to hold a far larger share of domestic stocks than their global
|
||||
weight. Investors in the US, UK, Canada, Sweden, India, and Australia all exhibit this behavior
|
||||
despite different market sizes.
|
||||
</p>
|
||||
|
||||
<h2>Why It Happens</h2>
|
||||
<ul className="mb-6 list-disc space-y-2 pl-5">
|
||||
<li>
|
||||
<strong>Familiarity:</strong> You know the brands and news cycle.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Currency needs:</strong> You expect to spend in your local currency.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Access & regulation:</strong> Some brokers limit foreign listings (PRIIPs/UCITS).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tax frictions:</strong> Forms, withholding tax, and paperwork discourage global
|
||||
exposure.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="mt-16">Why It’s Risky</h2>
|
||||
<ul className="mb-6 list-disc space-y-2 pl-5">
|
||||
<li>
|
||||
Country/sector concentration (e.g., US tech, Canadian financials/energy, Swedish industrials)
|
||||
</li>
|
||||
<li>Currency risk without diversification benefits</li>
|
||||
<li>Policy and regulatory risk (capital controls, tax changes)</li>
|
||||
<li>Missed growth in other regions and sectors</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="mt-16">Fixing Home Bias (Practical Steps)</h2>
|
||||
<ol className="list-decimal space-y-2 pl-5">
|
||||
<li>Measure: compare your country weight to global cap weights (ACWI/FTSE All-World).</li>
|
||||
<li>Adopt a global core: one All-World ETF (VWCE, VT, VWRA, DHHF) or IWDA+EMIM combo.</li>
|
||||
<li>Set a deliberate tilt: e.g., 20% home, 80% global. Rebalance to policy, not headlines.</li>
|
||||
<li>
|
||||
Match currency to liabilities: keep cash for near-term local spending; hedge bonds where
|
||||
available.
|
||||
</li>
|
||||
<li>Use local wrappers to handle tax but hold global funds inside them when allowed.</li>
|
||||
</ol>
|
||||
|
||||
<h2 className="mt-16">Tax & Wrapper Considerations</h2>
|
||||
<p>
|
||||
You can stay globally diversified while using local tax shelters. The key is picking the right
|
||||
share class and domicile:
|
||||
</p>
|
||||
<ul className="mb-6 list-disc space-y-2 pl-5">
|
||||
<li>
|
||||
US: Avoid PFICs; prefer US-domiciled ETFs in IRAs/401k. Consider foreign tax credits in
|
||||
taxable.
|
||||
</li>
|
||||
<li>
|
||||
UK/EU: UCITS ETFs (PRIIPs compliant). Accumulating classes reduce admin in many systems.
|
||||
</li>
|
||||
<li>Canada: US-domiciled ETFs in RRSP may reduce withholding; TFSA does not.</li>
|
||||
<li>
|
||||
Sweden: ISK/KF simplify reporting; IE-domiciled ETFs help withholding compared to
|
||||
US-domiciled.
|
||||
</li>
|
||||
<li>
|
||||
Australia/NZ: Use Super for tax efficiency; outside, consider broad local + global ETFs.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<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" —{' '}
|
||||
<Link
|
||||
href="https://www.msci.com/research-and-insights/quick-take/did-home-bias-help"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
MSCI
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Vanguard
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Oxford University Press
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Journal of Portfolio Management
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
Attig & Sy (2023), "Diversification during hard times" —{' '}
|
||||
<Link
|
||||
href="https://doi.org/10.1080/0015198X.2022.2160620"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Financial Analysts Journal
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Journal of Portfolio Management
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
SSRN
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
Goetzmann (2004),"Will history rhyme?" —{' '}
|
||||
<Link
|
||||
href="https://doi.org/10.3905/jpm.2004.442619"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Journal of Portfolio Management
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
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"
|
||||
>
|
||||
Journal of Applied Corporate Finance
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Dimensional
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Global Financial Data
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
Episode 200: Prof. Eugene Fama (2022) —{' '}
|
||||
<Link
|
||||
href="https://rationalreminder.ca/podcast/200"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Rational Reminder Podcast
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
Merton (1973),"An intertemporal capital asset pricing model" —{' '}
|
||||
<Link
|
||||
href="https://doi.org/10.2307/1913811"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Econometrica
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="my-10 grid gap-4 sm:grid-cols-2">
|
||||
<Link href="/learn/where-to-park-your-money">
|
||||
<Button size="lg" className="w-full text-lg">
|
||||
Build a Global Portfolio →
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/learn/safe-withdrawal-rate-4-percent-rule">
|
||||
<Button size="lg" variant="secondary" className="w-full text-lg">
|
||||
Plan Withdrawals →
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<FaqSection faqs={faqs} className="my-12" />
|
||||
|
||||
<AuthorBio />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
@@ -25,7 +47,7 @@ export default function LearnHubPage() {
|
||||
<BlurThing />
|
||||
<CardHeader>
|
||||
<div className="mb-2">
|
||||
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-300">
|
||||
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
Beginner
|
||||
</span>
|
||||
</div>
|
||||
@@ -51,7 +73,7 @@ export default function LearnHubPage() {
|
||||
<Card className="hover:border-primary/50 h-full cursor-pointer border-2">
|
||||
<CardHeader>
|
||||
<div className="mb-2">
|
||||
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
|
||||
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
Strategy
|
||||
</span>
|
||||
</div>
|
||||
@@ -75,7 +97,7 @@ export default function LearnHubPage() {
|
||||
<Card className="hover:border-primary/50 h-full cursor-pointer border-2">
|
||||
<CardHeader>
|
||||
<div className="mb-2">
|
||||
<span className="rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800 dark:bg-purple-900 dark:text-purple-300">
|
||||
<span className="rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800">
|
||||
Comparison
|
||||
</span>
|
||||
</div>
|
||||
@@ -90,6 +112,77 @@ export default function LearnHubPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Article 4 */}
|
||||
<Link href="/learn/where-to-park-your-money" className="transition-transform hover:scale-[1.02]">
|
||||
<Card className="hover:border-primary/50 h-full cursor-pointer border-2">
|
||||
<CardHeader>
|
||||
<div className="mb-2">
|
||||
<span className="rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-800">
|
||||
Portfolio
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Where to Park Your Money</CardTitle>
|
||||
<CardDescription>
|
||||
Global, low-cost index strategies, tax wrappers, and broker tips for FIRE.
|
||||
</CardDescription>
|
||||
</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.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Article 5 */}
|
||||
<Link href="/learn/home-bias-in-investing" className="transition-transform hover:scale-[1.02]">
|
||||
<Card className="hover:border-primary/50 h-full cursor-pointer border-2">
|
||||
<CardHeader>
|
||||
<div className="mb-2">
|
||||
<span className="rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800">
|
||||
Risk
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Home Bias Explained</CardTitle>
|
||||
<CardDescription>Why country concentration hurts—and how to fix it.</CardDescription>
|
||||
</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.
|
||||
</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">
|
||||
|
||||
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()));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,62 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
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';
|
||||
|
||||
export const metadata = {
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
question: 'What is the 4% rule and where does it come from?',
|
||||
answer:
|
||||
'The 4% rule comes from the Trinity Study (1998), which analyzed historical data to find a sustainable withdrawal rate. It states that withdrawing 4% of your initial portfolio in year one, then adjusting for inflation each year, has historically survived 95% of 30-year periods.',
|
||||
},
|
||||
{
|
||||
question: 'Is 4% still safe for early retirees?',
|
||||
answer:
|
||||
'For early retirees with 40-50+ year horizons, many experts recommend a more conservative 3.25-3.5% withdrawal rate. The original study only covered 30-year periods, and current market valuations may lead to lower future returns.',
|
||||
},
|
||||
{
|
||||
question: 'What is sequence of returns risk?',
|
||||
answer:
|
||||
'Sequence of returns risk is the danger of experiencing poor market returns early in retirement. If you withdraw from a declining portfolio, you sell more shares to maintain income, leaving less to recover when markets rebound. This can deplete your portfolio even if long-term average returns are good.',
|
||||
},
|
||||
{
|
||||
question: 'Should I withdraw 4% of my current balance each year?',
|
||||
answer:
|
||||
'No. The 4% rule uses your initial retirement portfolio value. You withdraw 4% of that starting amount, then increase it by inflation each year—regardless of market performance. Some prefer percentage-of-portfolio strategies, which adjust spending to market conditions.',
|
||||
},
|
||||
{
|
||||
question: 'What is the guardrails withdrawal strategy?',
|
||||
answer:
|
||||
'The guardrails approach sets upper and lower bounds on spending. If your portfolio drops significantly, you reduce withdrawals (skip discretionary spending). If it grows substantially, you give yourself a raise. This flexibility dramatically improves portfolio survival rates.',
|
||||
},
|
||||
{
|
||||
question: 'How does inflation affect the 4% rule?',
|
||||
answer:
|
||||
'Inflation is built into the 4% rule—you increase withdrawals by the inflation rate each year to maintain purchasing power. However, periods of unexpectedly high inflation (like recent years) can stress portfolios more than historical averages suggest.',
|
||||
},
|
||||
];
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -64,7 +111,7 @@ export default function SafeWithdrawalPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||
<div className="max-w-none">
|
||||
<h2>What is the 4% Rule?</h2>
|
||||
<p>
|
||||
The rule comes from the <strong>Trinity Study</strong> (1998), which looked at historical
|
||||
@@ -158,6 +205,8 @@ export default function SafeWithdrawalPage() {
|
||||
trigger on retirement.
|
||||
</p>
|
||||
|
||||
<FaqSection faqs={faqs} className="my-12" />
|
||||
|
||||
<AuthorBio />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,18 +1,64 @@
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
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';
|
||||
|
||||
export const metadata = {
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
question: 'How much money do I need to achieve FIRE?',
|
||||
answer:
|
||||
'The amount depends on your annual expenses. Using the Rule of 25, multiply your yearly spending by 25. For example, if you spend $40,000 per year, you need $1,000,000 invested. This is based on the 4% safe withdrawal rate.',
|
||||
},
|
||||
{
|
||||
question: 'What savings rate do I need to retire early?',
|
||||
answer:
|
||||
'The higher your savings rate, the faster you can retire. At a 50% savings rate, you can retire in about 17 years. At 70%, it drops to around 8.5 years. The key is the gap between your income and expenses, not your absolute income.',
|
||||
},
|
||||
{
|
||||
question: 'Is FIRE only for high-income earners?',
|
||||
answer:
|
||||
'No. While higher income makes it easier, FIRE is fundamentally about the savings rate—the percentage of income you save. Someone earning $50,000 saving 50% can reach FIRE faster than someone earning $200,000 saving 10%.',
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between Lean FIRE and Fat FIRE?',
|
||||
answer:
|
||||
'Lean FIRE means retiring on a minimal budget (typically under $40,000/year), requiring a smaller nest egg but more frugal living. Fat FIRE means retiring with a larger budget ($100,000+/year) for a more comfortable lifestyle, requiring a much larger portfolio.',
|
||||
},
|
||||
{
|
||||
question: 'Where should I invest for FIRE?',
|
||||
answer:
|
||||
'Most FIRE practitioners favor low-cost index funds (like total stock market funds) due to their diversification and minimal fees. Tax-advantaged accounts (401k, IRA, Roth IRA) should generally be maxed out before taxable accounts.',
|
||||
},
|
||||
{
|
||||
question: 'Can I still pursue FIRE if I have debt?',
|
||||
answer:
|
||||
'Yes, but prioritization matters. High-interest debt (credit cards, personal loans) should typically be paid off first. Low-interest debt like mortgages can often be managed alongside investing, depending on rates and your risk tolerance.',
|
||||
},
|
||||
];
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,7 +114,7 @@ export default function WhatIsFirePage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||
<div className="max-w-none">
|
||||
<p>
|
||||
Imagine waking up on a Monday morning without an alarm clock. You don't have to rush to a
|
||||
commute, sit in traffic, or answer to a boss. Instead, you have the ultimate luxury:{' '}
|
||||
@@ -137,7 +183,7 @@ export default function WhatIsFirePage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h2>Types of FIRE</h2>
|
||||
<h2 id="types-of-fire">Types of FIRE</h2>
|
||||
<p>FIRE isn't one-size-fits-all. Over the years, several variations have emerged:</p>
|
||||
<ul>
|
||||
<li>
|
||||
@@ -183,6 +229,8 @@ export default function WhatIsFirePage() {
|
||||
best time to plant a tree was 20 years ago. The second best time is today.
|
||||
</p>
|
||||
|
||||
<FaqSection faqs={faqs} className="my-12" />
|
||||
|
||||
<AuthorBio />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
442
src/app/learn/where-to-park-your-money/page.tsx
Normal file
442
src/app/learn/where-to-park-your-money/page.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import Link from 'next/link';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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[] = [
|
||||
{
|
||||
question: 'Is a single world ETF enough?',
|
||||
answer:
|
||||
'For most long-term investors, a single, low-cost global index fund (like VT in the US or VWCE in the EU) paired with a risk-appropriate bond fund is sufficient. Add regional tilts only if you have a clear, deliberate reason.',
|
||||
},
|
||||
{
|
||||
question: 'Should I choose accumulating or distributing share classes?',
|
||||
answer:
|
||||
'If your tax system does not tax unrealized gains and you want simplicity, accumulating share classes can reduce paperwork. In countries that tax deemed distributions or where you need cash flow, distributing classes may make sense.',
|
||||
},
|
||||
{
|
||||
question: 'How often should I rebalance?',
|
||||
answer:
|
||||
'Set simple guardrails: rebalance when an asset class is 5–10 percentage points away from target, or on a set cadence (e.g., annually). Avoid excessive trading to minimize taxes and fees.',
|
||||
},
|
||||
{
|
||||
question: 'Can I mix local pension schemes with global ETFs?',
|
||||
answer:
|
||||
'Yes—use tax-advantaged accounts first (IRA/401k, ISA/SIPP, RRSP/TFSA, ISK/KF, Superannuation, etc.). Align assets to account type: tax-inefficient assets (bonds/REITs) in tax shelters; tax-efficient broad equity ETFs in taxable.',
|
||||
},
|
||||
{
|
||||
question: 'What if my broker doesn’t offer fractional shares?',
|
||||
answer:
|
||||
'Use ETFs with lower share prices, contribute in larger but less frequent batches, or pick brokers that support fractional investing. Always compare FX costs and custody protections before moving.',
|
||||
},
|
||||
];
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function ParkYourMoneyPage() {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Where to Park Your Money for FIRE',
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'InvestingFIRE Team',
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'InvestingFIRE',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
},
|
||||
},
|
||||
datePublished: '2025-01-24',
|
||||
description:
|
||||
'A global guide to placing your money for FIRE: low-cost index funds, tax wrappers, and avoiding home bias.',
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="container mx-auto max-w-3xl 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">Where to Park Your Money</span>
|
||||
</nav>
|
||||
|
||||
<header className="mb-10">
|
||||
<h1 className="mb-6 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
Where to Park Your Money for FIRE <br />
|
||||
<span className="text-primary">Global, Low-Cost, Tax-Savvy</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xl leading-relaxed">
|
||||
The right accounts and funds can shave years off your FIRE timeline. This guide shows how to
|
||||
avoid home bias, keep costs low, and use country-specific tax wrappers without overcomplicating
|
||||
your plan.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="max-w-none">
|
||||
<Alert className="mb-8">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Key Principle</AlertTitle>
|
||||
<AlertDescription>
|
||||
Broad, low-cost diversification beats stock picking. Start with a simple global equity fund,
|
||||
add a bond sleeve matched to your risk tolerance, and automate contributions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<h2>Why Placement Matters</h2>
|
||||
<p>
|
||||
Costs, taxes, and diversification drive long-term returns. Optimizing where you hold assets can
|
||||
add 0.5–1.0% per year—a massive difference over decades.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-16" id="home-bias">
|
||||
Avoiding Home Bias
|
||||
</h2>
|
||||
<p>
|
||||
Home bias is the tendency to overweight your domestic market. This increases concentration risk
|
||||
(currency, regulation, sector tilt). Global market-cap exposure reduces single-country
|
||||
drawdowns and captures growth wherever it occurs.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Want a deeper dive? Read our{' '}
|
||||
<Link href="/learn/home-bias-in-investing" className="text-primary hover:underline">
|
||||
Home Bias Explained
|
||||
</Link>{' '}
|
||||
guide.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-16">Core Portfolio Recipe (Global First)</h2>
|
||||
<p>Pick one diversified equity base, then pair with a hedged bond fund if you need stability.</p>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary">Global Equity (One-Fund)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="list-disc space-y-2 pl-4">
|
||||
<li>US: VT (Vanguard Total World), or VTI + VXUS</li>
|
||||
<li>EU/EEA (PRIIPs): VWCE (FTSE All-World UCITS), or IWDA + EMIM</li>
|
||||
<li>UK: VWRA or VUAG</li>
|
||||
<li>Canada: VEQT or XEQT (all-in-one), or VTI+VEA+VEE if allowed</li>
|
||||
<li>Australia/NZ: DHHF, or VGS + VGE</li>
|
||||
<li>Asia (SG/HK): IE-domiciled ACWI/FTSE All-World equivalents where available</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-green-700">Bonds & Stability</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="list-disc space-y-2 pl-4">
|
||||
<li>US: BNDW (global aggregate) or BND/BNDX mix</li>
|
||||
<li>
|
||||
EU/EEA/UK: AGGH (global agg hedged), or government bond UCITS hedged to home currency
|
||||
</li>
|
||||
<li>Canada: VAB (aggregate) or ZAG</li>
|
||||
<li>Australia: VAF or GOVT; NZ: NZB hedged options if available</li>
|
||||
<li>Cash bucket: 6–12 months in high-yield savings/term deposits for near-term needs</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-16">Where to Hold (Tax Wrappers by Region)</h2>
|
||||
<div className="mt-8 grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>US</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>
|
||||
401k/403b, Traditional & Roth IRA, HSA. Avoid PFICs if abroad. Use total-market ETFs.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
IRS basics:{' '}
|
||||
<Link
|
||||
href="https://www.irs.gov/retirement-plans"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
irs.gov/retirement-plans
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>UK</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>
|
||||
ISA for tax-free growth; SIPP for tax relief. Consider accumulating UCITS ETFs for
|
||||
simplicity.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
HMRC ISA guidance:{' '}
|
||||
<Link
|
||||
href="https://www.gov.uk/individual-savings-accounts"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
gov.uk/individual-savings-accounts
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Canada</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>
|
||||
RRSP (treaty relief on US ETFs), TFSA (note US withholding not fully relieved), and RESP
|
||||
for kids.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
CRA TFSA rules:{' '}
|
||||
<Link
|
||||
href="https://www.canada.ca/en/revenue-agency/services/tax/individuals/topics/tax-free-savings-account.html"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
canada.ca/.../tax-free-savings-account
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sweden</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>
|
||||
ISK for simplified tax and automatic reporting; KF when holding US/IE ETFs for better
|
||||
withholding outcomes.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Skatteverket ISK info:{' '}
|
||||
<Link
|
||||
href="https://www.skatteverket.se/privat/skatter/vardepapper/investeringssparkontoisk"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
skatteverket.se/.../isk
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>EU / EEA</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>
|
||||
PRIIPs means UCITS ETFs. Choose accumulating share classes if tax-efficient. Mind local
|
||||
deemed-distribution rules.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
EU PRIIPs overview:{' '}
|
||||
<Link
|
||||
href="https://finance.ec.europa.eu/consumer-finance-and-payments/retail-financial-services/key-information-documents-packaged-retail-and-insurance-based-investment-products-priips_en"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
finance.ec.europa.eu/.../priips
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Australia / NZ</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>
|
||||
Superannuation for tax advantage. Outside super, consider broad ASX/NZX ETFs plus global
|
||||
UCITS/US-listed where permitted.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
ATO super basics:{' '}
|
||||
<Link
|
||||
href="https://www.ato.gov.au/individuals/super/"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
ato.gov.au/individuals/super
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Singapore / Hong Kong</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>
|
||||
Use reputable brokers with access to IE-domiciled ETFs (reduced withholding vs US). Watch
|
||||
FX and custody fees.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>India</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p>
|
||||
Domestic index funds (Nifty 50/500, Sensex) for core. Overseas ETFs via LRS subject to
|
||||
limits and tax on foreign assets.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
RBI LRS details:{' '}
|
||||
<Link
|
||||
href="https://rbi.org.in/scripts/FAQView.aspx?Id=115"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
rbi.org.in/.../FAQView.aspx?Id=115
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-16">Broker Checklist</h2>
|
||||
<ul className="mb-6 list-disc space-y-2 pl-5">
|
||||
<li>Regulation and investor protection (SIPC/FSCS/IIROC/etc.)</li>
|
||||
<li>All-in costs: commissions, FX spreads, custody, inactivity, and withdrawal fees</li>
|
||||
<li>Fractional shares and automatic DCA support</li>
|
||||
<li>Access to UCITS/PRIIPs-compliant funds if required</li>
|
||||
<li>Reliable tax documents (1099, T5, annual statements) and easy export</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="mt-16">Execution Playbook</h2>
|
||||
<ol className="list-decimal space-y-2 pl-5">
|
||||
<li>Define your target mix (e.g., 90/10 or 70/30) and write a one-page IPS.</li>
|
||||
<li>Automate monthly contributions; avoid market timing.</li>
|
||||
<li>Rebalance annually or when allocations drift 5–10 points.</li>
|
||||
<li>Keep 6–12 months of expenses in cash to manage withdrawal risk.</li>
|
||||
<li>Review tax changes yearly; wrappers and treaty benefits can shift.</li>
|
||||
</ol>
|
||||
|
||||
<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" —{' '}
|
||||
<Link
|
||||
href="https://corporate.vanguard.com/content/dam/corp/research/pdf/global-equity-investing-benefits-diversification.pdf"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Vanguard
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
MSCI
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
SPIVA scorecards (active vs passive) —{' '}
|
||||
<Link
|
||||
href="https://www.spglobal.com/spdji/en/research-insights/spiva/"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
S&P Dow Jones Indices
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
Bogleheads"Three-Fund Portfolio" —{' '}
|
||||
<Link
|
||||
href="https://www.bogleheads.org/wiki/Three-fund_portfolio"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Bogleheads Wiki
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="my-10 grid gap-4 sm:grid-cols-2">
|
||||
<Link href="/">
|
||||
<Button size="lg" className="w-full text-lg">
|
||||
Run the FIRE Calculator →
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/learn/safe-withdrawal-rate-4-percent-rule">
|
||||
<Button size="lg" variant="secondary" className="w-full text-lg">
|
||||
Learn Safe Withdrawals →
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<FaqSection faqs={faqs} className="my-12" />
|
||||
|
||||
<AuthorBio />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
224
src/app/page.tsx
224
src/app/page.tsx
@@ -1,66 +1,70 @@
|
||||
import Image from 'next/image';
|
||||
import { Suspense } from 'react';
|
||||
import FireCalculatorForm from './components/FireCalculatorForm';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
import BackgroundPattern from './components/BackgroundPattern';
|
||||
|
||||
import { FaqSection, type FaqItem } from './components/FaqSection';
|
||||
import { Testimonials } from './components/Testimonials';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export default function HomePage() {
|
||||
const faqData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: [
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
question: 'What methodology does this calculator use?',
|
||||
answer:
|
||||
'We run a multi-year projection in two phases: 1. Accumulation: Your balance grows by CAGR and you add monthly savings. 2. Retirement: The balance continues compounding, but you withdraw an inflation-adjusted monthly allowance. The result: a precise estimate of the capital you\'ll have at retirement (your "FIRE Number") and how long it will last until your chosen life expectancy.',
|
||||
},
|
||||
{
|
||||
question: "Why isn't this just the 4% rule?",
|
||||
answer:
|
||||
"The 4% rule is a useful starting point (25x annual spending), but it assumes a fixed withdrawal rate with inflation adjustments and doesn't model ongoing savings or dynamic market returns. Our calculator simulates each year's growth, contributions, and inflation-indexed withdrawals to give you a tailored picture.",
|
||||
},
|
||||
{
|
||||
question: 'How do I choose a realistic growth rate?',
|
||||
answer:
|
||||
'Historically, a diversified portfolio of equities and bonds has returned around 7-10% per year before inflation. We recommend starting around 6-8% (net of fees), then running "what-if" scenarios—5% on the conservative side, 10% on the aggressive side—to see how they affect your timeline.',
|
||||
},
|
||||
{
|
||||
question: 'How does inflation factor into my FIRE Number?',
|
||||
answer:
|
||||
"Cost of living rises. To maintain today's lifestyle, your monthly allowance must grow each year by your inflation rate. This calculator automatically inflates your desired monthly spending and subtracts it from your portfolio during retirement, ensuring your FIRE Number keeps pace with rising expenses.",
|
||||
},
|
||||
{
|
||||
question: 'Can I really retire early with FIRE?',
|
||||
answer:
|
||||
'Early retirement is achievable with disciplined saving, smart investing, and realistic assumptions. This tool helps you set targets, visualize outcomes, and adjust inputs—so you can build confidence in your plan and make informed trade-offs between lifestyle, risk, and timeline.',
|
||||
},
|
||||
{
|
||||
question: 'How should I use this calculator effectively?',
|
||||
answer:
|
||||
'Start with your actual numbers (capital, savings, age). Set conservative - mid - aggressive growth rates to bound possibilities. Slide your retirement age to explore "early" vs. "traditional" scenarios. Review the chart—especially the reference lines—to see when you hit FI and how withdrawals impact your balance. Experiment with higher savings rates or lower target spending to accelerate your path.',
|
||||
},
|
||||
];
|
||||
|
||||
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: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'What methodology does this calculator use?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: "We run a multi-year projection in two phases: 1. Accumulation: Your balance grows by CAGR and you add monthly savings. 2. Retirement: The balance continues compounding, but you withdraw an inflation-adjusted monthly allowance. The result: a precise estimate of the capital you'll have at retirement (your “FIRE Number”) and how long it will last until your chosen life expectancy.",
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: "Why isn't this just the 4% rule?",
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: "The 4% rule is a useful starting point (25× annual spending), but it assumes a fixed withdrawal rate with inflation adjustments and doesn't model ongoing savings or dynamic market returns. Our calculator simulates each year's growth, contributions, and inflation-indexed withdrawals to give you a tailored picture.",
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'How do I choose a realistic growth rate?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Historically, a diversified portfolio of equities and bonds has returned around 7-10% per year before inflation. We recommend starting around 6-8% (net of fees), then running “what-if” scenarios—5% on the conservative side, 10% on the aggressive side—to see how they affect your timeline.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'How does inflation factor into my FIRE Number?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: "Cost of living rises. To maintain today's lifestyle, your monthly allowance must grow each year by your inflation rate. This calculator automatically inflates your desired monthly spending and subtracts it from your portfolio during retirement, ensuring your FIRE Number keeps pace with rising expenses.",
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'Can I really retire early with FIRE?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Early retirement is achievable with disciplined saving, smart investing, and realistic assumptions. This tool helps you set targets, visualize outcomes, and adjust inputs—so you can build confidence in your plan and make informed trade-offs between lifestyle, risk, and timeline.',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: 'How should I use this calculator effectively?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Start with your actual numbers (capital, savings, age). Set conservative - mid - aggressive growth rates to bound possibilities. Slide your retirement age to explore “early” vs. “traditional” scenarios. Review the chart—especially the reference lines—to see when you hit FI and how withdrawals impact your balance. Experiment with higher savings rates or lower target spending to accelerate your path.',
|
||||
},
|
||||
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">
|
||||
<BackgroundPattern />
|
||||
@@ -87,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>
|
||||
|
||||
@@ -129,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">
|
||||
@@ -176,105 +182,11 @@ export default function HomePage() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqData) }}
|
||||
/>
|
||||
<h2 className="mb-4 text-3xl font-bold">FIRE & Investing Frequently Asked Questions (FAQ)</h2>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
What methodology does this calculator use?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
We run a multi-year projection in two phases:
|
||||
<ol className="ml-6 list-decimal space-y-1">
|
||||
<li>
|
||||
<strong>Accumulation:</strong> Your balance grows by CAGR and you add monthly
|
||||
savings.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Retirement:</strong> The balance continues compounding, but you withdraw an
|
||||
inflation-adjusted monthly allowance.
|
||||
</li>
|
||||
</ol>
|
||||
The result: a precise estimate of the capital you'll have at retirement (your “FIRE
|
||||
Number”) and how long it will last until your chosen life expectancy.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Why isn't this just the 4% rule?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
The 4% rule is a useful starting point (25× annual spending), but it assumes a fixed
|
||||
withdrawal rate with inflation adjustments and doesn't model ongoing savings or
|
||||
dynamic market returns. Our calculator simulates each year's growth, contributions,
|
||||
and inflation-indexed withdrawals to give you a tailored picture.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How do I choose a realistic growth rate?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Historically, a diversified portfolio of equities and bonds has returned around 7-10% per
|
||||
year before inflation. We recommend starting around 6-8% (net of fees), then running
|
||||
“what-if” scenarios—5% on the conservative side, 10% on the aggressive side—to see how
|
||||
they affect your timeline.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-4">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How does inflation factor into my FIRE Number?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Cost of living rises. To maintain today's lifestyle, your monthly allowance must
|
||||
grow each year by your inflation rate. This calculator automatically inflates your
|
||||
desired monthly spending and subtracts it from your portfolio during retirement, ensuring
|
||||
your FIRE Number keeps pace with rising expenses.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-5">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Can I really retire early with FIRE?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Early retirement is achievable with disciplined saving, smart investing, and realistic
|
||||
assumptions. This tool helps you set targets, visualize outcomes, and adjust inputs—so
|
||||
you can build confidence in your plan and make informed trade-offs between lifestyle,
|
||||
risk, and timeline.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-6">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How should I use this calculator effectively?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
<ul className="ml-6 list-disc space-y-1">
|
||||
<li>Start with your actual numbers (capital, savings, age).</li>
|
||||
<li>Set conservative - mid - aggressive growth rates to bound possibilities.</li>
|
||||
<li>Slide your retirement age to explore “early” vs. “traditional” scenarios.</li>
|
||||
<li>
|
||||
Review the chart—especially the reference lines—to see when you hit FI and how
|
||||
withdrawals impact your balance.
|
||||
</li>
|
||||
<li>
|
||||
Experiment with higher savings rates or lower target spending to accelerate your
|
||||
path.
|
||||
</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</section>
|
||||
<FaqSection
|
||||
faqs={faqs}
|
||||
title="FIRE & Investing Frequently Asked Questions (FAQ)"
|
||||
className="mb-12"
|
||||
/>
|
||||
|
||||
{/* Optional: Add a section for relevant resources/links here */}
|
||||
<section className="mb-12">
|
||||
@@ -394,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
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { BASE_URL } from "@/lib/constants";
|
||||
import { type MetadataRoute } from "next";
|
||||
import { type MetadataRoute } from 'next';
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: BASE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
];
|
||||
import { buildSitemapEntries } from '@/lib/sitemap';
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
return buildSitemapEntries();
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -5,19 +5,19 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-transparent text-sm font-semibold transition-[transform,colors,shadow] shadow-[0_10px_30px_-18px_rgba(0,0,0,0.45)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"z-30 inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-semibold transition-[transform,colors,shadow] shadow-[0_10px_30px_-18px_rgba(0,0,0,0.45)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-primary/20 bg-gradient-to-r from-primary to-secondary text-primary-foreground shadow-lg shadow-primary/30 hover:from-primary/90 hover:to-secondary/90',
|
||||
'bg-gradient-to-r from-primary to-secondary text-primary-foreground shadow-lg shadow-primary/30 hover:from-primary/90 hover:to-secondary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20',
|
||||
outline:
|
||||
'border border-primary/25 bg-background/80 shadow-sm hover:bg-primary/10 hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
'border border-primary/25 bg-background/80 shadow-sm hover:bg-primary/10 hover:text-foreground',
|
||||
secondary: 'bg-secondary/90 text-secondary-foreground shadow-md hover:bg-secondary',
|
||||
ghost: 'text-foreground/80 hover:bg-primary/10 hover:text-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
ghost: 'text-foreground/80 hover:bg-primary/10 hover:text-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline px-0! h-auto!',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3.5',
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as RechartsPrimitive from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
const THEMES = { light: '' } as const;
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
@@ -209,7 +209,7 @@ function ChartTooltipContent({
|
||||
<span className="text-muted-foreground">{itemConfig?.label ?? item.name}</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
<span className="text-foreground pl-2 font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -59,7 +59,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input bg-background flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input bg-background z-30 flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
||||
className,
|
||||
|
||||
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 };
|
||||
@@ -31,7 +31,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive bg-background flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive bg-background z-30 flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
133
src/components/ui/tooltip.tsx
Normal file
133
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'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
|
||||
}: Readonly<React.ComponentProps<typeof TooltipPrimitive.Provider>>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({ children, ...props }: TooltipProps) {
|
||||
const isTouch = useIsTouchDevice();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<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 }: 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 }: 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',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
23
src/lib/__tests__/sitemap.test.ts
Normal file
23
src/lib/__tests__/sitemap.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSitemapEntries } from '../sitemap';
|
||||
|
||||
describe('buildSitemapEntries', () => {
|
||||
it('includes known static routes', async () => {
|
||||
const sitemap = await buildSitemapEntries();
|
||||
const urls = sitemap.map((entry) => entry.url);
|
||||
|
||||
expect(urls).toContain('https://investingfire.com/');
|
||||
expect(urls).toContain('https://investingfire.com/learn');
|
||||
expect(urls).toContain('https://investingfire.com/learn/what-is-fire');
|
||||
expect(sitemap.every((entry) => entry.lastModified instanceof Date)).toBe(true);
|
||||
});
|
||||
|
||||
it('omits metadata routes from the sitemap output', async () => {
|
||||
const sitemap = await buildSitemapEntries();
|
||||
const urls = sitemap.map((entry) => entry.url);
|
||||
|
||||
expect(urls.some((url) => url.includes('sitemap'))).toBe(false);
|
||||
expect(urls.some((url) => url.includes('robots'))).toBe(false);
|
||||
});
|
||||
});
|
||||
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 }),
|
||||
};
|
||||
};
|
||||
75
src/lib/sitemap.ts
Normal file
75
src/lib/sitemap.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { type MetadataRoute } from 'next';
|
||||
|
||||
import { BASE_URL } from '@/lib/constants';
|
||||
|
||||
interface PageRoute {
|
||||
pathname: string;
|
||||
lastModified: Date;
|
||||
}
|
||||
|
||||
const PAGE_FILE_PATTERN = /^page\.(mdx|tsx?|jsx?)$/;
|
||||
const EXCLUDED_DIRECTORIES = new Set(['components', '__tests__', 'api']);
|
||||
const APP_DIR = path.join(process.cwd(), 'src', 'app');
|
||||
|
||||
const isRouteGroup = (name: string) => name.startsWith('(') && name.endsWith(')');
|
||||
const shouldSkipDirectory = (name: string) =>
|
||||
EXCLUDED_DIRECTORIES.has(name) || name.startsWith('_') || name.startsWith('.') || name.includes('[');
|
||||
|
||||
async function discoverPages(currentDir: string, segments: string[] = []): Promise<PageRoute[]> {
|
||||
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
const pages: PageRoute[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldSkipDirectory(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextSegments = isRouteGroup(entry.name) ? segments : [...segments, entry.name];
|
||||
const childPages = await discoverPages(entryPath, nextSegments);
|
||||
pages.push(...childPages);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && PAGE_FILE_PATTERN.test(entry.name)) {
|
||||
const pathname = segments.length === 0 ? '/' : `/${segments.join('/')}`;
|
||||
const stats = await fs.stat(entryPath);
|
||||
|
||||
pages.push({ pathname, lastModified: stats.mtime });
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function toAbsoluteUrl(pathname: string): string {
|
||||
const normalized = pathname === '/' ? '' : pathname;
|
||||
return new URL(normalized, BASE_URL).toString();
|
||||
}
|
||||
|
||||
export async function buildSitemapEntries(): Promise<MetadataRoute.Sitemap> {
|
||||
const pages = await discoverPages(APP_DIR);
|
||||
|
||||
const uniquePages = new Map<string, PageRoute>();
|
||||
for (const page of pages) {
|
||||
const existing = uniquePages.get(page.pathname);
|
||||
if (!existing || existing.lastModified < page.lastModified) {
|
||||
uniquePages.set(page.pathname, page);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedPages = Array.from(uniquePages.values()).sort((a, b) =>
|
||||
a.pathname.localeCompare(b.pathname),
|
||||
);
|
||||
|
||||
return sortedPages.map(({ pathname, lastModified }) => ({
|
||||
url: toAbsoluteUrl(pathname),
|
||||
lastModified,
|
||||
changeFrequency: 'weekly',
|
||||
priority: pathname === '/' ? 1 : 0.8,
|
||||
}));
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -60,9 +60,7 @@
|
||||
--secondary: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--secondary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--muted: oklch(0.67 0.0763 198.81 / 20%); /* verdigris with opacity */
|
||||
--muted-foreground: oklch(
|
||||
0.39 0.0215 96.47 / 80%
|
||||
); /* black olive with opacity */
|
||||
--muted-foreground: oklch(0.39 0.0215 96.47 / 80%); /* black olive with opacity */
|
||||
--accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--destructive: oklch(0.33 0.1316 336.24); /* palatinate */
|
||||
@@ -80,9 +78,7 @@
|
||||
--sidebar-primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--sidebar-accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--sidebar-border: oklch(
|
||||
0.67 0.0763 198.81 / 20%
|
||||
); /* verdigris with opacity */
|
||||
--sidebar-border: oklch(0.67 0.0763 198.81 / 20%); /* verdigris with opacity */
|
||||
--sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
}
|
||||
|
||||
@@ -116,9 +112,7 @@
|
||||
--sidebar-primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--sidebar-accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||
--sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||
--sidebar-border: oklch(
|
||||
0.97 0.0228 95.96 / 10%
|
||||
); /* cosmic latte with opacity */
|
||||
--sidebar-border: oklch(0.97 0.0228 95.96 / 10%); /* cosmic latte with opacity */
|
||||
--sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||
}
|
||||
|
||||
@@ -142,7 +136,7 @@
|
||||
@apply scroll-m-20 text-xl font-semibold tracking-tight;
|
||||
}
|
||||
p {
|
||||
@apply leading-7 [&:not(:first-child)]:mt-6;
|
||||
@apply mb-2 leading-7 [&:not(:first-child)]:mt-6;
|
||||
}
|
||||
blockquote {
|
||||
@apply mt-6 border-l-2 pl-6 italic;
|
||||
@@ -151,6 +145,6 @@
|
||||
@apply my-6 ml-6 list-disc [&>li]:mt-2;
|
||||
}
|
||||
code {
|
||||
@apply bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold
|
||||
@apply bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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