1 Commits

Author SHA1 Message Date
4afc5859db chore(deps): update actions/checkout action to v6
All checks were successful
Lint / Lint and Typecheck (push) Successful in 33s
Lint / Lint and Typecheck (pull_request) Successful in 34s
2025-11-22 01:03:00 +00:00
11 changed files with 364 additions and 2396 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.

5
.gitignore vendored
View File

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

View File

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

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",
"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,7 +23,7 @@
"@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",
@@ -36,36 +34,28 @@
"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",
"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"
}
}

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

2111
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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-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,150 +140,64 @@ 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,
balance: startingCapital,
untouchedBalance: startingCapital,
phase: "accumulation",
monthlyAllowance: 0,
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
untouchedBalance: untouchedBalance,
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,24 +205,15 @@ 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) {
return [yearData.untouchedBalance, yearData.age];
}
for (const yearData of yearlyData) {
if (
yearData.untouchedBalance >
(yearData.untouchedMonthlyAllowance * 12) / 0.04
) {
return [yearData.untouchedBalance, yearData.age];
}
return [null, null];
}
return [0, 0];
})();
if (retirementIndex === -1) {
@@ -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,
});
}
}
@@ -632,187 +490,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 +590,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"

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

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