1 Commits

Author SHA1 Message Date
61fb17f984 fix(deps): update dependency recharts to v3
Some checks failed
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Failing after 24s
Lint / Lint and Typecheck (pull_request) Failing after 24s
2025-11-15 16:03:07 +00:00
15 changed files with 512 additions and 2516 deletions

View File

@@ -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.

View File

@@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4

5
.gitignore vendored
View File

@@ -43,7 +43,4 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
# idea files # idea files
.idea .idea
playwright-report/
test-results/

View File

@@ -2,54 +2,15 @@
# 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. **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.
--- The projects 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").
--- ---
@@ -104,23 +65,7 @@ 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.
### Running Tests 🧪 Deployed version: [https://investingfire.com](https://investingfire.com)
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
```
--- ---

View File

@@ -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();
});

View File

@@ -11,9 +11,7 @@
"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",
@@ -25,47 +23,40 @@
"@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.554.0", "lucide-react": "^0.553.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",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.56.1", "react-hook-form": "^7.56.1",
"recharts": "^2.15.3", "recharts": "^3.0.0",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"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.5",
"@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", "eslint-plugin-react-hooks": "5.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.46.4"
"vitest": "^4.0.13"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"
}, },
"packageManager": "pnpm@10.23.0", "packageManager": "pnpm@10.22.0",
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"@types/react": "19.2.6", "@types/react": "19.2.5",
"@types/react-dom": "19.2.3" "@types/react-dom": "19.2.3"
} }
} }

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type React from "react";
import { import {
type LucideIcon, type LucideIcon,
HandCoins, HandCoins,
@@ -113,15 +112,14 @@ export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
Target, Target,
]; ];
const [icons, setIcons] = useState<React.ReactElement[]>([]); const renderIcons = ({
rows,
useEffect(() => { columns,
if (rows === 0 || columns === 0) { }: {
setIcons([]); rows: number;
return; columns: number;
} }) => {
const icons = [];
const iconElements: React.ReactElement[] = [];
for (let y = 0; y < rows; y++) { for (let y = 0; y < rows; y++) {
for (let x = 0; x < columns; x++) { for (let x = 0; x < columns; x++) {
// Pick a random icon component from the array // 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 size = 28 + Math.floor(Math.random() * 8);
const xOffset = Math.floor(Math.random() * (spacing / 1.618)); const xOffset = Math.floor(Math.random() * (spacing / 1.618));
const yOffset = 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 <IconComponent
key={`icon-${String(x)}-${String(y)}`} key={`icon-${x}-${y}`}
size={size} size={size}
className="text-primary fixed" className="text-primary fixed"
style={{ style={{
left: `${String(x * spacing + xOffset)}px`, left: `${x * spacing + xOffset}px`,
top: `${String(y * spacing + yOffset)}px`, top: `${y * spacing + yOffset}px`,
opacity: opacity, opacity: opacity,
transform: `rotate(${String(rotation)}deg)`, transform: `rotate(${Math.round((Math.random() - 0.5) * 30)}deg)`,
}} }}
/>, />,
); );
} }
} }
setIcons(iconElements); return icons;
// eslint-disable-next-line react-hooks/exhaustive-deps };
}, [rows, columns, spacing, opacity]);
return ( return (
<div className="absolute h-full w-full"> <div className="absolute h-full w-full">
{width > 0 && icons} {width > 0 && renderIcons({ rows, columns })}
</div> </div>
); );
} }

View File

@@ -31,17 +31,9 @@ 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,
@@ -72,19 +64,6 @@ 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
@@ -98,10 +77,6 @@ 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 {
@@ -110,15 +85,6 @@ 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
@@ -139,15 +105,7 @@ 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>
{data.balanceP50 !== undefined ? ( <p className="text-orange-500">{`Balance: ${formatNumber(data.balance)}`}</p>
<>
<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>
@@ -173,10 +131,6 @@ 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,
}, },
}); });
@@ -186,150 +140,64 @@ 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 cagr = values.cagr; const annualGrowthRate = 1 + values.cagr / 100;
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;
const numSimulations = simulationMode === "monte-carlo" ? 500 : 1; // Array to store yearly data for the chart
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 // Initial year data
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,
}); });
const numYears = ageOfDeath - age; // Calculate accumulation phase (before retirement)
for (let i = 0; i < numYears; i++) { for (let year = irlYear + 1; year <= irlYear + (ageOfDeath - age); year++) {
const year = irlYear + 1 + i; const currentAge = age + (year - irlYear);
const currentAge = age + 1 + i; const previousYearData = yearlyData[yearlyData.length - 1];
// 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";
// Reconstruct untouched balance for deterministic mode (for 4% rule) assert(!!previousYearData);
let untouchedBalance = 0; // Calculate balance based on phase
if (simulationMode === "deterministic") { let newBalance;
// We can just use the single run we have if (phase === "accumulation") {
// In deterministic mode, there's only 1 simulation, so balancesForYear[0] is it. // During accumulation: grow previous balance + add savings
// But wait, `simulationResults` stores the *actual* balance (with withdrawals). newBalance =
// We need a separate tracker for "untouched" (never withdrawing) if we want accurate 4% rule. previousYearData.balance * annualGrowthRate + monthlySavings * 12;
// Let's just re-calculate it simply here since it's deterministic. } else {
const prevUntouched = yearlyData[yearlyData.length - 1].untouchedBalance; // During retirement: grow previous balance - withdraw allowance
const growth = 1 + cagr / 100; newBalance =
untouchedBalance = prevUntouched * growth + monthlySavings * 12; previousYearData.balance * annualGrowthRate - inflatedAllowance * 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: p50, // Use Median for the main line balance: newBalance,
untouchedBalance: untouchedBalance, untouchedBalance: untouchedBalance,
phase: phase, phase: phase,
monthlyAllowance: phase === "retirement" ? inflatedAllowance : 0, monthlyAllowance: allowance,
untouchedMonthlyAllowance: inflatedAllowance, untouchedMonthlyAllowance: inflatedAllowance,
balanceP10: p10,
balanceP50: p50,
balanceP90: p90,
}); });
} }
// Calculate Success Rate (only for Monte Carlo) // Calculate FIRE number at retirement
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,
@@ -337,27 +205,18 @@ export default function FireCalculatorForm() {
const retirementData = yearlyData[retirementIndex]; const retirementData = yearlyData[retirementIndex];
const [fireNumber4percent, retirementAge4percent] = (() => { const [fireNumber4percent, retirementAge4percent] = (() => {
// Re-enable 4% rule for deterministic mode or use p50 for MC for (const yearData of yearlyData) {
// For MC, "untouchedBalance" isn't tracked per run in aggregate, but we can use balanceP50 roughly if (
// or just disable it as it's a different philosophy. yearData.untouchedBalance >
// For now, let's calculate it based on the main "balance" field (which is p50 in MC) (yearData.untouchedMonthlyAllowance * 12) / 0.04
for (const yearData of yearlyData) { ) {
// Estimate untouched roughly if not tracking exact return [yearData.untouchedBalance, yearData.age];
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 || !retirementData) {
setResult({ setResult({
fireNumber: null, fireNumber: null,
fireNumber4percent: null, fireNumber4percent: null,
@@ -369,10 +228,9 @@ export default function FireCalculatorForm() {
// Set the result // Set the result
setResult({ setResult({
fireNumber: retirementData.balance, fireNumber: retirementData.balance,
fireNumber4percent: null, fireNumber4percent: fireNumber4percent,
retirementAge4percent: null, retirementAge4percent: retirementAge4percent,
yearlyData: yearlyData, yearlyData: yearlyData,
successRate: simulationMode === "monte-carlo" ? (successCount / numSimulations) * 100 : undefined,
}); });
} }
} }
@@ -388,13 +246,7 @@ export default function FireCalculatorForm() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>
<form <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
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"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField <FormField
control={form.control} control={form.control}
@@ -413,8 +265,7 @@ export default function FireCalculatorForm() {
? undefined ? undefined
: Number(e.target.value), : Number(e.target.value),
); );
// eslint-disable-next-line @typescript-eslint/no-floating-promises void form.handleSubmit(onSubmit)();
form.handleSubmit(onSubmit)();
}} }}
onBlur={field.onBlur} onBlur={field.onBlur}
name={field.name} name={field.name}
@@ -442,8 +293,7 @@ export default function FireCalculatorForm() {
? undefined ? undefined
: Number(e.target.value), : Number(e.target.value),
); );
// eslint-disable-next-line @typescript-eslint/no-floating-promises void form.handleSubmit(onSubmit)();
form.handleSubmit(onSubmit)();
}} }}
onBlur={field.onBlur} onBlur={field.onBlur}
name={field.name} name={field.name}
@@ -471,8 +321,7 @@ export default function FireCalculatorForm() {
? undefined ? undefined
: Number(e.target.value), : Number(e.target.value),
); );
// eslint-disable-next-line @typescript-eslint/no-floating-promises void form.handleSubmit(onSubmit)();
form.handleSubmit(onSubmit)();
}} }}
onBlur={field.onBlur} onBlur={field.onBlur}
name={field.name} name={field.name}
@@ -500,8 +349,7 @@ export default function FireCalculatorForm() {
? undefined ? undefined
: Number(e.target.value), : Number(e.target.value),
); );
// eslint-disable-next-line @typescript-eslint/no-floating-promises void form.handleSubmit(onSubmit)();
form.handleSubmit(onSubmit)();
}} }}
onBlur={field.onBlur} onBlur={field.onBlur}
name={field.name} name={field.name}
@@ -530,8 +378,7 @@ export default function FireCalculatorForm() {
? undefined ? undefined
: Number(e.target.value), : Number(e.target.value),
); );
// eslint-disable-next-line @typescript-eslint/no-floating-promises void form.handleSubmit(onSubmit)();
form.handleSubmit(onSubmit)();
}} }}
onBlur={field.onBlur} onBlur={field.onBlur}
name={field.name} name={field.name}
@@ -560,8 +407,7 @@ export default function FireCalculatorForm() {
? undefined ? undefined
: Number(e.target.value), : Number(e.target.value),
); );
// eslint-disable-next-line @typescript-eslint/no-floating-promises void form.handleSubmit(onSubmit)();
form.handleSubmit(onSubmit)();
}} }}
onBlur={field.onBlur} onBlur={field.onBlur}
name={field.name} name={field.name}
@@ -591,8 +437,7 @@ export default function FireCalculatorForm() {
? undefined ? undefined
: Number(e.target.value), : Number(e.target.value),
); );
// eslint-disable-next-line @typescript-eslint/no-floating-promises void form.handleSubmit(onSubmit)();
form.handleSubmit(onSubmit)();
}} }}
onBlur={field.onBlur} onBlur={field.onBlur}
name={field.name} name={field.name}
@@ -622,8 +467,7 @@ export default function FireCalculatorForm() {
step={1} step={1}
onValueChange={(value: number[]) => { onValueChange={(value: number[]) => {
field.onChange(value[0]); field.onChange(value[0]);
// eslint-disable-next-line @typescript-eslint/no-floating-promises void form.handleSubmit(onSubmit)();
form.handleSubmit(onSubmit)();
}} }}
className="py-4" className="py-4"
/> />
@@ -632,187 +476,6 @@ 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 && (
@@ -913,28 +576,6 @@ 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"

View File

@@ -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();
});
});

View File

@@ -135,12 +135,12 @@ function ChartTooltipContent({
return null; return null;
} }
const item = payload[0]; const [item] = payload;
const key = labelKey ?? String(item.dataKey ?? item.name ?? "value"); const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key); const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value = const value =
!labelKey && typeof label === "string" !labelKey && typeof label === "string"
? (label in config && config[label].label ? config[label].label : undefined) ?? label ? (config[label]?.label ?? label)
: itemConfig?.label; : itemConfig?.label;
if (labelFormatter) { if (labelFormatter) {
@@ -182,7 +182,7 @@ function ChartTooltipContent({
{!nestLabel ? tooltipLabel : null} {!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{payload.map((item, index) => { {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 itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor: string | undefined = const indicatorColor: string | undefined =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
@@ -196,7 +196,7 @@ function ChartTooltipContent({
indicator === "dot" && "items-center", 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 // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
formatter(item.value, item.name, item, index, item.payload) formatter(item.value, item.name, item, index, item.payload)
) : ( ) : (

View File

@@ -134,7 +134,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
function FormMessage({ className, ...props }: React.ComponentProps<"p">) { function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField(); const { error, formMessageId } = useFormField();
const body = error ? (error.message ?? "") : props.children; const body = error ? String(error?.message ?? "") : props.children;
if (!body) { if (!body) {
return null; return null;

View File

@@ -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"),
},
},
});

View File

@@ -1,2 +0,0 @@
import "@testing-library/jest-dom";