FIRE chart
This commit is contained in:
parent
f05f3fe37c
commit
64669e5f58
@ -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,7 +462,8 @@ export default function FireCalculatorForm() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<Card>
|
<>
|
||||||
|
<Card className="mb-8">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Results</CardTitle>
|
<CardTitle>Results</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -398,7 +475,7 @@ export default function FireCalculatorForm() {
|
|||||||
<div>
|
<div>
|
||||||
<Label>FIRE Number (Required Capital)</Label>
|
<Label>FIRE Number (Required Capital)</Label>
|
||||||
<p className="text-2xl font-bold">
|
<p className="text-2xl font-bold">
|
||||||
{formatCurrency(result.fireNumber)}
|
{formatNumber(result.fireNumber)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -413,7 +490,7 @@ export default function FireCalculatorForm() {
|
|||||||
Monthly Allowance at Retirement (Inflation Adjusted)
|
Monthly Allowance at Retirement (Inflation Adjusted)
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-2xl font-bold">
|
<p className="text-2xl font-bold">
|
||||||
{formatCurrency(result.inflationAdjustedAllowance)}
|
{formatNumber(result.inflationAdjustedAllowance)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -429,6 +506,133 @@ export default function FireCalculatorForm() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user