FIRE calculator

This commit is contained in:
Felix Schulze 2025-04-29 18:32:26 +02:00
parent fe03807739
commit 31415c10a2
5 changed files with 403 additions and 66 deletions

View File

@ -0,0 +1,291 @@
"use client";
import * as React from "react";
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 { Label } from "@/components/ui/label";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "../../components/ui/card";
// Schema for form validation
const formSchema = z.object({
startingCapital: z.coerce
.number()
.min(0, "Starting capital must be a non-negative number"),
monthlySavings: z.coerce
.number()
.min(0, "Monthly savings must be a non-negative number"),
currentAge: z.coerce.number().min(18, "Age must be at least 18"),
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"),
swr: z.coerce.number().min(0.1, "Withdrawal rate must be at least 0.1%"),
});
// Type for form values
type FormValues = z.infer<typeof formSchema>;
interface CalculationResult {
fireNumber: number | null;
retirementAge: number | null;
error?: string;
}
export default function FireCalculatorForm() {
const [result, setResult] = useState<CalculationResult | null>(null);
// Initialize form with default values
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
startingCapital: 10000,
monthlySavings: 500,
currentAge: 30,
cagr: 7,
desiredMonthlyAllowance: 2000,
swr: 4,
},
});
function onSubmit(values: FormValues) {
setResult(null); // Reset previous results
const sc = values.startingCapital;
const ms = values.monthlySavings;
const ca = values.currentAge;
const annualRate = values.cagr / 100;
const monthlyAllowance = values.desiredMonthlyAllowance;
const safeWithdrawalRate = values.swr / 100;
// Calculate FIRE number (the amount needed for retirement)
const fireNumber = (monthlyAllowance * 12) / safeWithdrawalRate;
let currentCapital = sc;
let age = ca;
const monthlyRate = Math.pow(1 + annualRate, 1 / 12) - 1;
const maxYears = 100; // Set a limit to prevent infinite loops
if (currentCapital >= fireNumber) {
setResult({ fireNumber, retirementAge: age });
return;
}
for (let year = 0; year < maxYears; year++) {
const capitalAtYearStart = currentCapital;
for (let month = 0; month < 12; month++) {
currentCapital += ms;
currentCapital *= 1 + monthlyRate;
}
age++;
if (currentCapital >= fireNumber) {
setResult({ fireNumber, retirementAge: age });
return;
}
// Prevent infinite loop if savings don't outpace growth required
if (currentCapital <= capitalAtYearStart && ms <= 0) {
setResult({
fireNumber: null,
retirementAge: null,
error: "Cannot reach FIRE goal with current savings and growth rate.",
});
return;
}
}
// If loop finishes without reaching FIRE number
setResult({
fireNumber: null,
retirementAge: null,
error: `Could not reach FIRE goal within ${maxYears.toString()} years.`,
});
}
// Helper function to format currency
const formatCurrency = (value: number | null) => {
if (value === null) return "N/A";
return new Intl.NumberFormat("en", {
maximumFractionDigits: 0,
}).format(value);
};
return (
<div className="w-full max-w-3xl">
<Card className="mb-8">
<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"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="monthlySavings"
render={({ field }) => (
<FormItem>
<FormLabel>Monthly Savings</FormLabel>
<FormControl>
<Input
placeholder="e.g., 500"
type="number"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currentAge"
render={({ field }) => (
<FormItem>
<FormLabel>Current Age</FormLabel>
<FormControl>
<Input
placeholder="e.g., 30"
type="number"
{...field}
/>
</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"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="desiredMonthlyAllowance"
render={({ field }) => (
<FormItem>
<FormLabel>
Desired Monthly Allowance in Retirement
</FormLabel>
<FormControl>
<Input
placeholder="e.g., 2000"
type="number"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="swr"
render={({ field }) => (
<FormItem>
<FormLabel>Safe Withdrawal Rate (%)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 4"
type="number"
step="0.1"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" className="w-full">
Calculate
</Button>
</form>
</Form>
</CardContent>
</Card>
{result && (
<Card>
<CardHeader>
<CardTitle>Results</CardTitle>
</CardHeader>
<CardContent>
{result.error ? (
<p className="text-destructive">{result.error}</p>
) : (
<div className="space-y-4">
<div>
<Label>FIRE Number (Required Capital)</Label>
<p className="text-2xl font-bold">
{formatCurrency(result.fireNumber)}
</p>
</div>
<div>
<Label>Estimated Retirement Age</Label>
<p className="text-2xl font-bold">
{result.retirementAge ?? "N/A"}
</p>
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}

25
src/app/layout.tsx Normal file
View File

@ -0,0 +1,25 @@
import "@/styles/globals.css";
import { type Metadata } from "next";
import { Geist } from "next/font/google";
export const metadata: Metadata = {
title: "Create T3 App",
description: "Generated by create-t3-app",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
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}`}>
<body>{children}</body>
</html>
);
}

14
src/app/page.tsx Normal file
View File

@ -0,0 +1,14 @@
import FireCalculatorForm from "./components/FireCalculatorForm";
export default function HomePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[oklch(0.49_0.1326_259.29)] to-[oklch(0.33_0.1316_336.24)] p-4 text-[oklch(0.97_0.0228_95.96)]">
<div className="container mx-auto flex flex-col items-center justify-center gap-12 px-4 py-16">
<h1 className="text-primary-foreground text-5xl font-extrabold tracking-tight sm:text-[5rem]">
FIRE Calculator
</h1>
<FireCalculatorForm />
</div>
</main>
);
}

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,7 +4,8 @@
@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";
}
@ -48,71 +49,77 @@
:root {
--radius: 0.625rem;
--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);
--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 */
}
.dark {
--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);
--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 */
}
@layer base {