Compare commits

...

74 Commits

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

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

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

Simplifies calculator behavior and improves accessibility for stateful URLs.
2025-12-06 20:25:04 +01:00
2b0df3d100 quotes 2025-12-06 20:04:08 +01:00
15a32dc467 sharable calc, retire at pages 2025-12-06 20:04:08 +01:00
bfac54a194 chore(deps): update dependency @playwright/test to ^1.57.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-06 18:01:26 +00:00
9d9c8b0d37 chore(deps): update dependency zod to v4.1.13
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 37s
2025-12-06 17:01:10 +00:00
dc74f507c3 automatic sitemap
All checks were successful
Lint / Lint and Typecheck (push) Successful in 35s
2025-12-06 16:24:46 +01:00
80f0de3b57 Adds global portfolio and home bias educational articles
All checks were successful
Lint / Lint and Typecheck (push) Successful in 38s
Introduces two in-depth learning articles: one guiding users on global, low-cost portfolio construction and tax-optimized account selection; another explaining home bias risks and practical diversification steps. Updates the learning hub to highlight both articles for improved user education on investment risk and allocation.
2025-12-06 16:16:07 +01:00
9d66598c7e worktree config 2025-12-06 16:01:18 +01:00
ed31944963 visual bug
All checks were successful
Lint / Lint and Typecheck (push) Successful in 35s
2025-12-06 15:29:29 +01:00
e8f0269b75 homepage faq 2025-12-06 15:27:36 +01:00
597b7a5883 calc tooltips 2025-12-06 15:20:29 +01:00
14834024ec FAQs 2025-12-06 15:20:23 +01:00
8ac1c1a9df tests 2025-12-06 15:19:53 +01:00
46dd28482f shadcn tooltip 2025-12-06 14:48:41 +01:00
288a9b4992 calculator fix
All checks were successful
Lint / Lint and Typecheck (push) Successful in 36s
2025-12-06 14:47:38 +01:00
37d8511da7 chart style and descripitons 2025-12-06 14:23:06 +01:00
cd2179f7a0 formatting 2025-12-06 14:15:18 +01:00
21a8c95a2b style and visual fixes 2025-12-06 14:05:39 +01:00
1711c2d16b lint fix 2025-12-06 13:40:38 +01:00
8714d3a30d Merge branch 'main' into dev
Some checks failed
Lint / Lint and Typecheck (push) Failing after 26s
2025-12-06 13:37:46 +01:00
2b09cfc352 chore(deps): update actions/checkout digest to 8e8c483
Some checks failed
Lint / Lint and Typecheck (push) Has been cancelled
2025-12-06 02:21:05 +01:00
19709f531d content, blurthing 2025-12-06 02:20:40 +01:00
91dadaedaa visual improvements 2025-12-06 01:40:57 +01:00
fde6a3b7bf very nice footer, accordion fixes, homepage style 2025-12-06 01:11:26 +01:00
7b24da6f35 learn pages 2025-12-06 00:53:27 +01:00
67af131500 navbar and footer 2025-12-06 00:48:14 +01:00
67f7c96454 global styling 2025-12-06 00:47:46 +01:00
5e5c1e3c55 misc shadcn components 2025-12-05 15:45:08 +01:00
3851411a0e ignore slop 2025-12-05 15:30:44 +01:00
b097d82967 rules 2025-12-05 15:29:37 +01:00
1400b3f1ae tests 2025-12-05 15:29:11 +01:00
90f83ab62b Adds Vitest and Playwright testing setup with sample tests
Introduces a unified testing setup using Vitest for unit tests
and Playwright for E2E tests. Updates dependencies, adds sample
unit and E2E tests, documents test workflow, and codifies
testing and code standards in project guidelines.

Enables fast, automated test runs and improves code reliability
through enforced standards.
2025-12-05 13:42:10 +01:00
858185baf8 Adds Monte Carlo simulation and Coast FIRE options
Introduces Monte Carlo simulation mode with customizable market volatility, allowing users to visualize probabilistic retirement balances (median and percentiles) and estimate FIRE plan success rates. Adds fields for Coast FIRE age and Barista FIRE income to support more flexible FIRE scenarios. Updates forms, chart tooltips, and chart areas to display new data, improving the accuracy and insightfulness of retirement projections for advanced use cases.
2025-12-05 13:40:11 +01:00
472efbb3b1 plan 2025-12-05 13:40:11 +01:00
58 changed files with 7992 additions and 1446 deletions

3
.cursor/worktrees.json Normal file
View File

@@ -0,0 +1,3 @@
{
"setup-worktree": ["pnpm install"]
}

37
.cursorrules Normal file
View File

@@ -0,0 +1,37 @@
# Cursor Rules for InvestingFIRE 🔥
## General Principles
- **Quality First:** All new features must include appropriate tests.
- **User-Centric:** Prioritize user experience and accessibility in all changes.
- **Dry Code:** Avoid duplication; use utility functions and components.
## Testing Requirements 🧪
- **Unit Tests:** Required for all new utility functions, hooks, and complex logic.
- Use `vitest` and `react-testing-library`.
- Place tests in `__tests__` directories or alongside files with `.test.ts(x)` extension.
- **E2E Tests:** Required for new user flows and critical paths.
- Use `playwright`.
- Ensure tests cover happy paths and error states.
- **Visual Regression:** Consider for major UI changes.
## Coding Standards
- **Type Safety:** No `any`. Use proper Zod schemas for validation.
- **Components:** Use functional components with strict prop typing.
- **Styling:** Use Tailwind CSS. Avoid inline styles.
- **State Management:** Prefer local state or React Context. Avoid global state libraries unless necessary.
## Workflow
1. **Plan:** Break down tasks.
2. **Implement:** Write clean, commented code.
3. **Test:** specific unit and/or E2E tests.
4. **Verify:** Run linter and type checker (`pnpm check`), and run tests (`pnpm test`).
## Strict Rules
- **No "any" type:** Always define proper types. Use `unknown` if type is truly uncertain, but prefer specific types.
- **No "ts-ignore":** Fix the underlying issue instead of suppressing it.
## Specific Patterns
- **Forms:** Use `react-hook-form` with `zod` resolvers.
- **Charts:** Use `recharts` and ensure tooltips are accessible.
- **Calculations:** Keep financial logic separate from UI components where possible (e.g., in `lib/` or custom hooks) to facilitate testing.

7
.dockerignore Normal file
View File

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

View File

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

9
.gitignore vendored
View File

@@ -43,4 +43,11 @@ yarn-error.log*
*.tsbuildinfo
# idea files
.idea
.idea
playwright-report/
test-results/
CONTENT_STRATEGY.md
CONTENT_TASKS.md

69
Dockerfile Normal file
View File

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

View File

@@ -2,15 +2,54 @@
# InvestingFIRE 🔥 — The #1 Interactive FIRE Calculator
**InvestingFIRE** is a responsive web application for calculating your path to Financial Independence and Early Retirement (FIRE). It features a year-by-year projection engine that simulates both accumulation (savings and investment growth) and retirement (withdrawals) phases, allowing users to:
**InvestingFIRE** is a responsive web application for calculating your path to Financial Independence and Early Retirement (FIRE). It features a year-by-year projection engine that simulates both accumulation (savings and investment growth) and retirement (withdrawals) phases.
- Input starting capital, monthly savings, expected annual growth rate, inflation rate, current age, desired retirement age, life expectancy, and desired monthly retirement allowance.
- View a dynamic chart displaying projected portfolio balance and monthly allowance over time.
- Instantly see their estimated “FIRE number” (required capital at retirement), how long their capital will last, and compare results to the “4% rule.”
- Adjust assumptions live, with all calculations and visualizations updating automatically.
- Access explanatory content about FIRE methodology, key variables, and additional community resources, all on a single, consolidated page.
Deployed version: [https://investingfire.com](https://investingfire.com)
The projects code is structured using React/Next.js with TypeScript, focusing on user experience, modern UI components, and clarity of financial assumptions.
---
## 🎯 Goal & Vision
### **Goal**
To build the most comprehensive, user-friendly, and transparent open-source financial independence calculator on the web.
### **Vision**
Democratize financial planning by providing professional-grade simulation tools in an accessible, privacy-focused, and beautiful interface. We believe everyone should have the ability to model their financial future without needing a finance degree or expensive software.
### **Business Model**
InvestingFIRE operates on a transparent open-source model:
1. **Free Forever Core:** The essential calculation tools will always be free and open-source.
2. **Community Supported:** We rely on community contributions (code & feedback) to improve the tool.
3. **Educational Affiliates:** We may curate high-quality resources (books, courses, tools) to help users on their journey, keeping the tool free of intrusive ads.
---
## 🗺️ Roadmap
We are actively expanding the capabilities of InvestingFIRE. Below is our plan broken down into phases.
### **Phase 1: Enhanced Simulation (The Engine)**
Focus on making the math more robust and flexible.
- [ ] **Coast FIRE Mode:** Option to stop contributions at a certain age but retire later.
- [ ] **Barista FIRE Mode:** Include part-time income during "retirement" years.
- [ ] **Monte Carlo Simulations:** Add probabilistic outcomes (e.g., "95% chance of success") instead of just deterministic linear growth.
- [ ] **Variable Withdrawal Strategies:** Implement dynamic withdrawal rules (e.g., Guyton-Klinger) beyond just fixed inflation-adjusted withdrawals.
### **Phase 2: User Experience & Persistence**
Make the tool easier to use and return to.
- [ ] **URL State Sharing:** Encode form values into the URL so scenarios can be bookmarked and shared.
- [ ] **Local Persistence:** Automatically save user inputs to `localStorage` so they don't vanish on refresh.
- [ ] **Currency & Locale Support:** Allow users to select currency symbols and number formatting (USD, EUR, GBP, etc.).
### **Phase 3: Advanced Features & Analytics**
For the power users who need more detail.
- [ ] **Tax Considerations:** Simple toggles for Pre-tax vs. Post-tax estimations.
- [ ] **Scenario Comparison:** Compare two different plans side-by-side (e.g., "Retire at 45 vs 55").
- [ ] **Data Export:** Download projection data as CSV or PDF reports.
### **Phase 4: Content & Community**
- [ ] **Blog/Guides:** Integrate a CMS (like Markdown/MDX) for in-depth financial guides.
- [ ] **Community Presets:** "One-click" setups for common strategies (e.g., "Lean FIRE", "Fat FIRE").
---
@@ -65,7 +104,23 @@ To run locally:
```
4. Visit [http://localhost:3000](http://localhost:3000) and unleash the fire.
Deployed version: [https://investingfire.com](https://investingfire.com)
### Running Tests 🧪
We use **Vitest** for unit testing and **Playwright** for end-to-end (E2E) testing.
**Unit Tests:**
```bash
pnpm test
```
**E2E Tests:**
```bash
# First install browsers (only needed once)
pnpm exec playwright install
# Run tests
pnpm test:e2e
```
---

15
e2e/home.spec.ts Normal file
View File

@@ -0,0 +1,15 @@
import { test, expect } from "@playwright/test";
test("homepage has title and calculator", async ({ page }) => {
await page.goto("/");
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/InvestingFIRE/);
// Check for main heading
await expect(page.getByRole("heading", { name: "InvestingFIRE" })).toBeVisible();
// Check for Calculator
await expect(page.getByText("FIRE Calculator")).toBeVisible();
});

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

@@ -11,49 +11,66 @@
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next typegen && eslint . && npx tsc --noEmit",
"preview": "next build && next start",
"start": "next start"
"start": "next start",
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@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.556.0",
"next": "16.0.7",
"lucide-react": "^0.562.0",
"next": "16.1.0",
"next-plausible": "^3.12.4",
"react": "19.2.1",
"react-dom": "19.2.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.56.1",
"recharts": "^2.15.3",
"tailwind-merge": "^3.2.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.17",
"@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",
"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.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"
"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",

34
playwright.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "list",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
webServer: {
command: "pnpm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});

2901
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent } from '@/components/ui/card';
export function AuthorBio() {
return (
<Card className="bg-muted/50 mt-12">
<CardContent className="flex items-center gap-4 p-6">
<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-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>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import type React from "react";
'use client';
import { useState, useEffect } from 'react';
import type React from 'react';
import {
type LucideIcon,
HandCoins,
@@ -39,7 +39,7 @@ import {
Hourglass,
Sprout,
Target,
} from "lucide-react";
} from 'lucide-react';
export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
const [width, setWidth] = useState(0);
@@ -58,10 +58,10 @@ export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
window.addEventListener('resize', updateDimensions);
return () => {
window.removeEventListener("resize", updateDimensions);
window.removeEventListener('resize', updateDimensions);
};
}, [height, width, spacing]);
@@ -129,7 +129,7 @@ export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
const IconComponent = iconComponents[randomIndex];
// Slightly randomize size and position for more organic feel
const size = 28 + Math.floor(Math.random() * 8);
const size = 30 + Math.floor(Math.random() * 8);
const xOffset = Math.floor(Math.random() * (spacing / 1.618));
const yOffset = Math.floor(Math.random() * (spacing / 1.618));
const rotation = Math.round((Math.random() - 0.5) * 30);
@@ -138,7 +138,8 @@ export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
<IconComponent
key={`icon-${String(x)}-${String(y)}`}
size={size}
className="text-primary fixed"
className="text-primary/30 fixed"
strokeWidth={2.5}
style={{
left: `${String(x * spacing + xOffset)}px`,
top: `${String(y * spacing + yOffset)}px`,
@@ -153,9 +154,5 @@ export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, columns, spacing, opacity]);
return (
<div className="absolute h-full w-full">
{width > 0 && icons}
</div>
);
return <div className="absolute z-0 h-full w-full">{width > 0 && icons}</div>;
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Menu, Calculator, BookOpen, Flame, Percent, Anchor, Sparkles } from 'lucide-react';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
export function Navbar() {
return (
<header className="md:bg-background/90 md:supports-[backdrop-filter]:bg-background/70 sticky top-0 z-50 w-full md:border-b md:backdrop-blur">
<div className="container flex h-16 items-center justify-between px-4 md:px-6">
<div className="mr-4 hidden items-center gap-6 md:flex">
<Link
href="/"
className="hover:bg-primary/10 flex items-center gap-2 rounded-md px-2 py-1 transition-colors"
>
<Image
src="/investingfire_logo_no-bg.svg"
alt="InvestingFIRE"
width={28}
height={28}
className="h-7 w-7"
/>
<span className="hidden font-bold sm:inline-block">InvestingFIRE</span>
</Link>
<nav className="flex items-center space-x-4 text-sm font-medium">
<Link
href="/"
className="text-foreground/70 hover:bg-primary/10 hover:text-foreground flex items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors"
>
<Calculator className="h-4 w-4" />
Calculator
</Link>
<Link
href="/learn"
className="text-foreground/70 hover:bg-primary/10 hover:text-foreground flex items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors"
>
<BookOpen className="h-4 w-4" />
Learn
</Link>
<Link
href="/learn/what-is-fire"
className="text-foreground/70 hover:bg-primary/10 hover:text-foreground flex items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors"
>
<Flame className="h-4 w-4" />
What is FIRE?
</Link>
</nav>
</div>
{/* Mobile */}
<Sheet>
<SheetTrigger asChild>
<Button
variant="ghost"
className="border-primary/20 bg-primary/10 text-primary hover:bg-primary/20 focus-visible:bg-primary/20 mr-2 rounded-full border px-2 text-base focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden"
>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle Menu</span>
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="from-background via-primary/25 to-secondary/25 w-[86vw] max-w-sm border-r bg-gradient-to-b px-0 pb-10 shadow-xl"
>
<SheetHeader className="px-8 py-4">
<SheetTitle>
<div className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<Image
src="/investingfire_logo_no-bg.svg"
alt="InvestingFIRE"
width={24}
height={24}
/>
<span className="font-bold">InvestingFIRE</span>
</Link>
</div>
</SheetTitle>
<SheetDescription className="text-muted-foreground text-xs">
Built to make FIRE math simple and transparent for everyone.
</SheetDescription>
</SheetHeader>
<nav className="flex flex-col gap-2 px-8">
<Link
href="/"
className="text-foreground/80 hover:bg-primary/10 hover:text-foreground flex items-center gap-2 rounded-lg px-3 py-2 transition-colors"
>
<Calculator className="h-4 w-4" />
Calculator
</Link>
<Link
href="/learn"
className="text-foreground/80 hover:bg-primary/10 hover:text-foreground flex items-center gap-2 rounded-lg px-3 py-2 transition-colors"
>
<BookOpen className="h-4 w-4" />
Learn
</Link>
<Link
href="/learn/what-is-fire"
className="text-foreground/80 hover:bg-primary/10 hover:text-foreground flex items-center gap-2 rounded-lg px-3 py-2 transition-colors"
>
<Flame className="h-4 w-4" />
What is FIRE?
</Link>
<Link
href="/learn/safe-withdrawal-rate-4-percent-rule"
className="text-foreground/80 hover:bg-primary/10 hover:text-foreground flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors"
>
<Percent className="h-4 w-4" />
The 4% Rule
</Link>
<Link
href="/learn/coast-fire-vs-lean-fire"
className="text-foreground/80 hover:bg-primary/10 hover:text-foreground flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors"
>
<Anchor className="h-4 w-4" />
Coast vs. Lean FIRE
</Link>
</nav>
<div className="px-8 pt-6">
<Button className="w-full justify-center gap-2" variant="secondary">
<Sparkles className="h-4 w-4" />
Launch the calculator
</Button>
</div>
</SheetContent>
</Sheet>
<div className="flex flex-1 items-center justify-end space-x-2 md:flex-none">
{/* Future: Theme Toggle, GitHub Link etc */}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,44 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Quote } from "lucide-react";
export function Testimonials() {
return (
<section className="my-16 grid gap-6 md:grid-cols-3">
<Card className="bg-card border-none shadow-md">
<CardHeader className="pb-2">
<Quote className="h-8 w-8 text-primary/20" />
</CardHeader>
<CardContent>
<p className="mb-4 text-lg italic text-muted-foreground">
&quot;I always struggled with the math behind early retirement. This calculator made it click instantly. Seeing the graph change in real-time is a game changer.&quot;
</p>
<p className="font-semibold">- Sarah J., Software Engineer</p>
</CardContent>
</Card>
<Card className="bg-card border-none shadow-md">
<CardHeader className="pb-2">
<Quote className="h-8 w-8 text-primary/20" />
</CardHeader>
<CardContent>
<p className="mb-4 text-lg italic text-muted-foreground">
&quot;Most FIRE calculators are too simple. I love that I can toggle Monte Carlo simulations to see if my plan survives a market crash. Highly recommended.&quot;
</p>
<p className="font-semibold">- Mike T., Financial Analyst</p>
</CardContent>
</Card>
<Card className="bg-card border-none shadow-md">
<CardHeader className="pb-2">
<Quote className="h-8 w-8 text-primary/20" />
</CardHeader>
<CardContent>
<p className="mb-4 text-lg italic text-muted-foreground">
&quot;The inflation adjustment feature is crucial. It showed me I needed to save a bit more to be truly safe, but now I sleep better knowing the real numbers.&quot;
</p>
<p className="font-semibold">- Emily R., Teacher (Coast FIRE)</p>
</CardContent>
</Card>
</section>
);
}

View File

@@ -0,0 +1,146 @@
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 */
}
}
global.ResizeObserver = ResizeObserver;
// Fix for Radix UI pointer capture error in JSDOM
beforeAll(() => {
window.HTMLElement.prototype.hasPointerCapture = vi.fn();
window.HTMLElement.prototype.setPointerCapture = vi.fn();
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
window.HTMLElement.prototype.scrollIntoView = vi.fn();
});
// Mock Recharts ResponsiveContainer
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>
),
};
});
// 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.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 () => {
const user = userEvent.setup();
render(<FireCalculatorForm />);
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();
});
});
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.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 });
await user.click(modeTrigger);
// Select Monte Carlo from dropdown
const monteCarloOption = await screen.findByRole('option', { name: /Monte Carlo/i });
await user.click(monteCarloOption);
// Verify Volatility input appears
expect(await screen.findByRole('spinbutton', { name: /Market Volatility/i })).toBeInTheDocument();
});
it('shows Monte Carlo percentile bounds on the chart', async () => {
const user = userEvent.setup();
render(<FireCalculatorForm />);
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 });
await user.click(percentageOption);
expect(
await screen.findByRole('spinbutton', { name: /Withdrawal Percentage/i }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,11 @@
export default function BlurThing() {
return (
<>
{/* Decorative background elements */}
<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>
</>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import { Line, LineChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import BlurThing from '../blur-thing';
// Simulation
// Standard: Start 25, Retire 65. Save $10k/yr.
// Coast: Start 25, Save $30k/yr until 35. Then $0.
// Return: 7%
const generateData = () => {
const data = [];
let standardBal = 0;
let coastBal = 0;
const rate = 1.07;
for (let age = 25; age <= 65; age++) {
data.push({
age,
Standard: Math.round(standardBal),
Coast: Math.round(coastBal),
});
// Standard: consistent
standardBal = (standardBal + 10000) * rate;
// Coast: heavy early, then stop
if (age < 35) {
coastBal = (coastBal + 30000) * rate;
} else {
coastBal = coastBal * rate;
}
}
return data;
};
const data = generateData();
const chartConfig = {
age: {
label: 'Age',
},
Standard: {
label: 'Standard Path',
color: 'var(--chart-4)',
},
Coast: {
label: 'Coast FIRE',
color: 'var(--chart-1)',
},
} satisfies ChartConfig;
export function CoastFireChart() {
return (
<Card className="w-full">
{/* Decorative background elements */}
<BlurThing />
<CardHeader>
<CardTitle>Coast FIRE vs. Standard Path</CardTitle>
<CardDescription>
Comparing heavy early savings (Coast) vs. consistent saving (Standard)
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="aspect-auto h-[300px] w-full">
<LineChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="age" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
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 indicator="line" labelKey="age" />}
/>
<Line
dataKey="Standard"
type="natural"
stroke="var(--color-Standard)"
strokeWidth={2}
dot={false}
/>
<Line
dataKey="Coast"
type="natural"
stroke="var(--color-Coast)"
strokeWidth={2}
dot={false}
/>
<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>
);
}

View File

@@ -0,0 +1,113 @@
import { Banknote, Coins, Flame, Landmark, TrendingUp, Wallet } from 'lucide-react';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import BlurThing from '../blur-thing';
const steps = [
{
icon: Banknote,
title: 'Income',
description: 'Maximize earnings & side hustles',
color: 'from-emerald-400 to-teal-500',
glow: 'shadow-emerald-500/30',
},
{
icon: Wallet,
title: 'Low Expenses',
description: 'Frugality & mindful spending',
color: 'from-rose-400 to-pink-500',
glow: 'shadow-rose-500/30',
},
{
icon: Coins,
title: 'Savings Gap',
description: 'The difference is your fuel',
color: 'from-sky-400 to-blue-500',
glow: 'shadow-sky-500/30',
},
{
icon: TrendingUp,
title: 'Investments',
description: 'Index funds & compounding',
color: 'from-violet-400 to-purple-500',
glow: 'shadow-violet-500/30',
},
{
icon: Landmark,
title: 'Freedom',
description: 'Work becomes optional',
color: 'from-amber-400 to-orange-500',
glow: 'shadow-amber-500/30',
},
];
export function FireFlowchart() {
return (
<Card className="relative w-full overflow-hidden">
<BlurThing />
<CardHeader className="relative pb-0 text-center">
<CardTitle className="flex items-center justify-center gap-3 text-2xl">
<Flame className="h-7 w-7 text-orange-500" />
The FIRE Engine
</CardTitle>
</CardHeader>
<CardContent className="relative">
{/* Connecting line - visible on md+ */}
<div className="absolute top-10 right-12 left-12 hidden h-0.5 bg-gradient-to-r from-emerald-400 via-purple-400 to-orange-400 opacity-30 md:block" />
{/* Steps */}
<div className="relative grid grid-cols-1 gap-6 md:grid-cols-5 md:gap-4">
{steps.map((step, index) => (
<div key={step.title} className="group relative flex flex-col items-center">
{/* Step number badge */}
<div className="absolute -top-2 -left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-zinc-900 text-xs font-bold text-white md:-top-1 md:-left-1">
{index + 1}
</div>
{/* Icon container */}
<div
className={`relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br ${step.color} shadow-lg ${step.glow} transition-all duration-300 group-hover:scale-105 group-hover:shadow-xl`}
>
<step.icon className="h-9 w-9 text-white" strokeWidth={1.5} />
</div>
{/* Content */}
<div className="mt-4 text-center">
<h4 className="font-semibold tracking-tight">{step.title}</h4>
<p className="text-muted-foreground mt-1 max-w-[140px] text-xs leading-relaxed">
{step.description}
</p>
</div>
{/* Arrow connector for mobile */}
{index < steps.length - 1 && (
<div className="my-2 flex items-center justify-center md:hidden">
<svg
className="text-muted-foreground h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</div>
)}
</div>
))}
</div>
{/* Bottom tagline */}
<CardFooter>
<p className="text-muted-foreground mx-auto mt-8 text-center text-sm">
Build the gap. Invest the gap. Let time do the rest.
</p>
</CardFooter>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import BlurThing from '../blur-thing';
// Simulation data for 4% rule
const storyData = [
{ year: 0, w3: 100, w4: 100, w5: 100 },
{ year: 1, w3: 105, w4: 104, w5: 103 },
{ year: 2, w3: 90, w4: 88, w5: 86 }, // Crash
{ year: 3, w3: 95, w4: 92, w5: 89 },
{ year: 4, w3: 102, w4: 98, w5: 94 },
{ year: 5, w3: 110, w4: 105, w5: 100 },
{ year: 10, w3: 150, w4: 130, w5: 110 },
{ year: 15, w3: 200, w4: 160, w5: 100 }, // 5% starts dragging
{ year: 20, w3: 280, w4: 200, w5: 80 },
{ year: 25, w3: 380, w4: 250, w5: 40 },
{ year: 30, w3: 500, w4: 300, w5: 0 },
];
const chartConfig = {
year: {
label: 'Year',
},
w3: {
label: '3% Withdrawal (Safe)',
color: 'var(--chart-1)',
},
w4: {
label: '4% Withdrawal (Standard)',
color: 'var(--chart-2)',
},
w5: {
label: '5% Withdrawal (Risky)',
color: 'var(--chart-3)',
},
} satisfies ChartConfig;
export function FourPercentRuleChart() {
return (
<Card className="w-full">
<BlurThing />
<CardHeader>
<CardTitle>Portfolio Survival Scenarios</CardTitle>
<CardDescription>
Impact of initial withdrawal rate on portfolio longevity (Start: $1M)
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="aspect-auto h-[300px] w-full">
<AreaChart data={storyData}>
<defs>
<linearGradient id="fillW3" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-w3)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-w3)" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="fillW4" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-w4)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-w4)" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="fillW5" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-w5)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-w5)" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis dataKey="year" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => `${String(value)}%`}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => `Year ${String(value)}`}
indicator="line"
/>
}
/>
<Area dataKey="w3" type="natural" fill="url(#fillW3)" stroke="var(--color-w3)" />
<Area dataKey="w4" type="natural" fill="url(#fillW4)" stroke="var(--color-w4)" />
<Area dataKey="w5" type="natural" fill="url(#fillW5)" stroke="var(--color-w5)" />
<ChartLegend content={<ChartLegendContent />} />
</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 &quot;sweet spot.&quot;
</p>
</div>
</CardFooter>
</Card>
);
}

View File

@@ -1,16 +1,133 @@
import Link from 'next/link';
export default function Footer() {
return (
<footer className="w-full py-8 text-center text-xs">
<p className="text-xs">
© {new Date().getFullYear()} InvestingFIRE. All rights reserved.{" "}
<a
href="https://schulze.network"
target="_blank"
className="text-primary hover:underline"
>
Hosting by Schulze.network
</a>
</p>
<footer className="bg-background z-10 w-full border-t">
<div className="from-primary/15 to-secondary/10 bg-gradient-to-b py-12">
<div className="container mx-auto max-w-6xl px-4">
<div className="bg-background/80 shadow-primary/10 mb-6 flex flex-wrap items-center justify-between gap-3 rounded-lg px-4 py-3 shadow-sm backdrop-blur">
<div className="text-primary text-sm font-semibold">
InvestingFIRE is ad-free and built as an educational tool.
</div>
</div>
<div className="grid gap-8 md:grid-cols-4">
{/* Brand */}
<div className="space-y-3">
<h3 className="font-bold">InvestingFIRE</h3>
<p className="text-muted-foreground text-sm">
The most accurate FIRE calculator on the web. Plan your path to financial independence
with clarity and confidence.
</p>
</div>
{/* Tools */}
<div className="space-y-3">
<h4 className="font-semibold">Tools</h4>
<ul className="space-y-2 text-sm">
<li>
<Link href="/" className="text-muted-foreground hover:text-primary transition-colors">
#1 FIRE Calculator
</Link>
</li>
<li>
<Link
href="https://ghostfolio.schulze.network"
className="text-muted-foreground hover:text-primary transition-colors"
>
Free hosted Ghostfolio
</Link>
</li>
</ul>
</div>
{/* Learn */}
<div className="space-y-3">
<h4 className="font-semibold">Learn</h4>
<ul className="space-y-2 text-sm">
<li>
<Link
href="/learn"
className="text-muted-foreground hover:text-primary transition-colors"
>
Knowledge Base
</Link>
</li>
<li>
<Link
href="/learn/what-is-fire"
className="text-muted-foreground hover:text-primary transition-colors"
>
What is FIRE?
</Link>
</li>
<li>
<Link
href="/learn/safe-withdrawal-rate-4-percent-rule"
className="text-muted-foreground hover:text-primary transition-colors"
>
The 4% Rule
</Link>
</li>
<li>
<Link
href="/learn/coast-fire-vs-lean-fire"
className="text-muted-foreground hover:text-primary transition-colors"
>
Coast vs. Lean FIRE
</Link>
</li>
</ul>
</div>
{/* Legal / About */}
{/*}
<div className="space-y-3">
<h4 className="font-semibold">About</h4>
<ul className="space-y-2 text-sm">
<li>
<a
href="https://schulze.network"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary transition-colors"
>
Schulze.network
</a>
</li>
<li>
<a
href="https://github.com/SchulzeGit"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary transition-colors"
>
GitHub
</a>
</li>
</ul>
</div>
*/}
</div>
<div className="text-muted-foreground mt-8 border-t pt-8 text-center text-xs">
<p>
© {new Date().getFullYear().toString()} InvestingFIRE. All rights reserved. |{' '}
<a
href="https://schulze.network"
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary"
>
Hosting by Schulze.network
</a>
</p>
<p className="mt-2">
Disclaimer: This calculator is for educational purposes only. Consult a financial advisor
before making investment decisions.
</p>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -3,6 +3,8 @@ import PlausibleProvider from "next-plausible";
import { type Metadata, type Viewport } from "next";
import { Geist } from "next/font/google";
import { WebVitals } from "./components/web-vitals";
import { Navbar } from "./components/Navbar";
import Footer from "./components/footer";
export const viewport: Viewport = {
themeColor: [{ color: "oklch(0.97 0.0228 95.96)" }],
@@ -35,7 +37,11 @@ export default function RootLayout({
/>
</head>
<WebVitals />
<body>{children}</body>
<body className="flex min-h-screen flex-col">
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
</body>
</html>
);
}

View File

@@ -0,0 +1,236 @@
import Link from 'next/link';
import { Button } from '@/components/ui/button';
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';
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',
},
],
},
};
export default function CoastVsLeanPage() {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Coast FIRE vs. Lean FIRE: Which Strategy Is Right For You?',
author: {
'@type': 'Organization',
name: 'InvestingFIRE Team',
},
publisher: {
'@type': 'Organization',
name: 'InvestingFIRE',
logo: {
'@type': 'ImageObject',
url: 'https://investingfire.com/apple-icon.png',
},
},
datePublished: '2025-01-20',
description:
'Compare Coast FIRE vs Lean FIRE strategies to find your best path to financial independence.',
};
return (
<article className="container mx-auto max-w-3xl px-4 py-12">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
{/* Breadcrumb */}
<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">Coast vs. Lean FIRE</span>
</nav>
<header className="mb-10">
<h1 className="mb-6 text-4xl font-extrabold tracking-tight lg:text-5xl">
Coast FIRE vs. Lean FIRE <br />
<span className="text-primary">Choosing Your Path to Freedom</span>
</h1>
<p className="text-muted-foreground text-xl leading-relaxed">
Traditional FIRE requires a massive nest egg. But what if you could retire sooner by tweaking
the variables? Enter <strong>Coast FIRE</strong> and <strong>Lean FIRE</strong>two powerful
strategies for those who want freedom without the wait.
</p>
</header>
<div className="max-w-none">
<h2>The Quick Summary</h2>
<p>Not sure which one fits you? Here is the high-level breakdown:</p>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-primary">🏖 Coast FIRE</CardTitle>
</CardHeader>
<CardContent>
<ul className="mt-0 list-disc space-y-2 pl-4">
<li>
<strong>Goal:</strong> Save enough <em>early</em> so compound interest covers your
retirement.
</li>
<li>
<strong>Lifestyle:</strong> Work to cover <em>current</em> expenses only.
</li>
<li>
<strong>Best For:</strong> Young professionals with high savings rates who want to
&quot;downshift&quot; careers later.
</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-green-600">🌱 Lean FIRE</CardTitle>
</CardHeader>
<CardContent>
<ul className="mt-0 list-disc space-y-2 pl-4">
<li>
<strong>Goal:</strong> Retire completely on a smaller budget (e.g., $30k-$40k/year).
</li>
<li>
<strong>Lifestyle:</strong> Minimalist, frugal, simple living.
</li>
<li>
<strong>Best For:</strong> People who hate their jobs and value time over luxury.
</li>
</ul>
</CardContent>
</Card>
</div>
<h2 className="mt-12">Deep Dive: Coast FIRE</h2>
<p>
<strong>Coast FIRE</strong> is about reaching a &quot;tipping point&quot; where you no longer
need to contribute to your retirement accounts. Your existing investments, left alone to
compound for 10-20 years, will grow into a full retirement fund.
</p>
<p>
Once you hit your Coast number, you only need to earn enough money to pay your monthly bills.
This opens the door to:
</p>
<ul>
<li>Switching to a lower-stress job</li>
<li>Working part-time</li>
<li>Taking sabbaticals</li>
</ul>
<div className="my-8">
<CoastFireChart />
</div>
<h2 className="mt-12">Deep Dive: Lean FIRE</h2>
<p>
<strong>Lean FIRE</strong> attacks the equation from the expense side. By drastically lowering
your cost of living, you lower your required FIRE number.
</p>
<p>
If you can live happily on $35,000 a year, you &quot;only&quot; need $875,000 to retire (based
on the 4% rule). Compare that to a &quot;Fat FIRE&quot; lifestyle spending $100,000, which
requires $2.5 million. Lean FIRE is the fastest path out of the workforce, but it requires
discipline.
</p>
<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>
<ol>
<li>
<strong>For Coast FIRE:</strong> Input your current age and a &quot;Coast Age&quot; (e.g.,
35). See if your current balance grows enough by age 60 without adding more.
</li>
<li>
<strong>For Lean FIRE:</strong> Lower your &quot;Desired Monthly Allowance&quot; to a
minimalist level and see how fast you reach freedom.
</li>
</ol>
<div className="my-10 text-center">
<Link href="/">
<Button size="lg" className="text-lg">
Compare Strategies with the Calculator
</Button>
</Link>
</div>
<h2>Which Should You Choose?</h2>
<p>
You don&apos;t have to pick one today. Many people start with a <strong>Lean FIRE</strong>{' '}
mindset to save aggressively, then transition to <strong>Coast FIRE</strong> once they have a
safety net, allowing them to enjoy their 30s and 40s more.
</p>
<p>
The most important step is to just <strong>start</strong>.
</p>
<FaqSection faqs={faqs} className="my-12" />
<AuthorBio />
</div>
</article>
);
}

View 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 5560% US, 4045% international today). Some investors keep 1030% 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. Heres 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,
youre 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 Its 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,&quot;The Home Bias Effect in Global Portfolios&quot; {' '}
<Link
href="https://www.msci.com/research-and-insights/quick-take/did-home-bias-help"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
MSCI
</Link>
</li>
<li>
Vanguard Research,&quot;Global equity investing: The benefits of diversification&quot; {' '}
<Link
href="https://www.vanguardmexico.com/content/dam/intl/americas/documents/mexico/en/global-equity-investing-diversification-sizing.pdf"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
Vanguard
</Link>
</li>
<li>
Sercu &amp; Vanpee (2012),&quot;The home bias puzzle in equity portfolios&quot; {' '}
<Link
href="https://doi.org/10.1093/acprof:oso/9780199754656.003.0015"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
Oxford University Press
</Link>
</li>
<li>
Fisher, Shah &amp; Titman (2017),&quot;Should you tilt your equity portfolio to smaller
countries?&quot; {' '}
<Link
href="https://doi.org/10.3905/jpm.2017.44.1.127"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
Journal of Portfolio Management
</Link>
</li>
<li>
Attig &amp; Sy (2023), &quot;Diversification during hard times&quot; {' '}
<Link
href="https://doi.org/10.1080/0015198X.2022.2160620"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
Financial Analysts Journal
</Link>
</li>
<li>
Blanchett (2021),&quot;Foreign revenue: A new world of risk exposures&quot; {' '}
<Link
href="https://doi.org/10.3905/jpm.2021.1.237"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
Journal of Portfolio Management
</Link>
</li>
<li>
Anarkulova, Cederburg &amp; ODoherty (2023),&quot;Beyond the status quo: A critical
assessment of lifecycle investment advice&quot; {' '}
<Link
href="https://doi.org/10.2139/ssrn.4590406"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
SSRN
</Link>
</li>
<li>
Goetzmann (2004),&quot;Will history rhyme?&quot; {' '}
<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),&quot;Is economic growth good for investors?&quot; {' '}
<Link
href="https://doi.org/10.1111/j.1745-6622.2012.00385.x"
className="text-primary hover:underline"
>
Journal of Applied Corporate Finance
</Link>
</li>
<li>
French (2022),&quot;Five things I know about investing&quot; {' '}
<Link
href="https://www.dimensional.com/us-en/insights/five-things-i-know-about-investing"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
Dimensional
</Link>
</li>
<li>
Bryan (2018),&quot;World War 1 and global stock markets&quot; {' '}
<Link
href="https://globalfinancialdata.com/world-war-1-and-global-stock-markets"
className="text-primary hover:underline"
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),&quot;An intertemporal capital asset pricing model&quot; {' '}
<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>
);
}

202
src/app/learn/page.tsx Normal file
View File

@@ -0,0 +1,202 @@
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: 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">
<div className="mb-12 text-center">
<h1 className="mb-4 text-4xl font-extrabold tracking-tight lg:text-5xl">FIRE Knowledge Base</h1>
<p className="text-muted-foreground text-xl">
Everything you need to know to leave the rat race behind.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* Article 1 */}
<Link href="/learn/what-is-fire" className="transition-transform hover:scale-[1.02]">
<Card className="hover:border-primary/50 h-full cursor-pointer border-2 transition-all">
<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">
Beginner
</span>
</div>
<CardTitle className="text-2xl">What is FIRE?</CardTitle>
<CardDescription>
The comprehensive guide to Financial Independence, Retire Early.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
Understand the core philosophy, the math behind the movement, and how to start your
journey today.
</p>
</CardContent>
</Card>
</Link>
{/* Article 2 */}
<Link
href="/learn/safe-withdrawal-rate-4-percent-rule"
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-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
Strategy
</span>
</div>
<CardTitle className="text-2xl">The 4% Rule Explained</CardTitle>
<CardDescription>
Is it still safe in {new Date().getFullYear().toString()}? A data-driven look at
withdrawal rates.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
Dive into the Trinity Study, sequence of returns risk, and how to bulletproof your
retirement income.
</p>
</CardContent>
</Card>
</Link>
{/* Article 3 */}
<Link href="/learn/coast-fire-vs-lean-fire" 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-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800">
Comparison
</span>
</div>
<CardTitle className="text-2xl">Coast FIRE vs. Lean FIRE</CardTitle>
<CardDescription>Which strategy fits your lifestyle?</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
Comparing different flavors of financial independence to find your perfect fit.
Front-load your savings or minimize your expenses?
</p>
</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&apos;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 hurtsand 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">
<h2 className="mb-4 text-2xl font-bold">Ready to see the numbers?</h2>
<p className="text-muted-foreground mb-6">
Put theory into practice with our interactive projection tool.
</p>
<Link
href="/"
className="bg-primary text-primary-foreground ring-offset-background hover:bg-primary/90 focus-visible:ring-ring inline-flex h-10 items-center justify-center rounded-md px-8 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
>
Launch Calculator
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,288 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { FaqSection, type FaqItem } from '@/app/components/FaqSection';
import {
RETIRE_AT_AGE_PRESETS,
buildSpendScenarios,
calculateNestEggFromSpend,
extractCalculatorValuesFromSearch,
parseAgeParam,
} from '@/lib/retire-at';
import { BASE_URL } from '@/lib/constants';
export const dynamic = 'force-static';
export const dynamicParams = false;
interface RetireAtPageProps {
params: Promise<{ age: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}
const currencyFormatter = new Intl.NumberFormat('en', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
});
const faqForAge = (age: number): FaqItem[] => {
const ageLabel = age.toString();
return [
{
question: `How much do I need to retire at ${ageLabel}?`,
answer:
'A quick rule is your desired annual spending divided by a safe withdrawal rate. Using 4%, multiply your yearly spend by 25. Spending $60k/year means roughly $1.5M. Use the calculator below to tailor the projection to your own savings, growth, and inflation assumptions.',
},
{
question: `What savings rate should I target to retire at ${ageLabel}?`,
answer:
'Aim for a 4060% savings rate if you want to retire in 1015 years. The exact rate depends on your starting capital, investment returns, and spending goal. Slide the monthly savings input to see how it moves your FIRE number and timeline.',
},
{
question: 'Is the 4% rule safe for this timeline?',
answer:
'The 4% rule is a starting point, not a guarantee. Consider 3.54% for longer retirements or higher inflation periods. The calculator supports both fixed and percentage-based withdrawals so you can stress-test more conservative plans.',
},
{
question: 'What if markets underperform?',
answer:
'Use a lower CAGR (e.g., 56%) and a higher inflation rate (e.g., 3%) in the calculator. Switch to Monte Carlo mode to see success probabilities with volatility. Also build flexibility into spending: trimming costs in bad years greatly improves durability.',
},
];
};
export const generateStaticParams = () =>
RETIRE_AT_AGE_PRESETS.map((age) => ({
age: age.toString(),
}));
export const generateMetadata = async ({ params }: RetireAtPageProps): Promise<Metadata> => {
const { age: slugAge } = await params;
const age = parseAgeParam(slugAge);
const ageLabel = age.toString();
const title = `How Much Do You Need to Retire at ${ageLabel}? | InvestingFIRE`;
const description = `Instant answer plus calculator: see how much you need saved to retire at ${ageLabel}, modeled with your spending, returns, and inflation assumptions.`;
const canonical = `${BASE_URL.replace(/\/$/, '')}/learn/retire-at/${ageLabel}`;
return {
title,
description,
alternates: {
canonical,
},
openGraph: {
title,
description,
url: canonical,
siteName: 'InvestingFIRE',
type: 'article',
images: [
{
url: 'https://investingfire.com/apple-icon.png',
width: 180,
height: 180,
alt: 'InvestingFIRE Logo',
},
],
},
};
};
export default async function RetireAtPage({ params, searchParams }: RetireAtPageProps) {
const { age: slugAge } = await params;
const resolvedSearch = (await searchParams) ?? {};
const age = parseAgeParam(slugAge);
const ageLabel = age.toString();
const initialValues = extractCalculatorValuesFromSearch(resolvedSearch, age);
const monthlySpend = initialValues.desiredMonthlyAllowance ?? 4000;
const withdrawalRate = 0.04;
const quickNestEgg = calculateNestEggFromSpend(monthlySpend, withdrawalRate);
const scenarios = buildSpendScenarios(monthlySpend, withdrawalRate);
const canonical = `${BASE_URL.replace(/\/$/, '')}/learn/retire-at/${ageLabel}`;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: `How Much Do You Need to Retire at ${ageLabel}?`,
description:
'Detailed guidance plus an interactive calculator showing exactly how much you need saved to retire at your target age.',
mainEntityOfPage: canonical,
datePublished: '2025-01-25',
dateModified: new Date().toISOString().split('T')[0],
publisher: {
'@type': 'Organization',
name: 'InvestingFIRE',
logo: {
'@type': 'ImageObject',
url: `${BASE_URL}apple-icon.png`,
},
},
};
const queryParams = new URLSearchParams();
if (initialValues.currentAge) queryParams.set('currentAge', initialValues.currentAge.toString());
queryParams.set('retirementAge', age.toString());
queryParams.set('monthlySpend', monthlySpend.toString());
if (initialValues.monthlySavings)
queryParams.set('monthlySavings', initialValues.monthlySavings.toString());
if (initialValues.startingCapital)
queryParams.set('startingCapital', initialValues.startingCapital.toString());
return (
<article className="container mx-auto max-w-4xl px-4 py-12">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<nav className="text-muted-foreground mb-6 text-sm">
<Link href="/" className="hover:text-primary">
Home
</Link>
<span className="mx-2">/</span>
<Link href="/learn" className="hover:text-primary">
Learn
</Link>
<span className="mx-2">/</span>
<span className="text-foreground">Retire at {age}</span>
</nav>
<header className="mb-10">
<h1 className="mb-4 text-4xl font-extrabold tracking-tight lg:text-5xl">
How Much Do I Need to Retire at {age}?
</h1>
<p className="text-muted-foreground text-xl leading-relaxed">
Get an instant rule-of-thumb number, then dial in the details with the FIRE calculator loaded
for age {age}. Adjust savings, returns, inflation, and withdrawals to stress-test your plan.
</p>
</header>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Quick Answer</CardTitle>
<CardDescription>
Based on a {Math.round(withdrawalRate * 100)}% withdrawal rate
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-lg">
With a monthly spend of <strong>{currencyFormatter.format(monthlySpend)}</strong>, you need
roughly <strong>{currencyFormatter.format(quickNestEgg)}</strong> invested to retire at{' '}
{age}.
</p>
<ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li>Uses the classic&quot;Rule of 25&quot; (annual spend ÷ {withdrawalRate * 100}%)</li>
<li>Assumes inflation-adjusted withdrawals and a diversified portfolio</li>
<li>Refine the projection below with your exact savings, age, and market assumptions</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>At-a-Glance</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<div className="flex items-center justify-between">
<span>Target age</span>
<span className="text-foreground font-semibold">{age}</span>
</div>
<div className="flex items-center justify-between">
<span>Monthly spend (today)</span>
<span className="text-foreground font-semibold">
{currencyFormatter.format(monthlySpend)}
</span>
</div>
<div className="flex items-center justify-between">
<span>Withdrawal rate</span>
<span className="text-foreground font-semibold">{(withdrawalRate * 100).toFixed(1)}%</span>
</div>
<div className="flex items-center justify-between">
<span>Rule-of-25 nest egg</span>
<span className="text-foreground font-semibold">
{currencyFormatter.format(quickNestEgg)}
</span>
</div>
</CardContent>
</Card>
</div>
<section className="mt-12 space-y-6">
<div className="flex items-baseline justify-between gap-3">
<div>
<h2 className="text-2xl font-bold">Spend Scenarios</h2>
<p className="text-muted-foreground">
Lean, classic, and comfortable budgets with required nest eggs.
</p>
</div>
<Link
href="/learn/safe-withdrawal-rate-4-percent-rule"
className="text-primary text-sm hover:underline"
>
Why the {Math.round(withdrawalRate * 100)}% rule?
</Link>
</div>
<div className="grid gap-4 md:grid-cols-3">
{scenarios.map((scenario) => (
<Card key={scenario.key} className="h-full">
<CardHeader>
<CardTitle>{scenario.label}</CardTitle>
<CardDescription>
{currencyFormatter.format(scenario.monthlySpend)} / month
</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<div className="flex items-center justify-between">
<span>Annual spend</span>
<span className="text-foreground font-semibold">
{currencyFormatter.format(scenario.annualSpend)}
</span>
</div>
<div className="flex items-center justify-between">
<span>Needed to retire</span>
<span className="text-foreground font-semibold">
{currencyFormatter.format(scenario.nestEgg)}
</span>
</div>
</CardContent>
</Card>
))}
</div>
</section>
<section className="mt-14 space-y-6">
<div className="bg-primary/5 rounded-xl border p-8 text-center">
<h2 className="mb-4 text-3xl font-bold">Ready to Plan Your Details?</h2>
<p className="text-muted-foreground mx-auto mb-8 max-w-2xl text-lg">
This page gives you a ballpark estimate. Use our full-featured calculator to customize
inflation, market returns, simulation modes (Monte Carlo), and more for your specific
situation.
</p>
<Button size="lg" className="h-auto px-8 py-6 text-lg" asChild>
<Link href={`/?${queryParams.toString()}`}>Open Full Calculator for Age {age}</Link>
</Button>
</div>
</section>
<section className="mt-12 grid gap-6 md:grid-cols-2">
<Card className="col-span-full">
<CardHeader>
<CardTitle>Key Levers to Watch</CardTitle>
<CardDescription>Improve success odds for age {age}</CardDescription>
</CardHeader>
<CardContent>
<ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li>Boost savings rate in the final 510 years before {age}</li>
<li>Lower planned spending or add part-time income (Barista/Coast FIRE)</li>
<li>Use conservative returns (57%) and realistic inflation (23%)</li>
<li>Consider longer life expectancy (age {age + 30}+)</li>
</ul>
</CardContent>
</Card>
</section>
<FaqSection faqs={faqForAge(age)} className="my-12" />
</article>
);
}

View File

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

View File

@@ -0,0 +1,214 @@
import Link from 'next/link';
import { Button } from '@/components/ui/button';
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';
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',
},
],
},
};
export default function SafeWithdrawalPage() {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: `Safe Withdrawal Rates & The 4% Rule Explained (${new Date().getFullYear().toString()} Update)`,
author: {
'@type': 'Organization',
name: 'InvestingFIRE Team',
},
publisher: {
'@type': 'Organization',
name: 'InvestingFIRE',
logo: {
'@type': 'ImageObject',
url: 'https://investingfire.com/apple-icon.png',
},
},
datePublished: '2025-01-15',
description: `Is the 4% rule safe in ${new Date().getFullYear().toString()}? Analysis of the Trinity Study and modern withdrawal strategies.`,
};
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">Safe Withdrawal Rates</span>
</nav>
<header className="mb-10">
<h1 className="mb-6 text-4xl font-extrabold tracking-tight lg:text-5xl">
The 4% Rule Explained: <br />
<span className="text-primary">Is It Safe in {new Date().getFullYear().toString()}?</span>
</h1>
<p className="text-muted-foreground text-xl leading-relaxed">
The &quot;4% Rule&quot; is the bedrock of the FIRE movement. But originally published in 1994,
does it hold up against modern inflation and market valuations? Let&apos;s look at the data.
</p>
</header>
<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
stock/bond portfolios to see how often they would last for 30 years given various withdrawal
rates.
</p>
<p>
The Conclusion: A portfolio of 50% stocks and 50% bonds survived{' '}
<strong>95% of the time</strong> over 30-year periods when the retiree withdrew 4% of the
initial balance, adjusted annually for inflation.
</p>
<Alert className="my-6">
<Info className="h-4 w-4" />
<AlertTitle>Key Distinction</AlertTitle>
<AlertDescription>
<span>
The 4% is based on your <span className="italic">initial</span> portfolio value. If you
start with $1M, you withdraw $40k. In year 2, if inflation was 3%, you withdraw
$41,200regardless of whether the market is up or down.
</span>
</AlertDescription>
</Alert>
<div className="my-8">
<FourPercentRuleChart />
</div>
<h2>The Problem with 4% in {new Date().getFullYear().toString()}</h2>
<p>
While 4% worked historically, many experts argue it might be too aggressive for early retirees
today. Why?
</p>
<ul className="list-disc pl-5">
<li>
<strong>Longer Horizons:</strong> The Trinity Study looked at 30 years. If you retire at 35,
you might need your money to last 50 or 60 years.
</li>
<li>
<strong>Valuations:</strong> When stock market valuations (CAPE ratios) are high, future
returns tend to be lower.
</li>
<li>
<strong>Sequence of Returns Risk:</strong> If the market crashes right after you retire (like
in 2000 or 2008), depleting your portfolio early can make it impossible to recover, even if
the market rebounds later.
</li>
</ul>
<h2>Better Alternatives: Variable Withdrawal Rates</h2>
<p>
Instead of a rigid &quot;blind&quot; withdrawal, modern FIRE strategies suggest being dynamic.
</p>
<h3>1. The &quot;Guardrails&quot; Approach</h3>
<p>
If the market drops significantly, you cut your spending (e.g., skip the vacation, eat out
less). If the market booms, you give yourself a raise. This flexibility massively increases
your portfolio&apos;s success rate.
</p>
<h3>2. Lower the Initial Rate</h3>
<p>
Many cautious early retirees target a <strong>3.25% to 3.5%</strong> withdrawal rate. This
virtually guarantees capital preservation across almost all historical scenarios, even extended
bear markets.
</p>
<h2>Simulate Your Safe Rate</h2>
<p>
Reading about it is one thing; seeing it is another. We&apos;ve built these scenarios directly
into our calculator.
</p>
<p>
Go to the calculator, expand the advanced options (or check the &quot;Simulation Mode&quot; if
available), and switch between &quot;Deterministic&quot; (Fixed return) and &quot;Monte
Carlo&quot; (Randomized) to see how volatility impacts your success chance.
</p>
<div className="my-8 text-center">
<Link href="/?simulationMode=monte-carlo">
<Button size="lg" variant="secondary" className="text-lg">
Run Monte Carlo Simulation
</Button>
</Link>
</div>
<h2>Conclusion</h2>
<p>
The 4% rule is a fantastic rule of thumb for planning, but a dangerous rule of law for
execution. Use it to set your savings target, but remain flexible once you actually pull the
trigger on retirement.
</p>
<FaqSection faqs={faqs} className="my-12" />
<AuthorBio />
</div>
</article>
);
}

View File

@@ -0,0 +1,238 @@
import Link from 'next/link';
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[] = [
{
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',
},
],
},
};
export default function WhatIsFirePage() {
// JSON-LD for SEO
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'What is FIRE? The Ultimate Guide to Financial Independence',
author: {
'@type': 'Organization',
name: 'InvestingFIRE Team',
},
publisher: {
'@type': 'Organization',
name: 'InvestingFIRE',
logo: {
'@type': 'ImageObject',
url: 'https://investingfire.com/apple-icon.png',
},
},
datePublished: '2025-01-15',
description:
'Discover the FIRE movement. Learn how to calculate your FIRE number, savings rate, and retire decades ahead of schedule.',
};
return (
<article className="container mx-auto max-w-3xl px-4 py-12">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
{/* Breadcrumb */}
<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">What is FIRE?</span>
</nav>
<header className="mb-10">
<h1 className="mb-6 text-4xl font-extrabold tracking-tight lg:text-5xl">
What Is FIRE? <br />
<span className="text-primary">The Modern Guide to Financial Freedom</span>
</h1>
<p className="text-muted-foreground text-xl leading-relaxed">
FIRE stands for <strong>Financial Independence, Retire Early</strong>. Its not just about
quitting your jobits about reaching a point where work is optional, and your assets generate
enough income to cover your lifestyle forever.
</p>
</header>
<div className="max-w-none">
<p>
Imagine waking up on a Monday morning without an alarm clock. You don&apos;t have to rush to a
commute, sit in traffic, or answer to a boss. Instead, you have the ultimate luxury:{' '}
<strong>ownership of your time</strong>.
</p>
<div className="bg-card my-8 rounded-lg border p-6 shadow-sm">
<h3 className="mt-0 text-xl font-semibold">💡 The Core Equation</h3>
<p className="mb-4">
FIRE isn&apos;t magic; it&apos;s math. The speed at which you can retire depends on one
primary variable: <strong>Your Savings Rate</strong>.
</p>
<p className="mb-0">
<span className="text-primary font-mono">
High Income - Low Expenses = High Savings = Freedom
</span>
</p>
</div>
<div className="my-8">
<FireFlowchart />
</div>
<h2>The 3 Pillars of FIRE</h2>
<p>To achieve financial independence, you need to optimize three levers:</p>
<ol className="mb-8 list-inside list-decimal space-y-2">
<li>
<strong>Spend Less (Frugality):</strong> Cutting unnecessary costs is the most powerful lever
because it has a double effect: it increases your savings <em>and</em> lowers the amount you
need to save forever.
</li>
<li>
<strong>Earn More (Income):</strong> There is a floor to how much you can cut, but no ceiling
to how much you can earn. Side hustles, career growth, and upskilling are key.
</li>
<li>
<strong>Invest Wisely (Growth):</strong> Your money must work for you. Low-cost index funds
(like VTSAX) are the vehicle of choice for the FIRE community due to their diversification
and low fees.
</li>
</ol>
<h2>What is &quot;The Number&quot;?</h2>
<p>
Your <strong>FIRE Number</strong> is the net worth you need to retire. The most common rule of
thumb is the <strong>Rule of 25</strong>:
</p>
<blockquote>
<p className="text-foreground text-xl font-medium not-italic">
Annual Expenses × 25 = FIRE Number
</p>
</blockquote>
<p>
For example, if you spend <strong>$40,000</strong> per year, you need{' '}
<strong>$1,000,000</strong> invested. This is based on the <em>4% Rule</em>, which suggests you
can withdraw 4% of your portfolio in the first year of retirement (adjusted for inflation
thereafter) with a high probability of not running out of money.
</p>
<div className="my-8 text-center">
<Link href="/">
<Button size="lg" className="text-lg">
Calculate Your FIRE Number Now
</Button>
</Link>
</div>
<h2 id="types-of-fire">Types of FIRE</h2>
<p>FIRE isn&apos;t one-size-fits-all. Over the years, several variations have emerged:</p>
<ul>
<li>
<strong>Lean FIRE:</strong> Retiring on a budget (e.g., less than $40k/year). Great for
minimalists.
</li>
<li>
<strong>Fat FIRE:</strong> Retiring with abundance (e.g., $100k+/year). Requires a larger
nest egg but offers a luxurious lifestyle.
</li>
<li>
<strong>Barista FIRE:</strong> Reaching a portfolio size where you still work part-time
(perhaps as a barista) for benefits or extra cash, reducing withdrawal pressure.
</li>
<li>
<strong>Coast FIRE:</strong> Saving enough early on so that compound interest alone will hit
your retirement target by age 65, allowing you to stop saving and just cover expenses.
</li>
</ul>
<h2>Why {new Date().getFullYear().toString()} Changes Things</h2>
<p>
In {new Date().getFullYear().toString()}, we face unique challenges: higher inflation than the
previous decade and potentially lower future stock market returns. This makes{' '}
<strong>flexibility</strong> essential.
</p>
<p>
Static calculators often fail to capture this nuance. That&apos;s why{' '}
<Link href="/" className="text-primary hover:underline">
InvestingFIRE.com
</Link>{' '}
allows you to adjust inflation assumptions and growth rates dynamically, helping you
stress-test your plan against modern economic reality.
</p>
<h2>Conclusion</h2>
<p>
FIRE is more than a financial goal; it&apos;s a lifestyle design choice. It asks the question:{' '}
<em>&quot;What would you do if money were no object?&quot;</em>
</p>
<p>
Start by tracking your expenses, calculating your savings rate, and running your numbers. The
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>
);
}

View 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 510 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 doesnt 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.51.0% per yeara 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: 612 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 510 points.</li>
<li>Keep 612 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,&quot;Global equity investing: The benefits of diversification&quot; {' '}
<Link
href="https://corporate.vanguard.com/content/dam/corp/research/pdf/global-equity-investing-benefits-diversification.pdf"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
Vanguard
</Link>
</li>
<li>
MSCI,&quot;The Home Bias Effect in Global Portfolios&quot; {' '}
<Link
href="https://www.msci.com/research-and-insights/quick-take/did-home-bias-help"
className="text-primary hover:underline"
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&quot;Three-Fund Portfolio&quot; {' '}
<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>
);
}

View File

@@ -1,75 +1,75 @@
import Image from "next/image";
import FireCalculatorForm from "./components/FireCalculatorForm";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import Footer from "./components/footer";
import BackgroundPattern from "./components/BackgroundPattern";
import Image from 'next/image';
import { Suspense } from 'react';
import FireCalculatorForm from './components/FireCalculatorForm';
import BackgroundPattern from './components/BackgroundPattern';
import { FaqSection, type FaqItem } from './components/FaqSection';
import { Testimonials } from './components/Testimonials';
import type { Metadata } from 'next';
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 (
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-linear-to-b p-2">
<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 />
<div className="z-10 mx-auto flex flex-col items-center justify-center gap-4 text-center">
<div className="mt-8 flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
<div className="flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
<Image
priority
unoptimized
@@ -78,48 +78,55 @@ export default function HomePage() {
width={100}
height={100}
/>
<h1 className="from-primary via-primary-foreground to-primary bg-linear-to-r bg-clip-text text-5xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[5rem]">
<h1 className="from-primary via-accent to-primary bg-linear-to-r bg-clip-text text-5xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[5rem]">
InvestingFIRE
</h1>
</div>
<p className="text-primary-foreground/90 text-xl font-semibold md:text-2xl">
The #1 FIRE Calculator
<span className="bg-primary/15 text-primary rounded-full px-4 py-2 text-xs font-semibold tracking-wide uppercase shadow-sm">
100% free built for educational use
</span>
<p className="text-foreground/90 text-xl font-semibold md:text-2xl">The #1 FIRE Calculator</p>
<p className="text-foreground/80 max-w-2xl text-base text-balance md:text-lg">
Plan your path to financial independence with transparent mathad-free and built to teach you
how FIRE works.
</p>
<div className="mt-8 w-full max-w-2xl">
<FireCalculatorForm />
<Suspense fallback={<div>Loading calculator...</div>}>
<FireCalculatorForm />
</Suspense>
</div>
</div>
<div className="z-10 mx-auto max-w-4xl px-4">
<Testimonials />
</div>
{/* Added SEO Content Sections */}
<div className="z-10 mx-auto max-w-2xl py-12 text-left">
<section className="mb-12">
<h2 className="mb-4 text-3xl font-bold">
What Is FIRE? Understanding Financial Independence and Early
Retirement
What Is FIRE? Understanding Financial Independence and Early Retirement
</h2>
<p className="mb-4 text-lg leading-relaxed">
FIRE stands for{" "}
<strong>Financial Independence, Retire Early</strong>. It&apos;s a
lifestyle movement built around two core ideas:
FIRE stands for <strong>Financial Independence, Retire Early</strong>. It&apos;s a lifestyle
movement built around two core ideas:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
<li>
<strong>Aggressive saving & investing</strong>often 50%+ of
incomeso your capital grows rapidly.
<strong>Aggressive saving & investing</strong>often 50%+ of incomeso your capital grows
rapidly.
</li>
<li>
<strong>Passive-income coverage</strong>when your investment
returns exceed your living expenses, you gain freedom from a
traditional 9-5.
<strong>Passive-income coverage</strong>when your investment returns exceed your living
expenses, you gain freedom from a traditional 9-5.
</li>
</ul>
<p className="text-lg leading-relaxed">
By reaching your personal <em>FIRE Number</em>the nest egg needed
to cover your inflation-adjusted spendingyou unlock the option to
step away from a daily paycheck and pursue passion projects, travel,
family, or anything else. This calculator helps you simulate your
journey, estimate how much you need, and visualize both your
accumulation phase and your retirement withdrawals over time.
By reaching your personal <em>FIRE Number</em>the nest egg needed to cover your
inflation-adjusted spendingyou unlock the option to step away from a daily paycheck and
pursue passion projects, travel, family, or anything else. This calculator helps you simulate
your journey, estimate how much you need, and visualize both your accumulation phase and your
retirement withdrawals over time.
</p>
</section>
@@ -128,208 +135,82 @@ 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 <strong>year-by-year simulation</strong> of your
portfolio, combining:
Our interactive tool goes beyond a simple&quot;25x annual spending&quot; rule. It runs a{' '}
<strong>year-by-year simulation</strong> of your portfolio, combining:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
<li>
<strong>Starting Capital</strong>your current invested balance
</li>
<li>
<strong>Monthly Savings</strong>ongoing contributions to your
portfolio
<strong>Monthly Savings</strong>ongoing contributions to your portfolio
</li>
<li>
<strong>Expected Annual Growth Rate (CAGR)</strong>compounding
returns before inflation
<strong>Expected Annual Growth Rate (CAGR)</strong>compounding returns before inflation
</li>
<li>
<strong>Annual Inflation Rate</strong>to inflate your target
withdrawal each year
<strong>Annual Inflation Rate</strong>to inflate your target withdrawal each year
</li>
<li>
<strong>Desired Monthly Allowance</strong>today&apos;s-value
spending goal
<strong>Desired Monthly Allowance</strong>today&apos;s-value spending goal
</li>
<li>
<strong>Retirement Age & Life Expectancy</strong>defines your
accumulation horizon and payout period
<strong>Retirement Age & Life Expectancy</strong>defines your accumulation horizon and
payout period
</li>
</ul>
<p className="text-lg leading-relaxed">Key features:</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
<li>
<strong>Real-time calculation</strong>as you tweak any input,
your FIRE Number and chart update instantly.
<strong>Real-time calculation</strong>as you tweak any input, your FIRE Number and chart
update instantly.
</li>
<li>
<strong>Interactive chart</strong> with area plots for both{" "}
<em>portfolio balance</em> and{" "}
<em>inflation-adjusted allowance</em>, plus reference lines
showing your retirement date and required FIRE Number.
<strong>Interactive chart</strong> with area plots for both <em>portfolio balance</em> and{' '}
<em>inflation-adjusted allowance</em>, plus reference lines showing your retirement date
and required FIRE Number.
</li>
<li>
<strong>Custom simulation</strong>switches from accumulation
(adding savings) to retirement (withdrawing allowance),
compounding each year based on your growth rate.
<strong>Custom simulation</strong>switches from accumulation (adding savings) to
retirement (withdrawing allowance), compounding each year based on your growth rate.
</li>
</ul>
<p className="text-lg leading-relaxed">
With this level of granularity, you can confidently experiment with
savings rate, target retirement age, and investment assumptions to
discover how small tweaks speed up or delay your path to financial
independence.
With this level of granularity, you can confidently experiment with savings rate, target
retirement age, and investment assumptions to discover how small tweaks speed up or delay
your path to financial independence.
</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&apos;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&apos;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&apos;t model ongoing savings or dynamic
market returns. Our calculator simulates each year&apos;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
scenarios5% on the conservative side, 10% on the aggressive
sideto 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&apos;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 inputsso 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 chartespecially the reference linesto 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">
<h2 className="mb-4 text-3xl font-bold">
FIRE Journey & Investing Resources
</h2>
<h2 className="mb-4 text-3xl font-bold">FIRE Journey & Investing Resources</h2>
<p className="mb-6 text-lg leading-relaxed">
Ready to deepen your knowledge and build a bullet-proof plan? Below
are some of our favorite blogs, books, tools, and communities for
financial independence and smart investing.
Ready to deepen your knowledge and build a bullet-proof plan? Below are some of our favorite
blogs, books, tools, and communities for financial independence and smart investing.
</p>
<div className="bg-foreground my-8 rounded-md p-4 text-lg">
<div className="text-background bg-foreground my-8 rounded-md p-4 text-lg">
<p className="font-semibold">Getting Started with FIRE:</p>
<ol className="ml-6 list-decimal space-y-1">
<li>
Run your first projection above to find your target FIRE Number.
</li>
<li>Run your first projection above to find your target FIRE Number.</li>
<li>Identify areas to boost savings or reduce expenses.</li>
<li>Study index-fund strategies and low-cost investing advice.</li>
<li>
Study index-fund strategies and low-cost investing advice.
</li>
<li>
Join{" "}
Join{' '}
<a
href="https://www.reddit.com/r/Fire/"
target="_blank"
className="text-primary hover:underline"
>
supportive communities like r/Fire
</a>{" "}
</a>{' '}
to learn from real journeys.
</li>
</ol>
@@ -346,7 +227,7 @@ export default function HomePage() {
className="text-primary hover:underline"
>
Mr. Money Mustache
</a>{" "}
</a>{' '}
- Hardcore frugality & early retirement success stories.
</li>
<li>
@@ -356,7 +237,7 @@ export default function HomePage() {
className="text-primary hover:underline"
>
Playing With FIRE
</a>{" "}
</a>{' '}
- Community resources & real-life case studies.
</li>
<li>
@@ -366,7 +247,7 @@ export default function HomePage() {
className="text-primary hover:underline"
>
r/Fire
</a>{" "}
</a>{' '}
- Active forum for questions, tips, and support.
</li>
</ul>
@@ -382,7 +263,7 @@ export default function HomePage() {
className="text-primary hover:underline"
>
Your Money or Your Life
</a>{" "}
</a>{' '}
- The classic guide to aligning money with values.
</li>
<li>
@@ -392,7 +273,7 @@ export default function HomePage() {
className="text-primary hover:underline"
>
BiggerPockets Money Podcast
</a>{" "}
</a>{' '}
- Interviews on FIRE strategies and wealth building.
</li>
<li>
@@ -402,26 +283,19 @@ export default function HomePage() {
className="text-primary hover:underline"
>
InvestingFIRE Calculator Demo
</a>{" "}
- Deep dive on how interactive projections can guide your
plan.
</a>{' '}
- Deep dive on how interactive projections can guide your plan.
</li>
</ul>
</div>
<div>
<h3 className="mb-3 text-xl font-semibold">
Additional Calculators & Tools
</h3>
<h3 className="mb-3 text-xl font-semibold">Additional Calculators & Tools</h3>
<ul className="ml-6 list-disc space-y-2 text-lg">
<li>
<a
href="https://ghostfol.io"
target="_blank"
className="text-primary hover:underline"
>
<a href="https://ghostfol.io" target="_blank" className="text-primary hover:underline">
Ghostfolio
</a>{" "}
</a>{' '}
- Wealth management application for individuals.
</li>
<li>
@@ -431,9 +305,8 @@ export default function HomePage() {
className="text-primary hover:underline"
>
Coast FIRE Calculator
</a>{" "}
- When you max out early contributions but let compounding
do the rest.
</a>{' '}
- When you&quot;max out&quot; early contributions but let compounding do the rest.
</li>
<li>
<a
@@ -442,7 +315,7 @@ export default function HomePage() {
className="text-primary hover:underline"
>
Compound Interest Calculator
</a>{" "}
</a>{' '}
- Explore the power of growth rates in isolation.
</li>
</ul>
@@ -450,7 +323,6 @@ export default function HomePage() {
</div>
</section>
</div>
<Footer />
</main>
</div>
);
}

View File

@@ -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();
}

View File

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

View File

@@ -1,28 +1,20 @@
"use client";
'use client';
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn(
"border-primary-foreground/20 border-b last:border-b-0",
className,
)}
className={cn('border-primary-foreground/20 border-b last:border-b-0', className)}
{...props}
/>
);
@@ -38,13 +30,13 @@ function AccordionTrigger({
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-primary-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
<ChevronDownIcon className="text-secondary pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
@@ -61,7 +53,7 @@ function AccordionContent({
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
);
}

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,38 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
{...props}
/>
);
}
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -1,36 +1,34 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all 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:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] 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:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/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 bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
'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',
link: 'text-primary underline-offset-4 hover:underline px-0! h-auto!',
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
default: 'h-9 px-4 py-2 has-[>svg]:px-3.5',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-11 rounded-md px-5 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
},
);
@@ -41,18 +39,14 @@ function Button({
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
<Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
);
}

View File

@@ -1,26 +1,28 @@
import * as React from "react";
import * as React from 'react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<"div">) {
function Card({ className, children, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
'bg-card text-card-foreground relative flex flex-col gap-6 overflow-hidden rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
>
{children}
</div>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
@@ -28,65 +30,44 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
<div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View File

@@ -1,23 +1,20 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
"use client";
'use client';
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { cn } from "@/lib/utils";
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,
{
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
>;
interface ChartContextProps {
@@ -30,7 +27,7 @@ function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
@@ -42,14 +39,12 @@ function ChartContainer({
children,
config,
...props
}: React.ComponentProps<"div"> & {
}: React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
@@ -63,18 +58,14 @@ function ChartContainer({
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color,
);
const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);
if (!colorConfig.length) {
return null;
@@ -89,16 +80,14 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color;
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
.join('\n')}
}
`,
)
.join("\n"),
.join('\n'),
}}
/>
);
@@ -110,7 +99,7 @@ function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
@@ -121,10 +110,10 @@ function ChartTooltipContent({
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}) {
@@ -136,53 +125,41 @@ function ChartTooltipContent({
}
const item = payload[0];
const key = labelKey ?? String(item.dataKey ?? item.name ?? "value");
const key = labelKey ?? String(item.dataKey ?? item.name ?? 'value');
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? (label in config && config[label].label ? config[label].label : undefined) ?? label
!labelKey && typeof label === 'string'
? ((label in config && config[label].label ? config[label].label : undefined) ?? label)
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
return <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
'border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = nameKey ?? String(item.name ?? item.dataKey ?? "value");
const key = nameKey ?? String(item.name ?? item.dataKey ?? 'value');
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor: string | undefined =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
@@ -192,8 +169,8 @@ function ChartTooltipContent({
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item.value !== undefined && item.name ? (
@@ -206,20 +183,16 @@ function ChartTooltipContent({
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-border bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
className={cn('border-border shrink-0 rounded-[2px] bg-(--color-bg)', {
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
@@ -227,18 +200,16 @@ function ChartTooltipContent({
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
<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>
)}
@@ -259,10 +230,10 @@ function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
}: React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}) {
@@ -275,21 +246,21 @@ function ChartLegendContent({
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
const key = `${nameKey ?? item.dataKey ?? "value"}`;
const key = `${nameKey ?? item.dataKey ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3',
)}
>
{itemConfig?.icon && !hideIcon ? (
@@ -311,37 +282,26 @@ function ChartLegendContent({
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key];

View File

@@ -0,0 +1,228 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function DropdownMenu({ ...props }: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Root>>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Portal>>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
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 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}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Sub>>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@@ -1,16 +1,16 @@
import * as React from "react";
import * as React from 'react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent 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 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
'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,
)}
{...props}

View File

@@ -0,0 +1,161 @@
import * as React from 'react';
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from 'class-variance-authority';
import { ChevronDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn('group flex flex-1 list-none items-center justify-center gap-1', className)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn('relative', className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1',
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
className,
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div className={cn('absolute top-full left-0 isolate z-50 flex justify-center')}>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
className,
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};

View File

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

View File

@@ -1,43 +1,37 @@
"use client";
'use client';
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
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 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent 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}
@@ -53,7 +47,7 @@ function SelectTrigger({
function SelectContent({
className,
children,
position = "popper",
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
@@ -61,9 +55,9 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
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 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
'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 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
@@ -72,9 +66,9 @@ function SelectContent({
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1",
'p-1',
position === 'popper' &&
'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1',
)}
>
{children}
@@ -85,14 +79,11 @@ function SelectContent({
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
@@ -129,7 +120,7 @@ function SelectSeparator({
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
@@ -142,10 +133,7 @@ function SelectScrollUpButton({
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUpIcon className="size-4" />
@@ -160,10 +148,7 @@ function SelectScrollDownButton({
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon className="size-4" />

View File

@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
);
}
export { Separator };

123
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,123 @@
'use client';
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Sheet({ ...props }: Readonly<React.ComponentProps<typeof SheetPrimitive.Root>>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: Readonly<React.ComponentProps<typeof SheetPrimitive.Portal>>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left';
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
'from-foreground to-foreground/95 data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 bg-gradient-to-b shadow-lg backdrop-blur-md transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="sheet-header" className={cn('flex flex-col gap-1.5 p-4', className)} {...props} />
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View 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 };

View File

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

View File

@@ -0,0 +1,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);
});
});

View File

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

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

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

75
src/lib/sitemap.ts Normal file
View 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,
}));
}

View File

@@ -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 */
}
@@ -129,4 +123,28 @@
body {
@apply bg-background text-foreground;
}
h1 {
@apply scroll-m-20 text-4xl font-extrabold tracking-tight text-balance;
}
h2 {
@apply scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0;
}
h3 {
@apply scroll-m-20 text-2xl font-semibold tracking-tight;
}
h4 {
@apply scroll-m-20 text-xl font-semibold tracking-tight;
}
p {
@apply mb-2 leading-7 [&:not(:first-child)]:mt-6;
}
blockquote {
@apply mt-6 border-l-2 pl-6 italic;
}
ul {
@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;
}
}

17
vitest.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./vitest.setup.ts"],
exclude: ["node_modules", "e2e/**"],
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

21
vitest.setup.ts Normal file
View File

@@ -0,0 +1,21 @@
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(),
})),
});
}