diff --git a/src/app/components/FireCalculatorForm.tsx b/src/app/components/FireCalculatorForm.tsx new file mode 100644 index 0000000..ea77d75 --- /dev/null +++ b/src/app/components/FireCalculatorForm.tsx @@ -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; + +interface CalculationResult { + fireNumber: number | null; + retirementAge: number | null; + error?: string; +} + +export default function FireCalculatorForm() { + const [result, setResult] = useState(null); + + // Initialize form with default values + const form = useForm({ + 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 ( +
+ + + FIRE Calculator + + Calculate your path to financial independence and retirement + + + +
+ +
+ ( + + Starting Capital + + + + + + )} + /> + ( + + Monthly Savings + + + + + + )} + /> + ( + + Current Age + + + + + + )} + /> + ( + + Expected Annual Growth Rate (%) + + + + + + )} + /> + ( + + + Desired Monthly Allowance in Retirement + + + + + + + )} + /> + ( + + Safe Withdrawal Rate (%) + + + + + + )} + /> +
+ + +
+ +
+
+ + {result && ( + + + Results + + + {result.error ? ( +

{result.error}

+ ) : ( +
+
+ +

+ {formatCurrency(result.fireNumber)} +

+
+
+ +

+ {result.retirementAge ?? "N/A"} +

+
+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..d2ab6a6 --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..6770123 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,14 @@ +import FireCalculatorForm from "./components/FireCalculatorForm"; + +export default function HomePage() { + return ( +
+
+

+ FIRE Calculator +

+ +
+
+ ); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..eb1caef 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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)); } diff --git a/src/styles/globals.css b/src/styles/globals.css index 48b3fb5..5ae3063 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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 {