Compare commits
21 Commits
886ffa39ef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dda8cedd9a | |||
| 30e5198b14 | |||
| 8f2443572f | |||
| 28cb553ed0 | |||
| 013aa41965 | |||
| 6bb37bd6c3 | |||
| b9b16bcbf3 | |||
| bb9d09c653 | |||
| bf2c8b4e1a | |||
| 001a7b5c35 | |||
| 85089cd187 | |||
| 4b67c1ab2c | |||
| b9a2228422 | |||
| dda92f3f80 | |||
| ec52cbc116 | |||
| 17a694d4b5 | |||
| dc9cf1c1f2 | |||
| cb4a4e2f06 | |||
| 6c09c22656 | |||
| a7a2fe39ca | |||
| 3dc79aa425 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
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: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"]
|
||||
@@ -5,6 +5,8 @@
|
||||
import './src/env.ts';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {};
|
||||
const config = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
38
package.json
38
package.json
@@ -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
1417
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CoastFireChart } from '@/app/components/charts/CoastFireChart';
|
||||
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
@@ -39,16 +40,28 @@ const faqs: FaqItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: `Coast FIRE vs. Lean FIRE: Which Strategy Is Right For You? (${new Date().getFullYear().toString()})`,
|
||||
description:
|
||||
'Compare Coast FIRE (front-loading savings) with Lean FIRE (minimalist living). See the math, pros, cons, and find your path to freedom.',
|
||||
alternates: {
|
||||
canonical: 'https://investingfire.com/learn/coast-fire-vs-lean-fire',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Coast FIRE vs. Lean FIRE: The Ultimate Comparison',
|
||||
description:
|
||||
"Don't just retire early—retire smarter. We break down the two most popular alternative FIRE strategies.",
|
||||
type: 'article',
|
||||
siteName: 'InvestingFIRE',
|
||||
url: 'https://investingfire.com/learn/coast-fire-vs-lean-fire',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Info } from 'lucide-react';
|
||||
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
@@ -33,15 +34,27 @@ const faqs: FaqItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: 'Home Bias in Investing: Why It Matters and How to Fix It',
|
||||
description:
|
||||
'Home bias concentrates risk in one country. Learn why it happens, how it hurts returns, and simple steps to global diversification.',
|
||||
alternates: {
|
||||
canonical: 'https://investingfire.com/learn/home-bias-in-investing',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Home Bias in Investing: Why It Matters and How to Fix It',
|
||||
description: 'Reduce country concentration, improve diversification, and stay tax aware.',
|
||||
type: 'article',
|
||||
siteName: 'InvestingFIRE',
|
||||
url: 'https://investingfire.com/learn/home-bias-in-investing',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Info } from 'lucide-react';
|
||||
import { FourPercentRuleChart } from '@/app/components/charts/FourPercentRuleChart';
|
||||
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
@@ -39,14 +40,26 @@ const faqs: FaqItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: 'Safe Withdrawal Rates & The 4% Rule Explained (2025 Update)',
|
||||
description: `Is the 4% rule safe in ${new Date().getFullYear().toString()}? We analyze the Trinity Study, sequence of returns risk, and variable withdrawal strategies for a bulletproof retirement.`,
|
||||
alternates: {
|
||||
canonical: 'https://investingfire.com/learn/safe-withdrawal-rate-4-percent-rule',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Safe Withdrawal Rates & The 4% Rule Explained',
|
||||
description: "Don't run out of money. Understanding the math behind safe retirement withdrawals.",
|
||||
type: 'article',
|
||||
siteName: 'InvestingFIRE',
|
||||
url: 'https://investingfire.com/learn/safe-withdrawal-rate-4-percent-rule',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { FireFlowchart } from '@/app/components/charts/FireFlowchart';
|
||||
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
@@ -37,15 +38,27 @@ const faqs: FaqItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: `What is FIRE? The Ultimate Guide to Financial Independence (${new Date().getFullYear().toString()})`,
|
||||
description:
|
||||
'Discover the FIRE movement (Financial Independence, Retire Early). Learn how to calculate your FIRE number, savings rate, and retire decades ahead of schedule.',
|
||||
alternates: {
|
||||
canonical: 'https://investingfire.com/learn/what-is-fire',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'What is FIRE? The Ultimate Guide to Financial Independence',
|
||||
description: 'Stop trading time for money. The comprehensive guide to regaining your freedom.',
|
||||
type: 'article',
|
||||
siteName: 'InvestingFIRE',
|
||||
url: 'https://investingfire.com/learn/what-is-fire',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Info } from 'lucide-react';
|
||||
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const faqs: FaqItem[] = [
|
||||
{
|
||||
@@ -34,15 +35,27 @@ const faqs: FaqItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata = {
|
||||
export const metadata: Metadata = {
|
||||
title: `Where to Park Your Money for FIRE (${new Date().getFullYear().toString()})`,
|
||||
description:
|
||||
'Build a globally diversified, low-cost index portfolio, avoid home bias, and use the right tax wrappers—wherever you live. A practical guide for FIRE investors.',
|
||||
alternates: {
|
||||
canonical: 'https://investingfire.com/learn/where-to-park-your-money',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Where to Park Your Money for FIRE',
|
||||
description: 'Global index investing playbook: avoid home bias, cut fees, optimize taxes.',
|
||||
type: 'article',
|
||||
siteName: 'InvestingFIRE',
|
||||
url: 'https://investingfire.com/learn/where-to-park-your-money',
|
||||
images: [
|
||||
{
|
||||
url: 'https://investingfire.com/apple-icon.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
alt: 'InvestingFIRE Logo',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user