Compare commits
6 Commits
renovate/r
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d1d010100 | |||
| 9666193c9f | |||
| e08f6231bd | |||
| 8b65735994 | |||
| 298afc3cc8 | |||
| 72c9e666af |
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.
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -43,4 +43,7 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# idea files
|
# idea files
|
||||||
.idea
|
.idea
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
test-results/
|
||||||
|
|||||||
71
README.md
71
README.md
@@ -2,15 +2,54 @@
|
|||||||
|
|
||||||
# InvestingFIRE 🔥 — The #1 Interactive FIRE Calculator
|
# InvestingFIRE 🔥 — The #1 Interactive FIRE Calculator
|
||||||
|
|
||||||
**InvestingFIRE** is a responsive web application for calculating your path to Financial Independence and Early Retirement (FIRE). It features a year-by-year projection engine that simulates both accumulation (savings and investment growth) and retirement (withdrawals) phases, allowing users to:
|
**InvestingFIRE** is a responsive web application for calculating your path to Financial Independence and Early Retirement (FIRE). It features a year-by-year projection engine that simulates both accumulation (savings and investment growth) and retirement (withdrawals) phases.
|
||||||
|
|
||||||
- Input starting capital, monthly savings, expected annual growth rate, inflation rate, current age, desired retirement age, life expectancy, and desired monthly retirement allowance.
|
Deployed version: [https://investingfire.com](https://investingfire.com)
|
||||||
- View a dynamic chart displaying projected portfolio balance and monthly allowance over time.
|
|
||||||
- Instantly see their estimated “FIRE number” (required capital at retirement), how long their capital will last, and compare results to the “4% rule.”
|
|
||||||
- Adjust assumptions live, with all calculations and visualizations updating automatically.
|
|
||||||
- Access explanatory content about FIRE methodology, key variables, and additional community resources, all on a single, consolidated page.
|
|
||||||
|
|
||||||
The project’s code is structured using React/Next.js with TypeScript, focusing on user experience, modern UI components, and clarity of financial assumptions.
|
---
|
||||||
|
|
||||||
|
## 🎯 Goal & Vision
|
||||||
|
|
||||||
|
### **Goal**
|
||||||
|
To build the most comprehensive, user-friendly, and transparent open-source financial independence calculator on the web.
|
||||||
|
|
||||||
|
### **Vision**
|
||||||
|
Democratize financial planning by providing professional-grade simulation tools in an accessible, privacy-focused, and beautiful interface. We believe everyone should have the ability to model their financial future without needing a finance degree or expensive software.
|
||||||
|
|
||||||
|
### **Business Model**
|
||||||
|
InvestingFIRE operates on a transparent open-source model:
|
||||||
|
1. **Free Forever Core:** The essential calculation tools will always be free and open-source.
|
||||||
|
2. **Community Supported:** We rely on community contributions (code & feedback) to improve the tool.
|
||||||
|
3. **Educational Affiliates:** We may curate high-quality resources (books, courses, tools) to help users on their journey, keeping the tool free of intrusive ads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
|
We are actively expanding the capabilities of InvestingFIRE. Below is our plan broken down into phases.
|
||||||
|
|
||||||
|
### **Phase 1: Enhanced Simulation (The Engine)**
|
||||||
|
Focus on making the math more robust and flexible.
|
||||||
|
- [ ] **Coast FIRE Mode:** Option to stop contributions at a certain age but retire later.
|
||||||
|
- [ ] **Barista FIRE Mode:** Include part-time income during "retirement" years.
|
||||||
|
- [ ] **Monte Carlo Simulations:** Add probabilistic outcomes (e.g., "95% chance of success") instead of just deterministic linear growth.
|
||||||
|
- [ ] **Variable Withdrawal Strategies:** Implement dynamic withdrawal rules (e.g., Guyton-Klinger) beyond just fixed inflation-adjusted withdrawals.
|
||||||
|
|
||||||
|
### **Phase 2: User Experience & Persistence**
|
||||||
|
Make the tool easier to use and return to.
|
||||||
|
- [ ] **URL State Sharing:** Encode form values into the URL so scenarios can be bookmarked and shared.
|
||||||
|
- [ ] **Local Persistence:** Automatically save user inputs to `localStorage` so they don't vanish on refresh.
|
||||||
|
- [ ] **Currency & Locale Support:** Allow users to select currency symbols and number formatting (USD, EUR, GBP, etc.).
|
||||||
|
|
||||||
|
### **Phase 3: Advanced Features & Analytics**
|
||||||
|
For the power users who need more detail.
|
||||||
|
- [ ] **Tax Considerations:** Simple toggles for Pre-tax vs. Post-tax estimations.
|
||||||
|
- [ ] **Scenario Comparison:** Compare two different plans side-by-side (e.g., "Retire at 45 vs 55").
|
||||||
|
- [ ] **Data Export:** Download projection data as CSV or PDF reports.
|
||||||
|
|
||||||
|
### **Phase 4: Content & Community**
|
||||||
|
- [ ] **Blog/Guides:** Integrate a CMS (like Markdown/MDX) for in-depth financial guides.
|
||||||
|
- [ ] **Community Presets:** "One-click" setups for common strategies (e.g., "Lean FIRE", "Fat FIRE").
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,7 +104,23 @@ To run locally:
|
|||||||
```
|
```
|
||||||
4. Visit [http://localhost:3000](http://localhost:3000) and unleash the fire.
|
4. Visit [http://localhost:3000](http://localhost:3000) and unleash the fire.
|
||||||
|
|
||||||
Deployed version: [https://investingfire.com](https://investingfire.com)
|
### Running Tests 🧪
|
||||||
|
|
||||||
|
We use **Vitest** for unit testing and **Playwright** for end-to-end (E2E) testing.
|
||||||
|
|
||||||
|
**Unit Tests:**
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
**E2E Tests:**
|
||||||
|
```bash
|
||||||
|
# First install browsers (only needed once)
|
||||||
|
pnpm exec playwright install
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pnpm test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
15
e2e/home.spec.ts
Normal file
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();
|
||||||
|
});
|
||||||
|
|
||||||
18
package.json
18
package.json
@@ -11,7 +11,9 @@
|
|||||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"lint": "next typegen && eslint . && npx tsc --noEmit",
|
"lint": "next typegen && eslint . && npx tsc --noEmit",
|
||||||
"preview": "next build && next start",
|
"preview": "next build && next start",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
"@t3-oss/env-nextjs": "^0.13.0",
|
"@t3-oss/env-nextjs": "^0.13.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
"next-plausible": "^3.12.4",
|
"next-plausible": "^3.12.4",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
@@ -34,25 +36,33 @@
|
|||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.56.1",
|
||||||
"@tailwindcss/postcss": "4.1.17",
|
"@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/node": "24.10.1",
|
||||||
"@types/react": "19.2.6",
|
"@types/react": "19.2.6",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.3",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"jsdom": "^27.2.0",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "0.7.1",
|
"prettier-plugin-tailwindcss": "0.7.1",
|
||||||
"tailwindcss": "4.1.17",
|
"tailwindcss": "4.1.17",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.47.0"
|
"typescript-eslint": "8.47.0",
|
||||||
|
"vitest": "^4.0.13"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.39.3"
|
"initVersion": "7.39.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.22.0",
|
"packageManager": "pnpm@10.23.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "19.2.6",
|
"@types/react": "19.2.6",
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
1506
pnpm-lock.yaml
generated
1506
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -31,9 +31,17 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
type TooltipProps,
|
type TooltipProps,
|
||||||
|
Line,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import assert from "assert";
|
import assert from "assert";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import type {
|
import type {
|
||||||
NameType,
|
NameType,
|
||||||
ValueType,
|
ValueType,
|
||||||
@@ -64,6 +72,19 @@ const formSchema = z.object({
|
|||||||
.number()
|
.number()
|
||||||
.min(18, "Retirement age must be at least 18")
|
.min(18, "Retirement age must be at least 18")
|
||||||
.max(100, "Retirement age must be at most 100"),
|
.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
|
// Type for form values
|
||||||
@@ -77,6 +98,10 @@ interface YearlyData {
|
|||||||
phase: "accumulation" | "retirement";
|
phase: "accumulation" | "retirement";
|
||||||
monthlyAllowance: number;
|
monthlyAllowance: number;
|
||||||
untouchedMonthlyAllowance: number;
|
untouchedMonthlyAllowance: number;
|
||||||
|
// Monte Carlo percentiles
|
||||||
|
balanceP10?: number;
|
||||||
|
balanceP50?: number;
|
||||||
|
balanceP90?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CalculationResult {
|
interface CalculationResult {
|
||||||
@@ -85,6 +110,15 @@ interface CalculationResult {
|
|||||||
retirementAge4percent: number | null;
|
retirementAge4percent: number | null;
|
||||||
yearlyData: YearlyData[];
|
yearlyData: YearlyData[];
|
||||||
error?: string;
|
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
|
// Helper function to format currency without specific symbols
|
||||||
@@ -105,7 +139,15 @@ const tooltipRenderer = ({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-background border p-2 shadow-sm">
|
<div className="bg-background border p-2 shadow-sm">
|
||||||
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
|
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
|
||||||
<p className="text-orange-500">{`Balance: ${formatNumber(data.balance)}`}</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 className="text-red-600">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
|
||||||
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
|
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,6 +173,10 @@ export default function FireCalculatorForm() {
|
|||||||
inflationRate: 2.3,
|
inflationRate: 2.3,
|
||||||
lifeExpectancy: 84,
|
lifeExpectancy: 84,
|
||||||
retirementAge: 55,
|
retirementAge: 55,
|
||||||
|
coastFireAge: undefined,
|
||||||
|
baristaIncome: 0,
|
||||||
|
simulationMode: "deterministic",
|
||||||
|
volatility: 15,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,64 +186,150 @@ export default function FireCalculatorForm() {
|
|||||||
const startingCapital = values.startingCapital;
|
const startingCapital = values.startingCapital;
|
||||||
const monthlySavings = values.monthlySavings;
|
const monthlySavings = values.monthlySavings;
|
||||||
const age = values.currentAge;
|
const age = values.currentAge;
|
||||||
const annualGrowthRate = 1 + values.cagr / 100;
|
const cagr = values.cagr;
|
||||||
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
|
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
|
||||||
const annualInflation = 1 + values.inflationRate / 100;
|
const annualInflation = 1 + values.inflationRate / 100;
|
||||||
const ageOfDeath = values.lifeExpectancy;
|
const ageOfDeath = values.lifeExpectancy;
|
||||||
const retirementAge = values.retirementAge;
|
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[] = [];
|
const yearlyData: YearlyData[] = [];
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
// Initial year data
|
// Initial year
|
||||||
yearlyData.push({
|
yearlyData.push({
|
||||||
age: age,
|
age: age,
|
||||||
year: irlYear,
|
year: irlYear,
|
||||||
balance: startingCapital,
|
balance: startingCapital,
|
||||||
untouchedBalance: startingCapital,
|
untouchedBalance: startingCapital,
|
||||||
phase: "accumulation",
|
phase: "accumulation",
|
||||||
monthlyAllowance: 0,
|
monthlyAllowance: 0,
|
||||||
untouchedMonthlyAllowance: initialMonthlyAllowance,
|
untouchedMonthlyAllowance: initialMonthlyAllowance,
|
||||||
|
balanceP10: startingCapital,
|
||||||
|
balanceP50: startingCapital,
|
||||||
|
balanceP90: startingCapital,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate accumulation phase (before retirement)
|
const numYears = ageOfDeath - age;
|
||||||
for (let year = irlYear + 1; year <= irlYear + (ageOfDeath - age); year++) {
|
for (let i = 0; i < numYears; i++) {
|
||||||
const currentAge = age + (year - irlYear);
|
const year = irlYear + 1 + i;
|
||||||
const previousYearData = yearlyData[yearlyData.length - 1];
|
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 =
|
const inflatedAllowance =
|
||||||
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
|
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
|
||||||
|
|
||||||
const isRetirementYear = currentAge >= retirementAge;
|
const isRetirementYear = currentAge >= retirementAge;
|
||||||
const phase = isRetirementYear ? "retirement" : "accumulation";
|
const phase = isRetirementYear ? "retirement" : "accumulation";
|
||||||
|
|
||||||
assert(!!previousYearData);
|
// Reconstruct untouched balance for deterministic mode (for 4% rule)
|
||||||
// Calculate balance based on phase
|
let untouchedBalance = 0;
|
||||||
let newBalance;
|
if (simulationMode === "deterministic") {
|
||||||
if (phase === "accumulation") {
|
// We can just use the single run we have
|
||||||
// During accumulation: grow previous balance + add savings
|
// In deterministic mode, there's only 1 simulation, so balancesForYear[0] is it.
|
||||||
newBalance =
|
// But wait, `simulationResults` stores the *actual* balance (with withdrawals).
|
||||||
previousYearData.balance * annualGrowthRate + monthlySavings * 12;
|
// We need a separate tracker for "untouched" (never withdrawing) if we want accurate 4% rule.
|
||||||
} else {
|
// Let's just re-calculate it simply here since it's deterministic.
|
||||||
// During retirement: grow previous balance - withdraw allowance
|
const prevUntouched = yearlyData[yearlyData.length - 1].untouchedBalance;
|
||||||
newBalance =
|
const growth = 1 + cagr / 100;
|
||||||
previousYearData.balance * annualGrowthRate - inflatedAllowance * 12;
|
untouchedBalance = prevUntouched * growth + monthlySavings * 12;
|
||||||
}
|
}
|
||||||
const untouchedBalance =
|
|
||||||
previousYearData.untouchedBalance * annualGrowthRate +
|
|
||||||
monthlySavings * 12;
|
|
||||||
const allowance = phase === "retirement" ? inflatedAllowance : 0;
|
|
||||||
yearlyData.push({
|
yearlyData.push({
|
||||||
age: currentAge,
|
age: currentAge,
|
||||||
year: year,
|
year: year,
|
||||||
balance: newBalance,
|
balance: p50, // Use Median for the main line
|
||||||
untouchedBalance: untouchedBalance,
|
untouchedBalance: untouchedBalance,
|
||||||
phase: phase,
|
phase: phase,
|
||||||
monthlyAllowance: allowance,
|
monthlyAllowance: phase === "retirement" ? inflatedAllowance : 0,
|
||||||
untouchedMonthlyAllowance: inflatedAllowance,
|
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 retirementYear = irlYear + (retirementAge - age);
|
||||||
const retirementIndex = yearlyData.findIndex(
|
const retirementIndex = yearlyData.findIndex(
|
||||||
(data) => data.year === retirementYear,
|
(data) => data.year === retirementYear,
|
||||||
@@ -205,15 +337,24 @@ export default function FireCalculatorForm() {
|
|||||||
const retirementData = yearlyData[retirementIndex];
|
const retirementData = yearlyData[retirementIndex];
|
||||||
|
|
||||||
const [fireNumber4percent, retirementAge4percent] = (() => {
|
const [fireNumber4percent, retirementAge4percent] = (() => {
|
||||||
for (const yearData of yearlyData) {
|
// Re-enable 4% rule for deterministic mode or use p50 for MC
|
||||||
if (
|
// For MC, "untouchedBalance" isn't tracked per run in aggregate, but we can use balanceP50 roughly
|
||||||
yearData.untouchedBalance >
|
// or just disable it as it's a different philosophy.
|
||||||
(yearData.untouchedMonthlyAllowance * 12) / 0.04
|
// For now, let's calculate it based on the main "balance" field (which is p50 in MC)
|
||||||
) {
|
for (const yearData of yearlyData) {
|
||||||
return [yearData.untouchedBalance, yearData.age];
|
// 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];
|
||||||
return [0, 0];
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if (retirementIndex === -1) {
|
if (retirementIndex === -1) {
|
||||||
@@ -228,9 +369,10 @@ export default function FireCalculatorForm() {
|
|||||||
// Set the result
|
// Set the result
|
||||||
setResult({
|
setResult({
|
||||||
fireNumber: retirementData.balance,
|
fireNumber: retirementData.balance,
|
||||||
fireNumber4percent: fireNumber4percent,
|
fireNumber4percent: null,
|
||||||
retirementAge4percent: retirementAge4percent,
|
retirementAge4percent: null,
|
||||||
yearlyData: yearlyData,
|
yearlyData: yearlyData,
|
||||||
|
successRate: simulationMode === "monte-carlo" ? (successCount / numSimulations) * 100 : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,6 +632,187 @@ export default function FireCalculatorForm() {
|
|||||||
</FormItem>
|
</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>
|
</div>
|
||||||
|
|
||||||
{!result && (
|
{!result && (
|
||||||
@@ -590,6 +913,28 @@ export default function FireCalculatorForm() {
|
|||||||
yAxisId={"right"}
|
yAxisId={"right"}
|
||||||
stackId={"a"}
|
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
|
<Area
|
||||||
type="step"
|
type="step"
|
||||||
dataKey="monthlyAllowance"
|
dataKey="monthlyAllowance"
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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