FIRE chart

This commit is contained in:
Felix Schulze 2025-04-29 19:11:09 +02:00
parent f05f3fe37c
commit 64669e5f58

View File

@ -12,20 +12,27 @@ import { Label } from "@/components/ui/label";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "../../components/ui/form"; } from "@/components/ui/form";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../../components/ui/card"; } from "@/components/ui/card";
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
import {
Area,
AreaChart,
CartesianGrid,
XAxis,
YAxis,
ReferenceLine,
} from "recharts";
// Schema for form validation // Schema for form validation
const formSchema = z.object({ const formSchema = z.object({
@ -57,10 +64,17 @@ interface CalculationResult {
inflationAdjustedAllowance: number | null; inflationAdjustedAllowance: number | null;
retirementYears: number | null; retirementYears: number | null;
error?: string; error?: string;
yearlyData?: Array<{
age: number;
year: number;
balance: number;
phase: "accumulation" | "retirement";
}>;
} }
export default function FireCalculatorForm() { export default function FireCalculatorForm() {
const [result, setResult] = useState<CalculationResult | null>(null); const [result, setResult] = useState<CalculationResult | null>(null);
const currentYear = new Date().getFullYear();
// Initialize form with default values // Initialize form with default values
const form = useForm<FormValues>({ const form = useForm<FormValues>({
@ -72,7 +86,7 @@ export default function FireCalculatorForm() {
cagr: 7, cagr: 7,
desiredMonthlyAllowance: 2000, desiredMonthlyAllowance: 2000,
inflationRate: 2, inflationRate: 2,
lifeExpectancy: 90, lifeExpectancy: 84,
}, },
}); });
@ -105,6 +119,17 @@ export default function FireCalculatorForm() {
let monthlyAllowance = initialMonthlyAllowance; let monthlyAllowance = initialMonthlyAllowance;
let iterations = 0; let iterations = 0;
// Array to store yearly data for the chart
const yearlyData: CalculationResult["yearlyData"] = [];
// Add starting point
yearlyData.push({
age: currentAge,
year: currentYear,
balance: startingCapital,
phase: "accumulation",
});
// Accumulation phase simulation // Accumulation phase simulation
while (age < lifeExpectancy && iterations < maxIterations) { while (age < lifeExpectancy && iterations < maxIterations) {
// Simulate one year of saving and growth // Simulate one year of saving and growth
@ -117,6 +142,14 @@ export default function FireCalculatorForm() {
age++; age++;
iterations++; iterations++;
// Record yearly data
yearlyData.push({
age: age,
year: currentYear + (age - currentAge),
balance: Math.round(currentCapital),
phase: "accumulation",
});
// Check each possible retirement capital target through binary search // Check each possible retirement capital target through binary search
const mid = (low + high) / 2; const mid = (low + high) / 2;
if (high - low < 1) { if (high - low < 1) {
@ -178,6 +211,14 @@ export default function FireCalculatorForm() {
age++; age++;
iterations++; iterations++;
// Record yearly data
yearlyData.push({
age: age,
year: currentYear + (age - currentAge),
balance: Math.round(currentCapital),
phase: "accumulation",
});
// Test with current capital // Test with current capital
let testCapital = currentCapital; let testCapital = currentCapital;
let testAge = age; let testAge = age;
@ -209,12 +250,46 @@ export default function FireCalculatorForm() {
} }
} }
// If retirement is possible, simulate the retirement phase for the chart
if (canRetire) {
// Update the phase for all years after retirement
yearlyData.forEach((data) => {
if (data.age >= retirementAge) {
data.phase = "retirement";
}
});
// Continue simulation for retirement phase if needed
let simulationCapital = currentCapital;
let simulationAllowance = monthlyAllowance;
let simulationAge = age;
// If we haven't simulated up to life expectancy, continue
while (simulationAge < lifeExpectancy) {
for (let month = 0; month < 12; month++) {
simulationCapital -= simulationAllowance;
simulationCapital *= 1 + monthlyGrowthRate;
simulationAllowance *= 1 + monthlyInflationRate;
}
simulationAge++;
// Record yearly data
yearlyData.push({
age: simulationAge,
year: currentYear + (simulationAge - currentAge),
balance: Math.round(simulationCapital),
phase: "retirement",
});
}
}
if (canRetire) { if (canRetire) {
setResult({ setResult({
fireNumber: requiredCapital, fireNumber: requiredCapital,
retirementAge: retirementAge, retirementAge: retirementAge,
inflationAdjustedAllowance: finalInflationAdjustedAllowance, inflationAdjustedAllowance: finalInflationAdjustedAllowance,
retirementYears: lifeExpectancy - retirementAge, retirementYears: lifeExpectancy - retirementAge,
yearlyData: yearlyData,
error: undefined, error: undefined,
}); });
} else { } else {
@ -223,6 +298,7 @@ export default function FireCalculatorForm() {
retirementAge: null, retirementAge: null,
inflationAdjustedAllowance: null, inflationAdjustedAllowance: null,
retirementYears: null, retirementYears: null,
yearlyData: yearlyData,
error: error:
iterations >= maxIterations iterations >= maxIterations
? "Calculation exceeded maximum iterations." ? "Calculation exceeded maximum iterations."
@ -231,8 +307,8 @@ export default function FireCalculatorForm() {
} }
} }
// Helper function to format currency // Helper function to format currency without specific symbols
const formatCurrency = (value: number | null) => { const formatNumber = (value: number | null) => {
if (value === null) return "N/A"; if (value === null) return "N/A";
return new Intl.NumberFormat("en", { return new Intl.NumberFormat("en", {
maximumFractionDigits: 0, maximumFractionDigits: 0,
@ -386,49 +462,177 @@ export default function FireCalculatorForm() {
</Card> </Card>
{result && ( {result && (
<Card> <>
<CardHeader> <Card className="mb-8">
<CardTitle>Results</CardTitle> <CardHeader>
</CardHeader> <CardTitle>Results</CardTitle>
<CardContent> </CardHeader>
{result.error ? ( <CardContent>
<p className="text-destructive">{result.error}</p> {result.error ? (
) : ( <p className="text-destructive">{result.error}</p>
<div className="space-y-4"> ) : (
<div> <div className="space-y-4">
<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>
{result.inflationAdjustedAllowance && (
<div> <div>
<Label> <Label>FIRE Number (Required Capital)</Label>
Monthly Allowance at Retirement (Inflation Adjusted)
</Label>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
{formatCurrency(result.inflationAdjustedAllowance)} {formatNumber(result.fireNumber)}
</p> </p>
</div> </div>
)}
{result.retirementYears && (
<div> <div>
<Label>Retirement Duration (Years)</Label> <Label>Estimated Retirement Age</Label>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
{result.retirementYears} {result.retirementAge ?? "N/A"}
</p> </p>
</div> </div>
)} {result.inflationAdjustedAllowance && (
</div> <div>
)} <Label>
</CardContent> Monthly Allowance at Retirement (Inflation Adjusted)
</Card> </Label>
<p className="text-2xl font-bold">
{formatNumber(result.inflationAdjustedAllowance)}
</p>
</div>
)}
{result.retirementYears && (
<div>
<Label>Retirement Duration (Years)</Label>
<p className="text-2xl font-bold">
{result.retirementYears}
</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
{result && result.yearlyData && result.yearlyData.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Financial Projection</CardTitle>
<CardDescription>
Projected balance growth and FIRE number threshold
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer
className="h-80"
config={{
balance: {
label: "Balance",
color: "var(--chart-1)",
},
fireNumber: {
label: "FIRE Number",
color: "var(--chart-3)",
},
}}
>
<AreaChart
data={result.yearlyData}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="year"
label={{
value: "Year",
position: "insideBottom",
offset: -10,
}}
/>
<YAxis
tickFormatter={(value: number) => {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toFixed(0)}K`;
}
return `${value}`;
}}
width={80}
/>
<ChartTooltip
content={({ active, payload }) => {
if (active && payload?.[0]?.payload) {
const data = payload[0]
.payload as (typeof result.yearlyData)[0];
return (
<div className="bg-background border p-2 shadow-sm">
<p className="font-medium">{`Year: ${data.year} (Age: ${data.age})`}</p>
<p className="text-primary">{`Balance: ${formatNumber(data.balance)}`}</p>
{result.fireNumber && (
<p className="text-destructive">{`FIRE Number: ${formatNumber(result.fireNumber)}`}</p>
)}
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
</div>
);
}
return null;
}}
/>
<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>
</defs>
<Area
type="monotone"
dataKey="balance"
name="balance"
stroke="var(--chart-1)"
fill="url(#fillBalance)"
fillOpacity={0.4}
activeDot={{ r: 6 }}
/>
{result.fireNumber && (
<ReferenceLine
y={result.fireNumber}
stroke="var(--chart-3)"
strokeWidth={2}
strokeDasharray="5 5"
label={{
value: "FIRE Number",
position: "insideBottomRight",
}}
/>
)}
{result.retirementAge && (
<ReferenceLine
x={
currentYear +
(result.retirementAge - form.getValues().currentAge)
}
stroke="var(--chart-2)"
strokeWidth={2}
label={{
value: "Retirement",
position: "insideTopRight",
}}
/>
)}
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)}
</>
)} )}
</div> </div>
); );