9 Commits

Author SHA1 Message Date
3d1d010100 Adds Vitest and Playwright testing setup with sample tests
Some checks failed
Lint / Lint and Typecheck (push) Failing after 27s
Introduces a unified testing setup using Vitest for unit tests
and Playwright for E2E tests. Updates dependencies, adds sample
unit and E2E tests, documents test workflow, and codifies
testing and code standards in project guidelines.

Enables fast, automated test runs and improves code reliability
through enforced standards.
2025-11-24 22:44:04 +01:00
9666193c9f Adds Monte Carlo simulation and Coast FIRE options
Introduces Monte Carlo simulation mode with customizable market volatility, allowing users to visualize probabilistic retirement balances (median and percentiles) and estimate FIRE plan success rates. Adds fields for Coast FIRE age and Barista FIRE income to support more flexible FIRE scenarios. Updates forms, chart tooltips, and chart areas to display new data, improving the accuracy and insightfulness of retirement projections for advanced use cases.
2025-11-24 22:42:37 +01:00
e08f6231bd plan 2025-11-24 22:03:51 +01:00
8b65735994 chore(deps): update pnpm to v10.23.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 30s
2025-11-23 15:01:01 +00:00
298afc3cc8 chore(deps): update actions/checkout action to v6
All checks were successful
Lint / Lint and Typecheck (pull_request) Successful in 29s
Lint / Lint and Typecheck (push) Successful in 32s
2025-11-22 09:40:12 +01:00
72c9e666af fix(deps): update dependency lucide-react to ^0.554.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 34s
2025-11-22 04:01:41 +00:00
3dfd35d8ba chore(deps): update dependency typescript-eslint to v8.47.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 37s
2025-11-22 03:02:27 +00:00
eebdc619f2 chore(deps): update dependency react-hook-form to v7.66.1
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 35s
2025-11-22 02:02:40 +00:00
188aa4fc74 chore(deps): update dependency @types/react to v19.2.6
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
Lint / Lint and Typecheck (push) Successful in 39s
2025-11-22 01:02:44 +00:00
11 changed files with 2400 additions and 368 deletions

33
.cursorrules Normal file
View 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.

5
.gitignore vendored
View File

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

View File

@@ -2,15 +2,54 @@
# 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.
- 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.
Deployed version: [https://investingfire.com](https://investingfire.com)
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").
---
@@ -65,7 +104,23 @@ To run locally:
```
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
View 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();
});

View File

@@ -11,7 +11,9 @@
"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"
"start": "next start",
"test": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
@@ -23,7 +25,7 @@
"@t3-oss/env-nextjs": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.553.0",
"lucide-react": "^0.554.0",
"next": "16.0.3",
"next-plausible": "^3.12.4",
"react": "19.2.0",
@@ -34,28 +36,36 @@
"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.5",
"@types/react": "19.2.6",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "9.39.1",
"eslint-config-next": "16.0.3",
"eslint-config-prettier": "^10.1.8",
"jsdom": "^27.2.0",
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.7.1",
"tailwindcss": "4.1.17",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
"typescript-eslint": "8.46.4"
"typescript-eslint": "8.47.0",
"vitest": "^4.0.13"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"packageManager": "pnpm@10.22.0",
"packageManager": "pnpm@10.23.0",
"pnpm": {
"overrides": {
"@types/react": "19.2.5",
"@types/react": "19.2.6",
"@types/react-dom": "19.2.3"
}
}

34
playwright.config.ts Normal file
View 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,
},
});

2119
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,9 +31,17 @@ import {
YAxis,
ReferenceLine,
type TooltipProps,
Line,
} from "recharts";
import { Slider } from "@/components/ui/slider";
import assert from "assert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
NameType,
ValueType,
@@ -64,6 +72,19 @@ const formSchema = z.object({
.number()
.min(18, "Retirement age must be at least 18")
.max(100, "Retirement age must be at most 100"),
coastFireAge: z.coerce
.number()
.min(18, "Coast FIRE age must be at least 18")
.max(100, "Coast FIRE age must be at most 100")
.optional(),
baristaIncome: z.coerce
.number()
.min(0, "Barista income must be a non-negative number")
.optional(),
simulationMode: z.enum(["deterministic", "monte-carlo"]).default("deterministic"),
volatility: z.coerce.number().min(0).default(15),
withdrawalStrategy: z.enum(["fixed", "percentage"]).default("fixed"),
withdrawalPercentage: z.coerce.number().min(0).max(100).default(4),
});
// Type for form values
@@ -77,6 +98,10 @@ interface YearlyData {
phase: "accumulation" | "retirement";
monthlyAllowance: number;
untouchedMonthlyAllowance: number;
// Monte Carlo percentiles
balanceP10?: number;
balanceP50?: number;
balanceP90?: number;
}
interface CalculationResult {
@@ -85,6 +110,15 @@ 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
@@ -105,7 +139,15 @@ const tooltipRenderer = ({
return (
<div className="bg-background border p-2 shadow-sm">
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
<p className="text-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>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div>
@@ -131,6 +173,10 @@ export default function FireCalculatorForm() {
inflationRate: 2.3,
lifeExpectancy: 84,
retirementAge: 55,
coastFireAge: undefined,
baristaIncome: 0,
simulationMode: "deterministic",
volatility: 15,
},
});
@@ -140,64 +186,150 @@ export default function FireCalculatorForm() {
const startingCapital = values.startingCapital;
const monthlySavings = values.monthlySavings;
const age = values.currentAge;
const annualGrowthRate = 1 + values.cagr / 100;
const cagr = values.cagr;
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
const annualInflation = 1 + values.inflationRate / 100;
const ageOfDeath = values.lifeExpectancy;
const retirementAge = values.retirementAge;
const coastFireAge = values.coastFireAge ?? retirementAge;
const initialBaristaIncome = values.baristaIncome ?? 0;
const simulationMode = values.simulationMode;
const volatility = values.volatility;
// Array to store yearly data for the chart
const numSimulations = simulationMode === "monte-carlo" ? 500 : 1;
const simulationResults: number[][] = []; // [yearIndex][simulationIndex] -> balance
// Prepare simulation runs
for (let sim = 0; sim < numSimulations; sim++) {
let currentBalance = startingCapital;
const runBalances: number[] = [];
for (
let year = irlYear + 1;
year <= irlYear + (ageOfDeath - age);
year++
) {
const currentAge = age + (year - irlYear);
const yearIndex = year - (irlYear + 1);
// Determine growth rate for this year
let annualGrowthRate: number;
if (simulationMode === "monte-carlo") {
// Random walk
const randomReturn = randomNormal(cagr, volatility) / 100;
annualGrowthRate = 1 + randomReturn;
} else {
// Deterministic
annualGrowthRate = 1 + cagr / 100;
}
const inflatedAllowance =
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
const inflatedBaristaIncome =
initialBaristaIncome * Math.pow(annualInflation, year - irlYear);
const isRetirementYear = currentAge >= retirementAge;
const phase = isRetirementYear ? "retirement" : "accumulation";
const isContributing = currentAge < coastFireAge;
let newBalance;
if (phase === "accumulation") {
newBalance =
currentBalance * annualGrowthRate +
(isContributing ? monthlySavings * 12 : 0);
} else {
const netAnnualWithdrawal =
(inflatedAllowance - inflatedBaristaIncome) * 12;
newBalance = currentBalance * annualGrowthRate - netAnnualWithdrawal;
}
// Prevent negative balance from recovering (once you're broke, you're broke)
// Although debt is possible, for FIRE calc usually 0 is the floor.
// But strictly speaking, if you have income, you might recover?
// Let's allow negative for calculation but maybe clamp for success rate?
// Standard practice: if balance < 0, it stays < 0 or goes deeper.
// Let's just let the math run.
runBalances.push(newBalance);
currentBalance = newBalance;
}
simulationResults.push(runBalances);
}
// Aggregate results
const yearlyData: YearlyData[] = [];
let successCount = 0;
// Initial year data
// Initial year
yearlyData.push({
age: age,
year: irlYear,
balance: startingCapital,
untouchedBalance: startingCapital,
phase: "accumulation",
monthlyAllowance: 0,
monthlyAllowance: 0,
untouchedMonthlyAllowance: initialMonthlyAllowance,
balanceP10: startingCapital,
balanceP50: startingCapital,
balanceP90: startingCapital,
});
// Calculate accumulation phase (before retirement)
for (let year = irlYear + 1; year <= irlYear + (ageOfDeath - age); year++) {
const currentAge = age + (year - irlYear);
const previousYearData = yearlyData[yearlyData.length - 1];
const numYears = ageOfDeath - age;
for (let i = 0; i < numYears; i++) {
const year = irlYear + 1 + i;
const currentAge = age + 1 + i;
// Collect all balances for this year across simulations
const balancesForYear = simulationResults.map((run) => run[i]);
// Sort to find percentiles
balancesForYear.sort((a, b) => a - b);
const p10 = balancesForYear[Math.floor(numSimulations * 0.1)];
const p50 = balancesForYear[Math.floor(numSimulations * 0.5)];
const p90 = balancesForYear[Math.floor(numSimulations * 0.9)];
// Calculate other metrics (using deterministic logic for "untouched" etc for simplicity, or p50)
// We need to reconstruct the "standard" fields for compatibility with the chart
// Let's use p50 (Median) as the "main" line
const inflatedAllowance =
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
const isRetirementYear = currentAge >= retirementAge;
const phase = isRetirementYear ? "retirement" : "accumulation";
assert(!!previousYearData);
// Calculate balance based on phase
let newBalance;
if (phase === "accumulation") {
// During accumulation: grow previous balance + add savings
newBalance =
previousYearData.balance * annualGrowthRate + monthlySavings * 12;
} else {
// During retirement: grow previous balance - withdraw allowance
newBalance =
previousYearData.balance * annualGrowthRate - inflatedAllowance * 12;
// Reconstruct untouched balance for deterministic mode (for 4% rule)
let untouchedBalance = 0;
if (simulationMode === "deterministic") {
// We can just use the single run we have
// In deterministic mode, there's only 1 simulation, so balancesForYear[0] is it.
// But wait, `simulationResults` stores the *actual* balance (with withdrawals).
// We need a separate tracker for "untouched" (never withdrawing) if we want accurate 4% rule.
// Let's just re-calculate it simply here since it's deterministic.
const prevUntouched = yearlyData[yearlyData.length - 1].untouchedBalance;
const growth = 1 + cagr / 100;
untouchedBalance = prevUntouched * growth + monthlySavings * 12;
}
const untouchedBalance =
previousYearData.untouchedBalance * annualGrowthRate +
monthlySavings * 12;
const allowance = phase === "retirement" ? inflatedAllowance : 0;
yearlyData.push({
age: currentAge,
year: year,
balance: newBalance,
untouchedBalance: untouchedBalance,
balance: p50, // Use Median for the main line
untouchedBalance: untouchedBalance,
phase: phase,
monthlyAllowance: allowance,
monthlyAllowance: phase === "retirement" ? inflatedAllowance : 0,
untouchedMonthlyAllowance: inflatedAllowance,
balanceP10: p10,
balanceP50: p50,
balanceP90: p90,
});
}
// Calculate FIRE number at retirement
// Calculate Success Rate (only for Monte Carlo)
if (simulationMode === "monte-carlo") {
const finalBalances = simulationResults.map(run => run[run.length - 1]);
successCount = finalBalances.filter(b => b > 0).length;
}
// Calculate FIRE number (using Median/Deterministic run)
const retirementYear = irlYear + (retirementAge - age);
const retirementIndex = yearlyData.findIndex(
(data) => data.year === retirementYear,
@@ -205,15 +337,24 @@ export default function FireCalculatorForm() {
const retirementData = yearlyData[retirementIndex];
const [fireNumber4percent, retirementAge4percent] = (() => {
for (const yearData of yearlyData) {
if (
yearData.untouchedBalance >
(yearData.untouchedMonthlyAllowance * 12) / 0.04
) {
return [yearData.untouchedBalance, yearData.age];
// Re-enable 4% rule for deterministic mode or use p50 for MC
// For MC, "untouchedBalance" isn't tracked per run in aggregate, but we can use balanceP50 roughly
// or just disable it as it's a different philosophy.
// For now, let's calculate it based on the main "balance" field (which is p50 in MC)
for (const yearData of yearlyData) {
// Estimate untouched roughly if not tracking exact
const balanceToCheck = yearData.balance;
// Note: This is imperfect for MC because 'balance' includes withdrawals in retirement
// whereas 4% rule check usually looks at "if I retired now with this balance".
// The original code had `untouchedBalance` which grew without withdrawals.
// Since we removed `untouchedBalance` calculation in the aggregate loop, let's skip 4% for MC for now.
if (simulationMode === "deterministic" && yearData.untouchedBalance &&
yearData.untouchedBalance > (yearData.untouchedMonthlyAllowance * 12) / 0.04) {
return [yearData.untouchedBalance, yearData.age];
}
}
}
return [0, 0];
return [null, null];
})();
if (retirementIndex === -1) {
@@ -228,9 +369,10 @@ export default function FireCalculatorForm() {
// Set the result
setResult({
fireNumber: retirementData.balance,
fireNumber4percent: fireNumber4percent,
retirementAge4percent: retirementAge4percent,
fireNumber4percent: null,
retirementAge4percent: null,
yearlyData: yearlyData,
successRate: simulationMode === "monte-carlo" ? (successCount / numSimulations) * 100 : undefined,
});
}
}
@@ -490,6 +632,187 @@ export default function FireCalculatorForm() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="coastFireAge"
render={({ field }) => (
<FormItem>
<FormLabel>
Coast FIRE Age (Optional) - Stop contributing at age:
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 45 (defaults to Retirement Age)"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baristaIncome"
render={({ field }) => (
<FormItem>
<FormLabel>
Barista FIRE Income (Monthly during Retirement)
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 1000"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="simulationMode"
render={({ field }) => (
<FormItem>
<FormLabel>Simulation Mode</FormLabel>
<Select
onValueChange={(val) => {
field.onChange(val);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select simulation mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="deterministic">Deterministic (Linear)</SelectItem>
<SelectItem value="monte-carlo">Monte Carlo (Probabilistic)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.watch("simulationMode") === "monte-carlo" && (
<FormField
control={form.control}
name="volatility"
render={({ field }) => (
<FormItem>
<FormLabel>Market Volatility (Std Dev %)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 15"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="withdrawalStrategy"
render={({ field }) => (
<FormItem>
<FormLabel>Withdrawal Strategy</FormLabel>
<Select
onValueChange={(val) => {
field.onChange(val);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select withdrawal strategy" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fixed">Fixed Inflation-Adjusted</SelectItem>
<SelectItem value="percentage">Percentage of Portfolio</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.watch("withdrawalStrategy") === "percentage" && (
<FormField
control={form.control}
name="withdrawalPercentage"
render={({ field }) => (
<FormItem>
<FormLabel>Withdrawal Percentage (%)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 4.0"
type="number"
step="0.1"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
{!result && (
@@ -590,6 +913,28 @@ 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"

View 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
View 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
View File

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