sharable calc, retire at pages
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
@@ -22,11 +22,14 @@ import {
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||
import { Calculator, Info, Percent } from 'lucide-react';
|
||||
import { Calculator, Info, Percent, Share2, Check } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import BlurThing from './blur-thing';
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { FireCalculatorFormValues } from '@/lib/calculator-schema';
|
||||
import { fireCalculatorDefaultValues, fireCalculatorFormSchema } from '@/lib/calculator-schema';
|
||||
|
||||
// Helper component for info tooltips next to form labels
|
||||
function InfoTooltip({ content }: Readonly<{ content: string }>) {
|
||||
return (
|
||||
@@ -41,39 +44,8 @@ function InfoTooltip({ content }: Readonly<{ content: string }>) {
|
||||
);
|
||||
}
|
||||
|
||||
// Schema for form validation
|
||||
const formSchema = z.object({
|
||||
startingCapital: z.coerce.number(),
|
||||
monthlySavings: z.coerce.number().min(0, 'Monthly savings must be a non-negative number'),
|
||||
currentAge: z.coerce
|
||||
.number()
|
||||
.min(1, 'Age must be at least 1')
|
||||
.max(100, 'No point in starting this late'),
|
||||
cagr: z.coerce.number().min(0, 'Growth rate must be a non-negative number'),
|
||||
desiredMonthlyAllowance: z.coerce.number().min(0, 'Monthly allowance must be a non-negative number'),
|
||||
inflationRate: z.coerce.number().min(0, 'Inflation rate must be a non-negative number'),
|
||||
lifeExpectancy: z.coerce
|
||||
.number()
|
||||
.min(40, 'Be a bit more optimistic buddy :(')
|
||||
.max(100, 'You should be more realistic...'),
|
||||
retirementAge: z.coerce
|
||||
.number()
|
||||
.min(18, 'Retirement age must be at least 18')
|
||||
.max(100, 'Retirement age must be at most 100'),
|
||||
coastFireAge: z.coerce
|
||||
.number()
|
||||
.min(18, 'Coast FIRE age must be at least 18')
|
||||
.max(100, 'Coast FIRE age must be at most 100')
|
||||
.optional(),
|
||||
baristaIncome: z.coerce.number().min(0, 'Barista income must be a non-negative number').optional(),
|
||||
simulationMode: z.enum(['deterministic', 'monte-carlo']).default('deterministic'),
|
||||
volatility: z.coerce.number().min(0).default(15),
|
||||
withdrawalStrategy: z.enum(['fixed', 'percentage']).default('fixed'),
|
||||
withdrawalPercentage: z.coerce.number().min(0).max(100).default(4),
|
||||
});
|
||||
|
||||
// Type for form values
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
const formSchema = fireCalculatorFormSchema;
|
||||
type FormValues = FireCalculatorFormValues;
|
||||
|
||||
interface YearlyData {
|
||||
age: number;
|
||||
@@ -138,30 +110,22 @@ const tooltipRenderer = ({ active, payload }: TooltipProps<ValueType, NameType>)
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function FireCalculatorForm() {
|
||||
export default function FireCalculatorForm({
|
||||
initialValues,
|
||||
autoCalculate = false,
|
||||
}: {
|
||||
initialValues?: Partial<FireCalculatorFormValues>;
|
||||
autoCalculate?: boolean;
|
||||
}) {
|
||||
const [result, setResult] = useState<CalculationResult | null>(null);
|
||||
const irlYear = new Date().getFullYear();
|
||||
const [showing4percent, setShowing4percent] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Initialize form with default values
|
||||
const form = useForm<z.input<typeof formSchema>, undefined, FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
startingCapital: 50000,
|
||||
monthlySavings: 1500,
|
||||
currentAge: 25,
|
||||
cagr: 7,
|
||||
desiredMonthlyAllowance: 3000,
|
||||
inflationRate: 2.3,
|
||||
lifeExpectancy: 84,
|
||||
retirementAge: 55,
|
||||
coastFireAge: undefined,
|
||||
baristaIncome: 0,
|
||||
simulationMode: 'deterministic',
|
||||
volatility: 15,
|
||||
withdrawalStrategy: 'fixed',
|
||||
withdrawalPercentage: 4,
|
||||
},
|
||||
defaultValues: initialValues ?? fireCalculatorDefaultValues,
|
||||
});
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
@@ -350,6 +314,55 @@ 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();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (values.startingCapital !== undefined && values.startingCapital !== null)
|
||||
params.set('startingCapital', String(values.startingCapital));
|
||||
if (values.monthlySavings !== undefined && values.monthlySavings !== null)
|
||||
params.set('monthlySavings', String(values.monthlySavings));
|
||||
if (values.currentAge !== undefined && values.currentAge !== null)
|
||||
params.set('currentAge', String(values.currentAge));
|
||||
if (values.cagr !== undefined && values.cagr !== null) params.set('cagr', String(values.cagr));
|
||||
if (values.desiredMonthlyAllowance !== undefined && values.desiredMonthlyAllowance !== null)
|
||||
params.set('monthlySpend', String(values.desiredMonthlyAllowance));
|
||||
if (values.inflationRate !== undefined && values.inflationRate !== null)
|
||||
params.set('inflationRate', String(values.inflationRate));
|
||||
if (values.lifeExpectancy !== undefined && values.lifeExpectancy !== null)
|
||||
params.set('lifeExpectancy', String(values.lifeExpectancy));
|
||||
if (values.retirementAge !== undefined && values.retirementAge !== null)
|
||||
params.set('retirementAge', String(values.retirementAge));
|
||||
if (values.coastFireAge !== undefined && values.coastFireAge !== null)
|
||||
params.set('coastFireAge', String(values.coastFireAge));
|
||||
if (values.baristaIncome !== undefined && values.baristaIncome !== null)
|
||||
params.set('baristaIncome', String(values.baristaIncome));
|
||||
if (values.simulationMode) params.set('simulationMode', values.simulationMode);
|
||||
if (values.volatility !== undefined && values.volatility !== null)
|
||||
params.set('volatility', String(values.volatility));
|
||||
if (values.withdrawalStrategy) params.set('withdrawalStrategy', values.withdrawalStrategy);
|
||||
if (values.withdrawalPercentage !== undefined && values.withdrawalPercentage !== null)
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="border-primary/15 bg-background/90 shadow-primary/10 mb-6 border shadow-lg backdrop-blur">
|
||||
@@ -959,17 +972,30 @@ export default function FireCalculatorForm() {
|
||||
</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 gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowing4percent(!showing4percent);
|
||||
}}
|
||||
variant={showing4percent ? 'secondary' : 'default'}
|
||||
size={'sm'}
|
||||
className="gap-2"
|
||||
type="button"
|
||||
>
|
||||
<Percent className="h-4 w-4" />
|
||||
{showing4percent ? 'Hide' : 'Show'} 4%-Rule
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
variant="outline"
|
||||
size={'sm'}
|
||||
className="gap-2"
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import BlurThing from '../components/blur-thing';
|
||||
import { RETIRE_AT_AGE_PRESETS } from '@/lib/retire-at';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Learn FIRE | Financial Independence Guides & Resources',
|
||||
@@ -8,6 +9,8 @@ export const metadata = {
|
||||
'Master the art of Financial Independence and Early Retirement. Deep dives into safe withdrawal rates, asset allocation, and FIRE strategies.',
|
||||
};
|
||||
|
||||
const retireAgeLinks = RETIRE_AT_AGE_PRESETS;
|
||||
|
||||
export default function LearnHubPage() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl px-4 py-12">
|
||||
@@ -107,8 +110,8 @@ export default function LearnHubPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Build a world-allocation portfolio, avoid home bias, and choose the right accounts whether
|
||||
you're in the US, EU, UK, Canada, Australia, or elsewhere.
|
||||
Build a world-allocation portfolio, avoid home bias, and choose the right accounts
|
||||
whether you're in the US, EU, UK, Canada, Australia, or elsewhere.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -128,14 +131,41 @@ export default function LearnHubPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Understand the hidden risks of overweighting your domestic market and learn practical steps
|
||||
to diversify globally without creating tax headaches.
|
||||
Understand the hidden risks of overweighting your domestic market and learn practical
|
||||
steps to diversify globally without creating tax headaches.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 space-y-4">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold">Retire By Age</h2>
|
||||
<p className="text-muted-foreground">
|
||||
See exactly how much you need to retire at different ages, backed by the calculator.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{retireAgeLinks.map((age) => (
|
||||
<Link
|
||||
key={age}
|
||||
href={`/learn/retire-at/${age.toString()}`}
|
||||
className="transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
<Card className="hover:border-primary/50 h-full cursor-pointer border-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Retire at {age}</CardTitle>
|
||||
<CardDescription className="text-muted-foreground text-xs">
|
||||
How much to save, what to invest, and what to tweak for age {age}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted mt-16 rounded-xl p-8 text-center">
|
||||
<h2 className="mb-4 text-2xl font-bold">Ready to see the numbers?</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
|
||||
279
src/app/learn/retire-at/[age]/page.tsx
Normal file
279
src/app/learn/retire-at/[age]/page.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
|
||||
import {
|
||||
RETIRE_AT_AGE_PRESETS,
|
||||
buildSpendScenarios,
|
||||
calculateNestEggFromSpend,
|
||||
extractCalculatorValuesFromSearch,
|
||||
parseAgeParam,
|
||||
} from '@/lib/retire-at';
|
||||
import { BASE_URL } from '@/lib/constants';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
export const dynamicParams = false;
|
||||
|
||||
interface RetireAtPageProps {
|
||||
params: Promise<{ age: string }>;
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat('en', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const faqForAge = (age: number): FaqItem[] => {
|
||||
const ageLabel = age.toString();
|
||||
return [
|
||||
{
|
||||
question: `How much do I need to retire at ${ageLabel}?`,
|
||||
answer:
|
||||
'A quick rule is your desired annual spending divided by a safe withdrawal rate. Using 4%, multiply your yearly spend by 25. Spending $60k/year means roughly $1.5M. Use the calculator below to tailor the projection to your own savings, growth, and inflation assumptions.',
|
||||
},
|
||||
{
|
||||
question: `What savings rate should I target to retire at ${ageLabel}?`,
|
||||
answer:
|
||||
'Aim for a 40–60% savings rate if you want to retire in 10–15 years. The exact rate depends on your starting capital, investment returns, and spending goal. Slide the monthly savings input to see how it moves your FIRE number and timeline.',
|
||||
},
|
||||
{
|
||||
question: 'Is the 4% rule safe for this timeline?',
|
||||
answer:
|
||||
'The 4% rule is a starting point, not a guarantee. Consider 3.5–4% for longer retirements or higher inflation periods. The calculator supports both fixed and percentage-based withdrawals so you can stress-test more conservative plans.',
|
||||
},
|
||||
{
|
||||
question: 'What if markets underperform?',
|
||||
answer:
|
||||
'Use a lower CAGR (e.g., 5–6%) and a higher inflation rate (e.g., 3%) in the calculator. Switch to Monte Carlo mode to see success probabilities with volatility. Also build flexibility into spending: trimming costs in bad years greatly improves durability.',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const generateStaticParams = () =>
|
||||
RETIRE_AT_AGE_PRESETS.map((age) => ({
|
||||
age: age.toString(),
|
||||
}));
|
||||
|
||||
export const generateMetadata = async ({ params }: RetireAtPageProps): Promise<Metadata> => {
|
||||
const { age: slugAge } = await params;
|
||||
const age = parseAgeParam(slugAge);
|
||||
const ageLabel = age.toString();
|
||||
const title = `How Much Do You Need to Retire at ${ageLabel}? | InvestingFIRE`;
|
||||
const description = `Instant answer plus calculator: see how much you need saved to retire at ${ageLabel}, modeled with your spending, returns, and inflation assumptions.`;
|
||||
const canonical = `${BASE_URL.replace(/\/$/, '')}/learn/retire-at/${ageLabel}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical,
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: canonical,
|
||||
type: 'article',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default async function RetireAtPage({ params, searchParams }: RetireAtPageProps) {
|
||||
const { age: slugAge } = await params;
|
||||
const resolvedSearch = (await searchParams) ?? {};
|
||||
const age = parseAgeParam(slugAge);
|
||||
const ageLabel = age.toString();
|
||||
const initialValues = extractCalculatorValuesFromSearch(resolvedSearch, age);
|
||||
const monthlySpend = initialValues.desiredMonthlyAllowance ?? 4000;
|
||||
const withdrawalRate = 0.04;
|
||||
const quickNestEgg = calculateNestEggFromSpend(monthlySpend, withdrawalRate);
|
||||
const scenarios = buildSpendScenarios(monthlySpend, withdrawalRate);
|
||||
|
||||
const canonical = `${BASE_URL.replace(/\/$/, '')}/learn/retire-at/${ageLabel}`;
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: `How Much Do You Need to Retire at ${ageLabel}?`,
|
||||
description:
|
||||
'Detailed guidance plus an interactive calculator showing exactly how much you need saved to retire at your target age.',
|
||||
mainEntityOfPage: canonical,
|
||||
datePublished: '2025-01-25',
|
||||
dateModified: new Date().toISOString().split('T')[0],
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'InvestingFIRE',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${BASE_URL}apple-icon.png`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (initialValues.currentAge) queryParams.set('currentAge', initialValues.currentAge.toString());
|
||||
queryParams.set('retirementAge', age.toString());
|
||||
queryParams.set('monthlySpend', monthlySpend.toString());
|
||||
if (initialValues.monthlySavings)
|
||||
queryParams.set('monthlySavings', initialValues.monthlySavings.toString());
|
||||
if (initialValues.startingCapital)
|
||||
queryParams.set('startingCapital', initialValues.startingCapital.toString());
|
||||
|
||||
return (
|
||||
<article className="container mx-auto max-w-4xl px-4 py-12">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
|
||||
<nav className="text-muted-foreground mb-6 text-sm">
|
||||
<Link href="/" className="hover:text-primary">
|
||||
Home
|
||||
</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<Link href="/learn" className="hover:text-primary">
|
||||
Learn
|
||||
</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-foreground">Retire at {age}</span>
|
||||
</nav>
|
||||
|
||||
<header className="mb-10">
|
||||
<h1 className="mb-4 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
How Much Do I Need to Retire at {age}?
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xl leading-relaxed">
|
||||
Get an instant rule-of-thumb number, then dial in the details with the FIRE calculator loaded
|
||||
for age {age}. Adjust savings, returns, inflation, and withdrawals to stress-test your plan.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Answer</CardTitle>
|
||||
<CardDescription>
|
||||
Based on a {Math.round(withdrawalRate * 100)}% withdrawal rate
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-lg">
|
||||
With a monthly spend of <strong>{currencyFormatter.format(monthlySpend)}</strong>, you need
|
||||
roughly <strong>{currencyFormatter.format(quickNestEgg)}</strong> invested to retire at{' '}
|
||||
{age}.
|
||||
</p>
|
||||
<ul className="text-muted-foreground list-disc space-y-2 pl-5">
|
||||
<li>Uses the classic “Rule of 25” (annual spend ÷ {withdrawalRate * 100}%)</li>
|
||||
<li>Assumes inflation-adjusted withdrawals and a diversified portfolio</li>
|
||||
<li>Refine the projection below with your exact savings, age, and market assumptions</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>At-a-Glance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Target age</span>
|
||||
<span className="text-foreground font-semibold">{age}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Monthly spend (today)</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
{currencyFormatter.format(monthlySpend)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Withdrawal rate</span>
|
||||
<span className="text-foreground font-semibold">{(withdrawalRate * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Rule-of-25 nest egg</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
{currencyFormatter.format(quickNestEgg)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<section className="mt-12 space-y-6">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Spend Scenarios</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Lean, classic, and comfortable budgets with required nest eggs.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/learn/safe-withdrawal-rate-4-percent-rule"
|
||||
className="text-primary text-sm hover:underline"
|
||||
>
|
||||
Why the {Math.round(withdrawalRate * 100)}% rule?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{scenarios.map((scenario) => (
|
||||
<Card key={scenario.key} className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>{scenario.label}</CardTitle>
|
||||
<CardDescription>
|
||||
{currencyFormatter.format(scenario.monthlySpend)} / month
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Annual spend</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
{currencyFormatter.format(scenario.annualSpend)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Needed to retire</span>
|
||||
<span className="text-foreground font-semibold">
|
||||
{currencyFormatter.format(scenario.nestEgg)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-14 space-y-6">
|
||||
<div className="bg-primary/5 rounded-xl border p-8 text-center">
|
||||
<h2 className="mb-4 text-3xl font-bold">Ready to Plan Your Details?</h2>
|
||||
<p className="text-muted-foreground mx-auto mb-8 max-w-2xl text-lg">
|
||||
This page gives you a ballpark estimate. Use our full-featured calculator to customize
|
||||
inflation, market returns, simulation modes (Monte Carlo), and more for your specific
|
||||
situation.
|
||||
</p>
|
||||
<Button size="lg" className="h-auto px-8 py-6 text-lg" asChild>
|
||||
<Link href={`/?${queryParams.toString()}`}>Open Full Calculator for Age {age}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-12 grid gap-6 md:grid-cols-2">
|
||||
<Card className="col-span-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Key Levers to Watch</CardTitle>
|
||||
<CardDescription>Improve success odds for age {age}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="text-muted-foreground list-disc space-y-2 pl-5">
|
||||
<li>Boost savings rate in the final 5–10 years before {age}</li>
|
||||
<li>Lower planned spending or add part-time income (Barista/Coast FIRE)</li>
|
||||
<li>Use conservative returns (5–7%) and realistic inflation (2–3%)</li>
|
||||
<li>Consider longer life expectancy (age {age + 30}+)</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<FaqSection faqs={faqForAge(age)} className="my-12" />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
16
src/app/learn/retire-at/__tests__/page.test.ts
Normal file
16
src/app/learn/retire-at/__tests__/page.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { RETIRE_AT_AGE_PRESETS } from '@/lib/retire-at';
|
||||
import { generateStaticParams } from '../[age]/page';
|
||||
|
||||
describe('retire-at generateStaticParams', () => {
|
||||
it('returns all preset ages as strings with no duplicates', () => {
|
||||
const params = generateStaticParams();
|
||||
const ages = params.map((p) => p.age);
|
||||
|
||||
expect(ages).toHaveLength(RETIRE_AT_AGE_PRESETS.length);
|
||||
expect(new Set(ages).size).toBe(ages.length);
|
||||
expect(ages).toEqual(RETIRE_AT_AGE_PRESETS.map((age) => age.toString()));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import FireCalculatorForm from './components/FireCalculatorForm';
|
||||
import BackgroundPattern from './components/BackgroundPattern';
|
||||
import { FaqSection, type FaqItem } from './components/FaqSection';
|
||||
import { Testimonials } from './components/Testimonials';
|
||||
import { extractCalculatorValuesFromSearch } from '@/lib/retire-at';
|
||||
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
@@ -37,7 +38,23 @@ const faqs: FaqItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
export default async function HomePage({
|
||||
searchParams,
|
||||
}: Readonly<{
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}>) {
|
||||
const resolvedParams = await searchParams;
|
||||
|
||||
// Parse target age from params to seed defaults correctly (e.g. currentAge logic depends on it)
|
||||
const paramRetireAge = Array.isArray(resolvedParams.retirementAge)
|
||||
? resolvedParams.retirementAge[0]
|
||||
: resolvedParams.retirementAge;
|
||||
|
||||
const targetAge =
|
||||
paramRetireAge && !Number.isNaN(Number(paramRetireAge)) ? Number(paramRetireAge) : 55;
|
||||
|
||||
const initialValues = extractCalculatorValuesFromSearch(resolvedParams, targetAge);
|
||||
|
||||
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 />
|
||||
@@ -64,7 +81,7 @@ export default function HomePage() {
|
||||
how FIRE works.
|
||||
</p>
|
||||
<div className="mt-8 w-full max-w-2xl">
|
||||
<FireCalculatorForm />
|
||||
<FireCalculatorForm initialValues={initialValues} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
129
src/lib/__tests__/retire-at.test.ts
Normal file
129
src/lib/__tests__/retire-at.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
RETIRE_AT_AGE_PRESETS,
|
||||
buildSpendScenarios,
|
||||
calculateNestEggFromSpend,
|
||||
deriveDefaultInputs,
|
||||
extractCalculatorValuesFromSearch,
|
||||
parseAgeParam,
|
||||
} from '../retire-at';
|
||||
|
||||
describe('retire-at helpers', () => {
|
||||
it('calculates a rule-of-25 style nest egg', () => {
|
||||
const result = calculateNestEggFromSpend(4000, 0.04);
|
||||
expect(result).toBe(1200000);
|
||||
});
|
||||
|
||||
it('builds lean/base/comfortable spend scenarios', () => {
|
||||
const scenarios = buildSpendScenarios(4000, 0.04);
|
||||
expect(scenarios).toHaveLength(3);
|
||||
|
||||
const baseline = scenarios.find((scenario) => scenario.key === 'baseline');
|
||||
expect(baseline?.monthlySpend).toBe(4000);
|
||||
expect(baseline?.nestEgg).toBe(1200000);
|
||||
});
|
||||
|
||||
it('parses and clamps age params', () => {
|
||||
expect(parseAgeParam('90')).toBe(80);
|
||||
expect(parseAgeParam('42')).toBe(42);
|
||||
expect(parseAgeParam('not-a-number', 55)).toBe(55);
|
||||
});
|
||||
|
||||
it('derives calculator defaults for a target age', () => {
|
||||
const defaults = deriveDefaultInputs(50);
|
||||
expect(defaults.retirementAge).toBe(50);
|
||||
expect(defaults.currentAge).toBeLessThan(50);
|
||||
expect(defaults.desiredMonthlyAllowance).toBeGreaterThanOrEqual(500);
|
||||
});
|
||||
|
||||
it('exposes preset age list for sitemap/static params', () => {
|
||||
expect(RETIRE_AT_AGE_PRESETS).toContain(50);
|
||||
expect(Array.isArray(RETIRE_AT_AGE_PRESETS)).toBe(true);
|
||||
});
|
||||
|
||||
describe('extractCalculatorValuesFromSearch', () => {
|
||||
it('parses valid numeric params', () => {
|
||||
const searchParams = {
|
||||
currentAge: '30',
|
||||
retirementAge: '55',
|
||||
monthlySpend: '4000',
|
||||
monthlySavings: '1500',
|
||||
startingCapital: '100000',
|
||||
};
|
||||
const values = extractCalculatorValuesFromSearch(searchParams, 55);
|
||||
|
||||
expect(values.currentAge).toBe(30);
|
||||
expect(values.retirementAge).toBe(55);
|
||||
expect(values.desiredMonthlyAllowance).toBe(4000);
|
||||
expect(values.monthlySavings).toBe(1500);
|
||||
expect(values.startingCapital).toBe(100000);
|
||||
});
|
||||
|
||||
it('handles invalid numbers by falling back to defaults', () => {
|
||||
const searchParams = {
|
||||
currentAge: 'not-a-number',
|
||||
monthlySpend: 'invalid',
|
||||
};
|
||||
// targetAge 55 implies some defaults
|
||||
const values = extractCalculatorValuesFromSearch(searchParams, 55);
|
||||
|
||||
// currentAge should default based on logic in deriveDefaultInputs
|
||||
// for 55, defaultCurrentAge is around 40
|
||||
expect(values.currentAge).toBeGreaterThan(18);
|
||||
// desiredMonthlyAllowance has a default logic too
|
||||
expect(values.desiredMonthlyAllowance).toBeDefined();
|
||||
});
|
||||
|
||||
it('clamps values to safe bounds and business logic', () => {
|
||||
const searchParams = {
|
||||
currentAge: '150', // max 100, but further constrained by retirement age
|
||||
monthlySpend: '-500', // min 0
|
||||
};
|
||||
const values = extractCalculatorValuesFromSearch(searchParams, 60);
|
||||
|
||||
// Clamped to retirementAge (60) - 1 = 59 by deriveDefaultInputs
|
||||
expect(values.currentAge).toBe(59);
|
||||
// Clamped to min 500 by deriveDefaultInputs
|
||||
expect(values.desiredMonthlyAllowance).toBe(500);
|
||||
});
|
||||
|
||||
it('supports array params (takes first)', () => {
|
||||
const searchParams = {
|
||||
currentAge: ['30', '40'],
|
||||
};
|
||||
const values = extractCalculatorValuesFromSearch(searchParams, 60);
|
||||
expect(values.currentAge).toBe(30);
|
||||
});
|
||||
|
||||
it('parses simulation mode', () => {
|
||||
expect(
|
||||
extractCalculatorValuesFromSearch({ simulationMode: 'monte-carlo' }, 55).simulationMode,
|
||||
).toBe('monte-carlo');
|
||||
|
||||
expect(
|
||||
extractCalculatorValuesFromSearch({ simulationMode: 'deterministic' }, 55).simulationMode,
|
||||
).toBe('deterministic');
|
||||
|
||||
expect(
|
||||
extractCalculatorValuesFromSearch({ simulationMode: 'invalid-mode' }, 55).simulationMode,
|
||||
).toBeUndefined();
|
||||
});
|
||||
it('parses extra fields (volatility, withdrawal, barista)', () => {
|
||||
const searchParams = {
|
||||
volatility: '20',
|
||||
withdrawalStrategy: 'percentage',
|
||||
withdrawalPercentage: '3.5',
|
||||
coastFireAge: '45',
|
||||
baristaIncome: '1000',
|
||||
};
|
||||
const values = extractCalculatorValuesFromSearch(searchParams, 55);
|
||||
|
||||
expect(values.volatility).toBe(20);
|
||||
expect(values.withdrawalStrategy).toBe('percentage');
|
||||
expect(values.withdrawalPercentage).toBe(3.5);
|
||||
expect(values.coastFireAge).toBe(45);
|
||||
expect(values.baristaIncome).toBe(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
50
src/lib/calculator-schema.ts
Normal file
50
src/lib/calculator-schema.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const fireCalculatorFormSchema = z.object({
|
||||
startingCapital: z.coerce.number(),
|
||||
monthlySavings: z.coerce.number().min(0, 'Monthly savings must be a non-negative number'),
|
||||
currentAge: z.coerce
|
||||
.number()
|
||||
.min(1, 'Age must be at least 1')
|
||||
.max(100, 'No point in starting this late'),
|
||||
cagr: z.coerce.number().min(0, 'Growth rate must be a non-negative number'),
|
||||
desiredMonthlyAllowance: z.coerce.number().min(0, 'Monthly allowance must be a non-negative number'),
|
||||
inflationRate: z.coerce.number().min(0, 'Inflation rate must be a non-negative number'),
|
||||
lifeExpectancy: z.coerce
|
||||
.number()
|
||||
.min(40, 'Be a bit more optimistic buddy :(')
|
||||
.max(100, 'You should be more realistic...'),
|
||||
retirementAge: z.coerce
|
||||
.number()
|
||||
.min(20, 'Retirement age must be at least 20')
|
||||
.max(100, 'Retirement age must be at most 100'),
|
||||
coastFireAge: z.coerce
|
||||
.number()
|
||||
.min(20, 'Coast FIRE age must be at least 20')
|
||||
.max(100, 'Coast FIRE age must be at most 100')
|
||||
.optional(),
|
||||
baristaIncome: z.coerce.number().min(0, 'Barista income must be a non-negative number').optional(),
|
||||
simulationMode: z.enum(['deterministic', 'monte-carlo']).default('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),
|
||||
});
|
||||
|
||||
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: 'deterministic',
|
||||
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);
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
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 }),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user