Compare commits

..

21 Commits

Author SHA1 Message Date
dda8cedd9a fix(deps): update nextjs monorepo to v16.0.10
Some checks are pending
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Typecheck (pull_request) Successful in 38s
Lint / Lint and Typecheck (push) Successful in 44s
2025-12-13 22:22:57 +00:00
30e5198b14 fix(deps): update dependency lucide-react to ^0.561.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 41s
2025-12-13 18:21:35 +01:00
8f2443572f chore(deps): update dependency eslint to v9.39.2
Some checks failed
Lint / Lint and Typecheck (push) Has been cancelled
2025-12-13 18:18:34 +01:00
28cb553ed0 chore(deps): update dependency @types/node to v24.10.3
Some checks failed
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Typecheck (push) Has been cancelled
Lint / Lint and Typecheck (pull_request) Successful in 40s
2025-12-13 17:04:14 +00:00
013aa41965 fix(deps): update dependency lucide-react to ^0.559.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 41s
2025-12-13 14:03:19 +00:00
6bb37bd6c3 fix(deps): update dependency lucide-react to ^0.557.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (pull_request) Successful in 38s
Lint / Lint and Typecheck (push) Successful in 44s
2025-12-13 13:03:25 +00:00
b9b16bcbf3 chore(deps): pin dependencies
All checks were successful
Lint / Lint and Typecheck (push) Successful in 43s
2025-12-13 11:27:56 +01:00
bb9d09c653 chore(deps): update pnpm to v10.25.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 38s
2025-12-13 00:02:24 +00:00
bf2c8b4e1a chore(deps): update dependency typescript-eslint to v8.49.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 36s
2025-12-12 23:02:18 +00:00
001a7b5c35 chore(deps): update dependency jsdom to v27.3.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 41s
2025-12-12 22:02:49 +00:00
85089cd187 fix(deps): update nextjs monorepo to v16.0.8
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 40s
2025-12-12 21:02:38 +00:00
4b67c1ab2c chore(deps): update dependency @vitejs/plugin-react to v5.1.2
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 39s
2025-12-12 20:03:39 +00:00
b9a2228422 chore(deps): update dependency @types/node to v24.10.2
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 37s
2025-12-12 19:29:54 +00:00
dda92f3f80 fix(deps): update react monorepo to v19.2.3
All checks were successful
Lint / Lint and Typecheck (push) Successful in 39s
2025-12-12 19:50:14 +01:00
ec52cbc116 chore(deps): update tailwindcss monorepo to v4.1.18
Some checks failed
Lint / Lint and Typecheck (push) Has been cancelled
renovate/stability-days Updates have not met minimum release age requirement
Lint / Lint and Typecheck (pull_request) Successful in 40s
2025-12-12 18:24:48 +00:00
17a694d4b5 Adds Docker support for Next.js standalone
All checks were successful
Lint / Lint and Typecheck (push) Successful in 48s
Adds a production-ready Dockerfile and .dockerignore, and updates Next.js config to produce a standalone output.

Provides a multi-stage build that installs dependencies (yarn/npm/pnpm supported), runs the Next.js build, and assembles a slim runtime image on Node Alpine. Configures a non-root runtime user, exposes PORT 3000, and includes runtime utilities and compatibility packages to ensure reliable container execution. These changes enable consistent, smaller production container images and simplified deployment.
2025-12-09 13:46:54 +01:00
dc9cf1c1f2 openGraph/Metadata completion
All checks were successful
Lint / Lint and Typecheck (push) Successful in 43s
2025-12-08 09:34:35 +01:00
cb4a4e2f06 chore(deps): update dependency vitest to v4.0.15
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 43s
2025-12-07 00:53:00 +00:00
6c09c22656 chore(deps): update dependency react-hook-form to v7.68.0
All checks were successful
Lint / Lint and Typecheck (push) Successful in 45s
2025-12-07 01:48:41 +01:00
a7a2fe39ca chore(deps): pin dependencies
Some checks failed
Lint / Lint and Typecheck (pull_request) Successful in 37s
Lint / Lint and Typecheck (push) Has been cancelled
2025-12-07 01:45:50 +01:00
3dc79aa425 fix MC test
All checks were successful
Lint / Lint and Typecheck (push) Successful in 41s
2025-12-07 01:43:26 +01:00
15 changed files with 951 additions and 744 deletions

7
.dockerignore Normal file
View File

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

69
Dockerfile Normal file
View File

@@ -0,0 +1,69 @@
# syntax=docker.io/docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
FROM node:24-alpine@sha256:7e0bd0460b26eb3854ea5b99b887a6a14d665d14cae694b78ae2936d14b2befb AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
# wget needed for healthcheck
RUN apk add --no-cache wget
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy public files. "[c]" as workaround for conditional matching since the folder might not exist.
COPY --from=builder /app/publi[c] ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

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

View File

@@ -33,44 +33,44 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cssnano": "^7.1.2",
"lucide-react": "^0.556.0",
"next": "16.0.7",
"lucide-react": "^0.561.0",
"next": "16.0.10",
"next-plausible": "^3.12.4",
"react": "19.2.1",
"react-dom": "19.2.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.56.1",
"recharts": "^2.15.3",
"tailwind-merge": "^3.2.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "24.10.1",
"@playwright/test": "1.57.0",
"@tailwindcss/postcss": "4.1.18",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/node": "24.10.3",
"@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.0.10",
"eslint-config-prettier": "10.1.8",
"jsdom": "^27.2.0",
"jsdom": "27.3.0",
"postcss": "8.5.6",
"prettier": "3.7.4",
"prettier-plugin-tailwindcss": "0.7.2",
"tailwindcss": "4.1.17",
"tailwindcss": "4.1.18",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
"typescript-eslint": "8.48.1",
"vitest": "^4.0.13"
"typescript-eslint": "8.49.0",
"vitest": "4.0.15"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"packageManager": "pnpm@10.24.0",
"packageManager": "pnpm@10.25.0",
"pnpm": {
"overrides": {
"@types/react": "19.2.7",

1417
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -333,9 +333,18 @@ export default function FireCalculatorForm({
// Sort to find percentiles
balancesForYear.sort((a, b) => a - b);
const p10 = balancesForYear[Math.floor(numSimulations * 0.4)];
const p50 = balancesForYear[Math.floor(numSimulations * 0.5)];
const p90 = balancesForYear[Math.floor(numSimulations * 0.6)];
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
@@ -437,13 +446,23 @@ export default function FireCalculatorForm({
});
};
const isMonteCarlo = form.watch('simulationMode') === 'monte-carlo';
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',

View File

@@ -126,7 +126,7 @@ describe('FireCalculatorForm', () => {
await screen.findByText('Financial Projection');
const bandLegend = await screen.findByTestId('mc-band-legend');
expect(bandLegend).toHaveTextContent('10th-90th percentile');
expect(bandLegend).toHaveTextContent('40th-60th percentile');
});
it('handles withdrawal strategy selection', async () => {

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CoastFireChart } from '@/app/components/charts/CoastFireChart';
import { AuthorBio } from '@/app/components/AuthorBio';
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
import type { Metadata } from 'next';
const faqs: FaqItem[] = [
{
@@ -39,16 +40,28 @@ const faqs: FaqItem[] = [
},
];
export const metadata = {
export const metadata: Metadata = {
title: `Coast FIRE vs. Lean FIRE: Which Strategy Is Right For You? (${new Date().getFullYear().toString()})`,
description:
'Compare Coast FIRE (front-loading savings) with Lean FIRE (minimalist living). See the math, pros, cons, and find your path to freedom.',
alternates: {
canonical: 'https://investingfire.com/learn/coast-fire-vs-lean-fire',
},
openGraph: {
title: 'Coast FIRE vs. Lean FIRE: The Ultimate Comparison',
description:
"Don't just retire early—retire smarter. We break down the two most popular alternative FIRE strategies.",
type: 'article',
siteName: 'InvestingFIRE',
url: 'https://investingfire.com/learn/coast-fire-vs-lean-fire',
images: [
{
url: 'https://investingfire.com/apple-icon.png',
width: 180,
height: 180,
alt: 'InvestingFIRE Logo',
},
],
},
};

View File

@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { Info } from 'lucide-react';
import { AuthorBio } from '@/app/components/AuthorBio';
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
import type { Metadata } from 'next';
const faqs: FaqItem[] = [
{
@@ -33,15 +34,27 @@ const faqs: FaqItem[] = [
},
];
export const metadata = {
export const metadata: Metadata = {
title: 'Home Bias in Investing: Why It Matters and How to Fix It',
description:
'Home bias concentrates risk in one country. Learn why it happens, how it hurts returns, and simple steps to global diversification.',
alternates: {
canonical: 'https://investingfire.com/learn/home-bias-in-investing',
},
openGraph: {
title: 'Home Bias in Investing: Why It Matters and How to Fix It',
description: 'Reduce country concentration, improve diversification, and stay tax aware.',
type: 'article',
siteName: 'InvestingFIRE',
url: 'https://investingfire.com/learn/home-bias-in-investing',
images: [
{
url: 'https://investingfire.com/apple-icon.png',
width: 180,
height: 180,
alt: 'InvestingFIRE Logo',
},
],
},
};

View File

@@ -2,11 +2,30 @@ 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;

View File

@@ -76,7 +76,16 @@ export const generateMetadata = async ({ params }: RetireAtPageProps): Promise<M
title,
description,
url: canonical,
siteName: 'InvestingFIRE',
type: 'article',
images: [
{
url: 'https://investingfire.com/apple-icon.png',
width: 180,
height: 180,
alt: 'InvestingFIRE Logo',
},
],
},
};
};

View File

@@ -5,6 +5,7 @@ import { Info } from 'lucide-react';
import { FourPercentRuleChart } from '@/app/components/charts/FourPercentRuleChart';
import { AuthorBio } from '@/app/components/AuthorBio';
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
import type { Metadata } from 'next';
const faqs: FaqItem[] = [
{
@@ -39,14 +40,26 @@ const faqs: FaqItem[] = [
},
];
export const metadata = {
export const metadata: Metadata = {
title: 'Safe Withdrawal Rates & The 4% Rule Explained (2025 Update)',
description: `Is the 4% rule safe in ${new Date().getFullYear().toString()}? We analyze the Trinity Study, sequence of returns risk, and variable withdrawal strategies for a bulletproof retirement.`,
alternates: {
canonical: 'https://investingfire.com/learn/safe-withdrawal-rate-4-percent-rule',
},
openGraph: {
title: 'Safe Withdrawal Rates & The 4% Rule Explained',
description: "Don't run out of money. Understanding the math behind safe retirement withdrawals.",
type: 'article',
siteName: 'InvestingFIRE',
url: 'https://investingfire.com/learn/safe-withdrawal-rate-4-percent-rule',
images: [
{
url: 'https://investingfire.com/apple-icon.png',
width: 180,
height: 180,
alt: 'InvestingFIRE Logo',
},
],
},
};

View File

@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { FireFlowchart } from '@/app/components/charts/FireFlowchart';
import { AuthorBio } from '@/app/components/AuthorBio';
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
import type { Metadata } from 'next';
const faqs: FaqItem[] = [
{
@@ -37,15 +38,27 @@ const faqs: FaqItem[] = [
},
];
export const metadata = {
export const metadata: Metadata = {
title: `What is FIRE? The Ultimate Guide to Financial Independence (${new Date().getFullYear().toString()})`,
description:
'Discover the FIRE movement (Financial Independence, Retire Early). Learn how to calculate your FIRE number, savings rate, and retire decades ahead of schedule.',
alternates: {
canonical: 'https://investingfire.com/learn/what-is-fire',
},
openGraph: {
title: 'What is FIRE? The Ultimate Guide to Financial Independence',
description: 'Stop trading time for money. The comprehensive guide to regaining your freedom.',
type: 'article',
siteName: 'InvestingFIRE',
url: 'https://investingfire.com/learn/what-is-fire',
images: [
{
url: 'https://investingfire.com/apple-icon.png',
width: 180,
height: 180,
alt: 'InvestingFIRE Logo',
},
],
},
};

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Info } from 'lucide-react';
import { AuthorBio } from '@/app/components/AuthorBio';
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
import type { Metadata } from 'next';
const faqs: FaqItem[] = [
{
@@ -34,15 +35,27 @@ const faqs: FaqItem[] = [
},
];
export const metadata = {
export const metadata: Metadata = {
title: `Where to Park Your Money for FIRE (${new Date().getFullYear().toString()})`,
description:
'Build a globally diversified, low-cost index portfolio, avoid home bias, and use the right tax wrappers—wherever you live. A practical guide for FIRE investors.',
alternates: {
canonical: 'https://investingfire.com/learn/where-to-park-your-money',
},
openGraph: {
title: 'Where to Park Your Money for FIRE',
description: 'Global index investing playbook: avoid home bias, cut fees, optimize taxes.',
type: 'article',
siteName: 'InvestingFIRE',
url: 'https://investingfire.com/learn/where-to-park-your-money',
images: [
{
url: 'https://investingfire.com/apple-icon.png',
width: 180,
height: 180,
alt: 'InvestingFIRE Logo',
},
],
},
};

View File

@@ -4,6 +4,7 @@ import FireCalculatorForm from './components/FireCalculatorForm';
import BackgroundPattern from './components/BackgroundPattern';
import { FaqSection, type FaqItem } from './components/FaqSection';
import { Testimonials } from './components/Testimonials';
import type { Metadata } from 'next';
const faqs: FaqItem[] = [
{
@@ -38,6 +39,31 @@ const faqs: FaqItem[] = [
},
];
export const metadata: Metadata = {
title: `InvestingFIRE | Finance and Retirement Calculator ${new Date().getFullYear().toString()}`,
description:
'Achieve Financial Independence & Early Retirement! Plan your FIRE journey with the InvestingFIRE calculator and get personal projections in gorgeous graphs..',
alternates: {
canonical: 'https://investingfire.com',
},
openGraph: {
title: `InvestingFIRE | Finance and Retirement Calculator ${new Date().getFullYear().toString()}`,
description:
'Achieve Financial Independence & Early Retirement! Plan your FIRE journey with the InvestingFIRE calculator and get personal projections in gorgeous graphs.',
type: 'website',
url: 'https://investingfire.com',
siteName: 'InvestingFIRE',
images: [
{
url: 'https://investingfire.com/apple-icon.png',
width: 180,
height: 180,
alt: 'InvestingFIRE Logo',
},
],
},
};
export default function HomePage() {
return (
<div className="from-background via-primary/10 to-secondary/10 text-foreground relative flex min-h-screen w-full flex-col items-center overflow-hidden bg-gradient-to-b px-4 pt-6 pb-16">