Compare commits
300 Commits
541c443efd
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d1d010100 | |||
| 9666193c9f | |||
| e08f6231bd | |||
| 8b65735994 | |||
| 298afc3cc8 | |||
| 72c9e666af | |||
| 3dfd35d8ba | |||
| eebdc619f2 | |||
| 188aa4fc74 | |||
| 2d73cb5def | |||
| 431e654154 | |||
| 6b7e254014 | |||
| 8006a65e88 | |||
| fe4783ae97 | |||
| d880b3fc26 | |||
| 299de302d4 | |||
| 96a3c13cc7 | |||
| 782ba7b911 | |||
| 201f133863 | |||
| d46fea9ea3 | |||
| 7811949cc4 | |||
| 9cdedf2bf9 | |||
| 5720571416 | |||
| 22e7a6903b | |||
| 22d4d65ad8 | |||
| 85b9cdb28f | |||
| 67d73ad308 | |||
| 03d556fd92 | |||
| 095c681401 | |||
| e9fcc24e65 | |||
| a811f31238 | |||
| 28ffd7f7d2 | |||
| 06ca97dd36 | |||
| f359566266 | |||
| 7f009e5cb3 | |||
| a1c92b5cce | |||
| 7d6af6734a | |||
| 9fa7693d31 | |||
| c31198ac43 | |||
| a636d280f0 | |||
| 43db22f9a3 | |||
| 17e56aad5d | |||
| a7cbaf4290 | |||
| 77d296b2b4 | |||
| a34a4237d0 | |||
| 705fd2ae52 | |||
| 374b52497f | |||
| 4c1f070b2f | |||
| 8f354a03ff | |||
| d0e9cc5627 | |||
| ccc31c27ec | |||
| ad49570b89 | |||
| 7efdfbaf69 | |||
| 4e0289b82b | |||
| 4d98281cc2 | |||
| 47c1e72c1f | |||
| 82ef0e626a | |||
| 8b2b8359c8 | |||
| 8c61d3b7fb | |||
| d47a50d715 | |||
| 98b4e88cc9 | |||
| 4a87f8d20b | |||
| 6858054bee | |||
| ec3511f109 | |||
| 0688838a08 | |||
| 838983f9f0 | |||
| 4aac8fcb3a | |||
| 8b04f3125e | |||
| 57e21b36a5 | |||
| e80ee03b5f | |||
| 6166f3045b | |||
| fc76248c2a | |||
| 1224c87729 | |||
| 29a9d1e889 | |||
| b6de96d11b | |||
| 1ce7f5c2dd | |||
| a3053ef2bc | |||
| 8365d4a740 | |||
| b6acad8e42 | |||
| ae1fba6f91 | |||
| aff4d0cc4c | |||
| 27fbe99a8e | |||
| 29fa053677 | |||
| d2e6c6019c | |||
| d721116100 | |||
| 02455423e2 | |||
| 77a467c1db | |||
| 2b0e714713 | |||
| c1191b4753 | |||
| 324c48fe4d | |||
| afb32d59b6 | |||
| cafb1a19a5 | |||
| 4b6f79017e | |||
| 6399c38b97 | |||
| b0b88132dc | |||
| 2c6ed4b196 | |||
| 457cfeed6d | |||
| 1bcb7420ef | |||
| 4d09aba1ff | |||
| 4c8b4bb4d5 | |||
| 18c7a5f2cb | |||
| 7502069d16 | |||
| 45d57471d9 | |||
| 9d23916eb2 | |||
| 47b5100611 | |||
| 007353e2ab | |||
| d1fe3b29c3 | |||
| a4ba1bdc67 | |||
| 414eee9591 | |||
| 8e8652e2aa | |||
| 8d9b0e3792 | |||
| 0408287300 | |||
| bb30c066df | |||
| fcf5caee46 | |||
| de3a06384e | |||
| cfb594ef15 | |||
| cbdc49328a | |||
| 8dbc2c5de6 | |||
| c3771f85e0 | |||
| 1c68d65539 | |||
| d58a5bb2bb | |||
| 2740e57dd0 | |||
| e45c437015 | |||
| 033088185c | |||
| 9aa7a7e4a2 | |||
| 02c87ec8bf | |||
| 061942aa8e | |||
| 5e4aca4ec2 | |||
| 2451c43e73 | |||
| bf75fe7b2b | |||
| 375e93ce59 | |||
| 832e2cdd3d | |||
| 16d4dd53a1 | |||
| 1936fdff56 | |||
| 4dcbdc4366 | |||
| 418e1fcf52 | |||
| 2585e6fca9 | |||
| 031e5bb50e | |||
| 65711a7ad6 | |||
| 1ee037e5cc | |||
| fa35db17e0 | |||
| de7507323e | |||
| 9f1f4e1ba6 | |||
| c0a1ec0ec6 | |||
| 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 |
33
.cursorrules
Normal file
33
.cursorrules
Normal file
@@ -0,0 +1,33 @@
|
||||
# Cursor Rules for InvestingFIRE 🔥
|
||||
|
||||
## General Principles
|
||||
- **Quality First:** All new features must include appropriate tests.
|
||||
- **User-Centric:** Prioritize user experience and accessibility in all changes.
|
||||
- **Dry Code:** Avoid duplication; use utility functions and components.
|
||||
|
||||
## Testing Requirements 🧪
|
||||
- **Unit Tests:** Required for all new utility functions, hooks, and complex logic.
|
||||
- Use `vitest` and `react-testing-library`.
|
||||
- Place tests in `__tests__` directories or alongside files with `.test.ts(x)` extension.
|
||||
- **E2E Tests:** Required for new user flows and critical paths.
|
||||
- Use `playwright`.
|
||||
- Ensure tests cover happy paths and error states.
|
||||
- **Visual Regression:** Consider for major UI changes.
|
||||
|
||||
## Coding Standards
|
||||
- **Type Safety:** No `any`. Use proper Zod schemas for validation.
|
||||
- **Components:** Use functional components with strict prop typing.
|
||||
- **Styling:** Use Tailwind CSS. Avoid inline styles.
|
||||
- **State Management:** Prefer local state or React Context. Avoid global state libraries unless necessary.
|
||||
|
||||
## Workflow
|
||||
1. **Plan:** Break down tasks.
|
||||
2. **Implement:** Write clean, commented code.
|
||||
3. **Test:** specific unit and/or E2E tests.
|
||||
4. **Verify:** Run linter and type checker (`pnpm check`).
|
||||
|
||||
## Specific Patterns
|
||||
- **Forms:** Use `react-hook-form` with `zod` resolvers.
|
||||
- **Charts:** Use `recharts` and ensure tooltips are accessible.
|
||||
- **Calculations:** Keep financial logic separate from UI components where possible (e.g., in `lib/` or custom hooks) to facilitate testing.
|
||||
|
||||
31
.gitea/workflows/lint.yml
Normal file
31
.gitea/workflows/lint.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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Run lint
|
||||
run: pnpm run lint
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,3 +44,6 @@ yarn-error.log*
|
||||
|
||||
# idea files
|
||||
.idea
|
||||
playwright-report/
|
||||
|
||||
test-results/
|
||||
|
||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 105,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
164
README.md
164
README.md
@@ -1,3 +1,163 @@
|
||||
# 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.
|
||||
|
||||
Deployed version: [https://investingfire.com](https://investingfire.com)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goal & Vision
|
||||
|
||||
### **Goal**
|
||||
To build the most comprehensive, user-friendly, and transparent open-source financial independence calculator on the web.
|
||||
|
||||
### **Vision**
|
||||
Democratize financial planning by providing professional-grade simulation tools in an accessible, privacy-focused, and beautiful interface. We believe everyone should have the ability to model their financial future without needing a finance degree or expensive software.
|
||||
|
||||
### **Business Model**
|
||||
InvestingFIRE operates on a transparent open-source model:
|
||||
1. **Free Forever Core:** The essential calculation tools will always be free and open-source.
|
||||
2. **Community Supported:** We rely on community contributions (code & feedback) to improve the tool.
|
||||
3. **Educational Affiliates:** We may curate high-quality resources (books, courses, tools) to help users on their journey, keeping the tool free of intrusive ads.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
We are actively expanding the capabilities of InvestingFIRE. Below is our plan broken down into phases.
|
||||
|
||||
### **Phase 1: Enhanced Simulation (The Engine)**
|
||||
Focus on making the math more robust and flexible.
|
||||
- [ ] **Coast FIRE Mode:** Option to stop contributions at a certain age but retire later.
|
||||
- [ ] **Barista FIRE Mode:** Include part-time income during "retirement" years.
|
||||
- [ ] **Monte Carlo Simulations:** Add probabilistic outcomes (e.g., "95% chance of success") instead of just deterministic linear growth.
|
||||
- [ ] **Variable Withdrawal Strategies:** Implement dynamic withdrawal rules (e.g., Guyton-Klinger) beyond just fixed inflation-adjusted withdrawals.
|
||||
|
||||
### **Phase 2: User Experience & Persistence**
|
||||
Make the tool easier to use and return to.
|
||||
- [ ] **URL State Sharing:** Encode form values into the URL so scenarios can be bookmarked and shared.
|
||||
- [ ] **Local Persistence:** Automatically save user inputs to `localStorage` so they don't vanish on refresh.
|
||||
- [ ] **Currency & Locale Support:** Allow users to select currency symbols and number formatting (USD, EUR, GBP, etc.).
|
||||
|
||||
### **Phase 3: Advanced Features & Analytics**
|
||||
For the power users who need more detail.
|
||||
- [ ] **Tax Considerations:** Simple toggles for Pre-tax vs. Post-tax estimations.
|
||||
- [ ] **Scenario Comparison:** Compare two different plans side-by-side (e.g., "Retire at 45 vs 55").
|
||||
- [ ] **Data Export:** Download projection data as CSV or PDF reports.
|
||||
|
||||
### **Phase 4: Content & Community**
|
||||
- [ ] **Blog/Guides:** Integrate a CMS (like Markdown/MDX) for in-depth financial guides.
|
||||
- [ ] **Community Presets:** "One-click" setups for common strategies (e.g., "Lean FIRE", "Fat FIRE").
|
||||
|
||||
---
|
||||
|
||||
## 🚀 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.
|
||||
|
||||
### Running Tests 🧪
|
||||
|
||||
We use **Vitest** for unit testing and **Playwright** for end-to-end (E2E) testing.
|
||||
|
||||
**Unit Tests:**
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
**E2E Tests:**
|
||||
```bash
|
||||
# First install browsers (only needed once)
|
||||
pnpm exec playwright install
|
||||
|
||||
# Run tests
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✏️ 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.**
|
||||
|
||||
15
e2e/home.spec.ts
Normal file
15
e2e/home.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("homepage has title and calculator", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Expect a title "to contain" a substring.
|
||||
await expect(page).toHaveTitle(/InvestingFIRE/);
|
||||
|
||||
// Check for main heading
|
||||
await expect(page.getByRole("heading", { name: "InvestingFIRE" })).toBeVisible();
|
||||
|
||||
// Check for Calculator
|
||||
await expect(page.getByText("FIRE Calculator")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
});
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [".next"],
|
||||
},
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
extends: [
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: true,
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
37
eslint.config.mjs
Normal file
37
eslint.config.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
// @ts-check
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
// Next.js core-web-vitals and TypeScript configs
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Add strict TypeScript rules on top
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
// Configure TypeScript parser options
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Override default ignores of eslint-config-next
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Additional ignores:
|
||||
"*.mjs",
|
||||
"tailwind.config.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -2,7 +2,7 @@
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
||||
* for Docker builds.
|
||||
*/
|
||||
import "./src/env.js";
|
||||
import './src/env.ts';
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {};
|
||||
7300
package-lock.json
generated
7300
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
64
package.json
64
package.json
@@ -6,14 +6,14 @@
|
||||
"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",
|
||||
"lint": "next typegen && eslint . && npx tsc --noEmit",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
@@ -22,37 +22,51 @@
|
||||
"@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.554.0",
|
||||
"next": "16.0.3",
|
||||
"next-plausible": "^3.12.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.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"
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@tailwindcss/postcss": "4.1.17",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/react": "19.2.6",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"jsdom": "^27.2.0",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "0.7.1",
|
||||
"tailwindcss": "4.1.17",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.47.0",
|
||||
"vitest": "^4.0.13"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
},
|
||||
"packageManager": "npm@11.2.0"
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "19.2.6",
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
34
playwright.config.ts
Normal file
34
playwright.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "pnpm run dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
|
||||
6889
pnpm-lock.yaml
generated
Normal file
6889
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,4 +0,0 @@
|
||||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||
export default {
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
161
src/app/components/BackgroundPattern.tsx
Normal file
161
src/app/components/BackgroundPattern.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import type React 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 [icons, setIcons] = useState<React.ReactElement[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rows === 0 || columns === 0) {
|
||||
setIcons([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const iconElements: React.ReactElement[] = [];
|
||||
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));
|
||||
const rotation = Math.round((Math.random() - 0.5) * 30);
|
||||
|
||||
iconElements.push(
|
||||
<IconComponent
|
||||
key={`icon-${String(x)}-${String(y)}`}
|
||||
size={size}
|
||||
className="text-primary fixed"
|
||||
style={{
|
||||
left: `${String(x * spacing + xOffset)}px`,
|
||||
top: `${String(y * spacing + yOffset)}px`,
|
||||
opacity: opacity,
|
||||
transform: `rotate(${String(rotation)}deg)`,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
setIcons(iconElements);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, columns, spacing, opacity]);
|
||||
|
||||
return (
|
||||
<div className="absolute h-full w-full">
|
||||
{width > 0 && icons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,9 +31,17 @@ import {
|
||||
YAxis,
|
||||
ReferenceLine,
|
||||
type TooltipProps,
|
||||
Line,
|
||||
} from "recharts";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import assert from "assert";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type {
|
||||
NameType,
|
||||
ValueType,
|
||||
@@ -64,6 +72,19 @@ const formSchema = z.object({
|
||||
.number()
|
||||
.min(18, "Retirement age must be at least 18")
|
||||
.max(100, "Retirement age must be at most 100"),
|
||||
coastFireAge: z.coerce
|
||||
.number()
|
||||
.min(18, "Coast FIRE age must be at least 18")
|
||||
.max(100, "Coast FIRE age must be at most 100")
|
||||
.optional(),
|
||||
baristaIncome: z.coerce
|
||||
.number()
|
||||
.min(0, "Barista income must be a non-negative number")
|
||||
.optional(),
|
||||
simulationMode: z.enum(["deterministic", "monte-carlo"]).default("deterministic"),
|
||||
volatility: z.coerce.number().min(0).default(15),
|
||||
withdrawalStrategy: z.enum(["fixed", "percentage"]).default("fixed"),
|
||||
withdrawalPercentage: z.coerce.number().min(0).max(100).default(4),
|
||||
});
|
||||
|
||||
// Type for form values
|
||||
@@ -73,14 +94,31 @@ interface YearlyData {
|
||||
age: number;
|
||||
year: number;
|
||||
balance: number;
|
||||
untouchedBalance: number;
|
||||
phase: "accumulation" | "retirement";
|
||||
monthlyAllowance: number;
|
||||
untouchedMonthlyAllowance: number;
|
||||
// Monte Carlo percentiles
|
||||
balanceP10?: number;
|
||||
balanceP50?: number;
|
||||
balanceP90?: number;
|
||||
}
|
||||
|
||||
interface CalculationResult {
|
||||
fireNumber: number | null;
|
||||
fireNumber4percent: number | null;
|
||||
retirementAge4percent: number | null;
|
||||
yearlyData: YearlyData[];
|
||||
error?: string;
|
||||
successRate?: number; // For Monte Carlo
|
||||
}
|
||||
|
||||
// Box-Muller transform for normal distribution
|
||||
function randomNormal(mean: number, stdDev: number): number {
|
||||
const u = 1 - Math.random(); // Converting [0,1) to (0,1]
|
||||
const v = Math.random();
|
||||
const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
|
||||
return z * stdDev + mean;
|
||||
}
|
||||
|
||||
// Helper function to format currency without specific symbols
|
||||
@@ -101,8 +139,16 @@ 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>
|
||||
{data.balanceP50 !== undefined ? (
|
||||
<>
|
||||
<p className="text-orange-500">{`Median Balance: ${formatNumber(data.balanceP50)}`}</p>
|
||||
<p className="text-orange-300 text-xs">{`10th %: ${formatNumber(data.balanceP10 ?? 0)}`}</p>
|
||||
<p className="text-orange-300 text-xs">{`90th %: ${formatNumber(data.balanceP90 ?? 0)}`}</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 +159,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,9 +170,13 @@ export default function FireCalculatorForm() {
|
||||
currentAge: 25,
|
||||
cagr: 7,
|
||||
desiredMonthlyAllowance: 3000,
|
||||
inflationRate: 2,
|
||||
inflationRate: 2.3,
|
||||
lifeExpectancy: 84,
|
||||
retirementAge: 55,
|
||||
coastFireAge: undefined,
|
||||
baristaIncome: 0,
|
||||
simulationMode: "deterministic",
|
||||
volatility: 15,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -135,66 +186,182 @@ export default function FireCalculatorForm() {
|
||||
const startingCapital = values.startingCapital;
|
||||
const monthlySavings = values.monthlySavings;
|
||||
const age = values.currentAge;
|
||||
const annualGrowthRate = 1 + values.cagr / 100;
|
||||
const cagr = values.cagr;
|
||||
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
|
||||
const annualInflation = 1 + values.inflationRate / 100;
|
||||
const ageOfDeath = values.lifeExpectancy;
|
||||
const retirementAge = values.retirementAge;
|
||||
const coastFireAge = values.coastFireAge ?? retirementAge;
|
||||
const initialBaristaIncome = values.baristaIncome ?? 0;
|
||||
const simulationMode = values.simulationMode;
|
||||
const volatility = values.volatility;
|
||||
|
||||
// Array to store yearly data for the chart
|
||||
const numSimulations = simulationMode === "monte-carlo" ? 500 : 1;
|
||||
const simulationResults: number[][] = []; // [yearIndex][simulationIndex] -> balance
|
||||
|
||||
// Prepare simulation runs
|
||||
for (let sim = 0; sim < numSimulations; sim++) {
|
||||
let currentBalance = startingCapital;
|
||||
const runBalances: number[] = [];
|
||||
|
||||
for (
|
||||
let year = irlYear + 1;
|
||||
year <= irlYear + (ageOfDeath - age);
|
||||
year++
|
||||
) {
|
||||
const currentAge = age + (year - irlYear);
|
||||
const yearIndex = year - (irlYear + 1);
|
||||
|
||||
// Determine growth rate for this year
|
||||
let annualGrowthRate: number;
|
||||
if (simulationMode === "monte-carlo") {
|
||||
// Random walk
|
||||
const randomReturn = randomNormal(cagr, volatility) / 100;
|
||||
annualGrowthRate = 1 + randomReturn;
|
||||
} else {
|
||||
// Deterministic
|
||||
annualGrowthRate = 1 + cagr / 100;
|
||||
}
|
||||
|
||||
const inflatedAllowance =
|
||||
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
|
||||
const inflatedBaristaIncome =
|
||||
initialBaristaIncome * Math.pow(annualInflation, year - irlYear);
|
||||
|
||||
const isRetirementYear = currentAge >= retirementAge;
|
||||
const phase = isRetirementYear ? "retirement" : "accumulation";
|
||||
const isContributing = currentAge < coastFireAge;
|
||||
|
||||
let newBalance;
|
||||
if (phase === "accumulation") {
|
||||
newBalance =
|
||||
currentBalance * annualGrowthRate +
|
||||
(isContributing ? monthlySavings * 12 : 0);
|
||||
} else {
|
||||
const netAnnualWithdrawal =
|
||||
(inflatedAllowance - inflatedBaristaIncome) * 12;
|
||||
newBalance = currentBalance * annualGrowthRate - netAnnualWithdrawal;
|
||||
}
|
||||
// Prevent negative balance from recovering (once you're broke, you're broke)
|
||||
// Although debt is possible, for FIRE calc usually 0 is the floor.
|
||||
// But strictly speaking, if you have income, you might recover?
|
||||
// Let's allow negative for calculation but maybe clamp for success rate?
|
||||
// Standard practice: if balance < 0, it stays < 0 or goes deeper.
|
||||
// Let's just let the math run.
|
||||
|
||||
runBalances.push(newBalance);
|
||||
currentBalance = newBalance;
|
||||
}
|
||||
simulationResults.push(runBalances);
|
||||
}
|
||||
|
||||
// Aggregate results
|
||||
const yearlyData: YearlyData[] = [];
|
||||
let successCount = 0;
|
||||
|
||||
// Initial year data
|
||||
// Initial year
|
||||
yearlyData.push({
|
||||
age: age,
|
||||
year: irlYear,
|
||||
balance: startingCapital,
|
||||
untouchedBalance: startingCapital,
|
||||
phase: "accumulation",
|
||||
monthlyAllowance: initialMonthlyAllowance,
|
||||
monthlyAllowance: 0,
|
||||
untouchedMonthlyAllowance: initialMonthlyAllowance,
|
||||
balanceP10: startingCapital,
|
||||
balanceP50: startingCapital,
|
||||
balanceP90: startingCapital,
|
||||
});
|
||||
|
||||
// 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 numYears = ageOfDeath - age;
|
||||
for (let i = 0; i < numYears; i++) {
|
||||
const year = irlYear + 1 + i;
|
||||
const currentAge = age + 1 + i;
|
||||
|
||||
// Collect all balances for this year across simulations
|
||||
const balancesForYear = simulationResults.map((run) => run[i]);
|
||||
|
||||
// Sort to find percentiles
|
||||
balancesForYear.sort((a, b) => a - b);
|
||||
|
||||
const p10 = balancesForYear[Math.floor(numSimulations * 0.1)];
|
||||
const p50 = balancesForYear[Math.floor(numSimulations * 0.5)];
|
||||
const p90 = balancesForYear[Math.floor(numSimulations * 0.9)];
|
||||
|
||||
// Calculate other metrics (using deterministic logic for "untouched" etc for simplicity, or p50)
|
||||
// We need to reconstruct the "standard" fields for compatibility with the chart
|
||||
// Let's use p50 (Median) as the "main" line
|
||||
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;
|
||||
// Reconstruct untouched balance for deterministic mode (for 4% rule)
|
||||
let untouchedBalance = 0;
|
||||
if (simulationMode === "deterministic") {
|
||||
// We can just use the single run we have
|
||||
// In deterministic mode, there's only 1 simulation, so balancesForYear[0] is it.
|
||||
// But wait, `simulationResults` stores the *actual* balance (with withdrawals).
|
||||
// We need a separate tracker for "untouched" (never withdrawing) if we want accurate 4% rule.
|
||||
// Let's just re-calculate it simply here since it's deterministic.
|
||||
const prevUntouched = yearlyData[yearlyData.length - 1].untouchedBalance;
|
||||
const growth = 1 + cagr / 100;
|
||||
untouchedBalance = prevUntouched * growth + monthlySavings * 12;
|
||||
}
|
||||
|
||||
yearlyData.push({
|
||||
age: currentAge,
|
||||
year: year,
|
||||
balance: newBalance,
|
||||
balance: p50, // Use Median for the main line
|
||||
untouchedBalance: untouchedBalance,
|
||||
phase: phase,
|
||||
monthlyAllowance: inflatedAllowance,
|
||||
monthlyAllowance: phase === "retirement" ? inflatedAllowance : 0,
|
||||
untouchedMonthlyAllowance: inflatedAllowance,
|
||||
balanceP10: p10,
|
||||
balanceP50: p50,
|
||||
balanceP90: p90,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate FIRE number at retirement
|
||||
// Calculate Success Rate (only for Monte Carlo)
|
||||
if (simulationMode === "monte-carlo") {
|
||||
const finalBalances = simulationResults.map(run => run[run.length - 1]);
|
||||
successCount = finalBalances.filter(b => b > 0).length;
|
||||
}
|
||||
|
||||
// Calculate FIRE number (using Median/Deterministic run)
|
||||
const retirementYear = irlYear + (retirementAge - age);
|
||||
const retirementIndex = yearlyData.findIndex(
|
||||
(data) => data.year === retirementYear,
|
||||
);
|
||||
const retirementData = yearlyData[retirementIndex];
|
||||
|
||||
if (retirementIndex === -1 || !retirementData) {
|
||||
const [fireNumber4percent, retirementAge4percent] = (() => {
|
||||
// Re-enable 4% rule for deterministic mode or use p50 for MC
|
||||
// For MC, "untouchedBalance" isn't tracked per run in aggregate, but we can use balanceP50 roughly
|
||||
// or just disable it as it's a different philosophy.
|
||||
// For now, let's calculate it based on the main "balance" field (which is p50 in MC)
|
||||
for (const yearData of yearlyData) {
|
||||
// Estimate untouched roughly if not tracking exact
|
||||
const balanceToCheck = yearData.balance;
|
||||
// Note: This is imperfect for MC because 'balance' includes withdrawals in retirement
|
||||
// whereas 4% rule check usually looks at "if I retired now with this balance".
|
||||
// The original code had `untouchedBalance` which grew without withdrawals.
|
||||
// Since we removed `untouchedBalance` calculation in the aggregate loop, let's skip 4% for MC for now.
|
||||
|
||||
if (simulationMode === "deterministic" && yearData.untouchedBalance &&
|
||||
yearData.untouchedBalance > (yearData.untouchedMonthlyAllowance * 12) / 0.04) {
|
||||
return [yearData.untouchedBalance, yearData.age];
|
||||
}
|
||||
}
|
||||
return [null, null];
|
||||
})();
|
||||
|
||||
if (retirementIndex === -1) {
|
||||
setResult({
|
||||
fireNumber: null,
|
||||
fireNumber4percent: null,
|
||||
retirementAge4percent: null,
|
||||
error: "Could not calculate retirement data",
|
||||
yearlyData: yearlyData,
|
||||
});
|
||||
@@ -202,7 +369,10 @@ export default function FireCalculatorForm() {
|
||||
// Set the result
|
||||
setResult({
|
||||
fireNumber: retirementData.balance,
|
||||
fireNumber4percent: null,
|
||||
retirementAge4percent: null,
|
||||
yearlyData: yearlyData,
|
||||
successRate: simulationMode === "monte-carlo" ? (successCount / numSimulations) * 100 : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -218,7 +388,13 @@ export default function FireCalculatorForm() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void form.handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -230,11 +406,19 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 10000"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
value={field.value as number | string | undefined}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -251,11 +435,19 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 500"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
value={field.value as number | string | undefined}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -272,11 +464,19 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 30"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
value={field.value as number | string | undefined}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -293,11 +493,19 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 90"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
value={field.value as number | string | undefined}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -315,11 +523,19 @@ export default function FireCalculatorForm() {
|
||||
placeholder="e.g., 7"
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
value={field.value as number | string | undefined}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -337,11 +553,19 @@ export default function FireCalculatorForm() {
|
||||
placeholder="e.g., 2"
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
value={field.value as number | string | undefined}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -360,11 +584,19 @@ export default function FireCalculatorForm() {
|
||||
<Input
|
||||
placeholder="e.g., 2000"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
value={field.value as number | string | undefined}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -378,17 +610,20 @@ 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]}
|
||||
min={18}
|
||||
max={form.getValues("lifeExpectancy")}
|
||||
value={[field.value as number]}
|
||||
min={25}
|
||||
max={75}
|
||||
step={1}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(...value);
|
||||
void form.handleSubmit(onSubmit)();
|
||||
onValueChange={(value: number[]) => {
|
||||
field.onChange(value[0]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
className="py-4"
|
||||
/>
|
||||
@@ -397,6 +632,187 @@ export default function FireCalculatorForm() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="coastFireAge"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Coast FIRE Age (Optional) - Stop contributing at age:
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 45 (defaults to Retirement Age)"
|
||||
type="number"
|
||||
value={field.value as number | string | undefined}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baristaIncome"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Barista FIRE Income (Monthly during Retirement)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 1000"
|
||||
type="number"
|
||||
value={field.value as number | string | undefined}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="simulationMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Simulation Mode</FormLabel>
|
||||
<Select
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select simulation mode" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="deterministic">Deterministic (Linear)</SelectItem>
|
||||
<SelectItem value="monte-carlo">Monte Carlo (Probabilistic)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("simulationMode") === "monte-carlo" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="volatility"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Market Volatility (Std Dev %)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 15"
|
||||
type="number"
|
||||
value={field.value as number | string | undefined}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="withdrawalStrategy"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Withdrawal Strategy</FormLabel>
|
||||
<Select
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select withdrawal strategy" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="fixed">Fixed Inflation-Adjusted</SelectItem>
|
||||
<SelectItem value="percentage">Percentage of Portfolio</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("withdrawalStrategy") === "percentage" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="withdrawalPercentage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Withdrawal Percentage (%)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., 4.0"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={field.value as number | string | undefined}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!result && (
|
||||
@@ -412,23 +828,14 @@ export default function FireCalculatorForm() {
|
||||
Projected balance growth with your selected retirement age
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="px-2">
|
||||
<ChartContainer
|
||||
className="aspect-auto h-80 w-full"
|
||||
config={{
|
||||
balance: {
|
||||
label: "Balance",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
realBalance: {
|
||||
label: "Real Balance",
|
||||
color: "var(--chart-3)",
|
||||
},
|
||||
}}
|
||||
config={{}}
|
||||
>
|
||||
<AreaChart
|
||||
data={result.yearlyData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
|
||||
margin={{ top: 10, right: 20, left: 20, bottom: 10 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
@@ -439,7 +846,10 @@ 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`;
|
||||
@@ -452,7 +862,24 @@ export default function FireCalculatorForm() {
|
||||
}
|
||||
return value.toString();
|
||||
}}
|
||||
width={25}
|
||||
width={30}
|
||||
stroke="var(--color-orange-500)"
|
||||
tick={{}}
|
||||
/>
|
||||
{/* 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`;
|
||||
}
|
||||
return value.toString();
|
||||
}}
|
||||
width={30}
|
||||
stroke="var(--color-red-600)"
|
||||
/>
|
||||
<ChartTooltip content={tooltipRenderer} />
|
||||
<defs>
|
||||
@@ -465,30 +892,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)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="fillAllowance"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopColor="var(--color-orange-500)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
@@ -497,50 +906,114 @@ export default function FireCalculatorForm() {
|
||||
type="monotone"
|
||||
dataKey="balance"
|
||||
name="balance"
|
||||
stroke="var(--chart-1)"
|
||||
stroke="var(--color-orange-500)"
|
||||
fill="url(#fillBalance)"
|
||||
fillOpacity={0.4}
|
||||
fillOpacity={0.9}
|
||||
activeDot={{ r: 6 }}
|
||||
yAxisId={"right"}
|
||||
stackId={"a"}
|
||||
/>
|
||||
{form.getValues("simulationMode") === "monte-carlo" && (
|
||||
<>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="balanceP10"
|
||||
stroke="none"
|
||||
fill="var(--color-orange-500)"
|
||||
fillOpacity={0.1}
|
||||
yAxisId={"right"}
|
||||
connectNulls
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="balanceP90"
|
||||
stroke="none"
|
||||
fill="var(--color-orange-500)"
|
||||
fillOpacity={0.1}
|
||||
yAxisId={"right"}
|
||||
connectNulls
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Area
|
||||
type="step"
|
||||
dataKey="monthlyAllowance"
|
||||
name="allowance"
|
||||
stroke="var(--chart-2)"
|
||||
fill="url(#fillAllowance)"
|
||||
fillOpacity={0.4}
|
||||
stroke="var(--color-red-600)"
|
||||
fill="none"
|
||||
activeDot={{ r: 6 }}
|
||||
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={"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",
|
||||
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>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{result && (
|
||||
<Button
|
||||
onClick={() => { setShowing4percent(!showing4percent); }}
|
||||
variant={showing4percent ? "secondary" : "default"}
|
||||
size={"sm"}
|
||||
>
|
||||
{showing4percent ? "Hide" : "Show"} 4%-Rule
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
@@ -579,11 +1052,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>
|
||||
|
||||
27
src/app/components/__tests__/FireCalculatorForm.test.tsx
Normal file
27
src/app/components/__tests__/FireCalculatorForm.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import FireCalculatorForm from "../FireCalculatorForm";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Mocking ResizeObserver because it's not available in jsdom and Recharts uses it
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
global.ResizeObserver = ResizeObserver;
|
||||
|
||||
describe("FireCalculatorForm", () => {
|
||||
it("renders the form with default values", () => {
|
||||
render(<FireCalculatorForm />);
|
||||
|
||||
expect(screen.getByText("FIRE Calculator")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Starting Capital/i)).toHaveValue(50000);
|
||||
expect(screen.getByLabelText(/Monthly Savings/i)).toHaveValue(1500);
|
||||
});
|
||||
|
||||
it("renders the Calculate button", () => {
|
||||
render(<FireCalculatorForm />);
|
||||
expect(screen.getByRole("button", { name: /Calculate/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
16
src/app/components/footer.tsx
Normal file
16
src/app/components/footer.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
LFS
BIN
src/app/favicon.ico
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" />
|
||||
</head>
|
||||
<PlausibleProvider
|
||||
domain="investingfire.com"
|
||||
customDomain="https://analytics.schulze.network"
|
||||
selfHosted={true}
|
||||
enabled={true}
|
||||
trackOutboundLinks={true}
|
||||
>
|
||||
/>
|
||||
</head>
|
||||
<WebVitals />
|
||||
<body>{children}</body>
|
||||
</PlausibleProvider>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
459
src/app/page.tsx
459
src/app/page.tsx
@@ -6,19 +6,79 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import Footer from "./components/footer";
|
||||
import BackgroundPattern from "./components/BackgroundPattern";
|
||||
|
||||
export default function HomePage() {
|
||||
const faqData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: [
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "What methodology does this calculator use?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "We run a multi-year projection in two phases: 1. Accumulation: Your balance grows by CAGR and you add monthly savings. 2. Retirement: The balance continues compounding, but you withdraw an inflation-adjusted monthly allowance. The result: a precise estimate of the capital you'll have at retirement (your “FIRE Number”) and how long it will last until your chosen life expectancy.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Why isn't this just the 4% rule?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "The 4% rule is a useful starting point (25× annual spending), but it assumes a fixed withdrawal rate with inflation adjustments and doesn't model ongoing savings or dynamic market returns. Our calculator simulates each year's growth, contributions, and inflation-indexed withdrawals to give you a tailored picture.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "How do I choose a realistic growth rate?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Historically, a diversified portfolio of equities and bonds has returned around 7-10% per year before inflation. We recommend starting around 6-8% (net of fees), then running “what-if” scenarios—5% on the conservative side, 10% on the aggressive side—to see how they affect your timeline.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "How does inflation factor into my FIRE Number?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Cost of living rises. To maintain today's lifestyle, your monthly allowance must grow each year by your inflation rate. This calculator automatically inflates your desired monthly spending and subtracts it from your portfolio during retirement, ensuring your FIRE Number keeps pace with rising expenses.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "Can I really retire early with FIRE?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Early retirement is achievable with disciplined saving, smart investing, and realistic assumptions. This tool helps you set targets, visualize outcomes, and adjust inputs—so you can build confidence in your plan and make informed trade-offs between lifestyle, risk, and timeline.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
name: "How should I use this calculator effectively?",
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: "Start with your actual numbers (capital, savings, age). Set conservative - mid - aggressive growth rates to bound possibilities. Slide your retirement age to explore “early” vs. “traditional” scenarios. Review the chart—especially the reference lines—to see when you hit FI and how withdrawals impact your balance. Experiment with higher savings rates or lower target spending to accelerate your path.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<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="mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
|
||||
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-linear-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]">
|
||||
<h1 className="from-primary via-primary-foreground to-primary bg-linear-to-r bg-clip-text text-5xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[5rem]">
|
||||
InvestingFIRE
|
||||
</h1>
|
||||
</div>
|
||||
@@ -31,29 +91,35 @@ 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
|
||||
What Is FIRE? Understanding Financial Independence and Early
|
||||
Retirement
|
||||
</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
FIRE stands for "Financial Independence, Retire Early."
|
||||
It's a movement focused on aggressive saving and strategic
|
||||
investing to build a substantial portfolio. The goal is for
|
||||
investment returns to cover living expenses indefinitely, freeing
|
||||
you from traditional employment. Achieving FIRE means gaining the
|
||||
freedom to pursue passions, travel, or simply enjoy life without
|
||||
needing a regular paycheck. Sound investing advice is crucial for
|
||||
building the wealth needed.
|
||||
FIRE stands for{" "}
|
||||
<strong>Financial Independence, Retire Early</strong>. It's a
|
||||
lifestyle movement built around two core ideas:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<strong>Aggressive saving & investing</strong>—often 50%+ of
|
||||
income—so your capital grows rapidly.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Passive-income coverage</strong>—when your investment
|
||||
returns exceed your living expenses, you gain freedom from a
|
||||
traditional 9-5.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-lg leading-relaxed">
|
||||
The core principle involves maximizing your savings rate (often
|
||||
50%+) and investing wisely, typically in low-cost, diversified
|
||||
assets like index funds. Your "FIRE number" – the capital
|
||||
needed – is often estimated as 25 times your desired annual
|
||||
spending, derived from the 4% safe withdrawal rate guideline. This
|
||||
FIRE calculator helps you personalize this estimate.
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -62,141 +128,173 @@ export default function HomePage() {
|
||||
How This FIRE Calculator Provides Investing Insights
|
||||
</h2>
|
||||
<p className="mb-4 text-lg leading-relaxed">
|
||||
This calculator helps visualize your path to FIRE by projecting
|
||||
investment growth based on your inputs. Understanding these
|
||||
projections is a key piece of investing advice for long-term
|
||||
planning. Here's a breakdown of the inputs:
|
||||
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> The total amount you currently
|
||||
have invested.
|
||||
<strong>Starting Capital</strong>—your current invested balance
|
||||
</li>
|
||||
<li>
|
||||
<strong>Monthly Savings:</strong> The amount you consistently save
|
||||
and invest each month.
|
||||
<strong>Monthly Savings</strong>—ongoing contributions to your
|
||||
portfolio
|
||||
</li>
|
||||
<li>
|
||||
<strong>Current Age:</strong> Your current age in years.
|
||||
<strong>Expected Annual Growth Rate (CAGR)</strong>—compounding
|
||||
returns before inflation
|
||||
</li>
|
||||
<li>
|
||||
<strong>Expected Annual Growth Rate (%):</strong> The average
|
||||
annual return you expect from your investments (after fees, before
|
||||
inflation).
|
||||
<strong>Annual Inflation Rate</strong>—to inflate your target
|
||||
withdrawal each year
|
||||
</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.
|
||||
<strong>Desired Monthly Allowance</strong>—today's-value
|
||||
spending goal
|
||||
</li>
|
||||
<li>
|
||||
<strong>Annual Inflation Rate (%):</strong> The expected average
|
||||
rate at which the cost of living will increase.
|
||||
<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>Life Expectancy (Age):</strong> The age until which you
|
||||
want your funds to last.
|
||||
<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">
|
||||
The calculator simulates your investment growth year by year,
|
||||
incorporating monthly contributions, the power of compound growth (a
|
||||
core investing principle), and inflation's impact on your
|
||||
target allowance. It estimates the age at which your capital could
|
||||
sustain your desired, inflation-adjusted monthly spending throughout
|
||||
your expected retirement until your specified life expectancy. It
|
||||
calculates your potential "FIRE Number" and the age you
|
||||
might reach financial independence.
|
||||
With this level of granularity, you can confidently experiment with
|
||||
savings rate, target retirement age, and investment assumptions to
|
||||
discover how small tweaks speed up or delay your path to financial
|
||||
independence.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-12">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqData) }}
|
||||
/>
|
||||
<h2 className="mb-4 text-3xl font-bold">
|
||||
FIRE & Investing Frequently Asked Questions (FAQ)
|
||||
</h2>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
What is the 4% rule?
|
||||
What methodology does this calculator use?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
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.
|
||||
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.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Is the Expected Growth Rate realistic? Finding the right
|
||||
investing advice often starts here.
|
||||
Why isn't this just the 4% rule?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Historically, diversified stock market investments have returned
|
||||
around 7-10% annually long-term (before inflation). A rate of 7%
|
||||
(after fees) is common, but remember past performance
|
||||
doesn't guarantee future results, a fundamental piece of
|
||||
investing advice. Choose a rate reflecting your risk tolerance
|
||||
and investment strategy.
|
||||
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.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How does inflation impact my FIRE number?
|
||||
How do I choose a realistic growth rate?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
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.
|
||||
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.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-4">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
Can I really retire early with FIRE?
|
||||
How does inflation factor into my FIRE Number?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
Retiring significantly early is achievable but demands
|
||||
discipline, a high savings rate, and smart investing. Success
|
||||
depends on income, expenses, savings habits, and investment
|
||||
returns. Use this FIRE calculator as a planning tool,
|
||||
understanding it provides estimates based on your assumptions
|
||||
and chosen investing approach.
|
||||
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.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-5">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
What does FIRE stand for?
|
||||
Can I really retire early with FIRE?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
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.
|
||||
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.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-6">
|
||||
<AccordionTrigger className="text-xl font-semibold">
|
||||
How much should I save each month?
|
||||
How should I use this calculator effectively?
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-lg leading-relaxed">
|
||||
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.
|
||||
<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>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
@@ -208,77 +306,48 @@ export default function HomePage() {
|
||||
FIRE Journey & Investing Resources
|
||||
</h2>
|
||||
<p className="mb-6 text-lg leading-relaxed">
|
||||
Ready to dive deeper into FIRE and solidify your investing strategy?
|
||||
Explore these valuable resources for financial independence planning
|
||||
and investing advice:
|
||||
Ready to deepen your knowledge and build a bullet-proof plan? Below
|
||||
are some of our favorite blogs, books, tools, and communities for
|
||||
financial independence and smart investing.
|
||||
</p>
|
||||
|
||||
<div className="bg-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>
|
||||
Calculate your personal numbers using this FIRE calculator and
|
||||
other tools.
|
||||
</li>
|
||||
<li>
|
||||
Seek sound investing advice and consider joining communities
|
||||
like r/Fire for support.
|
||||
</li>
|
||||
<li>Explore books and podcasts to deepen your understanding</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
Blogs & Investing Websites
|
||||
</h3>
|
||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.mrmoneymustache.com/2012/01/13/the-shockingly-simple-math-behind-early-retirement/"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Mr. Money Mustache - Simple Math Behind Early Retirement &
|
||||
Investing
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.playingwithfire.co/resources"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Playing With FIRE - Comprehensive Resources
|
||||
</a>
|
||||
Run your first projection above to find your target FIRE Number.
|
||||
</li>
|
||||
<li>Identify areas to boost savings or reduce expenses.</li>
|
||||
<li>
|
||||
Study index-fund strategies and low-cost investing advice.
|
||||
</li>
|
||||
<li>
|
||||
Join{" "}
|
||||
<a
|
||||
href="https://www.reddit.com/r/Fire/"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
r/Fire Reddit Community
|
||||
</a>
|
||||
supportive communities like r/Fire
|
||||
</a>{" "}
|
||||
to learn from real journeys.
|
||||
</li>
|
||||
</ul>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
Books & Investment Learning
|
||||
</h3>
|
||||
<h3 className="mb-3 text-xl font-semibold">Blogs & Websites</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"
|
||||
href="https://www.mrmoneymustache.com/"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Your Money or Your Life - Foundational FIRE & Investing
|
||||
Principles
|
||||
</a>
|
||||
Mr. Money Mustache
|
||||
</a>{" "}
|
||||
- Hardcore frugality & early retirement success stories.
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@@ -286,8 +355,45 @@ export default function HomePage() {
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Playing With FIRE Documentary
|
||||
</a>
|
||||
Playing With FIRE
|
||||
</a>{" "}
|
||||
- Community resources & real-life case studies.
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.reddit.com/r/Fire/"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
r/Fire
|
||||
</a>{" "}
|
||||
- Active forum for questions, tips, and support.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">Books & Podcasts</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"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Your Money or Your Life
|
||||
</a>{" "}
|
||||
- The classic guide to aligning money with values.
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://podcasts.apple.com/us/podcast/biggerpockets-money-podcast/id1330225136"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
BiggerPockets Money Podcast
|
||||
</a>{" "}
|
||||
- Interviews on FIRE strategies and wealth building.
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@@ -295,37 +401,39 @@ export default function HomePage() {
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
BiggerPockets Money Podcast - FIRE Calculators & Investing
|
||||
Strategies
|
||||
</a>
|
||||
InvestingFIRE Calculator Demo
|
||||
</a>{" "}
|
||||
- Deep dive on how interactive projections can guide your
|
||||
plan.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
Additional FIRE & Investing Calculators
|
||||
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"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Coast FIRE Calculator - For those considering a partial
|
||||
early retirement
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.empower.com/retirement-calculator"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Empower Retirement Planner - Free portfolio analysis and net
|
||||
worth tracking
|
||||
</a>
|
||||
Coast FIRE Calculator
|
||||
</a>{" "}
|
||||
- When you “max out” early contributions but let compounding
|
||||
do the rest.
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@@ -333,43 +441,16 @@ export default function HomePage() {
|
||||
target="_blank"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
CAGR Compound Interest Calculator - Understand Investment
|
||||
Growth
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xl font-semibold">
|
||||
Recent Investing & FIRE Articles
|
||||
</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"
|
||||
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"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
The Social Side of FIRE: Finding Community in Financial
|
||||
Independence
|
||||
</a>
|
||||
Compound Interest Calculator
|
||||
</a>{" "}
|
||||
- Explore the power of growth rates in isolation.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -20,9 +20,9 @@ export type ChartConfig = Record<
|
||||
)
|
||||
>;
|
||||
|
||||
type ChartContextProps = {
|
||||
interface ChartContextProps {
|
||||
config: ChartConfig;
|
||||
};
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
@@ -135,12 +135,12 @@ function ChartTooltipContent({
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
||||
const item = payload[0];
|
||||
const key = labelKey ?? String(item.dataKey ?? item.name ?? "value");
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
? (label in config && config[label].label ? config[label].label : undefined) ?? label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
@@ -175,14 +175,14 @@ function ChartTooltipContent({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
"border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||
const key = nameKey ?? String(item.name ?? item.dataKey ?? "value");
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor: string | undefined =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
@@ -196,7 +196,7 @@ function ChartTooltipContent({
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
{formatter && item.value !== undefined && item.name ? (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
@@ -207,7 +207,7 @@ function ChartTooltipContent({
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
"shrink-0 rounded-[2px] border-border bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
|
||||
@@ -18,12 +18,12 @@ import { Label } from "@/components/ui/label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
interface FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
> {
|
||||
name: TName;
|
||||
};
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
@@ -61,9 +61,9 @@ const useFormField = () => {
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
interface FormItemContextValue {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
@@ -110,7 +110,7 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
? formDescriptionId
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
@@ -134,7 +134,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : props.children;
|
||||
const body = error ? (error.message ?? "") : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
|
||||
@@ -37,7 +37,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 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}
|
||||
@@ -61,7 +61,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
@@ -74,7 +74,7 @@ function SelectContent({
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -107,7 +107,7 @@ function SelectItem({
|
||||
<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",
|
||||
"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}
|
||||
|
||||
@@ -31,7 +31,7 @@ function Slider({
|
||||
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",
|
||||
"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}
|
||||
|
||||
1
src/lib/constants.ts
Normal file
1
src/lib/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const BASE_URL = "https://investingfire.com/";
|
||||
@@ -1,42 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Base Options: */
|
||||
"esModuleInterop": true,
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"target": "es2022",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
/* Strictness */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"checkJs": true,
|
||||
|
||||
/* Bundled projects */
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"plugins": [{ "name": "next" }],
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"target": "ES2022",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noImplicitReturns": false,
|
||||
"plugins": [{ "name": "next" }]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.cjs",
|
||||
"**/*.js",
|
||||
".next/types/**/*.ts"
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
17
vitest.config.ts
Normal file
17
vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
exclude: ["node_modules", "e2e/**"],
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
2
vitest.setup.ts
Normal file
2
vitest.setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
Reference in New Issue
Block a user