Compare commits
1 Commits
dev
...
61fb17f984
| Author | SHA1 | Date | |
|---|---|---|---|
| 61fb17f984 |
33
.cursorrules
33
.cursorrules
@@ -1,33 +0,0 @@
|
||||
# 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:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,6 +44,3 @@ yarn-error.log*
|
||||
|
||||
# idea files
|
||||
.idea
|
||||
playwright-report/
|
||||
|
||||
test-results/
|
||||
|
||||
71
README.md
71
README.md
@@ -2,54 +2,15 @@
|
||||
|
||||
# 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.
|
||||
**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:
|
||||
|
||||
Deployed version: [https://investingfire.com](https://investingfire.com)
|
||||
- Input starting capital, monthly savings, expected annual growth rate, inflation rate, current age, desired retirement age, life expectancy, and desired monthly retirement allowance.
|
||||
- View a dynamic chart displaying projected portfolio balance and monthly allowance over time.
|
||||
- Instantly see their estimated “FIRE number” (required capital at retirement), how long their capital will last, and compare results to the “4% rule.”
|
||||
- Adjust assumptions live, with all calculations and visualizations updating automatically.
|
||||
- Access explanatory content about FIRE methodology, key variables, and additional community resources, all on a single, consolidated page.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 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").
|
||||
The project’s code is structured using React/Next.js with TypeScript, focusing on user experience, modern UI components, and clarity of financial assumptions.
|
||||
|
||||
---
|
||||
|
||||
@@ -104,23 +65,7 @@ To run locally:
|
||||
```
|
||||
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
|
||||
```
|
||||
Deployed version: [https://investingfire.com](https://investingfire.com)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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();
|
||||
});
|
||||
|
||||
25
package.json
25
package.json
@@ -11,9 +11,7 @@
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"lint": "next typegen && eslint . && npx tsc --noEmit",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
@@ -25,47 +23,40 @@
|
||||
"@t3-oss/env-nextjs": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.554.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "16.0.3",
|
||||
"next-plausible": "^3.12.4",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.56.1",
|
||||
"recharts": "^2.15.3",
|
||||
"recharts": "^3.0.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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": "19.2.5",
|
||||
"@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",
|
||||
"eslint-plugin-react-hooks": "5.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"
|
||||
"typescript-eslint": "8.46.4"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"packageManager": "pnpm@10.22.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "19.2.6",
|
||||
"@types/react": "19.2.5",
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
2292
pnpm-lock.yaml
generated
2292
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import type React from "react";
|
||||
import {
|
||||
type LucideIcon,
|
||||
HandCoins,
|
||||
@@ -113,15 +112,14 @@ export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
|
||||
Target,
|
||||
];
|
||||
|
||||
const [icons, setIcons] = useState<React.ReactElement[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rows === 0 || columns === 0) {
|
||||
setIcons([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const iconElements: React.ReactElement[] = [];
|
||||
const renderIcons = ({
|
||||
rows,
|
||||
columns,
|
||||
}: {
|
||||
rows: number;
|
||||
columns: number;
|
||||
}) => {
|
||||
const icons = [];
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < columns; x++) {
|
||||
// Pick a random icon component from the array
|
||||
@@ -132,30 +130,28 @@ export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
|
||||
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(
|
||||
icons.push(
|
||||
<IconComponent
|
||||
key={`icon-${String(x)}-${String(y)}`}
|
||||
key={`icon-${x}-${y}`}
|
||||
size={size}
|
||||
className="text-primary fixed"
|
||||
style={{
|
||||
left: `${String(x * spacing + xOffset)}px`,
|
||||
top: `${String(y * spacing + yOffset)}px`,
|
||||
left: `${x * spacing + xOffset}px`,
|
||||
top: `${y * spacing + yOffset}px`,
|
||||
opacity: opacity,
|
||||
transform: `rotate(${String(rotation)}deg)`,
|
||||
transform: `rotate(${Math.round((Math.random() - 0.5) * 30)}deg)`,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
setIcons(iconElements);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, columns, spacing, opacity]);
|
||||
return icons;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute h-full w-full">
|
||||
{width > 0 && icons}
|
||||
{width > 0 && renderIcons({ rows, columns })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,17 +31,9 @@ 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,
|
||||
@@ -72,19 +64,6 @@ 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
|
||||
@@ -98,10 +77,6 @@ interface YearlyData {
|
||||
phase: "accumulation" | "retirement";
|
||||
monthlyAllowance: number;
|
||||
untouchedMonthlyAllowance: number;
|
||||
// Monte Carlo percentiles
|
||||
balanceP10?: number;
|
||||
balanceP50?: number;
|
||||
balanceP90?: number;
|
||||
}
|
||||
|
||||
interface CalculationResult {
|
||||
@@ -110,15 +85,6 @@ interface CalculationResult {
|
||||
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
|
||||
@@ -139,15 +105,7 @@ 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>
|
||||
{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>
|
||||
@@ -173,10 +131,6 @@ export default function FireCalculatorForm() {
|
||||
inflationRate: 2.3,
|
||||
lifeExpectancy: 84,
|
||||
retirementAge: 55,
|
||||
coastFireAge: undefined,
|
||||
baristaIncome: 0,
|
||||
simulationMode: "deterministic",
|
||||
volatility: 15,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -186,80 +140,16 @@ export default function FireCalculatorForm() {
|
||||
const startingCapital = values.startingCapital;
|
||||
const monthlySavings = values.monthlySavings;
|
||||
const age = values.currentAge;
|
||||
const cagr = values.cagr;
|
||||
const annualGrowthRate = 1 + values.cagr / 100;
|
||||
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;
|
||||
|
||||
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
|
||||
// Array to store yearly data for the chart
|
||||
const yearlyData: YearlyData[] = [];
|
||||
let successCount = 0;
|
||||
|
||||
// Initial year
|
||||
// Initial year data
|
||||
yearlyData.push({
|
||||
age: age,
|
||||
year: irlYear,
|
||||
@@ -268,68 +158,46 @@ export default function FireCalculatorForm() {
|
||||
phase: "accumulation",
|
||||
monthlyAllowance: 0,
|
||||
untouchedMonthlyAllowance: initialMonthlyAllowance,
|
||||
balanceP10: startingCapital,
|
||||
balanceP50: startingCapital,
|
||||
balanceP90: startingCapital,
|
||||
});
|
||||
|
||||
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
|
||||
// Calculate accumulation phase (before retirement)
|
||||
for (let year = irlYear + 1; year <= irlYear + (ageOfDeath - age); year++) {
|
||||
const currentAge = age + (year - irlYear);
|
||||
const previousYearData = yearlyData[yearlyData.length - 1];
|
||||
const inflatedAllowance =
|
||||
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
|
||||
|
||||
const isRetirementYear = currentAge >= retirementAge;
|
||||
const phase = isRetirementYear ? "retirement" : "accumulation";
|
||||
|
||||
// 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;
|
||||
assert(!!previousYearData);
|
||||
// Calculate balance based on phase
|
||||
let newBalance;
|
||||
if (phase === "accumulation") {
|
||||
// During accumulation: grow previous balance + add savings
|
||||
newBalance =
|
||||
previousYearData.balance * annualGrowthRate + monthlySavings * 12;
|
||||
} else {
|
||||
// During retirement: grow previous balance - withdraw allowance
|
||||
newBalance =
|
||||
previousYearData.balance * annualGrowthRate - inflatedAllowance * 12;
|
||||
}
|
||||
|
||||
const untouchedBalance =
|
||||
previousYearData.untouchedBalance * annualGrowthRate +
|
||||
monthlySavings * 12;
|
||||
const allowance = phase === "retirement" ? inflatedAllowance : 0;
|
||||
yearlyData.push({
|
||||
age: currentAge,
|
||||
year: year,
|
||||
balance: p50, // Use Median for the main line
|
||||
balance: newBalance,
|
||||
untouchedBalance: untouchedBalance,
|
||||
phase: phase,
|
||||
monthlyAllowance: phase === "retirement" ? inflatedAllowance : 0,
|
||||
monthlyAllowance: allowance,
|
||||
untouchedMonthlyAllowance: inflatedAllowance,
|
||||
balanceP10: p10,
|
||||
balanceP50: p50,
|
||||
balanceP90: p90,
|
||||
});
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Calculate FIRE number at retirement
|
||||
const retirementYear = irlYear + (retirementAge - age);
|
||||
const retirementIndex = yearlyData.findIndex(
|
||||
(data) => data.year === retirementYear,
|
||||
@@ -337,27 +205,18 @@ export default function FireCalculatorForm() {
|
||||
const retirementData = yearlyData[retirementIndex];
|
||||
|
||||
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) {
|
||||
if (
|
||||
yearData.untouchedBalance >
|
||||
(yearData.untouchedMonthlyAllowance * 12) / 0.04
|
||||
) {
|
||||
return [yearData.untouchedBalance, yearData.age];
|
||||
}
|
||||
}
|
||||
return [null, null];
|
||||
return [0, 0];
|
||||
})();
|
||||
|
||||
if (retirementIndex === -1) {
|
||||
if (retirementIndex === -1 || !retirementData) {
|
||||
setResult({
|
||||
fireNumber: null,
|
||||
fireNumber4percent: null,
|
||||
@@ -369,10 +228,9 @@ export default function FireCalculatorForm() {
|
||||
// Set the result
|
||||
setResult({
|
||||
fireNumber: retirementData.balance,
|
||||
fireNumber4percent: null,
|
||||
retirementAge4percent: null,
|
||||
fireNumber4percent: fireNumber4percent,
|
||||
retirementAge4percent: retirementAge4percent,
|
||||
yearlyData: yearlyData,
|
||||
successRate: simulationMode === "monte-carlo" ? (successCount / numSimulations) * 100 : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -388,13 +246,7 @@ export default function FireCalculatorForm() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void form.handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="space-y-8"
|
||||
>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -413,8 +265,7 @@ export default function FireCalculatorForm() {
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
void form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
@@ -442,8 +293,7 @@ export default function FireCalculatorForm() {
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
void form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
@@ -471,8 +321,7 @@ export default function FireCalculatorForm() {
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
void form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
@@ -500,8 +349,7 @@ export default function FireCalculatorForm() {
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
void form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
@@ -530,8 +378,7 @@ export default function FireCalculatorForm() {
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
void form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
@@ -560,8 +407,7 @@ export default function FireCalculatorForm() {
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
void form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
@@ -591,8 +437,7 @@ export default function FireCalculatorForm() {
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
void form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
@@ -622,8 +467,7 @@ export default function FireCalculatorForm() {
|
||||
step={1}
|
||||
onValueChange={(value: number[]) => {
|
||||
field.onChange(value[0]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
form.handleSubmit(onSubmit)();
|
||||
void form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
className="py-4"
|
||||
/>
|
||||
@@ -632,187 +476,6 @@ 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 && (
|
||||
@@ -913,28 +576,6 @@ export default function FireCalculatorForm() {
|
||||
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"
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,12 +135,12 @@ function ChartTooltipContent({
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = payload[0];
|
||||
const key = labelKey ?? String(item.dataKey ?? item.name ?? "value");
|
||||
const [item] = payload;
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (label in config && config[label].label ? config[label].label : undefined) ?? label
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
@@ -182,7 +182,7 @@ function ChartTooltipContent({
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = nameKey ?? String(item.name ?? item.dataKey ?? "value");
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor: string | undefined =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
@@ -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)
|
||||
) : (
|
||||
|
||||
@@ -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 ? (error.message ?? "") : props.children;
|
||||
const body = error ? String(error?.message ?? "") : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
Reference in New Issue
Block a user