Compare commits
156 Commits
5f90724cf6
...
main
Author | SHA1 | Date | |
---|---|---|---|
1ca5545db7 | |||
bd914b05dd | |||
fc452ebd4c | |||
201c1ee523 | |||
1c1b842a15 | |||
aba4e4a7f6 | |||
4dcd24f1fd | |||
440b759daa | |||
9a54bdf93f | |||
cabcbbb84a | |||
d815cab9d8 | |||
be6a875999 | |||
31c6c7106f | |||
93c1320651 | |||
b875d65fdc | |||
26a305a96b | |||
fb32bd381c | |||
c45cad8e96 | |||
86ee8c6b32 | |||
f6dd7c3012 | |||
e34fc5dffc | |||
57be648512 | |||
c131fba360 | |||
9803c3f33d | |||
7f7a8d8728 | |||
a2184c0ef2 | |||
198a8c8c45 | |||
d87fba0ca5 | |||
0cbc745798 | |||
e59620f619 | |||
52eaa7bd4f | |||
614d1a83cc | |||
d29d4e54ef | |||
c79f504c24 | |||
aa0c90e70b | |||
b0a1512911 | |||
f34cbabff2 | |||
1b58443e69 | |||
5fdeae83c6 | |||
fb62ae2011 | |||
55c8b7c079 | |||
e39ddfd7a6 | |||
990ef286c8 | |||
dc80ce98e5 | |||
f1b29f7c5f | |||
e4416ba3df | |||
3f0584ab51 | |||
b657c589e0 | |||
c008289e09 | |||
3cee1fff9e | |||
07de9a0062 | |||
00108ab629 | |||
d790a8bb3b | |||
24e6a4ef95 | |||
5ddd9cc58f | |||
adcdce67cb | |||
51c4bd7316 | |||
bff9c98db1 | |||
bedaa2090f | |||
71df024aa9 | |||
228a0bdaaf | |||
a4a3ed403b | |||
863d4f3268 | |||
9719383056 | |||
6c77a71a79 | |||
b54c8e3d2b | |||
1cffa649d6 | |||
7c38ed2a60 | |||
7a69e9ff45 | |||
e71f28d8c2 | |||
d26a3252cc | |||
2a7182625b | |||
d7cbcf3707 | |||
374cb17eeb | |||
d5f8c84f13 | |||
d116e01f8d | |||
a297b8a4aa | |||
acb4e5bc51 | |||
606512fad8 | |||
a5d07b8b3e | |||
0a7a51ef64 | |||
2d5f9b051d | |||
d83ef08f7c | |||
7362a6545b | |||
553f5155c7 | |||
44a98fe001 | |||
0736907b84 | |||
4f4a74dc72 | |||
6387e96bf7 | |||
96f95e7b08 | |||
87ea9e1ecc | |||
51add59741 | |||
29d42c0f22 | |||
e36c062b9c | |||
8273fce712 | |||
820d7f4883 | |||
6d487f8792 | |||
f4ab9d3745 | |||
e1da910a25 | |||
225f9ef1ab | |||
c071b9c052 | |||
11e1e31ac5 | |||
8ac784f49b | |||
0b0e6c1c9a | |||
c3168220fe | |||
229f2d7b56 | |||
a86eddda31 | |||
0a96a94cf3 | |||
0e6086a597 | |||
dafdd0d154 | |||
b9b52377e0 | |||
63fe8e5999 | |||
fc18e414cb | |||
a3eda1f0db | |||
2b2b5784d1 | |||
32ef797bf6 | |||
02761928a5 | |||
763c8b590d | |||
e616e8f261 | |||
a0c5665941 | |||
d24c8b910a | |||
8f7ebf7b5a | |||
7c05542d5e | |||
32a0b7a0ac | |||
c1e57577cd | |||
d2735a7020 | |||
4348e4bdf3 | |||
4026924e06 | |||
bf9098e3e5 | |||
1082dc3b69 | |||
670ed01ede | |||
2bc1d42cf7 | |||
23e03c9a32 | |||
fdd923cfbc | |||
6a6557c3bf | |||
5544c2f69f | |||
24547c3087 | |||
6a6f0ee9a5 | |||
439b7c395c | |||
d761ac0348 | |||
bc08871f86 | |||
a032a132e4 | |||
54ed15ff25 | |||
cdb67cae95 | |||
5888b46b25 | |||
26ceef1740 | |||
857f1a242b | |||
9531fcea99 | |||
2be1a6b947 | |||
dd40e92179 | |||
23c2c0ea21 | |||
3660cc0310 | |||
6018239c43 | |||
97fa489d6c | |||
a1e9b667f3 | |||
ab5eb23238 |
31
.gitea/workflows/check.yml
Normal file
31
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
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@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Run check
|
||||
run: pnpm run check
|
109
README.md
109
README.md
@@ -1,3 +1,108 @@
|
||||
# fire
|
||||

|
||||
|
||||
FIRE calculator
|
||||
# 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.**
|
||||
|
7300
package-lock.json
generated
7300
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
45
package.json
45
package.json
@@ -6,14 +6,12 @@
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"check": "next lint && tsc --noEmit",
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev --turbopack",
|
||||
"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",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
@@ -22,37 +20,38 @@
|
||||
"@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.12.0",
|
||||
"@t3-oss/env-nextjs": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.503.0",
|
||||
"next": "^15.2.3",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^15.4.1",
|
||||
"next-plausible": "^3.12.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.56.1",
|
||||
"recharts": "^2.15.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"zod": "^3.24.3"
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
"@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.4.1",
|
||||
"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"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
},
|
||||
"packageManager": "npm@11.2.0"
|
||||
"packageManager": "pnpm@10.13.1"
|
||||
}
|
||||
|
5055
pnpm-lock.yaml
generated
Normal file
5055
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
ignoredBuiltDependencies:
|
||||
- unrs-resolver
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@tailwindcss/oxide'
|
||||
- sharp
|
1
public/wgu5fuk8d5j5wp3pjtta9vrw8d9by9qk.txt
Normal file
1
public/wgu5fuk8d5j5wp3pjtta9vrw8d9by9qk.txt
Normal file
@@ -0,0 +1 @@
|
||||
wgu5fuk8d5j5wp3pjtta9vrw8d9by9qk
|
11
renovate.json
Normal file
11
renovate.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": ["config:best-practices", ":semanticCommits"],
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
|
||||
"automerge": true,
|
||||
"automergeType": "branch"
|
||||
}
|
||||
]
|
||||
}
|
157
src/app/components/BackgroundPattern.tsx
Normal file
157
src/app/components/BackgroundPattern.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"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>
|
||||
);
|
||||
}
|
@@ -73,12 +73,16 @@ interface YearlyData {
|
||||
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;
|
||||
}
|
||||
@@ -101,8 +105,8 @@ const tooltipRenderer = ({
|
||||
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-chart-1">{`Balance: ${formatNumber(data.balance)}`}</p>
|
||||
<p className="text-chart-2">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</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>
|
||||
);
|
||||
@@ -113,9 +117,10 @@ const tooltipRenderer = ({
|
||||
export default function FireCalculatorForm() {
|
||||
const [result, setResult] = useState<CalculationResult | null>(null);
|
||||
const irlYear = new Date().getFullYear();
|
||||
const [showing4percent, setShowing4percent] = useState(false);
|
||||
|
||||
// Initialize form with default values
|
||||
const form = useForm<FormValues>({
|
||||
const form = useForm<z.input<typeof formSchema>, undefined, FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
startingCapital: 50000,
|
||||
@@ -123,7 +128,7 @@ export default function FireCalculatorForm() {
|
||||
currentAge: 25,
|
||||
cagr: 7,
|
||||
desiredMonthlyAllowance: 3000,
|
||||
inflationRate: 2,
|
||||
inflationRate: 2.3,
|
||||
lifeExpectancy: 84,
|
||||
retirementAge: 55,
|
||||
},
|
||||
@@ -149,8 +154,10 @@ export default function FireCalculatorForm() {
|
||||
age: age,
|
||||
year: irlYear,
|
||||
balance: startingCapital,
|
||||
untouchedBalance: startingCapital,
|
||||
phase: "accumulation",
|
||||
monthlyAllowance: initialMonthlyAllowance,
|
||||
monthlyAllowance: 0,
|
||||
untouchedMonthlyAllowance: initialMonthlyAllowance,
|
||||
});
|
||||
|
||||
// Calculate accumulation phase (before retirement)
|
||||
@@ -175,13 +182,18 @@ export default function FireCalculatorForm() {
|
||||
newBalance =
|
||||
previousYearData.balance * annualGrowthRate - inflatedAllowance * 12;
|
||||
}
|
||||
|
||||
const untouchedBalance =
|
||||
previousYearData.untouchedBalance * annualGrowthRate +
|
||||
monthlySavings * 12;
|
||||
const allowance = phase === "retirement" ? inflatedAllowance : 0;
|
||||
yearlyData.push({
|
||||
age: currentAge,
|
||||
year: year,
|
||||
balance: newBalance,
|
||||
untouchedBalance: untouchedBalance,
|
||||
phase: phase,
|
||||
monthlyAllowance: inflatedAllowance,
|
||||
monthlyAllowance: allowance,
|
||||
untouchedMonthlyAllowance: inflatedAllowance,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -192,9 +204,23 @@ export default function FireCalculatorForm() {
|
||||
);
|
||||
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) {
|
||||
setResult({
|
||||
fireNumber: null,
|
||||
fireNumber4percent: null,
|
||||
retirementAge4percent: null,
|
||||
error: "Could not calculate retirement data",
|
||||
yearlyData: yearlyData,
|
||||
});
|
||||
@@ -202,6 +228,8 @@ export default function FireCalculatorForm() {
|
||||
// Set the result
|
||||
setResult({
|
||||
fireNumber: retirementData.balance,
|
||||
fireNumber4percent: fireNumber4percent,
|
||||
retirementAge4percent: retirementAge4percent,
|
||||
yearlyData: yearlyData,
|
||||
});
|
||||
}
|
||||
@@ -230,11 +258,18 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 10000"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
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 />
|
||||
@@ -251,11 +286,18 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 500"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
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 />
|
||||
@@ -272,11 +314,18 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 30"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
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 />
|
||||
@@ -293,11 +342,18 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 90"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
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 />
|
||||
@@ -315,11 +371,18 @@ export default function FireCalculatorForm() {
|
||||
placeholder="e.g., 7"
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
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 />
|
||||
@@ -337,11 +400,18 @@ export default function FireCalculatorForm() {
|
||||
placeholder="e.g., 2"
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
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 />
|
||||
@@ -360,11 +430,18 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 2000"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
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 />
|
||||
@@ -378,16 +455,18 @@ export default function FireCalculatorForm() {
|
||||
name="retirementAge"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Retirement Age: {field.value}</FormLabel>
|
||||
<FormLabel>
|
||||
Retirement Age: {field.value as number}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
name="retirementAge"
|
||||
value={[field.value]}
|
||||
value={[field.value as number]}
|
||||
min={25}
|
||||
max={75}
|
||||
step={1}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(...value);
|
||||
onValueChange={(value: number[]) => {
|
||||
field.onChange(value[0]);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
className="py-4"
|
||||
@@ -430,10 +509,10 @@ export default function FireCalculatorForm() {
|
||||
offset: -10,
|
||||
}}
|
||||
/>
|
||||
{/* Left Y axis */}
|
||||
{/* Right Y axis */}
|
||||
<YAxis
|
||||
yAxisId={"left"}
|
||||
orientation="left"
|
||||
yAxisId={"right"}
|
||||
orientation="right"
|
||||
tickFormatter={(value: number) => {
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toPrecision(3)}M`;
|
||||
@@ -447,11 +526,13 @@ export default function FireCalculatorForm() {
|
||||
return value.toString();
|
||||
}}
|
||||
width={30}
|
||||
stroke="var(--color-orange-500)"
|
||||
tick={{}}
|
||||
/>
|
||||
{/* Right Y axis */}
|
||||
{/* Left Y axis */}
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
yAxisId="left"
|
||||
orientation="left"
|
||||
tickFormatter={(value: number) => {
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toPrecision(3)}M`;
|
||||
@@ -461,6 +542,7 @@ export default function FireCalculatorForm() {
|
||||
return value.toString();
|
||||
}}
|
||||
width={30}
|
||||
stroke="var(--color-red-600)"
|
||||
/>
|
||||
<ChartTooltip content={tooltipRenderer} />
|
||||
<defs>
|
||||
@@ -473,12 +555,12 @@ export default function FireCalculatorForm() {
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopColor="var(--color-orange-500)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopColor="var(--color-orange-500)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
@@ -487,43 +569,55 @@ export default function FireCalculatorForm() {
|
||||
type="monotone"
|
||||
dataKey="balance"
|
||||
name="balance"
|
||||
stroke="var(--chart-1)"
|
||||
stroke="var(--color-orange-500)"
|
||||
fill="url(#fillBalance)"
|
||||
fillOpacity={0.9}
|
||||
activeDot={{ r: 6 }}
|
||||
yAxisId={"left"}
|
||||
yAxisId={"right"}
|
||||
stackId={"a"}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
type="step"
|
||||
dataKey="monthlyAllowance"
|
||||
name="allowance"
|
||||
stroke="var(--chart-2)"
|
||||
stroke="var(--color-red-600)"
|
||||
fill="none"
|
||||
activeDot={{ r: 6 }}
|
||||
yAxisId="right"
|
||||
stackId={"a"}
|
||||
yAxisId="left"
|
||||
/>
|
||||
{result.fireNumber && (
|
||||
<ReferenceLine
|
||||
y={result.fireNumber}
|
||||
stroke="var(--chart-3)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2 2"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="2 1"
|
||||
label={{
|
||||
value: "FIRE Number",
|
||||
position: "insideBottomRight",
|
||||
}}
|
||||
yAxisId={"left"}
|
||||
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"}
|
||||
/>
|
||||
)}
|
||||
<ReferenceLine
|
||||
x={
|
||||
irlYear +
|
||||
(form.getValues("retirementAge") -
|
||||
form.getValues("currentAge"))
|
||||
(Number(form.getValues("retirementAge")) -
|
||||
Number(form.getValues("currentAge")))
|
||||
}
|
||||
stroke="var(--chart-2)"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={2}
|
||||
label={{
|
||||
value: "Retirement",
|
||||
@@ -531,11 +625,36 @@ export default function FireCalculatorForm() {
|
||||
}}
|
||||
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>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{result && (
|
||||
<Button
|
||||
onClick={() => setShowing4percent(!showing4percent)}
|
||||
variant={showing4percent ? "secondary" : "default"}
|
||||
size={"sm"}
|
||||
>
|
||||
{showing4percent ? "Hide" : "Show"} 4%-Rule
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
@@ -574,11 +693,45 @@ export default function FireCalculatorForm() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">
|
||||
{form.getValues("lifeExpectancy") -
|
||||
form.getValues("retirementAge")}
|
||||
{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>
|
||||
|
BIN
src/app/favicon.ico
(Stored with Git LFS)
BIN
src/app/favicon.ico
(Stored with Git LFS)
Binary file not shown.
@@ -1,15 +1,17 @@
|
||||
import "@/styles/globals.css";
|
||||
import PlausibleProvider from "next-plausible";
|
||||
import { type Metadata } from "next";
|
||||
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)" }],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title:
|
||||
"InvestingFIRE Calculator | Plan Your Financial Independence & Early Retirement",
|
||||
title: "InvestingFIRE | Finance and Retirement Calculator",
|
||||
description:
|
||||
"Achieve Financial Independence, Retire Early (FIRE) with the InvestingFIRE calculator. Get personalized projections and investing advice to plan your journey.",
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
"Achieve Financial Independence & Early Retirement! Plan your FIRE journey with the InvestingFIRE calculator and get personalized projections in buttersmooth graphs.",
|
||||
};
|
||||
|
||||
const geist = Geist({
|
||||
@@ -24,17 +26,16 @@ export default function RootLayout({
|
||||
<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>
|
||||
<PlausibleProvider
|
||||
domain="investingfire.com"
|
||||
customDomain="https://analytics.schulze.network"
|
||||
selfHosted={true}
|
||||
enabled={true}
|
||||
trackOutboundLinks={true}
|
||||
>
|
||||
<WebVitals />
|
||||
<body>{children}</body>
|
||||
</PlausibleProvider>
|
||||
<WebVitals />
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
@@ -7,13 +7,17 @@ import {
|
||||
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">
|
||||
<div className="mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
||||
<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}
|
||||
@@ -32,7 +36,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
{/* Added SEO Content Sections */}
|
||||
<div className="mx-auto max-w-2xl py-12 text-left">
|
||||
<div className="z-10 mx-auto max-w-2xl py-12 text-left">
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
What Is FIRE? Understanding Financial Independence and Early
|
||||
@@ -247,7 +251,7 @@ export default function HomePage() {
|
||||
financial independence and smart investing.
|
||||
</p>
|
||||
|
||||
<div className="bg-secondary/20 my-8 rounded-md p-4 text-lg">
|
||||
<div className="bg-foreground my-8 rounded-md p-4 text-lg">
|
||||
<p className="font-semibold">Getting Started with FIRE:</p>
|
||||
<ol className="ml-6 list-decimal space-y-1">
|
||||
<li>
|
||||
|
11
src/app/robots.ts
Normal file
11
src/app/robots.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
sitemap: "https://investingfire.com/sitemap.xml",
|
||||
};
|
||||
}
|
13
src/app/sitemap.ts
Normal file
13
src/app/sitemap.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
1
src/lib/constants.ts
Normal file
1
src/lib/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const BASE_URL = "https://investingfire.com/";
|
Reference in New Issue
Block a user