1 Commits

Author SHA1 Message Date
eb14f9fa37 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 28s
Lint / Lint and Typecheck (pull_request) Failing after 22s
2025-11-15 02:03:43 +00:00
26 changed files with 894 additions and 3133 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:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
@@ -27,5 +27,5 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Run lint
run: pnpm run lint
- name: Run check
run: pnpm run check

5
.gitignore vendored
View File

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

View File

@@ -1,6 +0,0 @@
{
"tabWidth": 2,
"singleQuote": true,
"printWidth": 105,
"plugins": ["prettier-plugin-tailwindcss"]
}

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

48
eslint.config.js Normal file
View File

@@ -0,0 +1,48 @@
import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
export default tseslint.config(
{
ignores: [".next"],
},
...compat.extends("next/core-web-vitals"),
{
files: ["**/*.ts", "**/*.tsx"],
extends: [
...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
rules: {
"@typescript-eslint/array-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": [
"warn",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
},
},
{
linterOptions: {
reportUnusedDisableDirectives: true,
},
languageOptions: {
parserOptions: {
projectService: true,
},
},
},
);

View File

@@ -1,37 +0,0 @@
// @ts-check
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import tseslint from "typescript-eslint";
const eslintConfig = defineConfig([
// Next.js core-web-vitals and TypeScript configs
...nextVitals,
...nextTs,
// Add strict TypeScript rules on top
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
// Configure TypeScript parser options
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
// Override default ignores of eslint-config-next
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
// Additional ignores:
"*.mjs",
"tailwind.config.ts",
]),
]);
export default eslintConfig;

View File

@@ -2,7 +2,7 @@
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import './src/env.ts';
import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {};

View File

@@ -9,11 +9,9 @@
"dev": "next dev --turbopack",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next typegen && eslint . && npx tsc --noEmit",
"lint:fix": "next lint --fix",
"preview": "next build && next start",
"start": "next start",
"test": "vitest",
"test:e2e": "playwright test"
"start": "next start"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
@@ -25,48 +23,35 @@
"@t3-oss/env-nextjs": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.554.0",
"next": "16.0.3",
"lucide-react": "^0.553.0",
"next": "^15.4.1",
"next-plausible": "^3.12.4",
"react": "19.2.0",
"react-dom": "19.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.1",
"recharts": "^2.15.3",
"recharts": "^3.0.0",
"tailwind-merge": "^3.2.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@eslint/eslintrc": "3.3.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-dom": "19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.2",
"eslint": "9.39.1",
"eslint-config-next": "16.0.3",
"eslint-config-prettier": "^10.1.8",
"jsdom": "^27.2.0",
"eslint-config-next": "15.5.6",
"eslint-plugin-react-hooks": "5.2.0",
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.7.1",
"tailwindcss": "4.1.17",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
"typescript-eslint": "8.47.0",
"vitest": "^4.0.13"
"typescript-eslint": "8.46.3"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"packageManager": "pnpm@10.23.0",
"pnpm": {
"overrides": {
"@types/react": "19.2.6",
"@types/react-dom": "19.2.3"
}
}
"packageManager": "pnpm@10.20.0"
}

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

3091
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

4
prettier.config.js Normal file
View File

@@ -0,0 +1,4 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
export default {
plugins: ["prettier-plugin-tailwindcss"],
};

View File

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

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,27 +205,18 @@ export default function FireCalculatorForm() {
const retirementData = yearlyData[retirementIndex];
const [fireNumber4percent, retirementAge4percent] = (() => {
// Re-enable 4% rule for deterministic mode or use p50 for MC
// For MC, "untouchedBalance" isn't tracked per run in aggregate, but we can use balanceP50 roughly
// or just disable it as it's a different philosophy.
// For now, let's calculate it based on the main "balance" field (which is p50 in MC)
for (const yearData of yearlyData) {
// Estimate untouched roughly if not tracking exact
const balanceToCheck = yearData.balance;
// Note: This is imperfect for MC because 'balance' includes withdrawals in retirement
// whereas 4% rule check usually looks at "if I retired now with this balance".
// The original code had `untouchedBalance` which grew without withdrawals.
// Since we removed `untouchedBalance` calculation in the aggregate loop, let's skip 4% for MC for now.
if (simulationMode === "deterministic" && yearData.untouchedBalance &&
yearData.untouchedBalance > (yearData.untouchedMonthlyAllowance * 12) / 0.04) {
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) {
if (retirementIndex === -1 || !retirementData) {
setResult({
fireNumber: null,
fireNumber4percent: null,
@@ -369,10 +228,9 @@ export default function FireCalculatorForm() {
// Set the result
setResult({
fireNumber: retirementData.balance,
fireNumber4percent: null,
retirementAge4percent: null,
fireNumber4percent: fireNumber4percent,
retirementAge4percent: retirementAge4percent,
yearlyData: yearlyData,
successRate: simulationMode === "monte-carlo" ? (successCount / numSimulations) * 100 : undefined,
});
}
}
@@ -388,13 +246,7 @@ export default function FireCalculatorForm() {
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit(onSubmit)(e);
}}
className="space-y-8"
>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField
control={form.control}
@@ -413,8 +265,7 @@ export default function FireCalculatorForm() {
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
@@ -442,8 +293,7 @@ export default function FireCalculatorForm() {
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
@@ -471,8 +321,7 @@ export default function FireCalculatorForm() {
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
@@ -500,8 +349,7 @@ export default function FireCalculatorForm() {
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
@@ -530,8 +378,7 @@ export default function FireCalculatorForm() {
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
@@ -560,8 +407,7 @@ export default function FireCalculatorForm() {
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
@@ -591,8 +437,7 @@ export default function FireCalculatorForm() {
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
@@ -622,8 +467,7 @@ export default function FireCalculatorForm() {
step={1}
onValueChange={(value: number[]) => {
field.onChange(value[0]);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
void form.handleSubmit(onSubmit)();
}}
className="py-4"
/>
@@ -632,187 +476,6 @@ export default function FireCalculatorForm() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="coastFireAge"
render={({ field }) => (
<FormItem>
<FormLabel>
Coast FIRE Age (Optional) - Stop contributing at age:
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 45 (defaults to Retirement Age)"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baristaIncome"
render={({ field }) => (
<FormItem>
<FormLabel>
Barista FIRE Income (Monthly during Retirement)
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 1000"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="simulationMode"
render={({ field }) => (
<FormItem>
<FormLabel>Simulation Mode</FormLabel>
<Select
onValueChange={(val) => {
field.onChange(val);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select simulation mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="deterministic">Deterministic (Linear)</SelectItem>
<SelectItem value="monte-carlo">Monte Carlo (Probabilistic)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.watch("simulationMode") === "monte-carlo" && (
<FormField
control={form.control}
name="volatility"
render={({ field }) => (
<FormItem>
<FormLabel>Market Volatility (Std Dev %)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 15"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="withdrawalStrategy"
render={({ field }) => (
<FormItem>
<FormLabel>Withdrawal Strategy</FormLabel>
<Select
onValueChange={(val) => {
field.onChange(val);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select withdrawal strategy" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fixed">Fixed Inflation-Adjusted</SelectItem>
<SelectItem value="percentage">Percentage of Portfolio</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.watch("withdrawalStrategy") === "percentage" && (
<FormField
control={form.control}
name="withdrawalPercentage"
render={({ field }) => (
<FormItem>
<FormLabel>Withdrawal Percentage (%)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 4.0"
type="number"
step="0.1"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
{!result && (
@@ -913,28 +576,6 @@ export default function FireCalculatorForm() {
yAxisId={"right"}
stackId={"a"}
/>
{form.getValues("simulationMode") === "monte-carlo" && (
<>
<Area
type="monotone"
dataKey="balanceP10"
stroke="none"
fill="var(--color-orange-500)"
fillOpacity={0.1}
yAxisId={"right"}
connectNulls
/>
<Area
type="monotone"
dataKey="balanceP90"
stroke="none"
fill="var(--color-orange-500)"
fillOpacity={0.1}
yAxisId={"right"}
connectNulls
/>
</>
)}
<Area
type="step"
dataKey="monthlyAllowance"
@@ -1007,7 +648,7 @@ export default function FireCalculatorForm() {
)}
{result && (
<Button
onClick={() => { setShowing4percent(!showing4percent); }}
onClick={() => setShowing4percent(!showing4percent)}
variant={showing4percent ? "secondary" : "default"}
size={"sm"}
>

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

@@ -66,7 +66,7 @@ export default function HomePage() {
};
return (
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-linear-to-b p-2">
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-2">
<BackgroundPattern />
<div className="z-10 mx-auto flex flex-col items-center justify-center gap-4 text-center">
<div className="mt-8 flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
@@ -78,7 +78,7 @@ export default function HomePage() {
width={100}
height={100}
/>
<h1 className="from-primary via-primary-foreground to-primary bg-linear-to-r bg-clip-text text-5xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[5rem]">
<h1 className="from-primary via-primary-foreground to-primary bg-gradient-to-r bg-clip-text text-5xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[5rem]">
InvestingFIRE
</h1>
</div>

View File

@@ -20,9 +20,9 @@ export type ChartConfig = Record<
)
>;
interface ChartContextProps {
type ChartContextProps = {
config: ChartConfig;
}
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
@@ -135,12 +135,12 @@ function ChartTooltipContent({
return null;
}
const item = payload[0];
const key = labelKey ?? String(item.dataKey ?? item.name ?? "value");
const [item] = payload;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? (label in config && config[label].label ? config[label].label : undefined) ?? label
? (config[label]?.label ?? label)
: itemConfig?.label;
if (labelFormatter) {
@@ -175,14 +175,14 @@ function ChartTooltipContent({
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = nameKey ?? String(item.name ?? item.dataKey ?? "value");
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor: string | undefined =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
@@ -196,7 +196,7 @@ function ChartTooltipContent({
indicator === "dot" && "items-center",
)}
>
{formatter && item.value !== undefined && item.name ? (
{formatter && item?.value !== undefined && item.name ? (
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
formatter(item.value, item.name, item, index, item.payload)
) : (
@@ -207,7 +207,7 @@ function ChartTooltipContent({
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-border bg-(--color-bg)",
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",

View File

@@ -18,12 +18,12 @@ import { Label } from "@/components/ui/label";
const Form = FormProvider;
interface FormFieldContextValue<
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> {
> = {
name: TName;
}
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
@@ -61,9 +61,9 @@ const useFormField = () => {
};
};
interface FormItemContextValue {
type FormItemContextValue = {
id: string;
}
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
@@ -110,7 +110,7 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
id={formItemId}
aria-describedby={
!error
? formDescriptionId
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
@@ -134,7 +134,7 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? (error.message ?? "") : props.children;
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;

View File

@@ -37,7 +37,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -61,7 +61,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
@@ -74,7 +74,7 @@ function SelectContent({
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1",
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
@@ -107,7 +107,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}

View File

@@ -31,7 +31,7 @@ function Slider({
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}

View File

@@ -1,35 +1,42 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
/* Base Options: */
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"incremental": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"target": "ES2022",
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": false,
"exactOptionalPropertyTypes": false,
"noImplicitReturns": false,
"plugins": [{ "name": "next" }]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
"**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}

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