redesigned algorith, use user specified retirement age

This commit is contained in:
Felix Schulze 2025-05-01 15:25:22 +02:00
parent 383625aede
commit 09e9485f2f

View File

@ -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<string, YearlyData>;
}
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<number, YearlyData> = {
[irlYear]: {
// Initial year data
yearlyData.push({
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,
});
// 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() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="lifeExpectancy"
render={({ field }) => (
<FormItem>
<FormLabel>Life Expectancy (Age)</FormLabel>
<FormControl>
<Input
placeholder="e.g., 90"
type="number"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cagr"
@ -292,6 +283,24 @@ export default function FireCalculatorForm() {
</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"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="desiredMonthlyAllowance"
@ -311,159 +320,40 @@ export default function FireCalculatorForm() {
</FormItem>
)}
/>
{/* Retirement Age Slider */}
<FormField
control={form.control}
name="inflationRate"
name="retirementAge"
render={({ field }) => (
<FormItem>
<FormLabel>Annual Inflation Rate (%)</FormLabel>
<FormLabel>Retirement Age: {field.value}</FormLabel>
<FormControl>
<Input
placeholder="e.g., 2"
type="number"
step="0.1"
{...field}
/>
</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"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="retirementStrategy"
render={({ field }) => (
<FormItem>
<FormLabel>Retirement Strategy</FormLabel>
<Select
<Slider
value={[field.value]}
min={form.getValues().currentAge + 1}
max={form.getValues().lifeExpectancy - 1}
step={1}
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a retirement strategy" />
</SelectTrigger>
className="py-4"
/>
</FormControl>
<SelectContent>
<SelectItem value="Depletion">Depletion</SelectItem>
<SelectItem value="Maintenance">
Maintenance
</SelectItem>
<SelectItem value="4% Rule">4% Rule</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormItem className="col-span-full"></FormItem>
</div>
<Button type="submit" className="w-full">
Calculate
</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">
Required capital at retirement using{" "}
{form.getValues().retirementStrategy}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{formatNumber(result.fireNumber)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Retirement Age</CardTitle>
<CardDescription className="text-xs">
Estimated age when you can retire
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{result.retirementAge ?? "N/A"}
</p>
</CardContent>
</Card>
{result.inflationAdjustedAllowance && (
<Card>
<CardHeader>
<CardTitle>Monthly Allowance</CardTitle>
<CardDescription className="text-xs">
At retirement (inflation adjusted)
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{formatNumber(result.inflationAdjustedAllowance)}
</p>
</CardContent>
</Card>
)}
{result.retirementYears && (
<Card>
<CardHeader>
<CardTitle>Retirement Duration</CardTitle>
<CardDescription className="text-xs">
Years in retirement
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{result.retirementYears}
</p>
</CardContent>
</Card>
)}
</>
)}
</div>
)}
{result?.yearlyData && (
<Card>
<Card className="rounded-md shadow-none">
<CardHeader>
<CardTitle>Financial Projection</CardTitle>
<CardDescription>
Projected balance growth and FIRE number threshold
Projected balance growth with your selected retirement age
</CardDescription>
</CardHeader>
<CardContent>
@ -474,10 +364,6 @@ export default function FireCalculatorForm() {
label: "Balance",
color: "var(--chart-1)",
},
fireNumber: {
label: "FIRE Number",
color: "var(--chart-2)",
},
realBalance: {
label: "Real Balance",
color: "var(--chart-3)",
@ -503,6 +389,10 @@ export default function FireCalculatorForm() {
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();
}}
@ -517,11 +407,7 @@ export default function FireCalculatorForm() {
<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-chart-1">{`Balance: ${formatNumber(data.balance)}`}</p>
<p className="text-chart-2">{`FIRE number: ${formatNumber(data.fireNumber)}`}</p>
<p className="text-chart-4">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
{result.fireNumber !== null && (
<p className="text-destructive">{`Target FIRE Number: ${formatNumber(result.fireNumber)}`}</p>
)}
<p className="text-chart-2">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div>
);
@ -530,20 +416,8 @@ export default function FireCalculatorForm() {
}}
/>
<defs>
<linearGradient id="fillBalance" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--chart-1)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient
id="fillFireNumber"
id="fillBalance"
x1="0"
y1="0"
x2="0"
@ -551,12 +425,12 @@ export default function FireCalculatorForm() {
>
<stop
offset="5%"
stopColor="var(--chart-2)"
stopColor="var(--chart-1)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-2)"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
@ -569,12 +443,12 @@ export default function FireCalculatorForm() {
>
<stop
offset="5%"
stopColor="var(--chart-4)"
stopColor="var(--chart-2)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--chart-4)"
stopColor="var(--chart-2)"
stopOpacity={0.1}
/>
</linearGradient>
@ -588,20 +462,11 @@ export default function FireCalculatorForm() {
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
<Area
type="monotone"
dataKey="fireNumber"
name="fireNumber"
stroke="var(--chart-2)"
fill="url(#fillFireNumber)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
<Area
type="monotone"
dataKey="monthlyAllowance"
name="allowance"
stroke="var(--chart-4)"
stroke="var(--chart-2)"
fill="url(#fillAllowance)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
@ -610,19 +475,19 @@ export default function FireCalculatorForm() {
<ReferenceLine
y={result.fireNumber}
stroke="var(--chart-3)"
strokeWidth={2}
strokeDasharray="5 5"
strokeWidth={1}
strokeDasharray="2 2"
label={{
value: "FIRE Number",
position: "insideBottomRight",
}}
/>
)}
{result.retirementAge && (
<ReferenceLine
x={
irlYear +
(result.retirementAge - form.getValues().currentAge)
(form.getValues().retirementAge -
form.getValues().currentAge)
}
stroke="var(--chart-2)"
strokeWidth={2}
@ -631,12 +496,58 @@ export default function FireCalculatorForm() {
position: "insideTopRight",
}}
/>
)}
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)}
</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">
{form.getValues().lifeExpectancy -
form.getValues().retirementAge}
</p>
</CardContent>
</Card>
</>
)}
</div>
)}
</>
);
}