Compare commits

..

2 Commits

Author SHA1 Message Date
fe03807739 shadcn 2025-04-29 17:46:38 +02:00
30d27a212e initial files 2025-04-29 17:09:04 +02:00
35 changed files with 6303 additions and 7538 deletions

2
.gitattributes vendored
View File

@@ -1,2 +0,0 @@
*.png filter=lfs diff=lfs merge=lfs -text
*.ico filter=lfs diff=lfs merge=lfs -text

View File

@@ -1,31 +0,0 @@
name: Lint
on:
pull_request:
push:
branches:
- "**" # matches every branch
jobs:
lint_and_typecheck:
name: Lint and Typecheck
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Run check
run: pnpm run check

109
README.md
View File

@@ -1,108 +1,3 @@
![InvestingFIRE logo](/src/app/apple-icon.png)
# fire
# 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:
- Input starting capital, monthly savings, expected annual growth rate, inflation rate, current age, desired retirement age, life expectancy, and desired monthly retirement allowance.
- View a dynamic chart displaying projected portfolio balance and monthly allowance over time.
- Instantly see their estimated “FIRE number” (required capital at retirement), how long their capital will last, and compare results to the “4% rule.”
- Adjust assumptions live, with all calculations and visualizations updating automatically.
- Access explanatory content about FIRE methodology, key variables, and additional community resources, all on a single, consolidated page.
The projects code is structured using React/Next.js with TypeScript, focusing on user experience, modern UI components, and clarity of financial assumptions.
---
## 🚀 Features at a Glance
- **⚡️ Real-Time Projections:** Every field updates the chart _as you type_. Experiment with savings, growth rates, inflation, or retirement age and see your future instantly.
- **📈 Interactive Chart:** Dual-area plots for portfolio value and future monthly spending, plus reference lines for FIRE milestones and “4% rule” legends.
- **🧠 Education Baked In:** Contextual tooltips, deep-dive sections on how FIRE works, FAQs, and must-read resources included.
- **🔎 Detailed Methodology:** Not just a 25x rule — runs a full, year-by-year simulation with inflation-adjusted withdrawals and optional 4%-rule overlays.
- **👌 Modern UX:** Typing, sliding, or clicking feels _good_. Responsive on all devices.
---
## 🧰 How It Works
The calculator models your FIRE journey in two phases:
1. **Accumulation:**
- Your starting capital is grown by your expected CAGR (~7% by default).
- Monthly savings are added for each year until retirement.
- Every variable can be adjusted live (capital, savings, age, growth, inflation, spending, target retirement).
2. **Retirement:**
- Your balance continues to grow by CAGR.
- Each year, an inflation-adjusted monthly allowance is withdrawn.
- The simulation runs until your selected life expectancy, showing the possibility of portfolio depletion.
**Key Outputs:**
- 🔥 “FIRE Number”: Portfolio value at your defined retirement age
- 📊 Interactive projection chart: See how your nest egg and withdrawals evolve over time
- 4⃣ “4% Rule” overlays: Compare dynamic results to classic rule-of-thumb
---
## 🌟 Try It For Yourself
To run locally:
1. **Clone the repo**
```bash
git clone https://git.schulze.network/schulze/fire.git
cd fire
```
2. **Install dependencies**
```bash
pnpm install
```
3. **Run the app**
```bash
pnpm run dev
```
4. Visit [http://localhost:3000](http://localhost:3000) and unleash the fire.
Deployed version: [https://investingfire.com](https://investingfire.com)
---
## ✏️ Inputs & Variables
- **Starting Capital** — How much youve already invested
- **Monthly Savings** — What youll add each month
- **Current Age & Retirement Age** — Your FI timeline
- **Life Expectancy** — How long do you want income to last?
- **Expected Growth Rate (CAGR)** — Portfolio annual % return, before inflation
- **Inflation Rate** — Cost of living increases
- **Desired Monthly Allowance** — Your lifestyle, todays dollars
As you adjust these, all projections update instantly _without needing to hit “Calculate.”_
Try many “what ifs” fast.
---
## 👩‍💻 Contributing
Pull requests are welcome! Open issues for bugs, new features, or debate about safe withdrawal rates and tax assumptions.
---
## 📄 License
[GPL-3.0](./LICENSE)
---
## 🥇 Why Use InvestingFIRE?
- You want the truth — not just a 4% fantasy.
- You want to learn, not just punch in numbers.
- You want clarity, speed, and modern UI.
- You want to show your friends the best FIRE tool on the web.
Enjoy the _rocket ride_ to financial independence.
**InvestingFIRE — Know your number. Change your future.**
FIRE calculator

View File

@@ -11,11 +11,11 @@
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
"components": "~/components",
"utils": "~/lib/utils",
"ui": "~/components/ui",
"lib": "~/lib",
"hooks": "~/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -14,7 +14,7 @@ export default tseslint.config(
files: ["**/*.ts", "**/*.tsx"],
extends: [
...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
],
rules: {

6207
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,49 +9,45 @@
"dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint",
"lint:fix": "next lint --fix",
"preview": "next build && next start",
"start": "next start"
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0",
"@t3-oss/env-nextjs": "^0.13.0",
"@t3-oss/env-nextjs": "^0.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.525.0",
"lucide-react": "^0.503.0",
"next": "^15.2.3",
"next-plausible": "^3.12.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.1",
"recharts": "^3.0.0",
"tailwind-merge": "^3.2.0",
"zod": "^4.0.0"
"zod": "^3.24.3"
},
"devDependencies": {
"@eslint/eslintrc": "3.3.1",
"@tailwindcss/postcss": "4.1.11",
"@types/node": "22.16.3",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"eslint": "9.31.0",
"eslint-config-next": "15.3.5",
"eslint-plugin-react-hooks": "^5.2.0",
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.6.14",
"tailwindcss": "4.1.11",
"tw-animate-css": "1.3.5",
"typescript": "5.8.3",
"typescript-eslint": "8.36.0"
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.23.0",
"eslint-config-next": "^15.2.3",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.15",
"tw-animate-css": "^1.2.8",
"typescript": "^5.8.2",
"typescript-eslint": "^8.27.0"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
},
"packageManager": "pnpm@10.13.1"
"packageManager": "npm@11.2.0"
}

5070
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
ignoredBuiltDependencies:
- unrs-resolver
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- sharp

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" width="1000" height="1000" viewBox="0 0 264.58 264.58"><defs><linearGradient id="b"><stop offset="0" stop-color="#fd8315"/><stop offset="1" stop-color="#fa6b14"/></linearGradient><linearGradient id="a"><stop offset="0" stop-color="#f24b1b"/><stop offset="1" stop-color="#dc2f12"/></linearGradient><linearGradient xlink:href="#a" id="d" x1="172.49" x2="179.1" y1="64.48" y2="197.19" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="c" x1="118.9" x2="117.99" y1="14.21" y2="194.34" gradientUnits="userSpaceOnUse"/></defs><g stroke-linecap="round" stroke-linejoin="round" stroke-width=".13"><path fill="url(#c)" stroke="#f14a1b" d="m115.13 9.96-.26.01c-.97.11-1.29 1.02-.75 2.38a45.6 45.6 0 0 1 3.02 15.68c-.09 8.46.04 12.87-7.31 23.68s-23.16 21.9-33.96 34.66c-10.8 12.76-12.28 16.6-16.1 26.2A90.42 90.42 0 0 0 53.05 146c0 41.29 27.68 76.09 65.49 86.91 35.3-25.9 55.47-125.62 55.47-125.62s-14.45-10.54-18.57-18.89c-1.26-2.56-1.97-6.15-1.97-9.58.01-2.54 1.3-8.72 1.47-9.41a42.4 42.4 0 0 0 .94-12.14c-.07-.95-.17-1.9-.3-2.84v-.01a59.45 59.45 0 0 0-7.6-19h0a60.34 60.34 0 0 0-10.24-12.13 66.97 66.97 0 0 0-21.7-13.15 2.94 2.94 0 0 0-.92-.18z"/><path fill="url(#d)" stroke="#510a0c" d="M170.01 58.08a66.66 66.66 0 0 0-10.24 15.94 66.66 66.66 0 0 0-6.3 27.82 66.8 66.8 0 0 0 3.66 20.86h-.08l7.08 105.8s37.38-25.1 45.9-61a74.13 74.13 0 0 0 2.04-25.93c-1.34-14.35-4.35-21.67-9.85-30.3-5.5-8.62-10.36-14-17.63-22.78-6.17-7.43-9.44-18.25-10.39-28.87-.28-3.13-2.13-3.91-4.19-1.54z"/></g><path fill="#510a0c" d="M93.45 115.81h77.91c9.81 0 17.71 7.9 17.71 17.7v104.53c0 9.81-7.9 17.71-17.7 17.71H93.44c-9.81 0-17.71-7.9-17.71-17.7V133.51c0-9.81 7.9-17.71 17.7-17.71z"/><path fill="#e83c1b" d="M91.95 163.12h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67zm51.25-45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67z"/><g fill="#520a0c"><path d="M148.74 179.98h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm-51.54.04h18.29a2.41 2.41 0 1 1 0 4.83h-18.3a2.41 2.41 0 1 1 0-4.83z"/><path d="M108.76 173.3v18.28a2.41 2.41 0 1 1-4.84 0V173.3a2.41 2.41 0 1 1 4.84 0zm-10.59 59.18 12.93-12.93a2.41 2.41 0 1 1 3.42 3.42l-12.93 12.93a2.41 2.41 0 1 1-3.42-3.42z"/><path d="m101.59 219.55 12.93 12.93a2.41 2.41 0 1 1-3.42 3.42l-12.93-12.93a2.41 2.41 0 1 1 3.42-3.42zm47.15 1.49h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm0 10.73h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84z"/></g><path fill="#fcf2e4" d="M92.35 125.2h79.67a7.07 7.07 0 0 1 7.09 7.1v14.36a7.07 7.07 0 0 1-7.09 7.1H92.35a7.07 7.07 0 0 1-7.08-7.1V132.3a7.07 7.07 0 0 1 7.08-7.09z"/></svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

BIN
public/web-app-manifest-192x192.png (Stored with Git LFS)

Binary file not shown.

BIN
public/web-app-manifest-512x512.png (Stored with Git LFS)

Binary file not shown.

View File

@@ -1 +0,0 @@
wgu5fuk8d5j5wp3pjtta9vrw8d9by9qk

View File

@@ -1,11 +0,0 @@
{
"extends": ["config:best-practices", ":semanticCommits"],
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
"automerge": true,
"automergeType": "branch"
}
]
}

BIN
src/app/apple-icon.png (Stored with Git LFS)

Binary file not shown.

View File

@@ -1,157 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import {
type LucideIcon,
HandCoins,
Bitcoin,
Coins,
DollarSign,
Euro,
IndianRupee,
JapaneseYen,
PiggyBank,
PoundSterling,
Wallet,
Banknote,
ChartCandlestick,
CirclePercent,
CreditCard,
Gem,
Receipt,
ShoppingBasket,
Rocket,
RockingChair,
Sparkles,
ChartPie,
ChartBar,
BarChart3,
ChartLine,
TrendingDown,
TrendingUp,
Vault,
Landmark,
Briefcase,
Handshake,
Shield,
Lock,
CalendarRange,
Hourglass,
Sprout,
Target,
} from "lucide-react";
export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [rows, setRows] = useState(0);
const [columns, setColumns] = useState(0);
useEffect(() => {
const updateDimensions = () => {
if (window.innerWidth > width + spacing * 2) {
setWidth(window.innerWidth);
}
if (window.innerHeight > height + spacing * 2) {
setHeight(window.innerHeight);
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => {
window.removeEventListener("resize", updateDimensions);
};
}, [height, width, spacing]);
useEffect(() => {
setColumns(Math.ceil(width / spacing) + 3);
}, [width, spacing]);
useEffect(() => {
setRows(Math.ceil(height / spacing) + 3);
}, [height, spacing]);
// Explicitly type the array as LucideIcon[]
const iconComponents: LucideIcon[] = [
HandCoins,
Bitcoin,
Coins,
DollarSign,
Euro,
IndianRupee,
JapaneseYen,
PiggyBank,
PoundSterling,
Wallet,
Banknote,
ChartCandlestick,
CirclePercent,
CreditCard,
Gem,
Receipt,
ShoppingBasket,
Rocket,
RockingChair,
Sparkles,
ChartPie,
ChartBar,
BarChart3,
ChartLine,
TrendingDown,
TrendingUp,
Vault,
Landmark,
Briefcase,
Handshake,
Shield,
Lock,
CalendarRange,
Hourglass,
Sprout,
Target,
];
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]!;
// 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));
icons.push(
<IconComponent
key={`icon-${x}-${y}`}
size={size}
className="text-primary fixed"
style={{
left: `${x * spacing + xOffset}px`,
top: `${y * spacing + yOffset}px`,
opacity: opacity,
transform: `rotate(${Math.round((Math.random() - 0.5) * 30)}deg)`,
}}
/>,
);
}
}
return icons;
};
return (
<div className="absolute h-full w-full">
{width > 0 && renderIcons({ rows, columns })}
</div>
);
}

View File

@@ -1,741 +0,0 @@
"use client";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
import {
Area,
AreaChart,
CartesianGrid,
XAxis,
YAxis,
ReferenceLine,
type TooltipProps,
} from "recharts";
import { Slider } from "@/components/ui/slider";
import assert from "assert";
import type {
NameType,
ValueType,
} from "recharts/types/component/DefaultTooltipContent";
// Schema for form validation
const formSchema = z.object({
startingCapital: z.coerce.number(),
monthlySavings: z.coerce
.number()
.min(0, "Monthly savings must be a non-negative number"),
currentAge: z.coerce
.number()
.min(1, "Age must be at least 1")
.max(100, "No point in starting this late"),
cagr: z.coerce.number().min(0, "Growth rate must be a non-negative number"),
desiredMonthlyAllowance: z.coerce
.number()
.min(0, "Monthly allowance must be a non-negative number"),
inflationRate: z.coerce
.number()
.min(0, "Inflation rate must be a non-negative number"),
lifeExpectancy: z.coerce
.number()
.min(40, "Be a bit more optimistic buddy :(")
.max(100, "You should be more realistic..."),
retirementAge: z.coerce
.number()
.min(18, "Retirement age must be at least 18")
.max(100, "Retirement age must be at most 100"),
});
// Type for form values
type FormValues = z.infer<typeof formSchema>;
interface YearlyData {
age: number;
year: number;
balance: number;
untouchedBalance: number;
phase: "accumulation" | "retirement";
monthlyAllowance: number;
untouchedMonthlyAllowance: number;
}
interface CalculationResult {
fireNumber: number | null;
fireNumber4percent: number | null;
retirementAge4percent: number | null;
yearlyData: YearlyData[];
error?: string;
}
// Helper function to format currency without specific symbols
const formatNumber = (value: number | null) => {
if (!value) return "N/A";
return new Intl.NumberFormat("en", {
maximumFractionDigits: 0,
}).format(value);
};
// Helper function to render tooltip for chart
const tooltipRenderer = ({
active,
payload,
}: TooltipProps<ValueType, NameType>) => {
if (active && payload?.[0]?.payload) {
const data = payload[0].payload as YearlyData;
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>
<p className="text-red-600">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div>
);
}
return null;
};
export default function FireCalculatorForm() {
const [result, setResult] = useState<CalculationResult | null>(null);
const irlYear = new Date().getFullYear();
const [showing4percent, setShowing4percent] = useState(false);
// Initialize form with default values
const form = useForm<z.input<typeof formSchema>, undefined, FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
startingCapital: 50000,
monthlySavings: 1500,
currentAge: 25,
cagr: 7,
desiredMonthlyAllowance: 3000,
inflationRate: 2.3,
lifeExpectancy: 84,
retirementAge: 55,
},
});
function onSubmit(values: FormValues) {
setResult(null); // Reset previous results
const startingCapital = values.startingCapital;
const monthlySavings = values.monthlySavings;
const age = values.currentAge;
const annualGrowthRate = 1 + values.cagr / 100;
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
const annualInflation = 1 + values.inflationRate / 100;
const ageOfDeath = values.lifeExpectancy;
const retirementAge = values.retirementAge;
// Array to store yearly data for the chart
const yearlyData: YearlyData[] = [];
// Initial year data
yearlyData.push({
age: age,
year: irlYear,
balance: startingCapital,
untouchedBalance: startingCapital,
phase: "accumulation",
monthlyAllowance: 0,
untouchedMonthlyAllowance: initialMonthlyAllowance,
});
// 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";
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: newBalance,
untouchedBalance: untouchedBalance,
phase: phase,
monthlyAllowance: allowance,
untouchedMonthlyAllowance: inflatedAllowance,
});
}
// Calculate FIRE number at retirement
const retirementYear = irlYear + (retirementAge - age);
const retirementIndex = yearlyData.findIndex(
(data) => data.year === retirementYear,
);
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];
}
}
return [0, 0];
})();
if (retirementIndex === -1 || !retirementData) {
setResult({
fireNumber: null,
fireNumber4percent: null,
retirementAge4percent: null,
error: "Could not calculate retirement data",
yearlyData: yearlyData,
});
} else {
// Set the result
setResult({
fireNumber: retirementData.balance,
fireNumber4percent: fireNumber4percent,
retirementAge4percent: retirementAge4percent,
yearlyData: yearlyData,
});
}
}
return (
<>
<Card className="mb-4">
<CardHeader>
<CardTitle className="text-2xl">FIRE Calculator</CardTitle>
<CardDescription>
Calculate your path to financial independence and retirement
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<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}
name="startingCapital"
render={({ field }) => (
<FormItem>
<FormLabel>Starting Capital</FormLabel>
<FormControl>
<Input
placeholder="e.g., 10000"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="monthlySavings"
render={({ field }) => (
<FormItem>
<FormLabel>Monthly Savings</FormLabel>
<FormControl>
<Input
placeholder="e.g., 500"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currentAge"
render={({ field }) => (
<FormItem>
<FormLabel>Current Age</FormLabel>
<FormControl>
<Input
placeholder="e.g., 30"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lifeExpectancy"
render={({ field }) => (
<FormItem>
<FormLabel>Life Expectancy (Age)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 90"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cagr"
render={({ field }) => (
<FormItem>
<FormLabel>Expected Annual Growth Rate (%)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 7"
type="number"
step="0.1"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="inflationRate"
render={({ field }) => (
<FormItem>
<FormLabel>Annual Inflation Rate (%)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 2"
type="number"
step="0.1"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="desiredMonthlyAllowance"
render={({ field }) => (
<FormItem>
<FormLabel>
Desired Monthly Allowance (Today&apos;s Value)
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 2000"
type="number"
value={field.value as number | string | undefined}
onChange={(e) => {
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value),
);
void form.handleSubmit(onSubmit)();
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Retirement Age Slider */}
<FormField
control={form.control}
name="retirementAge"
render={({ field }) => (
<FormItem>
<FormLabel>
Retirement Age: {field.value as number}
</FormLabel>
<FormControl>
<Slider
name="retirementAge"
value={[field.value as number]}
min={25}
max={75}
step={1}
onValueChange={(value: number[]) => {
field.onChange(value[0]);
void form.handleSubmit(onSubmit)();
}}
className="py-4"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{!result && (
<Button type="submit" className="w-full">
Calculate
</Button>
)}
{result?.yearlyData && (
<Card className="rounded-md shadow-none">
<CardHeader>
<CardTitle>Financial Projection</CardTitle>
<CardDescription>
Projected balance growth with your selected retirement age
</CardDescription>
</CardHeader>
<CardContent className="px-2">
<ChartContainer
className="aspect-auto h-80 w-full"
config={{}}
>
<AreaChart
data={result.yearlyData}
margin={{ top: 10, right: 20, left: 20, bottom: 10 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="year"
label={{
value: "Year",
position: "insideBottom",
offset: -10,
}}
/>
{/* Right Y axis */}
<YAxis
yAxisId={"right"}
orientation="right"
tickFormatter={(value: number) => {
if (value >= 1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toPrecision(3)}K`;
} else if (value <= -1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value <= -1000) {
return `${(value / 1000).toPrecision(3)}K`;
}
return value.toString();
}}
width={30}
stroke="var(--color-orange-500)"
tick={{}}
/>
{/* Left Y axis */}
<YAxis
yAxisId="left"
orientation="left"
tickFormatter={(value: number) => {
if (value >= 1000000) {
return `${(value / 1000000).toPrecision(3)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toPrecision(3)}K`;
}
return value.toString();
}}
width={30}
stroke="var(--color-red-600)"
/>
<ChartTooltip content={tooltipRenderer} />
<defs>
<linearGradient
id="fillBalance"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--color-orange-500)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-orange-500)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="balance"
name="balance"
stroke="var(--color-orange-500)"
fill="url(#fillBalance)"
fillOpacity={0.9}
activeDot={{ r: 6 }}
yAxisId={"right"}
stackId={"a"}
/>
<Area
type="step"
dataKey="monthlyAllowance"
name="allowance"
stroke="var(--color-red-600)"
fill="none"
activeDot={{ r: 6 }}
yAxisId="left"
/>
{result.fireNumber && (
<ReferenceLine
y={result.fireNumber}
stroke="var(--primary)"
strokeWidth={2}
strokeDasharray="2 1"
label={{
value: "FIRE Number",
position: "insideBottomRight",
}}
yAxisId={"right"}
/>
)}
{result.fireNumber4percent && showing4percent && (
<ReferenceLine
y={result.fireNumber4percent}
stroke="var(--secondary)"
strokeWidth={1}
strokeDasharray="1 1"
label={{
value: "4%-Rule FIRE Number",
position: "insideBottomLeft",
}}
yAxisId={"right"}
/>
)}
<ReferenceLine
x={
irlYear +
(Number(form.getValues("retirementAge")) -
Number(form.getValues("currentAge")))
}
stroke="var(--primary)"
strokeWidth={2}
label={{
value: "Retirement",
position: "insideTopRight",
}}
yAxisId={"left"}
/>
{result.retirementAge4percent && showing4percent && (
<ReferenceLine
x={
irlYear +
(result.retirementAge4percent -
Number(form.getValues("currentAge")))
}
stroke="var(--secondary)"
strokeWidth={1}
label={{
value: "4%-Rule Retirement",
position: "insideBottomLeft",
}}
yAxisId={"left"}
/>
)}
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)}
{result && (
<Button
onClick={() => setShowing4percent(!showing4percent)}
variant={showing4percent ? "secondary" : "default"}
size={"sm"}
>
{showing4percent ? "Hide" : "Show"} 4%-Rule
</Button>
)}
</form>
</Form>
</CardContent>
</Card>
{result && (
<div className="mb-4 grid grid-cols-1 gap-2 md:grid-cols-2">
{result.error ? (
<Card className="col-span-full">
<CardContent className="pt-6">
<p className="text-destructive">{result.error}</p>
</CardContent>
</Card>
) : (
<>
<Card>
<CardHeader>
<CardTitle>FIRE Number</CardTitle>
<CardDescription className="text-xs">
Capital at retirement
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{formatNumber(result.fireNumber)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Retirement Duration</CardTitle>
<CardDescription className="text-xs">
Years to enjoy your financial independence
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{Number(form.getValues("lifeExpectancy")) -
Number(form.getValues("retirementAge"))}
</p>
</CardContent>
</Card>
{showing4percent && (
<>
<Card>
<CardHeader>
<CardTitle>4%-Rule FIRE Number</CardTitle>
<CardDescription className="text-xs">
Capital needed for 4% of it to be greater than your
yearly allowance
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{formatNumber(result.fireNumber4percent)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>4%-Rule Retirement Duration</CardTitle>
<CardDescription className="text-xs">
Years to enjoy your financial independence if you follow
the 4% rule
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{Number(form.getValues("lifeExpectancy")) -
(result.retirementAge4percent ?? 0)}
</p>
</CardContent>
</Card>
</>
)}
</>
)}
</div>
)}
</>
);
}

View File

@@ -1,16 +0,0 @@
export default function Footer() {
return (
<footer className="w-full py-8 text-center text-xs">
<p className="text-xs">
© {new Date().getFullYear()} InvestingFIRE. All rights reserved.{" "}
<a
href="https://schulze.network"
target="_blank"
className="text-primary hover:underline"
>
Hosting by Schulze.network
</a>
</p>
</footer>
);
}

View File

@@ -1,77 +0,0 @@
"use client";
import { usePlausible } from "next-plausible";
import { useReportWebVitals } from "next/web-vitals";
interface Metric {
/**
* The name of the metric (in acronym form).
*/
name: "CLS" | "FCP" | "FID" | "INP" | "LCP" | "TTFB";
/**
* The current value of the metric.
*/
value: number;
/**
* The rating as to whether the metric value is within the "good",
* "needs improvement", or "poor" thresholds of the metric.
*/
rating: "good" | "needs-improvement" | "poor";
/**
* The delta between the current value and the last-reported value.
* On the first report, `delta` and `value` will always be the same.
*/
delta: number;
/**
* A unique ID representing this particular metric instance. This ID can
* be used by an analytics tool to dedupe multiple values sent for the same
* metric instance, or to group multiple deltas together and calculate a
* total. It can also be used to differentiate multiple different metric
* instances sent from the same page, which can happen if the page is
* restored from the back/forward cache (in that case new metrics object
* get created).
*/
id: string;
/**
* Any performance entries relevant to the metric value calculation.
* The array may also be empty if the metric value was not based on any
* entries (e.g. a CLS value of 0 given no layout shifts).
*/
entries: PerformanceEntry[];
/**
* The type of navigation.
*
* This will be the value returned by the Navigation Timing API (or
* `undefined` if the browser doesn't support that API), with the following
* exceptions:
* - 'back-forward-cache': for pages that are restored from the bfcache.
* - 'back_forward' is renamed to 'back-forward' for consistency.
* - 'prerender': for pages that were prerendered.
* - 'restore': for pages that were discarded by the browser and then
* restored by the user.
*/
navigationType:
| "navigate"
| "reload"
| "back-forward"
| "back-forward-cache"
| "prerender"
| "restore";
}
export function WebVitals() {
const plausible = usePlausible();
useReportWebVitals((metric: Metric) => {
plausible("web-vitals", {
props: {
[metric.name]: metric.rating,
},
});
});
return <></>;
}

BIN
src/app/favicon.ico (Stored with Git LFS)

Binary file not shown.

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" width="1000" height="1000" viewBox="0 0 264.58 264.58"><defs><linearGradient id="b"><stop offset="0" stop-color="#fd8315"/><stop offset="1" stop-color="#fa6b14"/></linearGradient><linearGradient id="a"><stop offset="0" stop-color="#f24b1b"/><stop offset="1" stop-color="#dc2f12"/></linearGradient><linearGradient xlink:href="#a" id="d" x1="172.49" x2="179.1" y1="64.48" y2="197.19" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="c" x1="118.9" x2="117.99" y1="14.21" y2="194.34" gradientUnits="userSpaceOnUse"/></defs><rect width="264.58" height="264.58" fill="#fdf2e4" ry="42.08" style="-inkscape-stroke:none"/><g stroke-linecap="round" stroke-linejoin="round" stroke-width=".13"><path fill="url(#c)" stroke="#f14a1b" d="m115.13 9.96-.26.01c-.97.11-1.29 1.02-.75 2.38a45.6 45.6 0 0 1 3.02 15.68c-.09 8.46.04 12.87-7.31 23.68s-23.16 21.9-33.96 34.66c-10.8 12.76-12.28 16.6-16.1 26.2A90.42 90.42 0 0 0 53.05 146c0 41.29 27.68 76.09 65.49 86.91 35.3-25.9 55.47-125.62 55.47-125.62s-14.45-10.54-18.57-18.89c-1.26-2.56-1.97-6.15-1.97-9.58.01-2.54 1.3-8.72 1.47-9.41a42.4 42.4 0 0 0 .94-12.14c-.07-.95-.17-1.9-.3-2.84v-.01a59.45 59.45 0 0 0-7.6-19h0a60.34 60.34 0 0 0-10.24-12.13 66.97 66.97 0 0 0-21.7-13.15 2.94 2.94 0 0 0-.92-.18z"/><path fill="url(#d)" stroke="#510a0c" d="M170.01 58.08a66.66 66.66 0 0 0-10.24 15.94 66.66 66.66 0 0 0-6.3 27.82 66.8 66.8 0 0 0 3.66 20.86h-.08l7.08 105.8s37.38-25.1 45.9-61a74.13 74.13 0 0 0 2.04-25.93c-1.34-14.35-4.35-21.67-9.85-30.3-5.5-8.62-10.36-14-17.63-22.78-6.17-7.43-9.44-18.25-10.39-28.87-.28-3.13-2.13-3.91-4.19-1.54z"/></g><path fill="#510a0c" d="M93.45 115.81h77.91c9.81 0 17.71 7.9 17.71 17.7v104.53c0 9.81-7.9 17.71-17.7 17.71H93.44c-9.81 0-17.71-7.9-17.71-17.7V133.51c0-9.81 7.9-17.71 17.7-17.71z"/><path fill="#e83c1b" d="M91.95 163.12h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67zm51.25-45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67z"/><g fill="#520a0c"><path d="M148.74 179.98h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm-51.54.04h18.29a2.41 2.41 0 1 1 0 4.83h-18.3a2.41 2.41 0 1 1 0-4.83z"/><path d="M108.76 173.3v18.28a2.41 2.41 0 1 1-4.84 0V173.3a2.41 2.41 0 1 1 4.84 0zm-10.59 59.18 12.93-12.93a2.41 2.41 0 1 1 3.42 3.42l-12.93 12.93a2.41 2.41 0 1 1-3.42-3.42z"/><path d="m101.59 219.55 12.93 12.93a2.41 2.41 0 1 1-3.42 3.42l-12.93-12.93a2.41 2.41 0 1 1 3.42-3.42zm47.15 1.49h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm0 10.73h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84z"/></g><path fill="#fcf2e4" d="M92.35 125.2h79.67a7.07 7.07 0 0 1 7.09 7.1v14.36a7.07 7.07 0 0 1-7.09 7.1H92.35a7.07 7.07 0 0 1-7.08-7.1V132.3a7.07 7.07 0 0 1 7.08-7.09z"/></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

BIN
src/app/icon1.png (Stored with Git LFS)

Binary file not shown.

View File

@@ -1,41 +0,0 @@
import "@/styles/globals.css";
import PlausibleProvider from "next-plausible";
import { type Metadata, type Viewport } from "next";
import { Geist } from "next/font/google";
import { WebVitals } from "./components/web-vitals";
export const viewport: Viewport = {
themeColor: [{ color: "oklch(0.97 0.0228 95.96)" }],
};
export const metadata: Metadata = {
title: "InvestingFIRE | Finance and Retirement Calculator",
description:
"Achieve Financial Independence & Early Retirement! Plan your FIRE journey with the InvestingFIRE calculator and get personalized projections in buttersmooth graphs.",
};
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={geist.variable}>
<head>
<meta name="apple-mobile-web-app-title" content="FIRE" />
<PlausibleProvider
domain="investingfire.com"
customDomain="https://analytics.schulze.network"
selfHosted={true}
enabled={true}
trackOutboundLinks={true}
/>
</head>
<WebVitals />
<body>{children}</body>
</html>
);
}

View File

@@ -1,21 +0,0 @@
{
"name": "InvestingFIRE",
"short_name": "FIRE",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#fdf2e4",
"background_color": "#fdf2e4",
"display": "standalone"
}

View File

@@ -1,396 +0,0 @@
import Image from "next/image";
import FireCalculatorForm from "./components/FireCalculatorForm";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import Footer from "./components/footer";
import BackgroundPattern from "./components/BackgroundPattern";
export default function HomePage() {
return (
<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">
<Image
priority
unoptimized
src="/investingfire_logo_no-bg.svg"
alt="InvestingFIRE Logo"
width={100}
height={100}
/>
<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>
<p className="text-primary-foreground/90 text-xl font-semibold md:text-2xl">
The #1 FIRE Calculator
</p>
<div className="mt-8 w-full max-w-2xl">
<FireCalculatorForm />
</div>
</div>
{/* Added SEO Content Sections */}
<div className="z-10 mx-auto max-w-2xl py-12 text-left">
<section className="mb-12">
<h2 className="mb-4 text-3xl font-bold">
What Is FIRE? Understanding Financial Independence and Early
Retirement
</h2>
<p className="mb-4 text-lg leading-relaxed">
FIRE stands for{" "}
<strong>Financial Independence, Retire Early</strong>. It&apos;s a
lifestyle movement built around two core ideas:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
<li>
<strong>Aggressive saving & investing</strong>often 50%+ of
incomeso your capital grows rapidly.
</li>
<li>
<strong>Passive-income coverage</strong>when your investment
returns exceed your living expenses, you gain freedom from a
traditional 9-5.
</li>
</ul>
<p className="text-lg leading-relaxed">
By reaching your personal <em>FIRE Number</em>the nest egg needed
to cover your inflation-adjusted spendingyou unlock the option to
step away from a daily paycheck and pursue passion projects, travel,
family, or anything else. This calculator helps you simulate your
journey, estimate how much you need, and visualize both your
accumulation phase and your retirement withdrawals over time.
</p>
</section>
<section className="mb-12">
<h2 className="mb-4 text-3xl font-bold">
How This FIRE Calculator Provides Investing Insights
</h2>
<p className="mb-4 text-lg leading-relaxed">
Our interactive tool goes beyond a simple 25x annual spending
rule. It runs a <strong>year-by-year simulation</strong> of your
portfolio, combining:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
<li>
<strong>Starting Capital</strong>your current invested balance
</li>
<li>
<strong>Monthly Savings</strong>ongoing contributions to your
portfolio
</li>
<li>
<strong>Expected Annual Growth Rate (CAGR)</strong>compounding
returns before inflation
</li>
<li>
<strong>Annual Inflation Rate</strong>to inflate your target
withdrawal each year
</li>
<li>
<strong>Desired Monthly Allowance</strong>today&apos;s-value
spending goal
</li>
<li>
<strong>Retirement Age & Life Expectancy</strong>defines your
accumulation horizon and payout period
</li>
</ul>
<p className="text-lg leading-relaxed">Key features:</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
<li>
<strong>Real-time calculation</strong>as you tweak any input,
your FIRE Number and chart update instantly.
</li>
<li>
<strong>Interactive chart</strong> with area plots for both{" "}
<em>portfolio balance</em> and{" "}
<em>inflation-adjusted allowance</em>, plus reference lines
showing your retirement date and required FIRE Number.
</li>
<li>
<strong>Custom simulation</strong>switches from accumulation
(adding savings) to retirement (withdrawing allowance),
compounding each year based on your growth rate.
</li>
</ul>
<p className="text-lg leading-relaxed">
With this level of granularity, you can confidently experiment with
savings rate, target retirement age, and investment assumptions to
discover how small tweaks speed up or delay your path to financial
independence.
</p>
</section>
<section className="mb-12">
<h2 className="mb-4 text-3xl font-bold">
FIRE & Investing Frequently Asked Questions (FAQ)
</h2>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger className="text-xl font-semibold">
What methodology does this calculator use?
</AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed">
We run a multi-year projection in two phases:
<ol className="ml-6 list-decimal space-y-1">
<li>
<strong>Accumulation:</strong> Your balance grows by CAGR
and you add monthly savings.
</li>
<li>
<strong>Retirement:</strong> The balance continues
compounding, but you withdraw an inflation-adjusted monthly
allowance.
</li>
</ol>
The result: a precise estimate of the capital you&apos;ll have
at retirement (your FIRE Number) and how long it will last
until your chosen life expectancy.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger className="text-xl font-semibold">
Why isn&apos;t this just the 4% rule?
</AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed">
The 4% rule is a useful starting point (25× annual spending),
but it assumes a fixed withdrawal rate with inflation
adjustments and doesn&apos;t model ongoing savings or dynamic
market returns. Our calculator simulates each year&apos;s
growth, contributions, and inflation-indexed withdrawals to give
you a tailored picture.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger className="text-xl font-semibold">
How do I choose a realistic growth rate?
</AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed">
Historically, a diversified portfolio of equities and bonds has
returned around 7-10% per year before inflation. We recommend
starting around 6-8% (net of fees), then running what-if
scenarios5% on the conservative side, 10% on the aggressive
sideto see how they affect your timeline.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-4">
<AccordionTrigger className="text-xl font-semibold">
How does inflation factor into my FIRE Number?
</AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed">
Cost of living rises. To maintain today&apos;s lifestyle, your
monthly allowance must grow each year by your inflation rate.
This calculator automatically inflates your desired monthly
spending and subtracts it from your portfolio during retirement,
ensuring your FIRE Number keeps pace with rising expenses.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-5">
<AccordionTrigger className="text-xl font-semibold">
Can I really retire early with FIRE?
</AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed">
Early retirement is achievable with disciplined saving, smart
investing, and realistic assumptions. This tool helps you set
targets, visualize outcomes, and adjust inputsso you can build
confidence in your plan and make informed trade-offs between
lifestyle, risk, and timeline.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-6">
<AccordionTrigger className="text-xl font-semibold">
How should I use this calculator effectively?
</AccordionTrigger>
<AccordionContent className="text-lg leading-relaxed">
<ul className="ml-6 list-disc space-y-1">
<li>
Start with your actual numbers (capital, savings, age).
</li>
<li>
Set conservative - mid - aggressive growth rates to bound
possibilities.
</li>
<li>
Slide your retirement age to explore early vs.
traditional scenarios.
</li>
<li>
Review the chartespecially the reference linesto see when
you hit FI and how withdrawals impact your balance.
</li>
<li>
Experiment with higher savings rates or lower target
spending to accelerate your path.
</li>
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
</section>
{/* Optional: Add a section for relevant resources/links here */}
<section className="mb-12">
<h2 className="mb-4 text-3xl font-bold">
FIRE Journey & Investing Resources
</h2>
<p className="mb-6 text-lg leading-relaxed">
Ready to deepen your knowledge and build a bullet-proof plan? Below
are some of our favorite blogs, books, tools, and communities for
financial independence and smart investing.
</p>
<div className="bg-foreground my-8 rounded-md p-4 text-lg">
<p className="font-semibold">Getting Started with FIRE:</p>
<ol className="ml-6 list-decimal space-y-1">
<li>
Run your first projection above to find your target FIRE Number.
</li>
<li>Identify areas to boost savings or reduce expenses.</li>
<li>
Study index-fund strategies and low-cost investing advice.
</li>
<li>
Join{" "}
<a
href="https://www.reddit.com/r/Fire/"
target="_blank"
className="text-primary hover:underline"
>
supportive communities like r/Fire
</a>{" "}
to learn from real journeys.
</li>
</ol>
</div>
<div className="grid gap-8 md:grid-cols-2">
<div>
<h3 className="mb-3 text-xl font-semibold">Blogs & Websites</h3>
<ul className="ml-6 list-disc space-y-2 text-lg">
<li>
<a
href="https://www.mrmoneymustache.com/"
target="_blank"
className="text-primary hover:underline"
>
Mr. Money Mustache
</a>{" "}
- Hardcore frugality & early retirement success stories.
</li>
<li>
<a
href="https://www.playingwithfire.co/"
target="_blank"
className="text-primary hover:underline"
>
Playing With FIRE
</a>{" "}
- Community resources & real-life case studies.
</li>
<li>
<a
href="https://www.reddit.com/r/Fire/"
target="_blank"
className="text-primary hover:underline"
>
r/Fire
</a>{" "}
- Active forum for questions, tips, and support.
</li>
</ul>
</div>
<div>
<h3 className="mb-3 text-xl font-semibold">Books & Podcasts</h3>
<ul className="ml-6 list-disc space-y-2 text-lg">
<li>
<a
href="https://www.amazon.com/Your-Money-Life-Transforming-Relationship/dp/0143115766"
target="_blank"
className="text-primary hover:underline"
>
Your Money or Your Life
</a>{" "}
- The classic guide to aligning money with values.
</li>
<li>
<a
href="https://podcasts.apple.com/us/podcast/biggerpockets-money-podcast/id1330225136"
target="_blank"
className="text-primary hover:underline"
>
BiggerPockets Money Podcast
</a>{" "}
- Interviews on FIRE strategies and wealth building.
</li>
<li>
<a
href="https://podcasts.apple.com/us/podcast/can-you-retire-now-this-fire-calculator-will-tell-you/id1330225136?i=1000683436292"
target="_blank"
className="text-primary hover:underline"
>
InvestingFIRE Calculator Demo
</a>{" "}
- Deep dive on how interactive projections can guide your
plan.
</li>
</ul>
</div>
<div>
<h3 className="mb-3 text-xl font-semibold">
Additional Calculators & Tools
</h3>
<ul className="ml-6 list-disc space-y-2 text-lg">
<li>
<a
href="https://ghostfol.io"
target="_blank"
className="text-primary hover:underline"
>
Ghostfolio
</a>{" "}
- Wealth management application for individuals.
</li>
<li>
<a
href="https://walletburst.com/tools/coast-fire-calculator/"
target="_blank"
className="text-primary hover:underline"
>
Coast FIRE Calculator
</a>{" "}
- When you max out early contributions but let compounding
do the rest.
</li>
<li>
<a
href="https://www.investor.gov/financial-tools-calculators/calculators/compound-interest-calculator"
target="_blank"
className="text-primary hover:underline"
>
Compound Interest Calculator
</a>{" "}
- Explore the power of growth rates in isolation.
</li>
</ul>
</div>
</div>
</section>
</div>
<Footer />
</main>
);
}

View File

@@ -1,11 +0,0 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: "https://investingfire.com/sitemap.xml",
};
}

View File

@@ -1,13 +0,0 @@
import { BASE_URL } from "@/lib/constants";
import { type MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
];
}

View File

@@ -1,69 +0,0 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn(
"border-primary-foreground/20 border-b last:border-b-0",
className,
)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-primary-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -1,357 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>;
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"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 ?? 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
color ?? item.payload.fill ?? item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
const key = `${nameKey ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -1,185 +0,0 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
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",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<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-[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,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<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",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -1,63 +0,0 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
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",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@@ -1,40 +0,0 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
NODE_ENV: z.enum(["development", "test", "production"]),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

View File

@@ -1 +0,0 @@
export const BASE_URL = "https://investingfire.com/";

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs))
}

View File

@@ -4,8 +4,7 @@
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans:
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@@ -49,77 +48,71 @@
:root {
--radius: 0.625rem;
--background: oklch(0.97 0.0228 95.96); /* cosmic latte */
--foreground: oklch(0.39 0.0215 96.47); /* black olive */
--card: oklch(0.97 0.0228 95.96); /* cosmic latte */
--card-foreground: oklch(0.39 0.0215 96.47); /* black olive */
--popover: oklch(0.97 0.0228 95.96); /* cosmic latte */
--popover-foreground: oklch(0.39 0.0215 96.47); /* black olive */
--primary: oklch(0.67 0.0763 198.81); /* verdigris */
--primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--secondary: oklch(0.49 0.1326 259.29); /* denim */
--secondary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--muted: oklch(0.67 0.0763 198.81 / 20%); /* verdigris with opacity */
--muted-foreground: oklch(
0.39 0.0215 96.47 / 80%
); /* black olive with opacity */
--accent: oklch(0.49 0.1326 259.29); /* denim */
--accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--destructive: oklch(0.33 0.1316 336.24); /* palatinate */
--border: oklch(0.67 0.0763 198.81 / 30%); /* verdigris with opacity */
--input: oklch(0.67 0.0763 198.81 / 30%); /* verdigris with opacity */
--ring: oklch(0.67 0.0763 198.81); /* verdigris */
--chart-1: oklch(0.67 0.0763 198.81); /* verdigris */
--chart-2: oklch(0.49 0.1326 259.29); /* denim */
--chart-3: oklch(0.33 0.1316 336.24); /* palatinate */
--chart-4: oklch(0.39 0.0215 96.47); /* black olive */
--chart-5: oklch(0.67 0.0763 198.81 / 70%); /* verdigris with opacity */
--sidebar: oklch(0.49 0.1326 259.29 / 10%); /* denim with opacity */
--sidebar-foreground: oklch(0.39 0.0215 96.47); /* black olive */
--sidebar-primary: oklch(0.67 0.0763 198.81); /* verdigris */
--sidebar-primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--sidebar-accent: oklch(0.49 0.1326 259.29); /* denim */
--sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--sidebar-border: oklch(
0.67 0.0763 198.81 / 20%
); /* verdigris with opacity */
--sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.39 0.0215 96.47); /* black olive */
--foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--card: oklch(0.39 0.0215 96.47 / 80%); /* black olive with opacity */
--card-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--popover: oklch(0.39 0.0215 96.47); /* black olive */
--popover-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--primary: oklch(0.67 0.0763 198.81); /* verdigris */
--primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--secondary: oklch(0.49 0.1326 259.29); /* denim */
--secondary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--muted: oklch(0.39 0.0215 96.47 / 70%); /* black olive with opacity */
--muted-foreground: oklch(0.67 0.0763 198.81); /* verdigris */
--accent: oklch(0.49 0.1326 259.29); /* denim */
--accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--destructive: oklch(0.33 0.1316 336.24); /* palatinate */
--border: oklch(0.97 0.0228 95.96 / 20%); /* cosmic latte with opacity */
--input: oklch(0.97 0.0228 95.96 / 20%); /* cosmic latte with opacity */
--ring: oklch(0.67 0.0763 198.81); /* verdigris */
--chart-1: oklch(0.67 0.0763 198.81); /* verdigris */
--chart-2: oklch(0.49 0.1326 259.29); /* denim */
--chart-3: oklch(0.33 0.1316 336.24); /* palatinate */
--chart-4: oklch(0.97 0.0228 95.96); /* cosmic latte */
--chart-5: oklch(0.67 0.0763 198.81 / 70%); /* verdigris with opacity */
--sidebar: oklch(0.39 0.0215 96.47 / 90%); /* black olive with opacity */
--sidebar-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--sidebar-primary: oklch(0.67 0.0763 198.81); /* verdigris */
--sidebar-primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--sidebar-accent: oklch(0.49 0.1326 259.29); /* denim */
--sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
--sidebar-border: oklch(
0.97 0.0228 95.96 / 10%
); /* cosmic latte with opacity */
--sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {