Compare commits
7 Commits
renovate/r
...
64669e5f58
Author | SHA1 | Date | |
---|---|---|---|
64669e5f58 | |||
f05f3fe37c | |||
896b0bf063 | |||
716bcc6fef | |||
31415c10a2 | |||
fe03807739 | |||
30d27a212e |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1,2 +0,0 @@
|
|||||||
*.png filter=lfs diff=lfs merge=lfs -text
|
|
||||||
*.ico filter=lfs diff=lfs merge=lfs -text
|
|
@@ -1,31 +0,0 @@
|
|||||||
name: Lint
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "**" # matches every branch
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint_and_typecheck:
|
|
||||||
name: Lint and Typecheck
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: "pnpm"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Run check
|
|
||||||
run: pnpm run check
|
|
109
README.md
109
README.md
@@ -1,108 +1,3 @@
|
|||||||

|
# fire
|
||||||
|
|
||||||
# InvestingFIRE 🔥 — The #1 Interactive FIRE Calculator
|
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:
|
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
The project’s code is structured using React/Next.js with TypeScript, focusing on user experience, modern UI components, and clarity of financial assumptions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Features at a Glance
|
|
||||||
|
|
||||||
- **⚡️ Real-Time Projections:** Every field updates the chart _as you type_. Experiment with savings, growth rates, inflation, or retirement age and see your future instantly.
|
|
||||||
- **📈 Interactive Chart:** Dual-area plots for portfolio value and future monthly spending, plus reference lines for FIRE milestones and “4% rule” legends.
|
|
||||||
- **🧠 Education Baked In:** Contextual tooltips, deep-dive sections on how FIRE works, FAQs, and must-read resources included.
|
|
||||||
- **🔎 Detailed Methodology:** Not just a 25x rule — runs a full, year-by-year simulation with inflation-adjusted withdrawals and optional 4%-rule overlays.
|
|
||||||
- **👌 Modern UX:** Typing, sliding, or clicking feels _good_. Responsive on all devices.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧰 How It Works
|
|
||||||
|
|
||||||
The calculator models your FIRE journey in two phases:
|
|
||||||
|
|
||||||
1. **Accumulation:**
|
|
||||||
- Your starting capital is grown by your expected CAGR (~7% by default).
|
|
||||||
- Monthly savings are added for each year until retirement.
|
|
||||||
- Every variable can be adjusted live (capital, savings, age, growth, inflation, spending, target retirement).
|
|
||||||
|
|
||||||
2. **Retirement:**
|
|
||||||
- Your balance continues to grow by CAGR.
|
|
||||||
- Each year, an inflation-adjusted monthly allowance is withdrawn.
|
|
||||||
- The simulation runs until your selected life expectancy, showing the possibility of portfolio depletion.
|
|
||||||
|
|
||||||
**Key Outputs:**
|
|
||||||
|
|
||||||
- 🔥 “FIRE Number”: Portfolio value at your defined retirement age
|
|
||||||
- 📊 Interactive projection chart: See how your nest egg and withdrawals evolve over time
|
|
||||||
- 4️⃣ “4% Rule” overlays: Compare dynamic results to classic rule-of-thumb
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌟 Try It For Yourself
|
|
||||||
|
|
||||||
To run locally:
|
|
||||||
|
|
||||||
1. **Clone the repo**
|
|
||||||
```bash
|
|
||||||
git clone https://git.schulze.network/schulze/fire.git
|
|
||||||
cd fire
|
|
||||||
```
|
|
||||||
2. **Install dependencies**
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
```
|
|
||||||
3. **Run the app**
|
|
||||||
```bash
|
|
||||||
pnpm run dev
|
|
||||||
```
|
|
||||||
4. Visit [http://localhost:3000](http://localhost:3000) and unleash the fire.
|
|
||||||
|
|
||||||
Deployed version: [https://investingfire.com](https://investingfire.com)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✏️ Inputs & Variables
|
|
||||||
|
|
||||||
- **Starting Capital** — How much you’ve already invested
|
|
||||||
- **Monthly Savings** — What you’ll add each month
|
|
||||||
- **Current Age & Retirement Age** — Your FI timeline
|
|
||||||
- **Life Expectancy** — How long do you want income to last?
|
|
||||||
- **Expected Growth Rate (CAGR)** — Portfolio annual % return, before inflation
|
|
||||||
- **Inflation Rate** — Cost of living increases
|
|
||||||
- **Desired Monthly Allowance** — Your lifestyle, today’s dollars
|
|
||||||
|
|
||||||
As you adjust these, all projections update instantly _without needing to hit “Calculate.”_
|
|
||||||
|
|
||||||
Try many “what ifs” fast.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 👩💻 Contributing
|
|
||||||
|
|
||||||
Pull requests are welcome! Open issues for bugs, new features, or debate about safe withdrawal rates and tax assumptions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
[GPL-3.0](./LICENSE)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🥇 Why Use InvestingFIRE?
|
|
||||||
|
|
||||||
- You want the truth — not just a 4% fantasy.
|
|
||||||
- You want to learn, not just punch in numbers.
|
|
||||||
- You want clarity, speed, and modern UI.
|
|
||||||
- You want to show your friends the best FIRE tool on the web.
|
|
||||||
|
|
||||||
Enjoy the _rocket ride_ to financial independence.
|
|
||||||
**InvestingFIRE — Know your number. Change your future.**
|
|
@@ -14,7 +14,7 @@ export default tseslint.config(
|
|||||||
files: ["**/*.ts", "**/*.tsx"],
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
extends: [
|
extends: [
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
...tseslint.configs.strictTypeChecked,
|
||||||
...tseslint.configs.stylisticTypeChecked,
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
|
6765
package-lock.json
generated
Normal file
6765
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
50
package.json
@@ -6,52 +6,50 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"check": "next lint && tsc --noEmit",
|
"check": "next lint && tsc --noEmit",
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbo",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
|
"lint": "next lint",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"preview": "next build && next start",
|
"preview": "next build && next start",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.8",
|
"@radix-ui/react-accordion": "^1.2.8",
|
||||||
"@radix-ui/react-label": "^2.1.4",
|
"@radix-ui/react-label": "^2.1.4",
|
||||||
"@radix-ui/react-select": "^2.2.2",
|
|
||||||
"@radix-ui/react-slider": "^1.3.2",
|
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@t3-oss/env-nextjs": "^0.13.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.503.0",
|
||||||
"next": "^15.4.1",
|
"next": "^15.2.3",
|
||||||
"next-plausible": "^3.12.4",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.56.1",
|
"react-hook-form": "^7.56.1",
|
||||||
"recharts": "^3.0.0",
|
"recharts": "^2.15.3",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^3.24.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@tailwindcss/postcss": "4.1.13",
|
"@tailwindcss/postcss": "^4.0.15",
|
||||||
"@types/node": "22.18.1",
|
"@types/node": "^20.14.10",
|
||||||
"@types/react": "19.1.12",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "19.1.9",
|
"@types/react-dom": "^19.0.0",
|
||||||
"eslint": "9.35.0",
|
"eslint": "^9.23.0",
|
||||||
"eslint-config-next": "15.5.2",
|
"eslint-config-next": "^15.2.3",
|
||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"postcss": "^8.5.3",
|
||||||
"postcss": "8.5.6",
|
"prettier": "^3.5.3",
|
||||||
"prettier": "3.6.2",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"prettier-plugin-tailwindcss": "0.6.14",
|
"tailwindcss": "^4.0.15",
|
||||||
"tailwindcss": "4.1.13",
|
"tw-animate-css": "^1.2.8",
|
||||||
"tw-animate-css": "1.3.8",
|
"typescript": "^5.8.2",
|
||||||
"typescript": "5.9.2",
|
"typescript-eslint": "^8.27.0"
|
||||||
"typescript-eslint": "8.41.0"
|
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.39.3"
|
"initVersion": "7.39.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.15.1"
|
"packageManager": "npm@11.2.0"
|
||||||
}
|
}
|
||||||
|
5231
pnpm-lock.yaml
generated
5231
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
ignoredBuiltDependencies:
|
|
||||||
- unrs-resolver
|
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
|
||||||
- '@tailwindcss/oxide'
|
|
||||||
- sharp
|
|
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" width="1000" height="1000" viewBox="0 0 264.58 264.58"><defs><linearGradient id="b"><stop offset="0" stop-color="#fd8315"/><stop offset="1" stop-color="#fa6b14"/></linearGradient><linearGradient id="a"><stop offset="0" stop-color="#f24b1b"/><stop offset="1" stop-color="#dc2f12"/></linearGradient><linearGradient xlink:href="#a" id="d" x1="172.49" x2="179.1" y1="64.48" y2="197.19" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="c" x1="118.9" x2="117.99" y1="14.21" y2="194.34" gradientUnits="userSpaceOnUse"/></defs><g stroke-linecap="round" stroke-linejoin="round" stroke-width=".13"><path fill="url(#c)" stroke="#f14a1b" d="m115.13 9.96-.26.01c-.97.11-1.29 1.02-.75 2.38a45.6 45.6 0 0 1 3.02 15.68c-.09 8.46.04 12.87-7.31 23.68s-23.16 21.9-33.96 34.66c-10.8 12.76-12.28 16.6-16.1 26.2A90.42 90.42 0 0 0 53.05 146c0 41.29 27.68 76.09 65.49 86.91 35.3-25.9 55.47-125.62 55.47-125.62s-14.45-10.54-18.57-18.89c-1.26-2.56-1.97-6.15-1.97-9.58.01-2.54 1.3-8.72 1.47-9.41a42.4 42.4 0 0 0 .94-12.14c-.07-.95-.17-1.9-.3-2.84v-.01a59.45 59.45 0 0 0-7.6-19h0a60.34 60.34 0 0 0-10.24-12.13 66.97 66.97 0 0 0-21.7-13.15 2.94 2.94 0 0 0-.92-.18z"/><path fill="url(#d)" stroke="#510a0c" d="M170.01 58.08a66.66 66.66 0 0 0-10.24 15.94 66.66 66.66 0 0 0-6.3 27.82 66.8 66.8 0 0 0 3.66 20.86h-.08l7.08 105.8s37.38-25.1 45.9-61a74.13 74.13 0 0 0 2.04-25.93c-1.34-14.35-4.35-21.67-9.85-30.3-5.5-8.62-10.36-14-17.63-22.78-6.17-7.43-9.44-18.25-10.39-28.87-.28-3.13-2.13-3.91-4.19-1.54z"/></g><path fill="#510a0c" d="M93.45 115.81h77.91c9.81 0 17.71 7.9 17.71 17.7v104.53c0 9.81-7.9 17.71-17.7 17.71H93.44c-9.81 0-17.71-7.9-17.71-17.7V133.51c0-9.81 7.9-17.71 17.7-17.71z"/><path fill="#e83c1b" d="M91.95 163.12h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67zm51.25-45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67z"/><g fill="#520a0c"><path d="M148.74 179.98h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm-51.54.04h18.29a2.41 2.41 0 1 1 0 4.83h-18.3a2.41 2.41 0 1 1 0-4.83z"/><path d="M108.76 173.3v18.28a2.41 2.41 0 1 1-4.84 0V173.3a2.41 2.41 0 1 1 4.84 0zm-10.59 59.18 12.93-12.93a2.41 2.41 0 1 1 3.42 3.42l-12.93 12.93a2.41 2.41 0 1 1-3.42-3.42z"/><path d="m101.59 219.55 12.93 12.93a2.41 2.41 0 1 1-3.42 3.42l-12.93-12.93a2.41 2.41 0 1 1 3.42-3.42zm47.15 1.49h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm0 10.73h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84z"/></g><path fill="#fcf2e4" d="M92.35 125.2h79.67a7.07 7.07 0 0 1 7.09 7.1v14.36a7.07 7.07 0 0 1-7.09 7.1H92.35a7.07 7.07 0 0 1-7.08-7.1V132.3a7.07 7.07 0 0 1 7.08-7.09z"/></svg>
|
|
Before Width: | Height: | Size: 3.0 KiB |
BIN
public/web-app-manifest-192x192.png
(Stored with Git LFS)
BIN
public/web-app-manifest-192x192.png
(Stored with Git LFS)
Binary file not shown.
BIN
public/web-app-manifest-512x512.png
(Stored with Git LFS)
BIN
public/web-app-manifest-512x512.png
(Stored with Git LFS)
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
wgu5fuk8d5j5wp3pjtta9vrw8d9by9qk
|
|
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["config:best-practices", ":semanticCommits"],
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
|
|
||||||
"automerge": true,
|
|
||||||
"automergeType": "branch"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
BIN
src/app/apple-icon.png
(Stored with Git LFS)
BIN
src/app/apple-icon.png
(Stored with Git LFS)
Binary file not shown.
@@ -1,157 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
type LucideIcon,
|
|
||||||
HandCoins,
|
|
||||||
Bitcoin,
|
|
||||||
Coins,
|
|
||||||
DollarSign,
|
|
||||||
Euro,
|
|
||||||
IndianRupee,
|
|
||||||
JapaneseYen,
|
|
||||||
PiggyBank,
|
|
||||||
PoundSterling,
|
|
||||||
Wallet,
|
|
||||||
Banknote,
|
|
||||||
ChartCandlestick,
|
|
||||||
CirclePercent,
|
|
||||||
CreditCard,
|
|
||||||
Gem,
|
|
||||||
Receipt,
|
|
||||||
ShoppingBasket,
|
|
||||||
Rocket,
|
|
||||||
RockingChair,
|
|
||||||
Sparkles,
|
|
||||||
ChartPie,
|
|
||||||
ChartBar,
|
|
||||||
BarChart3,
|
|
||||||
ChartLine,
|
|
||||||
TrendingDown,
|
|
||||||
TrendingUp,
|
|
||||||
Vault,
|
|
||||||
Landmark,
|
|
||||||
Briefcase,
|
|
||||||
Handshake,
|
|
||||||
Shield,
|
|
||||||
Lock,
|
|
||||||
CalendarRange,
|
|
||||||
Hourglass,
|
|
||||||
Sprout,
|
|
||||||
Target,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
|
|
||||||
const [width, setWidth] = useState(0);
|
|
||||||
const [height, setHeight] = useState(0);
|
|
||||||
const [rows, setRows] = useState(0);
|
|
||||||
const [columns, setColumns] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateDimensions = () => {
|
|
||||||
if (window.innerWidth > width + spacing * 2) {
|
|
||||||
setWidth(window.innerWidth);
|
|
||||||
}
|
|
||||||
if (window.innerHeight > height + spacing * 2) {
|
|
||||||
setHeight(window.innerHeight);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateDimensions();
|
|
||||||
window.addEventListener("resize", updateDimensions);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("resize", updateDimensions);
|
|
||||||
};
|
|
||||||
}, [height, width, spacing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setColumns(Math.ceil(width / spacing) + 3);
|
|
||||||
}, [width, spacing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRows(Math.ceil(height / spacing) + 3);
|
|
||||||
}, [height, spacing]);
|
|
||||||
|
|
||||||
// Explicitly type the array as LucideIcon[]
|
|
||||||
const iconComponents: LucideIcon[] = [
|
|
||||||
HandCoins,
|
|
||||||
Bitcoin,
|
|
||||||
Coins,
|
|
||||||
DollarSign,
|
|
||||||
Euro,
|
|
||||||
IndianRupee,
|
|
||||||
JapaneseYen,
|
|
||||||
PiggyBank,
|
|
||||||
PoundSterling,
|
|
||||||
Wallet,
|
|
||||||
Banknote,
|
|
||||||
ChartCandlestick,
|
|
||||||
CirclePercent,
|
|
||||||
CreditCard,
|
|
||||||
Gem,
|
|
||||||
Receipt,
|
|
||||||
ShoppingBasket,
|
|
||||||
Rocket,
|
|
||||||
RockingChair,
|
|
||||||
Sparkles,
|
|
||||||
ChartPie,
|
|
||||||
ChartBar,
|
|
||||||
BarChart3,
|
|
||||||
ChartLine,
|
|
||||||
TrendingDown,
|
|
||||||
TrendingUp,
|
|
||||||
Vault,
|
|
||||||
Landmark,
|
|
||||||
Briefcase,
|
|
||||||
Handshake,
|
|
||||||
Shield,
|
|
||||||
Lock,
|
|
||||||
CalendarRange,
|
|
||||||
Hourglass,
|
|
||||||
Sprout,
|
|
||||||
Target,
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderIcons = ({
|
|
||||||
rows,
|
|
||||||
columns,
|
|
||||||
}: {
|
|
||||||
rows: number;
|
|
||||||
columns: number;
|
|
||||||
}) => {
|
|
||||||
const icons = [];
|
|
||||||
for (let y = 0; y < rows; y++) {
|
|
||||||
for (let x = 0; x < columns; x++) {
|
|
||||||
// Pick a random icon component from the array
|
|
||||||
const randomIndex = Math.floor(Math.random() * iconComponents.length);
|
|
||||||
const IconComponent = iconComponents[randomIndex]!;
|
|
||||||
|
|
||||||
// Slightly randomize size and position for more organic feel
|
|
||||||
const size = 28 + Math.floor(Math.random() * 8);
|
|
||||||
const xOffset = Math.floor(Math.random() * (spacing / 1.618));
|
|
||||||
const yOffset = Math.floor(Math.random() * (spacing / 1.618));
|
|
||||||
|
|
||||||
icons.push(
|
|
||||||
<IconComponent
|
|
||||||
key={`icon-${x}-${y}`}
|
|
||||||
size={size}
|
|
||||||
className="text-primary fixed"
|
|
||||||
style={{
|
|
||||||
left: `${x * spacing + xOffset}px`,
|
|
||||||
top: `${y * spacing + yOffset}px`,
|
|
||||||
opacity: opacity,
|
|
||||||
transform: `rotate(${Math.round((Math.random() - 0.5) * 30)}deg)`,
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return icons;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute h-full w-full">
|
|
||||||
{width > 0 && renderIcons({ rows, columns })}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -7,6 +8,7 @@ import * as z from "zod";
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -30,25 +32,17 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
type TooltipProps,
|
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Slider } from "@/components/ui/slider";
|
|
||||||
import assert from "assert";
|
|
||||||
import type {
|
|
||||||
NameType,
|
|
||||||
ValueType,
|
|
||||||
} from "recharts/types/component/DefaultTooltipContent";
|
|
||||||
|
|
||||||
// Schema for form validation
|
// Schema for form validation
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
startingCapital: z.coerce.number(),
|
startingCapital: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(0, "Starting capital must be a non-negative number"),
|
||||||
monthlySavings: z.coerce
|
monthlySavings: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.min(0, "Monthly savings must be a non-negative number"),
|
.min(0, "Monthly savings must be a non-negative number"),
|
||||||
currentAge: z.coerce
|
currentAge: z.coerce.number().min(18, "Age must be at least 18"),
|
||||||
.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"),
|
cagr: z.coerce.number().min(0, "Growth rate must be a non-negative number"),
|
||||||
desiredMonthlyAllowance: z.coerce
|
desiredMonthlyAllowance: z.coerce
|
||||||
.number()
|
.number()
|
||||||
@@ -58,79 +52,41 @@ const formSchema = z.object({
|
|||||||
.min(0, "Inflation rate must be a non-negative number"),
|
.min(0, "Inflation rate must be a non-negative number"),
|
||||||
lifeExpectancy: z.coerce
|
lifeExpectancy: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.min(40, "Be a bit more optimistic buddy :(")
|
.min(50, "Life expectancy must be at least 50"),
|
||||||
.max(100, "You should be more realistic..."),
|
|
||||||
retirementAge: z.coerce
|
|
||||||
.number()
|
|
||||||
.min(18, "Retirement age must be at least 18")
|
|
||||||
.max(100, "Retirement age must be at most 100"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Type for form values
|
// Type for form values
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
interface YearlyData {
|
interface CalculationResult {
|
||||||
|
fireNumber: number | null;
|
||||||
|
retirementAge: number | null;
|
||||||
|
inflationAdjustedAllowance: number | null;
|
||||||
|
retirementYears: number | null;
|
||||||
|
error?: string;
|
||||||
|
yearlyData?: Array<{
|
||||||
age: number;
|
age: number;
|
||||||
year: number;
|
year: number;
|
||||||
balance: number;
|
balance: number;
|
||||||
untouchedBalance: number;
|
|
||||||
phase: "accumulation" | "retirement";
|
phase: "accumulation" | "retirement";
|
||||||
monthlyAllowance: number;
|
}>;
|
||||||
untouchedMonthlyAllowance: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CalculationResult {
|
|
||||||
fireNumber: number | null;
|
|
||||||
fireNumber4percent: number | null;
|
|
||||||
retirementAge4percent: number | null;
|
|
||||||
yearlyData: YearlyData[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to format currency without specific symbols
|
|
||||||
const formatNumber = (value: number | null) => {
|
|
||||||
if (!value) return "N/A";
|
|
||||||
return new Intl.NumberFormat("en", {
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to render tooltip for chart
|
|
||||||
const tooltipRenderer = ({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
}: TooltipProps<ValueType, NameType>) => {
|
|
||||||
if (active && payload?.[0]?.payload) {
|
|
||||||
const data = payload[0].payload as YearlyData;
|
|
||||||
return (
|
|
||||||
<div className="bg-background border p-2 shadow-sm">
|
|
||||||
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
|
|
||||||
<p className="text-orange-500">{`Balance: ${formatNumber(data.balance)}`}</p>
|
|
||||||
<p className="text-red-600">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
|
|
||||||
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function FireCalculatorForm() {
|
export default function FireCalculatorForm() {
|
||||||
const [result, setResult] = useState<CalculationResult | null>(null);
|
const [result, setResult] = useState<CalculationResult | null>(null);
|
||||||
const irlYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const [showing4percent, setShowing4percent] = useState(false);
|
|
||||||
|
|
||||||
// Initialize form with default values
|
// Initialize form with default values
|
||||||
const form = useForm<z.input<typeof formSchema>, undefined, FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
startingCapital: 50000,
|
startingCapital: 50000,
|
||||||
monthlySavings: 1500,
|
monthlySavings: 1500,
|
||||||
currentAge: 25,
|
currentAge: 25,
|
||||||
cagr: 7,
|
cagr: 7,
|
||||||
desiredMonthlyAllowance: 3000,
|
desiredMonthlyAllowance: 2000,
|
||||||
inflationRate: 2.3,
|
inflationRate: 2,
|
||||||
lifeExpectancy: 84,
|
lifeExpectancy: 84,
|
||||||
retirementAge: 55,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,105 +95,229 @@ export default function FireCalculatorForm() {
|
|||||||
|
|
||||||
const startingCapital = values.startingCapital;
|
const startingCapital = values.startingCapital;
|
||||||
const monthlySavings = values.monthlySavings;
|
const monthlySavings = values.monthlySavings;
|
||||||
const age = values.currentAge;
|
const currentAge = values.currentAge;
|
||||||
const annualGrowthRate = 1 + values.cagr / 100;
|
const annualGrowthRate = values.cagr / 100;
|
||||||
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
|
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
|
||||||
const annualInflation = 1 + values.inflationRate / 100;
|
const annualInflation = values.inflationRate / 100;
|
||||||
const ageOfDeath = values.lifeExpectancy;
|
const lifeExpectancy = values.lifeExpectancy;
|
||||||
const retirementAge = values.retirementAge;
|
|
||||||
|
const monthlyGrowthRate = Math.pow(1 + annualGrowthRate, 1 / 12) - 1;
|
||||||
|
const monthlyInflationRate = Math.pow(1 + annualInflation, 1 / 12) - 1;
|
||||||
|
const maxIterations = 1000; // Safety limit for iterations
|
||||||
|
|
||||||
|
// Binary search for the required retirement capital
|
||||||
|
let low = initialMonthlyAllowance * 12; // Minimum: one year of expenses
|
||||||
|
let high = initialMonthlyAllowance * 12 * 100; // Maximum: hundred years of expenses
|
||||||
|
let requiredCapital = 0;
|
||||||
|
let retirementAge = 0;
|
||||||
|
let finalInflationAdjustedAllowance = 0;
|
||||||
|
|
||||||
|
// First, find when retirement is possible with accumulation phase
|
||||||
|
let canRetire = false;
|
||||||
|
let currentCapital = startingCapital;
|
||||||
|
let age = currentAge;
|
||||||
|
let monthlyAllowance = initialMonthlyAllowance;
|
||||||
|
let iterations = 0;
|
||||||
|
|
||||||
// Array to store yearly data for the chart
|
// Array to store yearly data for the chart
|
||||||
const yearlyData: YearlyData[] = [];
|
const yearlyData: CalculationResult["yearlyData"] = [];
|
||||||
|
|
||||||
// Initial year data
|
// Add starting point
|
||||||
yearlyData.push({
|
|
||||||
age: age,
|
|
||||||
year: irlYear,
|
|
||||||
balance: startingCapital,
|
|
||||||
untouchedBalance: startingCapital,
|
|
||||||
phase: "accumulation",
|
|
||||||
monthlyAllowance: 0,
|
|
||||||
untouchedMonthlyAllowance: initialMonthlyAllowance,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate accumulation phase (before retirement)
|
|
||||||
for (let year = irlYear + 1; year <= irlYear + (ageOfDeath - age); year++) {
|
|
||||||
const currentAge = age + (year - irlYear);
|
|
||||||
const previousYearData = yearlyData[yearlyData.length - 1];
|
|
||||||
const inflatedAllowance =
|
|
||||||
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
|
|
||||||
|
|
||||||
const isRetirementYear = currentAge >= retirementAge;
|
|
||||||
const phase = isRetirementYear ? "retirement" : "accumulation";
|
|
||||||
|
|
||||||
assert(!!previousYearData);
|
|
||||||
// Calculate balance based on phase
|
|
||||||
let newBalance;
|
|
||||||
if (phase === "accumulation") {
|
|
||||||
// During accumulation: grow previous balance + add savings
|
|
||||||
newBalance =
|
|
||||||
previousYearData.balance * annualGrowthRate + monthlySavings * 12;
|
|
||||||
} else {
|
|
||||||
// During retirement: grow previous balance - withdraw allowance
|
|
||||||
newBalance =
|
|
||||||
previousYearData.balance * annualGrowthRate - inflatedAllowance * 12;
|
|
||||||
}
|
|
||||||
const untouchedBalance =
|
|
||||||
previousYearData.untouchedBalance * annualGrowthRate +
|
|
||||||
monthlySavings * 12;
|
|
||||||
const allowance = phase === "retirement" ? inflatedAllowance : 0;
|
|
||||||
yearlyData.push({
|
yearlyData.push({
|
||||||
age: currentAge,
|
age: currentAge,
|
||||||
year: year,
|
year: currentYear,
|
||||||
balance: newBalance,
|
balance: startingCapital,
|
||||||
untouchedBalance: untouchedBalance,
|
phase: "accumulation",
|
||||||
phase: phase,
|
});
|
||||||
monthlyAllowance: allowance,
|
|
||||||
untouchedMonthlyAllowance: inflatedAllowance,
|
// Accumulation phase simulation
|
||||||
|
while (age < lifeExpectancy && iterations < maxIterations) {
|
||||||
|
// Simulate one year of saving and growth
|
||||||
|
for (let month = 0; month < 12; month++) {
|
||||||
|
currentCapital += monthlySavings;
|
||||||
|
currentCapital *= 1 + monthlyGrowthRate;
|
||||||
|
// Update allowance for inflation
|
||||||
|
monthlyAllowance *= 1 + monthlyInflationRate;
|
||||||
|
}
|
||||||
|
age++;
|
||||||
|
iterations++;
|
||||||
|
|
||||||
|
// Record yearly data
|
||||||
|
yearlyData.push({
|
||||||
|
age: age,
|
||||||
|
year: currentYear + (age - currentAge),
|
||||||
|
balance: Math.round(currentCapital),
|
||||||
|
phase: "accumulation",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check each possible retirement capital target through binary search
|
||||||
|
const mid = (low + high) / 2;
|
||||||
|
if (high - low < 1) {
|
||||||
|
// Binary search converged
|
||||||
|
requiredCapital = mid;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test if this retirement capital is sufficient
|
||||||
|
let testCapital = mid;
|
||||||
|
let testAge = age;
|
||||||
|
let testAllowance = monthlyAllowance;
|
||||||
|
let isSufficient = true;
|
||||||
|
|
||||||
|
// Simulate retirement phase with this capital
|
||||||
|
while (testAge < lifeExpectancy) {
|
||||||
|
for (let month = 0; month < 12; month++) {
|
||||||
|
// Withdraw inflation-adjusted allowance
|
||||||
|
testCapital -= testAllowance;
|
||||||
|
// Grow remaining capital
|
||||||
|
testCapital *= 1 + monthlyGrowthRate;
|
||||||
|
// Adjust allowance for inflation
|
||||||
|
testAllowance *= 1 + monthlyInflationRate;
|
||||||
|
}
|
||||||
|
testAge++;
|
||||||
|
|
||||||
|
// Check if we've depleted capital before life expectancy
|
||||||
|
if (testCapital <= 0) {
|
||||||
|
isSufficient = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSufficient) {
|
||||||
|
high = mid; // This capital or less might be enough
|
||||||
|
if (currentCapital >= mid) {
|
||||||
|
// We can retire now with this capital
|
||||||
|
canRetire = true;
|
||||||
|
retirementAge = age;
|
||||||
|
requiredCapital = mid;
|
||||||
|
finalInflationAdjustedAllowance = monthlyAllowance;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
low = mid; // We need more capital
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we didn't find retirement possible in the loop
|
||||||
|
if (!canRetire && iterations < maxIterations) {
|
||||||
|
// Continue accumulation phase until we reach sufficient capital
|
||||||
|
while (age < lifeExpectancy && iterations < maxIterations) {
|
||||||
|
// Simulate one year
|
||||||
|
for (let month = 0; month < 12; month++) {
|
||||||
|
currentCapital += monthlySavings;
|
||||||
|
currentCapital *= 1 + monthlyGrowthRate;
|
||||||
|
monthlyAllowance *= 1 + monthlyInflationRate;
|
||||||
|
}
|
||||||
|
age++;
|
||||||
|
iterations++;
|
||||||
|
|
||||||
|
// Record yearly data
|
||||||
|
yearlyData.push({
|
||||||
|
age: age,
|
||||||
|
year: currentYear + (age - currentAge),
|
||||||
|
balance: Math.round(currentCapital),
|
||||||
|
phase: "accumulation",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with current capital
|
||||||
|
let testCapital = currentCapital;
|
||||||
|
let testAge = age;
|
||||||
|
let testAllowance = monthlyAllowance;
|
||||||
|
let isSufficient = true;
|
||||||
|
|
||||||
|
// Simulate retirement with current capital
|
||||||
|
while (testAge < lifeExpectancy) {
|
||||||
|
for (let month = 0; month < 12; month++) {
|
||||||
|
testCapital -= testAllowance;
|
||||||
|
testCapital *= 1 + monthlyGrowthRate;
|
||||||
|
testAllowance *= 1 + monthlyInflationRate;
|
||||||
|
}
|
||||||
|
testAge++;
|
||||||
|
|
||||||
|
if (testCapital <= 0) {
|
||||||
|
isSufficient = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSufficient) {
|
||||||
|
canRetire = true;
|
||||||
|
retirementAge = age;
|
||||||
|
requiredCapital = currentCapital;
|
||||||
|
finalInflationAdjustedAllowance = monthlyAllowance;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If retirement is possible, simulate the retirement phase for the chart
|
||||||
|
if (canRetire) {
|
||||||
|
// Update the phase for all years after retirement
|
||||||
|
yearlyData.forEach((data) => {
|
||||||
|
if (data.age >= retirementAge) {
|
||||||
|
data.phase = "retirement";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Continue simulation for retirement phase if needed
|
||||||
|
let simulationCapital = currentCapital;
|
||||||
|
let simulationAllowance = monthlyAllowance;
|
||||||
|
let simulationAge = age;
|
||||||
|
|
||||||
|
// If we haven't simulated up to life expectancy, continue
|
||||||
|
while (simulationAge < lifeExpectancy) {
|
||||||
|
for (let month = 0; month < 12; month++) {
|
||||||
|
simulationCapital -= simulationAllowance;
|
||||||
|
simulationCapital *= 1 + monthlyGrowthRate;
|
||||||
|
simulationAllowance *= 1 + monthlyInflationRate;
|
||||||
|
}
|
||||||
|
simulationAge++;
|
||||||
|
|
||||||
|
// Record yearly data
|
||||||
|
yearlyData.push({
|
||||||
|
age: simulationAge,
|
||||||
|
year: currentYear + (simulationAge - currentAge),
|
||||||
|
balance: Math.round(simulationCapital),
|
||||||
|
phase: "retirement",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate FIRE number at retirement
|
|
||||||
const retirementYear = irlYear + (retirementAge - age);
|
|
||||||
const retirementIndex = yearlyData.findIndex(
|
|
||||||
(data) => data.year === retirementYear,
|
|
||||||
);
|
|
||||||
const retirementData = yearlyData[retirementIndex];
|
|
||||||
|
|
||||||
const [fireNumber4percent, retirementAge4percent] = (() => {
|
|
||||||
for (const yearData of yearlyData) {
|
|
||||||
if (
|
|
||||||
yearData.untouchedBalance >
|
|
||||||
(yearData.untouchedMonthlyAllowance * 12) / 0.04
|
|
||||||
) {
|
|
||||||
return [yearData.untouchedBalance, yearData.age];
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return [0, 0];
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (retirementIndex === -1 || !retirementData) {
|
if (canRetire) {
|
||||||
setResult({
|
setResult({
|
||||||
fireNumber: null,
|
fireNumber: requiredCapital,
|
||||||
fireNumber4percent: null,
|
retirementAge: retirementAge,
|
||||||
retirementAge4percent: null,
|
inflationAdjustedAllowance: finalInflationAdjustedAllowance,
|
||||||
error: "Could not calculate retirement data",
|
retirementYears: lifeExpectancy - retirementAge,
|
||||||
yearlyData: yearlyData,
|
yearlyData: yearlyData,
|
||||||
|
error: undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Set the result
|
|
||||||
setResult({
|
setResult({
|
||||||
fireNumber: retirementData.balance,
|
fireNumber: null,
|
||||||
fireNumber4percent: fireNumber4percent,
|
retirementAge: null,
|
||||||
retirementAge4percent: retirementAge4percent,
|
inflationAdjustedAllowance: null,
|
||||||
|
retirementYears: null,
|
||||||
yearlyData: yearlyData,
|
yearlyData: yearlyData,
|
||||||
|
error:
|
||||||
|
iterations >= maxIterations
|
||||||
|
? "Calculation exceeded maximum iterations."
|
||||||
|
: "Cannot reach FIRE goal before life expectancy with current parameters.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to format currency without specific symbols
|
||||||
|
const formatNumber = (value: number | null) => {
|
||||||
|
if (value === null) return "N/A";
|
||||||
|
return new Intl.NumberFormat("en", {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="w-full max-w-3xl">
|
||||||
<Card className="mb-4">
|
<Card className="mb-8">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">FIRE Calculator</CardTitle>
|
<CardTitle className="text-2xl">FIRE Calculator</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -258,18 +338,7 @@ export default function FireCalculatorForm() {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="e.g., 10000"
|
placeholder="e.g., 10000"
|
||||||
type="number"
|
type="number"
|
||||||
value={field.value as number | string | undefined}
|
{...field}
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(
|
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
void form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -286,18 +355,7 @@ export default function FireCalculatorForm() {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="e.g., 500"
|
placeholder="e.g., 500"
|
||||||
type="number"
|
type="number"
|
||||||
value={field.value as number | string | undefined}
|
{...field}
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(
|
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
void form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -314,46 +372,7 @@ export default function FireCalculatorForm() {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="e.g., 30"
|
placeholder="e.g., 30"
|
||||||
type="number"
|
type="number"
|
||||||
value={field.value as number | string | undefined}
|
{...field}
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(
|
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
void form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="lifeExpectancy"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Life Expectancy (Age)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g., 90"
|
|
||||||
type="number"
|
|
||||||
value={field.value as number | string | undefined}
|
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(
|
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
void form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -371,47 +390,7 @@ export default function FireCalculatorForm() {
|
|||||||
placeholder="e.g., 7"
|
placeholder="e.g., 7"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={field.value as number | string | undefined}
|
{...field}
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(
|
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
void form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="inflationRate"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Annual Inflation Rate (%)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g., 2"
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={field.value as number | string | undefined}
|
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(
|
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
void form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -430,46 +409,42 @@ export default function FireCalculatorForm() {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="e.g., 2000"
|
placeholder="e.g., 2000"
|
||||||
type="number"
|
type="number"
|
||||||
value={field.value as number | string | undefined}
|
{...field}
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(
|
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
void form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Retirement Age Slider */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="retirementAge"
|
name="inflationRate"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>Annual Inflation Rate (%)</FormLabel>
|
||||||
Retirement Age: {field.value as number}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Slider
|
<Input
|
||||||
name="retirementAge"
|
placeholder="e.g., 2"
|
||||||
value={[field.value as number]}
|
type="number"
|
||||||
min={25}
|
step="0.1"
|
||||||
max={75}
|
{...field}
|
||||||
step={1}
|
/>
|
||||||
onValueChange={(value: number[]) => {
|
</FormControl>
|
||||||
field.onChange(value[0]);
|
<FormMessage />
|
||||||
void form.handleSubmit(onSubmit)();
|
</FormItem>
|
||||||
}}
|
)}
|
||||||
className="py-4"
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lifeExpectancy"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Life Expectancy (Age)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., 90"
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -478,27 +453,85 @@ export default function FireCalculatorForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!result && (
|
|
||||||
<Button type="submit" className="w-full">
|
<Button type="submit" className="w-full">
|
||||||
Calculate
|
Calculate
|
||||||
</Button>
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<>
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Results</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{result.error ? (
|
||||||
|
<p className="text-destructive">{result.error}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>FIRE Number (Required Capital)</Label>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatNumber(result.fireNumber)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Estimated Retirement Age</Label>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{result.retirementAge ?? "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{result.inflationAdjustedAllowance && (
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
Monthly Allowance at Retirement (Inflation Adjusted)
|
||||||
|
</Label>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{formatNumber(result.inflationAdjustedAllowance)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{result?.yearlyData && (
|
{result.retirementYears && (
|
||||||
<Card className="rounded-md shadow-none">
|
<div>
|
||||||
|
<Label>Retirement Duration (Years)</Label>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{result.retirementYears}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{result && result.yearlyData && result.yearlyData.length > 0 && (
|
||||||
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Financial Projection</CardTitle>
|
<CardTitle>Financial Projection</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Projected balance growth with your selected retirement age
|
Projected balance growth and FIRE number threshold
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-2">
|
<CardContent>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
className="aspect-auto h-80 w-full"
|
className="h-80"
|
||||||
config={{}}
|
config={{
|
||||||
|
balance: {
|
||||||
|
label: "Balance",
|
||||||
|
color: "var(--chart-1)",
|
||||||
|
},
|
||||||
|
fireNumber: {
|
||||||
|
label: "FIRE Number",
|
||||||
|
color: "var(--chart-3)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart
|
||||||
data={result.yearlyData}
|
data={result.yearlyData}
|
||||||
margin={{ top: 10, right: 20, left: 20, bottom: 10 }}
|
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
@@ -509,42 +542,36 @@ export default function FireCalculatorForm() {
|
|||||||
offset: -10,
|
offset: -10,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Right Y axis */}
|
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId={"right"}
|
|
||||||
orientation="right"
|
|
||||||
tickFormatter={(value: number) => {
|
tickFormatter={(value: number) => {
|
||||||
if (value >= 1000000) {
|
if (value >= 1000000) {
|
||||||
return `${(value / 1000000).toPrecision(3)}M`;
|
return `${(value / 1000000).toFixed(1)}M`;
|
||||||
} else if (value >= 1000) {
|
} else if (value >= 1000) {
|
||||||
return `${(value / 1000).toPrecision(3)}K`;
|
return `${(value / 1000).toFixed(0)}K`;
|
||||||
} else if (value <= -1000000) {
|
|
||||||
return `${(value / 1000000).toPrecision(3)}M`;
|
|
||||||
} else if (value <= -1000) {
|
|
||||||
return `${(value / 1000).toPrecision(3)}K`;
|
|
||||||
}
|
}
|
||||||
return value.toString();
|
return `${value}`;
|
||||||
}}
|
}}
|
||||||
width={30}
|
width={80}
|
||||||
stroke="var(--color-orange-500)"
|
|
||||||
tick={{}}
|
|
||||||
/>
|
/>
|
||||||
{/* Left Y axis */}
|
<ChartTooltip
|
||||||
<YAxis
|
content={({ active, payload }) => {
|
||||||
yAxisId="left"
|
if (active && payload?.[0]?.payload) {
|
||||||
orientation="left"
|
const data = payload[0]
|
||||||
tickFormatter={(value: number) => {
|
.payload as (typeof result.yearlyData)[0];
|
||||||
if (value >= 1000000) {
|
return (
|
||||||
return `${(value / 1000000).toPrecision(3)}M`;
|
<div className="bg-background border p-2 shadow-sm">
|
||||||
} else if (value >= 1000) {
|
<p className="font-medium">{`Year: ${data.year} (Age: ${data.age})`}</p>
|
||||||
return `${(value / 1000).toPrecision(3)}K`;
|
<p className="text-primary">{`Balance: ${formatNumber(data.balance)}`}</p>
|
||||||
|
{result.fireNumber && (
|
||||||
|
<p className="text-destructive">{`FIRE Number: ${formatNumber(result.fireNumber)}`}</p>
|
||||||
|
)}
|
||||||
|
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return value.toString();
|
return null;
|
||||||
}}
|
}}
|
||||||
width={30}
|
|
||||||
stroke="var(--color-red-600)"
|
|
||||||
/>
|
/>
|
||||||
<ChartTooltip content={tooltipRenderer} />
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient
|
<linearGradient
|
||||||
id="fillBalance"
|
id="fillBalance"
|
||||||
@@ -555,12 +582,12 @@ export default function FireCalculatorForm() {
|
|||||||
>
|
>
|
||||||
<stop
|
<stop
|
||||||
offset="5%"
|
offset="5%"
|
||||||
stopColor="var(--color-orange-500)"
|
stopColor="var(--chart-1)"
|
||||||
stopOpacity={0.8}
|
stopOpacity={0.8}
|
||||||
/>
|
/>
|
||||||
<stop
|
<stop
|
||||||
offset="95%"
|
offset="95%"
|
||||||
stopColor="var(--color-orange-500)"
|
stopColor="var(--chart-1)"
|
||||||
stopOpacity={0.1}
|
stopOpacity={0.1}
|
||||||
/>
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
@@ -569,76 +596,35 @@ export default function FireCalculatorForm() {
|
|||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="balance"
|
dataKey="balance"
|
||||||
name="balance"
|
name="balance"
|
||||||
stroke="var(--color-orange-500)"
|
stroke="var(--chart-1)"
|
||||||
fill="url(#fillBalance)"
|
fill="url(#fillBalance)"
|
||||||
fillOpacity={0.9}
|
fillOpacity={0.4}
|
||||||
activeDot={{ r: 6 }}
|
activeDot={{ r: 6 }}
|
||||||
yAxisId={"right"}
|
|
||||||
stackId={"a"}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="step"
|
|
||||||
dataKey="monthlyAllowance"
|
|
||||||
name="allowance"
|
|
||||||
stroke="var(--color-red-600)"
|
|
||||||
fill="none"
|
|
||||||
activeDot={{ r: 6 }}
|
|
||||||
yAxisId="left"
|
|
||||||
/>
|
/>
|
||||||
{result.fireNumber && (
|
{result.fireNumber && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={result.fireNumber}
|
y={result.fireNumber}
|
||||||
stroke="var(--primary)"
|
stroke="var(--chart-3)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeDasharray="2 1"
|
strokeDasharray="5 5"
|
||||||
label={{
|
label={{
|
||||||
value: "FIRE Number",
|
value: "FIRE Number",
|
||||||
position: "insideBottomRight",
|
position: "insideBottomRight",
|
||||||
}}
|
}}
|
||||||
yAxisId={"right"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{result.fireNumber4percent && showing4percent && (
|
|
||||||
<ReferenceLine
|
|
||||||
y={result.fireNumber4percent}
|
|
||||||
stroke="var(--secondary)"
|
|
||||||
strokeWidth={1}
|
|
||||||
strokeDasharray="1 1"
|
|
||||||
label={{
|
|
||||||
value: "4%-Rule FIRE Number",
|
|
||||||
position: "insideBottomLeft",
|
|
||||||
}}
|
|
||||||
yAxisId={"right"}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{result.retirementAge && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
x={
|
x={
|
||||||
irlYear +
|
currentYear +
|
||||||
(Number(form.getValues("retirementAge")) -
|
(result.retirementAge - form.getValues().currentAge)
|
||||||
Number(form.getValues("currentAge")))
|
|
||||||
}
|
}
|
||||||
stroke="var(--primary)"
|
stroke="var(--chart-2)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
label={{
|
label={{
|
||||||
value: "Retirement",
|
value: "Retirement",
|
||||||
position: "insideTopRight",
|
position: "insideTopRight",
|
||||||
}}
|
}}
|
||||||
yAxisId={"left"}
|
|
||||||
/>
|
|
||||||
{result.retirementAge4percent && showing4percent && (
|
|
||||||
<ReferenceLine
|
|
||||||
x={
|
|
||||||
irlYear +
|
|
||||||
(result.retirementAge4percent -
|
|
||||||
Number(form.getValues("currentAge")))
|
|
||||||
}
|
|
||||||
stroke="var(--secondary)"
|
|
||||||
strokeWidth={1}
|
|
||||||
label={{
|
|
||||||
value: "4%-Rule Retirement",
|
|
||||||
position: "insideBottomLeft",
|
|
||||||
}}
|
|
||||||
yAxisId={"left"}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
@@ -646,96 +632,8 @@ export default function FireCalculatorForm() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{result && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowing4percent(!showing4percent)}
|
|
||||||
variant={showing4percent ? "secondary" : "default"}
|
|
||||||
size={"sm"}
|
|
||||||
>
|
|
||||||
{showing4percent ? "Hide" : "Show"} 4%-Rule
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<div className="mb-4 grid grid-cols-1 gap-2 md:grid-cols-2">
|
|
||||||
{result.error ? (
|
|
||||||
<Card className="col-span-full">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<p className="text-destructive">{result.error}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>FIRE Number</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Capital at retirement
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-3xl font-bold">
|
|
||||||
{formatNumber(result.fireNumber)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Retirement Duration</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Years to enjoy your financial independence
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-3xl font-bold">
|
|
||||||
{Number(form.getValues("lifeExpectancy")) -
|
|
||||||
Number(form.getValues("retirementAge"))}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
{showing4percent && (
|
|
||||||
<>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>4%-Rule FIRE Number</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Capital needed for 4% of it to be greater than your
|
|
||||||
yearly allowance
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-3xl font-bold">
|
|
||||||
{formatNumber(result.fireNumber4percent)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>4%-Rule Retirement Duration</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Years to enjoy your financial independence if you follow
|
|
||||||
the 4% rule
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-3xl font-bold">
|
|
||||||
{Number(form.getValues("lifeExpectancy")) -
|
|
||||||
(result.retirementAge4percent ?? 0)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,16 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -1,77 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { usePlausible } from "next-plausible";
|
|
||||||
import { useReportWebVitals } from "next/web-vitals";
|
|
||||||
interface Metric {
|
|
||||||
/**
|
|
||||||
* The name of the metric (in acronym form).
|
|
||||||
*/
|
|
||||||
name: "CLS" | "FCP" | "FID" | "INP" | "LCP" | "TTFB";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current value of the metric.
|
|
||||||
*/
|
|
||||||
value: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The rating as to whether the metric value is within the "good",
|
|
||||||
* "needs improvement", or "poor" thresholds of the metric.
|
|
||||||
*/
|
|
||||||
rating: "good" | "needs-improvement" | "poor";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The delta between the current value and the last-reported value.
|
|
||||||
* On the first report, `delta` and `value` will always be the same.
|
|
||||||
*/
|
|
||||||
delta: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A unique ID representing this particular metric instance. This ID can
|
|
||||||
* be used by an analytics tool to dedupe multiple values sent for the same
|
|
||||||
* metric instance, or to group multiple deltas together and calculate a
|
|
||||||
* total. It can also be used to differentiate multiple different metric
|
|
||||||
* instances sent from the same page, which can happen if the page is
|
|
||||||
* restored from the back/forward cache (in that case new metrics object
|
|
||||||
* get created).
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Any performance entries relevant to the metric value calculation.
|
|
||||||
* The array may also be empty if the metric value was not based on any
|
|
||||||
* entries (e.g. a CLS value of 0 given no layout shifts).
|
|
||||||
*/
|
|
||||||
entries: PerformanceEntry[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The type of navigation.
|
|
||||||
*
|
|
||||||
* This will be the value returned by the Navigation Timing API (or
|
|
||||||
* `undefined` if the browser doesn't support that API), with the following
|
|
||||||
* exceptions:
|
|
||||||
* - 'back-forward-cache': for pages that are restored from the bfcache.
|
|
||||||
* - 'back_forward' is renamed to 'back-forward' for consistency.
|
|
||||||
* - 'prerender': for pages that were prerendered.
|
|
||||||
* - 'restore': for pages that were discarded by the browser and then
|
|
||||||
* restored by the user.
|
|
||||||
*/
|
|
||||||
navigationType:
|
|
||||||
| "navigate"
|
|
||||||
| "reload"
|
|
||||||
| "back-forward"
|
|
||||||
| "back-forward-cache"
|
|
||||||
| "prerender"
|
|
||||||
| "restore";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WebVitals() {
|
|
||||||
const plausible = usePlausible();
|
|
||||||
useReportWebVitals((metric: Metric) => {
|
|
||||||
plausible("web-vitals", {
|
|
||||||
props: {
|
|
||||||
[metric.name]: metric.rating,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return <></>;
|
|
||||||
}
|
|
BIN
src/app/favicon.ico
(Stored with Git LFS)
BIN
src/app/favicon.ico
(Stored with Git LFS)
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" width="1000" height="1000" viewBox="0 0 264.58 264.58"><defs><linearGradient id="b"><stop offset="0" stop-color="#fd8315"/><stop offset="1" stop-color="#fa6b14"/></linearGradient><linearGradient id="a"><stop offset="0" stop-color="#f24b1b"/><stop offset="1" stop-color="#dc2f12"/></linearGradient><linearGradient xlink:href="#a" id="d" x1="172.49" x2="179.1" y1="64.48" y2="197.19" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="c" x1="118.9" x2="117.99" y1="14.21" y2="194.34" gradientUnits="userSpaceOnUse"/></defs><rect width="264.58" height="264.58" fill="#fdf2e4" ry="42.08" style="-inkscape-stroke:none"/><g stroke-linecap="round" stroke-linejoin="round" stroke-width=".13"><path fill="url(#c)" stroke="#f14a1b" d="m115.13 9.96-.26.01c-.97.11-1.29 1.02-.75 2.38a45.6 45.6 0 0 1 3.02 15.68c-.09 8.46.04 12.87-7.31 23.68s-23.16 21.9-33.96 34.66c-10.8 12.76-12.28 16.6-16.1 26.2A90.42 90.42 0 0 0 53.05 146c0 41.29 27.68 76.09 65.49 86.91 35.3-25.9 55.47-125.62 55.47-125.62s-14.45-10.54-18.57-18.89c-1.26-2.56-1.97-6.15-1.97-9.58.01-2.54 1.3-8.72 1.47-9.41a42.4 42.4 0 0 0 .94-12.14c-.07-.95-.17-1.9-.3-2.84v-.01a59.45 59.45 0 0 0-7.6-19h0a60.34 60.34 0 0 0-10.24-12.13 66.97 66.97 0 0 0-21.7-13.15 2.94 2.94 0 0 0-.92-.18z"/><path fill="url(#d)" stroke="#510a0c" d="M170.01 58.08a66.66 66.66 0 0 0-10.24 15.94 66.66 66.66 0 0 0-6.3 27.82 66.8 66.8 0 0 0 3.66 20.86h-.08l7.08 105.8s37.38-25.1 45.9-61a74.13 74.13 0 0 0 2.04-25.93c-1.34-14.35-4.35-21.67-9.85-30.3-5.5-8.62-10.36-14-17.63-22.78-6.17-7.43-9.44-18.25-10.39-28.87-.28-3.13-2.13-3.91-4.19-1.54z"/></g><path fill="#510a0c" d="M93.45 115.81h77.91c9.81 0 17.71 7.9 17.71 17.7v104.53c0 9.81-7.9 17.71-17.7 17.71H93.44c-9.81 0-17.71-7.9-17.71-17.7V133.51c0-9.81 7.9-17.71 17.7-17.71z"/><path fill="#e83c1b" d="M91.95 163.12h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67zm51.25-45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67z"/><g fill="#520a0c"><path d="M148.74 179.98h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm-51.54.04h18.29a2.41 2.41 0 1 1 0 4.83h-18.3a2.41 2.41 0 1 1 0-4.83z"/><path d="M108.76 173.3v18.28a2.41 2.41 0 1 1-4.84 0V173.3a2.41 2.41 0 1 1 4.84 0zm-10.59 59.18 12.93-12.93a2.41 2.41 0 1 1 3.42 3.42l-12.93 12.93a2.41 2.41 0 1 1-3.42-3.42z"/><path d="m101.59 219.55 12.93 12.93a2.41 2.41 0 1 1-3.42 3.42l-12.93-12.93a2.41 2.41 0 1 1 3.42-3.42zm47.15 1.49h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm0 10.73h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84z"/></g><path fill="#fcf2e4" d="M92.35 125.2h79.67a7.07 7.07 0 0 1 7.09 7.1v14.36a7.07 7.07 0 0 1-7.09 7.1H92.35a7.07 7.07 0 0 1-7.08-7.1V132.3a7.07 7.07 0 0 1 7.08-7.09z"/></svg>
|
|
Before Width: | Height: | Size: 3.1 KiB |
BIN
src/app/icon1.png
(Stored with Git LFS)
BIN
src/app/icon1.png
(Stored with Git LFS)
Binary file not shown.
@@ -1,17 +1,14 @@
|
|||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
import PlausibleProvider from "next-plausible";
|
|
||||||
import { type Metadata, type Viewport } from "next";
|
|
||||||
import { Geist } from "next/font/google";
|
|
||||||
import { WebVitals } from "./components/web-vitals";
|
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
import { type Metadata } from "next";
|
||||||
themeColor: [{ color: "oklch(0.97 0.0228 95.96)" }],
|
import { Geist } from "next/font/google";
|
||||||
};
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "InvestingFIRE | Finance and Retirement Calculator",
|
title:
|
||||||
|
"FIRE Calculator - Plan Your Financial Independence & Early Retirement",
|
||||||
description:
|
description:
|
||||||
"Achieve Financial Independence & Early Retirement! Plan your FIRE journey with the InvestingFIRE calculator and get personalized projections in buttersmooth graphs.",
|
"Calculate your FIRE number, estimate your retirement age, and plan your path to financial independence with this comprehensive FIRE calculator.",
|
||||||
|
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const geist = Geist({
|
const geist = Geist({
|
||||||
@@ -24,17 +21,6 @@ export default function RootLayout({
|
|||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={geist.variable}>
|
<html lang="en" className={geist.variable}>
|
||||||
<head>
|
|
||||||
<meta name="apple-mobile-web-app-title" content="FIRE" />
|
|
||||||
<PlausibleProvider
|
|
||||||
domain="investingfire.com"
|
|
||||||
customDomain="https://analytics.schulze.network"
|
|
||||||
selfHosted={true}
|
|
||||||
enabled={true}
|
|
||||||
trackOutboundLinks={true}
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<WebVitals />
|
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "InvestingFIRE",
|
|
||||||
"short_name": "FIRE",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/web-app-manifest-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/web-app-manifest-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"theme_color": "#fdf2e4",
|
|
||||||
"background_color": "#fdf2e4",
|
|
||||||
"display": "standalone"
|
|
||||||
}
|
|
466
src/app/page.tsx
466
src/app/page.tsx
@@ -1,4 +1,3 @@
|
|||||||
import Image from "next/image";
|
|
||||||
import FireCalculatorForm from "./components/FireCalculatorForm";
|
import FireCalculatorForm from "./components/FireCalculatorForm";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -6,295 +5,175 @@ import {
|
|||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import Footer from "./components/footer";
|
|
||||||
import BackgroundPattern from "./components/BackgroundPattern";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const faqData = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "FAQPage",
|
|
||||||
mainEntity: [
|
|
||||||
{
|
|
||||||
"@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.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-2">
|
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-4">
|
||||||
<BackgroundPattern />
|
<div className="container mx-auto flex flex-col items-center justify-center gap-12 px-4 py-16">
|
||||||
<div className="z-10 mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
<h1 className="text-primary-foreground text-5xl font-extrabold tracking-tight sm:text-[5rem]">
|
||||||
<div className="mt-8 flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
|
FIRE Calculator
|
||||||
<Image
|
|
||||||
priority
|
|
||||||
unoptimized
|
|
||||||
src="/investingfire_logo_no-bg.svg"
|
|
||||||
alt="InvestingFIRE Logo"
|
|
||||||
width={100}
|
|
||||||
height={100}
|
|
||||||
/>
|
|
||||||
<h1 className="from-primary via-primary-foreground to-primary bg-gradient-to-r bg-clip-text text-5xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[5rem]">
|
|
||||||
InvestingFIRE
|
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
|
||||||
<p className="text-primary-foreground/90 text-xl font-semibold md:text-2xl">
|
|
||||||
The #1 FIRE Calculator
|
|
||||||
</p>
|
|
||||||
<div className="mt-8 w-full max-w-2xl">
|
|
||||||
<FireCalculatorForm />
|
<FireCalculatorForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Added SEO Content Sections */}
|
{/* Added SEO Content Sections */}
|
||||||
<div className="z-10 mx-auto max-w-2xl py-12 text-left">
|
<div className="container mx-auto max-w-4xl px-4 py-8 text-left">
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="mb-4 text-3xl font-bold">
|
<h2 className="mb-4 text-3xl font-bold">What is FIRE?</h2>
|
||||||
What Is FIRE? Understanding Financial Independence and Early
|
|
||||||
Retirement
|
|
||||||
</h2>
|
|
||||||
<p className="mb-4 text-lg leading-relaxed">
|
<p className="mb-4 text-lg leading-relaxed">
|
||||||
FIRE stands for{" "}
|
FIRE stands for "Financial Independence, Retire Early."
|
||||||
<strong>Financial Independence, Retire Early</strong>. It's a
|
It's a movement focused on aggressive saving and investing to
|
||||||
lifestyle movement built around two core ideas:
|
build a large enough portfolio that the returns can cover living
|
||||||
|
expenses indefinitely. Achieving FIRE means you are no longer
|
||||||
|
dependent on traditional employment to fund your lifestyle, giving
|
||||||
|
you the freedom to pursue passions, travel, or simply enjoy life
|
||||||
|
without the need for a regular paycheck.
|
||||||
|
</p>
|
||||||
|
<p className="text-lg leading-relaxed">
|
||||||
|
The core principle often involves saving a high percentage of income
|
||||||
|
(sometimes 50% or more) and investing it wisely, typically in
|
||||||
|
low-cost index funds. The target amount, often called the "FIRE
|
||||||
|
number," is usually calculated as 25 times your desired annual
|
||||||
|
spending, based on the 4% safe withdrawal rate rule.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="mb-4 text-3xl font-bold">How This Calculator Works</h2>
|
||||||
|
<p className="mb-4 text-lg leading-relaxed">
|
||||||
|
This calculator helps you estimate your path to FIRE based on your
|
||||||
|
current financial situation and future projections. Here's a
|
||||||
|
breakdown of the inputs:
|
||||||
</p>
|
</p>
|
||||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
|
||||||
<li>
|
<li>
|
||||||
<strong>Aggressive saving & investing</strong>—often 50%+ of
|
<strong>Starting Capital:</strong> The total amount you currently
|
||||||
income—so your capital grows rapidly.
|
have invested.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Passive-income coverage</strong>—when your investment
|
<strong>Monthly Savings:</strong> The amount you consistently save
|
||||||
returns exceed your living expenses, you gain freedom from a
|
and invest each month.
|
||||||
traditional 9-5.
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Current Age:</strong> Your current age in years.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Expected Annual Growth Rate (%):</strong> The average
|
||||||
|
annual return you expect from your investments (after fees, before
|
||||||
|
inflation).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Desired Monthly Allowance (Today's Value):</strong>{" "}
|
||||||
|
How much you want to be able to spend each month in retirement, in
|
||||||
|
today's money value.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Annual Inflation Rate (%):</strong> The expected average
|
||||||
|
rate at which the cost of living will increase.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Life Expectancy (Age):</strong> The age until which you
|
||||||
|
want your funds to last.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-lg leading-relaxed">
|
<p className="text-lg leading-relaxed">
|
||||||
By reaching your personal <em>FIRE Number</em>—the nest egg needed
|
The calculator simulates your investment growth year by year,
|
||||||
to cover your inflation-adjusted spending—you unlock the option to
|
factoring in monthly contributions, compound growth, and
|
||||||
step away from a daily paycheck and pursue passion projects, travel,
|
inflation's effect on your target allowance. It then determines
|
||||||
family, or anything else. This calculator helps you simulate your
|
the age at which your accumulated capital is sufficient to sustain
|
||||||
journey, estimate how much you need, and visualize both your
|
your desired, inflation-adjusted monthly allowance throughout your
|
||||||
accumulation phase and your retirement withdrawals over time.
|
expected retirement years until your specified life expectancy. It
|
||||||
|
estimates your "FIRE Number" (the capital needed at
|
||||||
|
retirement) and the age you might reach it.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="mb-4 text-3xl font-bold">
|
<h2 className="mb-4 text-3xl font-bold">
|
||||||
How This FIRE Calculator Provides Investing Insights
|
Frequently Asked Questions (FAQ)
|
||||||
</h2>
|
</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:
|
|
||||||
</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
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<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
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Desired Monthly Allowance</strong>—today's-value
|
|
||||||
spending goal
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<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.
|
|
||||||
</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.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<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.
|
|
||||||
</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">
|
<Accordion type="single" collapsible className="w-full">
|
||||||
<AccordionItem value="item-1">
|
<AccordionItem value="item-1">
|
||||||
<AccordionTrigger className="text-xl font-semibold">
|
<AccordionTrigger className="text-xl font-semibold">
|
||||||
What methodology does this calculator use?
|
What is the 4% rule?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-lg leading-relaxed">
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
We run a multi-year projection in two phases:
|
The 4% rule is a guideline suggesting that you can safely
|
||||||
<ol className="ml-6 list-decimal space-y-1">
|
withdraw 4% of your investment portfolio's value in your
|
||||||
<li>
|
first year of retirement, and then adjust that amount for
|
||||||
<strong>Accumulation:</strong> Your balance grows by CAGR
|
inflation each subsequent year, with a high probability of your
|
||||||
and you add monthly savings.
|
money lasting for at least 30 years. This calculator uses a more
|
||||||
</li>
|
dynamic simulation based on your life expectancy but is related
|
||||||
<li>
|
to this concept.
|
||||||
<strong>Retirement:</strong> The balance continues
|
|
||||||
compounding, but you withdraw an inflation-adjusted monthly
|
|
||||||
allowance.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
The result: a precise estimate of the capital you'll have
|
|
||||||
at retirement (your “FIRE Number”) and how long it will last
|
|
||||||
until your chosen life expectancy.
|
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem value="item-2">
|
<AccordionItem value="item-2">
|
||||||
<AccordionTrigger className="text-xl font-semibold">
|
<AccordionTrigger className="text-xl font-semibold">
|
||||||
Why isn't this just the 4% rule?
|
Is the Expected Growth Rate realistic?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-lg leading-relaxed">
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
The 4% rule is a useful starting point (25× annual spending),
|
Historically, diversified stock market investments have returned
|
||||||
but it assumes a fixed withdrawal rate with inflation
|
around 7-10% annually over the long term, before inflation. A
|
||||||
adjustments and doesn't model ongoing savings or dynamic
|
rate of 7% (after fees) is often used as a reasonable estimate,
|
||||||
market returns. Our calculator simulates each year's
|
but past performance doesn't guarantee future results.
|
||||||
growth, contributions, and inflation-indexed withdrawals to give
|
It's crucial to choose a rate you feel comfortable with and
|
||||||
you a tailored picture.
|
understand the associated risks.
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem value="item-3">
|
<AccordionItem value="item-3">
|
||||||
<AccordionTrigger className="text-xl font-semibold">
|
<AccordionTrigger className="text-xl font-semibold">
|
||||||
How do I choose a realistic growth rate?
|
How does inflation impact my FIRE number?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-lg leading-relaxed">
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
Historically, a diversified portfolio of equities and bonds has
|
Inflation erodes the purchasing power of money over time. Your
|
||||||
returned around 7-10% per year before inflation. We recommend
|
desired monthly allowance needs to increase each year just to
|
||||||
starting around 6-8% (net of fees), then running “what-if”
|
maintain the same standard of living. This calculator accounts
|
||||||
scenarios—5% on the conservative side, 10% on the aggressive
|
for this by adjusting your target allowance upwards based on the
|
||||||
side—to see how they affect your timeline.
|
inflation rate you provide, ensuring the calculated FIRE number
|
||||||
|
supports your desired lifestyle in future dollars.
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem value="item-4">
|
<AccordionItem value="item-4">
|
||||||
<AccordionTrigger className="text-xl font-semibold">
|
<AccordionTrigger className="text-xl font-semibold">
|
||||||
How does inflation factor into my FIRE Number?
|
Can I really retire early?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-lg leading-relaxed">
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
Cost of living rises. To maintain today's lifestyle, your
|
Retiring significantly earlier than traditional retirement age
|
||||||
monthly allowance must grow each year by your inflation rate.
|
is possible but requires discipline, a high savings rate, and
|
||||||
This calculator automatically inflates your desired monthly
|
consistent investment growth. The feasibility depends heavily on
|
||||||
spending and subtracts it from your portfolio during retirement,
|
your income, expenses, savings habits, and investment returns.
|
||||||
ensuring your FIRE Number keeps pace with rising expenses.
|
Use this calculator as a tool for planning and motivation, but
|
||||||
|
remember it provides estimates based on your inputs.
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem value="item-5">
|
<AccordionItem value="item-5">
|
||||||
<AccordionTrigger className="text-xl font-semibold">
|
<AccordionTrigger className="text-xl font-semibold">
|
||||||
Can I really retire early with FIRE?
|
What does FIRE stand for?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-lg leading-relaxed">
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
Early retirement is achievable with disciplined saving, smart
|
FIRE stands for Financial Independence, Retire Early. It
|
||||||
investing, and realistic assumptions. This tool helps you set
|
represents a lifestyle movement aimed at maximizing your savings
|
||||||
targets, visualize outcomes, and adjust inputs—so you can build
|
rate through increased income and/or decreased expenses to
|
||||||
confidence in your plan and make informed trade-offs between
|
achieve financial independence and retire much earlier than
|
||||||
lifestyle, risk, and timeline.
|
traditional retirement age.
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem value="item-6">
|
<AccordionItem value="item-6">
|
||||||
<AccordionTrigger className="text-xl font-semibold">
|
<AccordionTrigger className="text-xl font-semibold">
|
||||||
How should I use this calculator effectively?
|
How much should I save each month?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-lg leading-relaxed">
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
<ul className="ml-6 list-disc space-y-1">
|
FIRE enthusiasts typically aim to save 50-70% of their income.
|
||||||
<li>
|
The more you can save, the faster you'll reach your FIRE
|
||||||
Start with your actual numbers (capital, savings, age).
|
goal. However, the right amount depends on your income,
|
||||||
</li>
|
lifestyle, and target retirement age. Use the calculator to
|
||||||
<li>
|
experiment with different monthly savings amounts to see their
|
||||||
Set conservative - mid - aggressive growth rates to bound
|
impact on your retirement timeline.
|
||||||
possibilities.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Slide your retirement age to explore “early” vs.
|
|
||||||
“traditional” scenarios.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Review the chart—especially the reference lines—to see when
|
|
||||||
you hit FI and how withdrawals impact your balance.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Experiment with higher savings rates or lower target
|
|
||||||
spending to accelerate your path.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
@@ -303,35 +182,28 @@ export default function HomePage() {
|
|||||||
{/* Optional: Add a section for relevant resources/links here */}
|
{/* Optional: Add a section for relevant resources/links here */}
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="mb-4 text-3xl font-bold">
|
<h2 className="mb-4 text-3xl font-bold">
|
||||||
FIRE Journey & Investing Resources
|
Further Reading & Resources
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-6 text-lg leading-relaxed">
|
<p className="mb-6 text-lg leading-relaxed">
|
||||||
Ready to deepen your knowledge and build a bullet-proof plan? Below
|
Want to learn more about FIRE and continue your journey to financial
|
||||||
are some of our favorite blogs, books, tools, and communities for
|
independence? Here are some valuable resources to explore:
|
||||||
financial independence and smart investing.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-foreground my-8 rounded-md p-4 text-lg">
|
<div className="bg-secondary/20 my-8 rounded-md p-4 text-lg">
|
||||||
<p className="font-semibold">Getting Started with FIRE:</p>
|
<p className="font-semibold">Getting Started with FIRE:</p>
|
||||||
<ol className="ml-6 list-decimal space-y-1">
|
<ol className="ml-6 list-decimal space-y-1">
|
||||||
<li>
|
<li>
|
||||||
Run your first projection above to find your target FIRE Number.
|
Read foundational content like Mr. Money Mustache's simple
|
||||||
</li>
|
math article
|
||||||
<li>Identify areas to boost savings or reduce expenses.</li>
|
|
||||||
<li>
|
|
||||||
Study index-fund strategies and low-cost investing advice.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Join{" "}
|
Calculate your personal numbers using this and other FIRE
|
||||||
<a
|
calculators
|
||||||
href="https://www.reddit.com/r/Fire/"
|
|
||||||
target="_blank"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
supportive communities like r/Fire
|
|
||||||
</a>{" "}
|
|
||||||
to learn from real journeys.
|
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
Join communities like r/Fire to ask questions and find support
|
||||||
|
</li>
|
||||||
|
<li>Explore books and podcasts to deepen your understanding</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -341,70 +213,70 @@ export default function HomePage() {
|
|||||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://www.mrmoneymustache.com/"
|
href="https://www.mrmoneymustache.com/2012/01/13/the-shockingly-simple-math-behind-early-retirement/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Mr. Money Mustache
|
Mr. Money Mustache - The Shockingly Simple Math Behind Early
|
||||||
</a>{" "}
|
Retirement
|
||||||
- Hardcore frugality & early retirement success stories.
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://www.playingwithfire.co/"
|
href="https://www.playingwithfire.co/resources"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Playing With FIRE
|
Playing With FIRE - Comprehensive Resources
|
||||||
</a>{" "}
|
</a>
|
||||||
- Community resources & real-life case studies.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://www.reddit.com/r/Fire/"
|
href="https://www.reddit.com/r/Fire/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
r/Fire
|
r/Fire Reddit Community
|
||||||
</a>{" "}
|
</a>
|
||||||
- Active forum for questions, tips, and support.
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-xl font-semibold">Books & Podcasts</h3>
|
<h3 className="mb-3 text-xl font-semibold">Books & Learning</h3>
|
||||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://www.amazon.com/Your-Money-Life-Transforming-Relationship/dp/0143115766"
|
href="https://www.amazon.com/Your-Money-Life-Transforming-Relationship/dp/0143115766"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Your Money or Your Life
|
Your Money or Your Life - Vicki Robin & Joe Dominguez
|
||||||
</a>{" "}
|
</a>
|
||||||
- The classic guide to aligning money with values.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://podcasts.apple.com/us/podcast/biggerpockets-money-podcast/id1330225136"
|
href="https://www.playingwithfire.co/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
BiggerPockets Money Podcast
|
Playing With FIRE Documentary
|
||||||
</a>{" "}
|
</a>
|
||||||
- Interviews on FIRE strategies and wealth building.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://podcasts.apple.com/us/podcast/can-you-retire-now-this-fire-calculator-will-tell-you/id1330225136?i=1000683436292"
|
href="https://podcasts.apple.com/us/podcast/can-you-retire-now-this-fire-calculator-will-tell-you/id1330225136?i=1000683436292"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
InvestingFIRE Calculator Demo
|
BiggerPockets Money Podcast - FIRE Calculators
|
||||||
</a>{" "}
|
</a>
|
||||||
- Deep dive on how interactive projections can guide your
|
|
||||||
plan.
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,43 +286,63 @@ export default function HomePage() {
|
|||||||
Additional Calculators & Tools
|
Additional Calculators & Tools
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://ghostfol.io"
|
|
||||||
target="_blank"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
Ghostfolio
|
|
||||||
</a>{" "}
|
|
||||||
- Wealth management application for individuals.
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://walletburst.com/tools/coast-fire-calculator/"
|
href="https://walletburst.com/tools/coast-fire-calculator/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Coast FIRE Calculator
|
Coast FIRE Calculator - For those considering a partial
|
||||||
</a>{" "}
|
early retirement
|
||||||
- When you “max out” early contributions but let compounding
|
</a>
|
||||||
do the rest.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://www.investor.gov/financial-tools-calculators/calculators/compound-interest-calculator"
|
href="https://www.empower.com/retirement-calculator"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Compound Interest Calculator
|
Empower Retirement Planner - Free portfolio analysis and net
|
||||||
</a>{" "}
|
worth tracking
|
||||||
- Explore the power of growth rates in isolation.
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-xl font-semibold">
|
||||||
|
Recent Articles & Trends
|
||||||
|
</h3>
|
||||||
|
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.businessinsider.com/retiring-tech-early-coast-fire-make-me-millionaire-2025-4"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Coast FIRE: Retiring in your 30s while becoming a
|
||||||
|
millionaire by 60
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.businessinsider.com/financial-independence-retire-early-saving-loneliness-retreat-bali-making-friends-2025-2"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
The Social Side of FIRE: Finding Community in Financial
|
||||||
|
Independence
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +0,0 @@
|
|||||||
import type { MetadataRoute } from "next";
|
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
|
||||||
return {
|
|
||||||
rules: {
|
|
||||||
userAgent: "*",
|
|
||||||
allow: "/",
|
|
||||||
},
|
|
||||||
sitemap: "https://investingfire.com/sitemap.xml",
|
|
||||||
};
|
|
||||||
}
|
|
@@ -1,13 +0,0 @@
|
|||||||
import { BASE_URL } from "@/lib/constants";
|
|
||||||
import { type MetadataRoute } from "next";
|
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
url: BASE_URL,
|
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "yearly",
|
|
||||||
priority: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
@@ -44,7 +44,7 @@ function AccordionTrigger({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronDownIcon className="text-primary-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
</AccordionPrimitive.Trigger>
|
</AccordionPrimitive.Trigger>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionPrimitive.Header>
|
||||||
);
|
);
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -9,16 +8,15 @@ import { cn } from "@/lib/utils";
|
|||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const;
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
export type ChartConfig = Record<
|
export type ChartConfig = {
|
||||||
string,
|
[k in string]: {
|
||||||
{
|
|
||||||
label?: React.ReactNode;
|
label?: React.ReactNode;
|
||||||
icon?: React.ComponentType;
|
icon?: React.ComponentType;
|
||||||
} & (
|
} & (
|
||||||
| { color?: string; theme?: never }
|
| { color?: string; theme?: never }
|
||||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
)
|
);
|
||||||
>;
|
};
|
||||||
|
|
||||||
type ChartContextProps = {
|
type ChartContextProps = {
|
||||||
config: ChartConfig;
|
config: ChartConfig;
|
||||||
@@ -49,7 +47,7 @@ function ChartContainer({
|
|||||||
>["children"];
|
>["children"];
|
||||||
}) {
|
}) {
|
||||||
const uniqueId = React.useId();
|
const uniqueId = React.useId();
|
||||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContext.Provider value={{ config }}>
|
<ChartContext.Provider value={{ config }}>
|
||||||
@@ -73,7 +71,7 @@ function ChartContainer({
|
|||||||
|
|
||||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
const colorConfig = Object.entries(config).filter(
|
const colorConfig = Object.entries(config).filter(
|
||||||
([, config]) => config.theme ?? config.color,
|
([, config]) => config.theme || config.color,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!colorConfig.length) {
|
if (!colorConfig.length) {
|
||||||
@@ -90,7 +88,7 @@ ${prefix} [data-chart=${id}] {
|
|||||||
${colorConfig
|
${colorConfig
|
||||||
.map(([key, itemConfig]) => {
|
.map(([key, itemConfig]) => {
|
||||||
const color =
|
const color =
|
||||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
itemConfig.color;
|
itemConfig.color;
|
||||||
return color ? ` --color-${key}: ${color};` : null;
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
})
|
})
|
||||||
@@ -136,11 +134,11 @@ function ChartTooltipContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [item] = payload;
|
const [item] = payload;
|
||||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
const value =
|
const value =
|
||||||
!labelKey && typeof label === "string"
|
!labelKey && typeof label === "string"
|
||||||
? (config[label]?.label ?? label)
|
? config[label as keyof typeof config]?.label || label
|
||||||
: itemConfig?.label;
|
: itemConfig?.label;
|
||||||
|
|
||||||
if (labelFormatter) {
|
if (labelFormatter) {
|
||||||
@@ -182,11 +180,9 @@ function ChartTooltipContent({
|
|||||||
{!nestLabel ? tooltipLabel : null}
|
{!nestLabel ? tooltipLabel : null}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
{payload.map((item, index) => {
|
{payload.map((item, index) => {
|
||||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
const indicatorColor: string | undefined =
|
const indicatorColor = color || item.payload.fill || item.color;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
||||||
color ?? item.payload.fill ?? item.color;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -197,7 +193,6 @@ function ChartTooltipContent({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatter && item?.value !== undefined && item.name ? (
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
formatter(item.value, item.name, item, index, item.payload)
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -234,7 +229,7 @@ function ChartTooltipContent({
|
|||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
{nestLabel ? tooltipLabel : null}
|
{nestLabel ? tooltipLabel : null}
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{itemConfig?.label ?? item.name}
|
{itemConfig?.label || item.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{item.value && (
|
{item.value && (
|
||||||
@@ -281,8 +276,7 @@ function ChartLegendContent({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{payload.map((item) => {
|
{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);
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -344,7 +338,9 @@ function getPayloadConfigFromPayload(
|
|||||||
] as string;
|
] as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
return configLabelKey in config ? config[configLabelKey] : config[key];
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config];
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@@ -1,185 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
function Select({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectValue({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectTrigger({
|
|
||||||
className,
|
|
||||||
size = "default",
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
|
||||||
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",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SelectPrimitive.Icon asChild>
|
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
|
||||||
</SelectPrimitive.Icon>
|
|
||||||
</SelectPrimitive.Trigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
position = "popper",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<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-[8rem] 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}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SelectScrollUpButton />
|
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
className={cn(
|
|
||||||
"p-1",
|
|
||||||
position === "popper" &&
|
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Viewport>
|
|
||||||
<SelectScrollDownButton />
|
|
||||||
</SelectPrimitive.Content>
|
|
||||||
</SelectPrimitive.Portal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
data-slot="select-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
data-slot="select-separator"
|
|
||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollUpButton
|
|
||||||
data-slot="select-scroll-up-button"
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronUpIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ScrollUpButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollDownButton
|
|
||||||
data-slot="select-scroll-down-button"
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronDownIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ScrollDownButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
};
|
|
@@ -1,63 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function Slider({
|
|
||||||
className,
|
|
||||||
defaultValue,
|
|
||||||
value,
|
|
||||||
min = 0,
|
|
||||||
max = 100,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
|
||||||
const _values = React.useMemo(
|
|
||||||
() =>
|
|
||||||
Array.isArray(value)
|
|
||||||
? value
|
|
||||||
: Array.isArray(defaultValue)
|
|
||||||
? defaultValue
|
|
||||||
: [min, max],
|
|
||||||
[value, defaultValue, min, max],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SliderPrimitive.Root
|
|
||||||
data-slot="slider"
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
value={value}
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SliderPrimitive.Track
|
|
||||||
data-slot="slider-track"
|
|
||||||
className={cn(
|
|
||||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SliderPrimitive.Range
|
|
||||||
data-slot="slider-range"
|
|
||||||
className={cn(
|
|
||||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SliderPrimitive.Track>
|
|
||||||
{Array.from({ length: _values.length }, (_, index) => (
|
|
||||||
<SliderPrimitive.Thumb
|
|
||||||
data-slot="slider-thumb"
|
|
||||||
key={index}
|
|
||||||
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SliderPrimitive.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Slider };
|
|
40
src/env.js
40
src/env.js
@@ -1,40 +0,0 @@
|
|||||||
import { createEnv } from "@t3-oss/env-nextjs";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const env = createEnv({
|
|
||||||
/**
|
|
||||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
|
||||||
* isn't built with invalid env vars.
|
|
||||||
*/
|
|
||||||
server: {
|
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Specify your client-side environment variables schema here. This way you can ensure the app
|
|
||||||
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
|
||||||
* `NEXT_PUBLIC_`.
|
|
||||||
*/
|
|
||||||
client: {
|
|
||||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
|
||||||
* middlewares) or client-side so we need to destruct manually.
|
|
||||||
*/
|
|
||||||
runtimeEnv: {
|
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
|
||||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
|
||||||
* useful for Docker builds.
|
|
||||||
*/
|
|
||||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
|
||||||
/**
|
|
||||||
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
|
|
||||||
* `SOME_VAR=''` will throw an error.
|
|
||||||
*/
|
|
||||||
emptyStringAsUndefined: true,
|
|
||||||
});
|
|
@@ -1 +0,0 @@
|
|||||||
export const BASE_URL = "https://investingfire.com/";
|
|
Reference in New Issue
Block a user