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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@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
|
||||
|
||||
**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.**
|
||||
FIRE calculator
|
@@ -14,7 +14,7 @@ export default tseslint.config(
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
extends: [
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
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
46
package.json
46
package.json
@@ -9,49 +9,47 @@
|
||||
"dev": "next dev --turbo",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.8",
|
||||
"@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",
|
||||
"@t3-oss/env-nextjs": "^0.13.0",
|
||||
"@t3-oss/env-nextjs": "^0.12.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"lucide-react": "^0.503.0",
|
||||
"next": "^15.2.3",
|
||||
"next-plausible": "^3.12.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.56.1",
|
||||
"recharts": "^3.0.0",
|
||||
"recharts": "^2.15.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "3.3.1",
|
||||
"@tailwindcss/postcss": "4.1.11",
|
||||
"@types/node": "22.16.3",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"eslint": "9.31.0",
|
||||
"eslint-config-next": "15.3.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"tailwindcss": "4.1.11",
|
||||
"tw-animate-css": "1.3.5",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.36.0"
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-config-next": "^15.2.3",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tw-animate-css": "^1.2.8",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.27.0"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.13.1"
|
||||
"packageManager": "npm@11.2.0"
|
||||
}
|
||||
|
5070
pnpm-lock.yaml
generated
5070
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";
|
||||
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -7,6 +8,7 @@ import * as z from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -30,25 +32,17 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
ReferenceLine,
|
||||
type TooltipProps,
|
||||
} 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
|
||||
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
|
||||
.number()
|
||||
.min(0, "Monthly savings must be a non-negative number"),
|
||||
currentAge: z.coerce
|
||||
.number()
|
||||
.min(1, "Age must be at least 1")
|
||||
.max(100, "No point in starting this late"),
|
||||
currentAge: z.coerce.number().min(18, "Age must be at least 18"),
|
||||
cagr: z.coerce.number().min(0, "Growth rate must be a non-negative number"),
|
||||
desiredMonthlyAllowance: z.coerce
|
||||
.number()
|
||||
@@ -58,79 +52,41 @@ const formSchema = z.object({
|
||||
.min(0, "Inflation rate must be a non-negative number"),
|
||||
lifeExpectancy: z.coerce
|
||||
.number()
|
||||
.min(40, "Be a bit more optimistic buddy :(")
|
||||
.max(100, "You should be more realistic..."),
|
||||
retirementAge: z.coerce
|
||||
.number()
|
||||
.min(18, "Retirement age must be at least 18")
|
||||
.max(100, "Retirement age must be at most 100"),
|
||||
.min(50, "Life expectancy must be at least 50"),
|
||||
});
|
||||
|
||||
// Type for form values
|
||||
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;
|
||||
year: number;
|
||||
balance: number;
|
||||
untouchedBalance: number;
|
||||
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() {
|
||||
const [result, setResult] = useState<CalculationResult | null>(null);
|
||||
const irlYear = new Date().getFullYear();
|
||||
const [showing4percent, setShowing4percent] = useState(false);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// Initialize form with default values
|
||||
const form = useForm<z.input<typeof formSchema>, undefined, FormValues>({
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
startingCapital: 50000,
|
||||
monthlySavings: 1500,
|
||||
currentAge: 25,
|
||||
cagr: 7,
|
||||
desiredMonthlyAllowance: 3000,
|
||||
inflationRate: 2.3,
|
||||
desiredMonthlyAllowance: 2000,
|
||||
inflationRate: 2,
|
||||
lifeExpectancy: 84,
|
||||
retirementAge: 55,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -139,105 +95,229 @@ export default function FireCalculatorForm() {
|
||||
|
||||
const startingCapital = values.startingCapital;
|
||||
const monthlySavings = values.monthlySavings;
|
||||
const age = values.currentAge;
|
||||
const annualGrowthRate = 1 + values.cagr / 100;
|
||||
const currentAge = values.currentAge;
|
||||
const annualGrowthRate = values.cagr / 100;
|
||||
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
|
||||
const annualInflation = 1 + values.inflationRate / 100;
|
||||
const ageOfDeath = values.lifeExpectancy;
|
||||
const retirementAge = values.retirementAge;
|
||||
const annualInflation = values.inflationRate / 100;
|
||||
const lifeExpectancy = values.lifeExpectancy;
|
||||
|
||||
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
|
||||
const yearlyData: YearlyData[] = [];
|
||||
const yearlyData: CalculationResult["yearlyData"] = [];
|
||||
|
||||
// Initial year data
|
||||
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;
|
||||
// Add starting point
|
||||
yearlyData.push({
|
||||
age: currentAge,
|
||||
year: year,
|
||||
balance: newBalance,
|
||||
untouchedBalance: untouchedBalance,
|
||||
phase: phase,
|
||||
monthlyAllowance: allowance,
|
||||
untouchedMonthlyAllowance: inflatedAllowance,
|
||||
year: currentYear,
|
||||
balance: startingCapital,
|
||||
phase: "accumulation",
|
||||
});
|
||||
|
||||
// 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({
|
||||
fireNumber: null,
|
||||
fireNumber4percent: null,
|
||||
retirementAge4percent: null,
|
||||
error: "Could not calculate retirement data",
|
||||
fireNumber: requiredCapital,
|
||||
retirementAge: retirementAge,
|
||||
inflationAdjustedAllowance: finalInflationAdjustedAllowance,
|
||||
retirementYears: lifeExpectancy - retirementAge,
|
||||
yearlyData: yearlyData,
|
||||
error: undefined,
|
||||
});
|
||||
} else {
|
||||
// Set the result
|
||||
setResult({
|
||||
fireNumber: retirementData.balance,
|
||||
fireNumber4percent: fireNumber4percent,
|
||||
retirementAge4percent: retirementAge4percent,
|
||||
fireNumber: null,
|
||||
retirementAge: null,
|
||||
inflationAdjustedAllowance: null,
|
||||
retirementYears: null,
|
||||
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 (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<div className="w-full max-w-3xl">
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">FIRE Calculator</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -258,18 +338,7 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 10000"
|
||||
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}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -286,18 +355,7 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 500"
|
||||
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}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -314,46 +372,7 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 30"
|
||||
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>
|
||||
<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}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -371,47 +390,7 @@ export default function FireCalculatorForm() {
|
||||
placeholder="e.g., 7"
|
||||
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>
|
||||
<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}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -430,46 +409,42 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 2000"
|
||||
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}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Retirement Age Slider */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="retirementAge"
|
||||
name="inflationRate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Retirement Age: {field.value as number}
|
||||
</FormLabel>
|
||||
<FormLabel>Annual Inflation Rate (%)</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
name="retirementAge"
|
||||
value={[field.value as number]}
|
||||
min={25}
|
||||
max={75}
|
||||
step={1}
|
||||
onValueChange={(value: number[]) => {
|
||||
field.onChange(value[0]);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
className="py-4"
|
||||
<Input
|
||||
placeholder="e.g., 2"
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
/>
|
||||
</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"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -478,27 +453,85 @@ export default function FireCalculatorForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!result && (
|
||||
<Button type="submit" className="w-full">
|
||||
Calculate
|
||||
</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 && (
|
||||
<Card className="rounded-md shadow-none">
|
||||
{result.retirementYears && (
|
||||
<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>
|
||||
<CardTitle>Financial Projection</CardTitle>
|
||||
<CardDescription>
|
||||
Projected balance growth with your selected retirement age
|
||||
Projected balance growth and FIRE number threshold
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2">
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
className="aspect-auto h-80 w-full"
|
||||
config={{}}
|
||||
className="h-80"
|
||||
config={{
|
||||
balance: {
|
||||
label: "Balance",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
fireNumber: {
|
||||
label: "FIRE Number",
|
||||
color: "var(--chart-3)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AreaChart
|
||||
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" />
|
||||
<XAxis
|
||||
@@ -509,42 +542,36 @@ export default function FireCalculatorForm() {
|
||||
offset: -10,
|
||||
}}
|
||||
/>
|
||||
{/* Right Y axis */}
|
||||
<YAxis
|
||||
yAxisId={"right"}
|
||||
orientation="right"
|
||||
tickFormatter={(value: number) => {
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toPrecision(3)}M`;
|
||||
return `${(value / 1000000).toFixed(1)}M`;
|
||||
} else if (value >= 1000) {
|
||||
return `${(value / 1000).toPrecision(3)}K`;
|
||||
} else if (value <= -1000000) {
|
||||
return `${(value / 1000000).toPrecision(3)}M`;
|
||||
} else if (value <= -1000) {
|
||||
return `${(value / 1000).toPrecision(3)}K`;
|
||||
return `${(value / 1000).toFixed(0)}K`;
|
||||
}
|
||||
return value.toString();
|
||||
return `${value}`;
|
||||
}}
|
||||
width={30}
|
||||
stroke="var(--color-orange-500)"
|
||||
tick={{}}
|
||||
width={80}
|
||||
/>
|
||||
{/* Left Y axis */}
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
orientation="left"
|
||||
tickFormatter={(value: number) => {
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toPrecision(3)}M`;
|
||||
} else if (value >= 1000) {
|
||||
return `${(value / 1000).toPrecision(3)}K`;
|
||||
<ChartTooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload?.[0]?.payload) {
|
||||
const data = payload[0]
|
||||
.payload as (typeof result.yearlyData)[0];
|
||||
return (
|
||||
<div className="bg-background border p-2 shadow-sm">
|
||||
<p className="font-medium">{`Year: ${data.year} (Age: ${data.age})`}</p>
|
||||
<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>
|
||||
<linearGradient
|
||||
id="fillBalance"
|
||||
@@ -555,12 +582,12 @@ export default function FireCalculatorForm() {
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-orange-500)"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-orange-500)"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
@@ -569,76 +596,35 @@ export default function FireCalculatorForm() {
|
||||
type="monotone"
|
||||
dataKey="balance"
|
||||
name="balance"
|
||||
stroke="var(--color-orange-500)"
|
||||
stroke="var(--chart-1)"
|
||||
fill="url(#fillBalance)"
|
||||
fillOpacity={0.9}
|
||||
fillOpacity={0.4}
|
||||
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 && (
|
||||
<ReferenceLine
|
||||
y={result.fireNumber}
|
||||
stroke="var(--primary)"
|
||||
stroke="var(--chart-3)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="2 1"
|
||||
strokeDasharray="5 5"
|
||||
label={{
|
||||
value: "FIRE Number",
|
||||
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
|
||||
x={
|
||||
irlYear +
|
||||
(Number(form.getValues("retirementAge")) -
|
||||
Number(form.getValues("currentAge")))
|
||||
currentYear +
|
||||
(result.retirementAge - form.getValues().currentAge)
|
||||
}
|
||||
stroke="var(--primary)"
|
||||
stroke="var(--chart-2)"
|
||||
strokeWidth={2}
|
||||
label={{
|
||||
value: "Retirement",
|
||||
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>
|
||||
@@ -646,96 +632,8 @@ export default function FireCalculatorForm() {
|
||||
</CardContent>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -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 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 = {
|
||||
themeColor: [{ color: "oklch(0.97 0.0228 95.96)" }],
|
||||
};
|
||||
import { type Metadata } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "InvestingFIRE | Finance and Retirement Calculator",
|
||||
title:
|
||||
"FIRE Calculator - Plan Your Financial Independence & Early Retirement",
|
||||
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({
|
||||
@@ -24,17 +21,6 @@ export default function RootLayout({
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<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>
|
||||
</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"
|
||||
}
|
406
src/app/page.tsx
406
src/app/page.tsx
@@ -1,4 +1,3 @@
|
||||
import Image from "next/image";
|
||||
import FireCalculatorForm from "./components/FireCalculatorForm";
|
||||
import {
|
||||
Accordion,
|
||||
@@ -6,235 +5,175 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import Footer from "./components/footer";
|
||||
import BackgroundPattern from "./components/BackgroundPattern";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-2">
|
||||
<BackgroundPattern />
|
||||
<div className="z-10 mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="mt-8 flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
|
||||
<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
|
||||
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-4">
|
||||
<div className="container mx-auto flex flex-col items-center justify-center gap-12 px-4 py-16">
|
||||
<h1 className="text-primary-foreground text-5xl font-extrabold tracking-tight sm:text-[5rem]">
|
||||
FIRE Calculator
|
||||
</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 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
What Is FIRE? Understanding Financial Independence and Early
|
||||
Retirement
|
||||
</h2>
|
||||
<h2 className="mb-4 text-3xl font-bold">What is FIRE?</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
FIRE stands for{" "}
|
||||
<strong>Financial Independence, Retire Early</strong>. It's a
|
||||
lifestyle movement built around two core ideas:
|
||||
FIRE stands for "Financial Independence, Retire Early."
|
||||
It's a movement focused on aggressive saving and investing to
|
||||
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>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<strong>Aggressive saving & investing</strong>—often 50%+ of
|
||||
income—so your capital grows rapidly.
|
||||
<strong>Starting Capital:</strong> The total amount you currently
|
||||
have invested.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Passive-income coverage</strong>—when your investment
|
||||
returns exceed your living expenses, you gain freedom from a
|
||||
traditional 9-5.
|
||||
<strong>Monthly Savings:</strong> The amount you consistently save
|
||||
and invest each month.
|
||||
</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>
|
||||
</ul>
|
||||
<p className="text-lg leading-relaxed">
|
||||
By reaching your personal <em>FIRE Number</em>—the nest egg needed
|
||||
to cover your inflation-adjusted spending—you unlock the option to
|
||||
step away from a daily paycheck and pursue passion projects, travel,
|
||||
family, or anything else. This calculator helps you simulate your
|
||||
journey, estimate how much you need, and visualize both your
|
||||
accumulation phase and your retirement withdrawals over time.
|
||||
The calculator simulates your investment growth year by year,
|
||||
factoring in monthly contributions, compound growth, and
|
||||
inflation's effect on your target allowance. It then determines
|
||||
the age at which your accumulated capital is sufficient to sustain
|
||||
your desired, inflation-adjusted monthly allowance throughout your
|
||||
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>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
How This FIRE Calculator Provides Investing Insights
|
||||
</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
Our interactive tool goes beyond a simple “25x annual spending”
|
||||
rule. It runs a <strong>year-by-year simulation</strong> of your
|
||||
portfolio, combining:
|
||||
</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">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
FIRE & Investing Frequently Asked Questions (FAQ)
|
||||
Frequently Asked Questions (FAQ)
|
||||
</h2>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
What methodology does this calculator use?
|
||||
What is the 4% rule?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
We run a multi-year projection in two phases:
|
||||
<ol className="ml-6 list-decimal space-y-1">
|
||||
<li>
|
||||
<strong>Accumulation:</strong> Your balance grows by CAGR
|
||||
and you add monthly savings.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Retirement:</strong> The balance continues
|
||||
compounding, but you withdraw an inflation-adjusted monthly
|
||||
allowance.
|
||||
</li>
|
||||
</ol>
|
||||
The result: a precise estimate of the capital you'll have
|
||||
at retirement (your “FIRE Number”) and how long it will last
|
||||
until your chosen life expectancy.
|
||||
The 4% rule is a guideline suggesting that you can safely
|
||||
withdraw 4% of your investment portfolio's value in your
|
||||
first year of retirement, and then adjust that amount for
|
||||
inflation each subsequent year, with a high probability of your
|
||||
money lasting for at least 30 years. This calculator uses a more
|
||||
dynamic simulation based on your life expectancy but is related
|
||||
to this concept.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Why isn't this just the 4% rule?
|
||||
Is the Expected Growth Rate realistic?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
The 4% rule is a useful starting point (25× annual spending),
|
||||
but it assumes a fixed withdrawal rate with inflation
|
||||
adjustments and doesn'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.
|
||||
Historically, diversified stock market investments have returned
|
||||
around 7-10% annually over the long term, before inflation. A
|
||||
rate of 7% (after fees) is often used as a reasonable estimate,
|
||||
but past performance doesn't guarantee future results.
|
||||
It's crucial to choose a rate you feel comfortable with and
|
||||
understand the associated risks.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How do I choose a realistic growth rate?
|
||||
How does inflation impact my FIRE number?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Historically, a diversified portfolio of equities and bonds has
|
||||
returned around 7-10% per year before inflation. We recommend
|
||||
starting around 6-8% (net of fees), then running “what-if”
|
||||
scenarios—5% on the conservative side, 10% on the aggressive
|
||||
side—to see how they affect your timeline.
|
||||
Inflation erodes the purchasing power of money over time. Your
|
||||
desired monthly allowance needs to increase each year just to
|
||||
maintain the same standard of living. This calculator accounts
|
||||
for this by adjusting your target allowance upwards based on the
|
||||
inflation rate you provide, ensuring the calculated FIRE number
|
||||
supports your desired lifestyle in future dollars.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-4">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How does inflation factor into my FIRE Number?
|
||||
Can I really retire early?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
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.
|
||||
Retiring significantly earlier than traditional retirement age
|
||||
is possible but requires discipline, a high savings rate, and
|
||||
consistent investment growth. The feasibility depends heavily on
|
||||
your income, expenses, savings habits, and investment returns.
|
||||
Use this calculator as a tool for planning and motivation, but
|
||||
remember it provides estimates based on your inputs.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-5">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Can I really retire early with FIRE?
|
||||
What does FIRE stand for?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Early retirement is achievable with disciplined saving, smart
|
||||
investing, and realistic assumptions. This tool helps you set
|
||||
targets, visualize outcomes, and adjust inputs—so you can build
|
||||
confidence in your plan and make informed trade-offs between
|
||||
lifestyle, risk, and timeline.
|
||||
FIRE stands for Financial Independence, Retire Early. It
|
||||
represents a lifestyle movement aimed at maximizing your savings
|
||||
rate through increased income and/or decreased expenses to
|
||||
achieve financial independence and retire much earlier than
|
||||
traditional retirement age.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-6">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How should I use this calculator effectively?
|
||||
How much should I save each month?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
<ul className="ml-6 list-disc space-y-1">
|
||||
<li>
|
||||
Start with your actual numbers (capital, savings, age).
|
||||
</li>
|
||||
<li>
|
||||
Set conservative - mid - aggressive growth rates to bound
|
||||
possibilities.
|
||||
</li>
|
||||
<li>
|
||||
Slide your retirement age to explore “early” vs.
|
||||
“traditional” scenarios.
|
||||
</li>
|
||||
<li>
|
||||
Review the 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>
|
||||
FIRE enthusiasts typically aim to save 50-70% of their income.
|
||||
The more you can save, the faster you'll reach your FIRE
|
||||
goal. However, the right amount depends on your income,
|
||||
lifestyle, and target retirement age. Use the calculator to
|
||||
experiment with different monthly savings amounts to see their
|
||||
impact on your retirement timeline.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
@@ -243,35 +182,28 @@ export default function HomePage() {
|
||||
{/* Optional: Add a section for relevant resources/links here */}
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
FIRE Journey & Investing Resources
|
||||
Further Reading & Resources
|
||||
</h2>
|
||||
<p className="mb-6 text-lg leading-relaxed">
|
||||
Ready to deepen your knowledge and build a bullet-proof plan? Below
|
||||
are some of our favorite blogs, books, tools, and communities for
|
||||
financial independence and smart investing.
|
||||
Want to learn more about FIRE and continue your journey to financial
|
||||
independence? Here are some valuable resources to explore:
|
||||
</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>
|
||||
<ol className="ml-6 list-decimal space-y-1">
|
||||
<li>
|
||||
Run your first projection above to find your target FIRE Number.
|
||||
</li>
|
||||
<li>Identify areas to boost savings or reduce expenses.</li>
|
||||
<li>
|
||||
Study index-fund strategies and low-cost investing advice.
|
||||
Read foundational content like Mr. Money Mustache's simple
|
||||
math article
|
||||
</li>
|
||||
<li>
|
||||
Join{" "}
|
||||
<a
|
||||
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.
|
||||
Calculate your personal numbers using this and other FIRE
|
||||
calculators
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -281,70 +213,70 @@ export default function HomePage() {
|
||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.mrmoneymustache.com/"
|
||||
href="https://www.mrmoneymustache.com/2012/01/13/the-shockingly-simple-math-behind-early-retirement/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Mr. Money Mustache
|
||||
</a>{" "}
|
||||
- Hardcore frugality & early retirement success stories.
|
||||
Mr. Money Mustache - The Shockingly Simple Math Behind Early
|
||||
Retirement
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.playingwithfire.co/"
|
||||
href="https://www.playingwithfire.co/resources"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Playing With FIRE
|
||||
</a>{" "}
|
||||
- Community resources & real-life case studies.
|
||||
Playing With FIRE - Comprehensive Resources
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.reddit.com/r/Fire/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
r/Fire
|
||||
</a>{" "}
|
||||
- Active forum for questions, tips, and support.
|
||||
r/Fire Reddit Community
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</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">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.amazon.com/Your-Money-Life-Transforming-Relationship/dp/0143115766"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Your Money or Your Life
|
||||
</a>{" "}
|
||||
- The classic guide to aligning money with values.
|
||||
Your Money or Your Life - Vicki Robin & Joe Dominguez
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://podcasts.apple.com/us/podcast/biggerpockets-money-podcast/id1330225136"
|
||||
href="https://www.playingwithfire.co/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
BiggerPockets Money Podcast
|
||||
</a>{" "}
|
||||
- Interviews on FIRE strategies and wealth building.
|
||||
Playing With FIRE Documentary
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://podcasts.apple.com/us/podcast/can-you-retire-now-this-fire-calculator-will-tell-you/id1330225136?i=1000683436292"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
InvestingFIRE Calculator Demo
|
||||
</a>{" "}
|
||||
- Deep dive on how interactive projections can guide your
|
||||
plan.
|
||||
BiggerPockets Money Podcast - FIRE Calculators
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -354,43 +286,63 @@ export default function HomePage() {
|
||||
Additional Calculators & Tools
|
||||
</h3>
|
||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<a
|
||||
href="https://ghostfol.io"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Ghostfolio
|
||||
</a>{" "}
|
||||
- Wealth management application for individuals.
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://walletburst.com/tools/coast-fire-calculator/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Coast FIRE Calculator
|
||||
</a>{" "}
|
||||
- When you “max out” early contributions but let compounding
|
||||
do the rest.
|
||||
Coast FIRE Calculator - For those considering a partial
|
||||
early retirement
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.investor.gov/financial-tools-calculators/calculators/compound-interest-calculator"
|
||||
href="https://www.empower.com/retirement-calculator"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Compound Interest Calculator
|
||||
</a>{" "}
|
||||
- Explore the power of growth rates in isolation.
|
||||
Empower Retirement Planner - Free portfolio analysis and net
|
||||
worth tracking
|
||||
</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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<Footer />
|
||||
</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}
|
||||
>
|
||||
{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.Header>
|
||||
);
|
||||
|
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
@@ -9,16 +8,15 @@ import { cn } from "@/lib/utils";
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>;
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
@@ -49,7 +47,7 @@ function ChartContainer({
|
||||
>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
@@ -73,7 +71,7 @@ function ChartContainer({
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color,
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
@@ -90,7 +88,7 @@ ${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
@@ -136,11 +134,11 @@ function ChartTooltipContent({
|
||||
}
|
||||
|
||||
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 value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
@@ -182,11 +180,9 @@ function ChartTooltipContent({
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{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 indicatorColor: string | undefined =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
color ?? item.payload.fill ?? item.color;
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -197,7 +193,6 @@ function ChartTooltipContent({
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
@@ -234,7 +229,7 @@ function ChartTooltipContent({
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
@@ -281,8 +276,7 @@ function ChartLegendContent({
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
@@ -344,7 +338,9 @@ function getPayloadConfigFromPayload(
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
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