diff --git a/src/app/components/FireCalculatorForm.tsx b/src/app/components/FireCalculatorForm.tsx index 87c109f..d36f1d9 100644 --- a/src/app/components/FireCalculatorForm.tsx +++ b/src/app/components/FireCalculatorForm.tsx @@ -32,23 +32,19 @@ import { YAxis, ReferenceLine, } from "recharts"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; +import assert from "assert"; // Schema for form validation const formSchema = z.object({ - startingCapital: z.coerce - .number() - .min(0, "Starting capital must be a non-negative number"), + startingCapital: z.coerce.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"), + 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() @@ -58,8 +54,12 @@ const formSchema = z.object({ .min(0, "Inflation rate must be a non-negative number"), lifeExpectancy: z.coerce .number() - .min(50, "Life expectancy must be at least 50"), - retirementStrategy: z.enum(["Depletion", "Maintenance", "4% Rule"]), + .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 @@ -71,16 +71,12 @@ interface YearlyData { balance: number; phase: "accumulation" | "retirement"; monthlyAllowance: number; - fireNumber: number | null; } interface CalculationResult { fireNumber: number | null; - retirementAge: number | null; - inflationAdjustedAllowance: number | null; - retirementYears: number | null; + yearlyData: YearlyData[]; error?: string; - yearlyData?: Record; } export default function FireCalculatorForm() { @@ -95,23 +91,14 @@ export default function FireCalculatorForm() { monthlySavings: 1500, currentAge: 25, cagr: 7, - desiredMonthlyAllowance: 2000, + desiredMonthlyAllowance: 3000, inflationRate: 2, lifeExpectancy: 84, - retirementStrategy: "Depletion", + retirementAge: 55, }, }); function onSubmit(values: FormValues) { - /* - PSEUDOCODE - 1. calculate all balances if no retirement. - 2. calculate all required FIRE numbers for each possible year of retirement for the selected strategy. - 2.1 calculate the monthly allowance for each year of retirement for all years, fire number is these but cumulative. - 3. binary search the crossover - 4. calculate new balance for each year of retirement - 5. graph balance, balance if no retirement, fire numbers, allowances - */ setResult(null); // Reset previous results const startingCapital = values.startingCapital; @@ -121,85 +108,72 @@ export default function FireCalculatorForm() { const initialMonthlyAllowance = values.desiredMonthlyAllowance; const annualInflation = 1 + values.inflationRate / 100; const ageOfDeath = values.lifeExpectancy; - const retirementStrategy = values.retirementStrategy; + const retirementAge = values.retirementAge; - let requiredCapital: number | null = null; - let retirementAge: number | null = null; - let finalInflationAdjustedAllowance: number | null = null; + // Array to store yearly data for the chart + const yearlyData: YearlyData[] = []; - // Array to store yearly data for the chart with initial value - const yearlyData: Record = { - [irlYear]: { - age: age, - year: irlYear, - balance: startingCapital, - phase: "accumulation", - monthlyAllowance: initialMonthlyAllowance, - fireNumber: null, - }, - }; - // calculate all balances if no retirement - for (let year = irlYear + 1; year <= irlYear + ageOfDeath - age; year++) { - const previousYearData = yearlyData[year - 1]; - if (!previousYearData) { - continue; - } - yearlyData[year] = { - age: age + year - irlYear, - year: year, - balance: - previousYearData.balance * annualGrowthRate + monthlySavings * 12, - phase: "accumulation", - monthlyAllowance: previousYearData.monthlyAllowance * annualInflation, - fireNumber: null, - }; - } - // calculate FIRE numbers based on allowances - for (let year = irlYear + ageOfDeath - age; year >= irlYear; year--) { - const yearData = yearlyData[year]; - if (!yearData) { - continue; - } - yearData.fireNumber = - (yearlyData[year + 1]?.fireNumber ?? 0) + - 12 * yearData.monthlyAllowance; - } - - // calculate new balance and retirement age - for (let year = irlYear; year <= irlYear + ageOfDeath - age; year++) { - const yearData = yearlyData[year]; - const previousYearData = yearlyData[year - 1]; - if (!yearData?.fireNumber) { - continue; - } - if (!previousYearData) { - yearData.monthlyAllowance = 0; - continue; - } - if (yearData.balance > yearData.fireNumber) { - retirementAge ??= yearData.age; - requiredCapital ??= yearData.balance; - finalInflationAdjustedAllowance ??= yearData.monthlyAllowance; - yearData.phase = "retirement"; - yearData.balance = - previousYearData.balance * annualGrowthRate - - yearData.monthlyAllowance * 12; - } else { - yearData.monthlyAllowance = 0; - } - } - - // --- Calculation Logic based on Strategy --- - - // --- Set Final Result --- - setResult({ - fireNumber: requiredCapital, - retirementAge: retirementAge, - inflationAdjustedAllowance: finalInflationAdjustedAllowance, - retirementYears: ageOfDeath - retirementAge, - yearlyData: Object.values(yearlyData), - error: undefined, + // Initial year data + yearlyData.push({ + age: age, + year: irlYear, + balance: startingCapital, + phase: "accumulation", + monthlyAllowance: 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; + } + + yearlyData.push({ + age: currentAge, + year: year, + balance: newBalance, + phase: phase, + monthlyAllowance: inflatedAllowance, + }); + } + + // Calculate FIRE number at retirement + const retirementYear = irlYear + (retirementAge - age); + const retirementIndex = yearlyData.findIndex( + (data) => data.year === retirementYear, + ); + const retirementData = yearlyData[retirementIndex]; + + if (retirementIndex === -1 || !retirementData) { + setResult({ + fireNumber: null, + error: "Could not calculate retirement data", + yearlyData: yearlyData, + }); + } else { + // Set the result + setResult({ + fireNumber: retirementData.balance, + yearlyData: yearlyData, + }); + } } // Helper function to format currency without specific symbols @@ -274,6 +248,23 @@ export default function FireCalculatorForm() { )} /> + ( + + Life Expectancy (Age) + + + + + + )} + /> )} /> + ( + + Annual Inflation Rate (%) + + + + + + )} + /> )} /> + + {/* Retirement Age Slider */} ( - Annual Inflation Rate (%) + Retirement Age: {field.value} - )} /> - ( - - Life Expectancy (Age) - - - - - - )} - /> - ( - - Retirement Strategy - - - - )} - /> + + {result?.yearlyData && ( + + + Financial Projection + + Projected balance growth with your selected retirement age + + + + + + + + { + 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={25} + /> + { + if (active && payload?.[0]?.payload) { + const data = payload[0] + .payload as (typeof result.yearlyData)[0]; + return ( +
+

{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}

+

{`Balance: ${formatNumber(data.balance)}`}

+

{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}

+

{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}

+
+ ); + } + return null; + }} + /> + + + + + + + + + + + + + {result.fireNumber && ( + + )} + +
+
+
+
+ )} @@ -397,8 +520,7 @@ export default function FireCalculatorForm() { FIRE Number - Required capital at retirement using{" "} - {form.getValues().retirementStrategy} + Capital at retirement @@ -410,233 +532,22 @@ export default function FireCalculatorForm() { - Retirement Age + Retirement Duration - Estimated age when you can retire + Years to enjoy your financial independence

- {result.retirementAge ?? "N/A"} + {form.getValues().lifeExpectancy - + form.getValues().retirementAge}

- - {result.inflationAdjustedAllowance && ( - - - Monthly Allowance - - At retirement (inflation adjusted) - - - -

- {formatNumber(result.inflationAdjustedAllowance)} -

-
-
- )} - - {result.retirementYears && ( - - - Retirement Duration - - Years in retirement - - - -

- {result.retirementYears} -

-
-
- )} )} )} - - {result?.yearlyData && ( - - - Financial Projection - - Projected balance growth and FIRE number threshold - - - - - - - - { - if (value >= 1000000) { - return `${(value / 1000000).toPrecision(3)}M`; - } else if (value >= 1000) { - return `${(value / 1000).toPrecision(3)}K`; - } - return value.toString(); - }} - width={25} - /> - { - if (active && payload?.[0]?.payload) { - const data = payload[0] - .payload as (typeof result.yearlyData)[0]; - return ( -
-

{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}

-

{`Balance: ${formatNumber(data.balance)}`}

-

{`FIRE number: ${formatNumber(data.fireNumber)}`}

-

{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}

- {result.fireNumber !== null && ( -

{`Target FIRE Number: ${formatNumber(result.fireNumber)}`}

- )} -

{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}

-
- ); - } - return null; - }} - /> - - - - - - - - - - - - - - - - - - {result.fireNumber && ( - - )} - {result.retirementAge && ( - - )} -
-
-
-
- )} ); }