add optional "show 4%-rule" button with extra cards and reference lines
This commit is contained in:
parent
090cbcaecd
commit
bb5d17b9cf
@ -73,12 +73,16 @@ interface YearlyData {
|
|||||||
age: number;
|
age: number;
|
||||||
year: number;
|
year: number;
|
||||||
balance: number;
|
balance: number;
|
||||||
|
untouchedBalance: number;
|
||||||
phase: "accumulation" | "retirement";
|
phase: "accumulation" | "retirement";
|
||||||
monthlyAllowance: number;
|
monthlyAllowance: number;
|
||||||
|
untouchedMonthlyAllowance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CalculationResult {
|
interface CalculationResult {
|
||||||
fireNumber: number | null;
|
fireNumber: number | null;
|
||||||
|
fireNumber4percent: number | null;
|
||||||
|
retirementAge4percent: number | null;
|
||||||
yearlyData: YearlyData[];
|
yearlyData: YearlyData[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@ -101,8 +105,8 @@ const tooltipRenderer = ({
|
|||||||
return (
|
return (
|
||||||
<div className="bg-background border p-2 shadow-sm">
|
<div className="bg-background border p-2 shadow-sm">
|
||||||
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
|
<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-orange-500">{`Balance: ${formatNumber(data.balance)}`}</p>
|
||||||
<p className="text-chart-2">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
|
<p className="text-red-600">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
|
||||||
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
|
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -113,6 +117,7 @@ const tooltipRenderer = ({
|
|||||||
export default function FireCalculatorForm() {
|
export default function FireCalculatorForm() {
|
||||||
const [result, setResult] = useState<CalculationResult | null>(null);
|
const [result, setResult] = useState<CalculationResult | null>(null);
|
||||||
const irlYear = new Date().getFullYear();
|
const irlYear = new Date().getFullYear();
|
||||||
|
const [showing4percent, setShowing4percent] = useState(false);
|
||||||
|
|
||||||
// Initialize form with default values
|
// Initialize form with default values
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
@ -149,8 +154,10 @@ export default function FireCalculatorForm() {
|
|||||||
age: age,
|
age: age,
|
||||||
year: irlYear,
|
year: irlYear,
|
||||||
balance: startingCapital,
|
balance: startingCapital,
|
||||||
|
untouchedBalance: startingCapital,
|
||||||
phase: "accumulation",
|
phase: "accumulation",
|
||||||
monthlyAllowance: initialMonthlyAllowance,
|
monthlyAllowance: 0,
|
||||||
|
untouchedMonthlyAllowance: initialMonthlyAllowance,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate accumulation phase (before retirement)
|
// Calculate accumulation phase (before retirement)
|
||||||
@ -175,13 +182,18 @@ export default function FireCalculatorForm() {
|
|||||||
newBalance =
|
newBalance =
|
||||||
previousYearData.balance * annualGrowthRate - inflatedAllowance * 12;
|
previousYearData.balance * annualGrowthRate - inflatedAllowance * 12;
|
||||||
}
|
}
|
||||||
|
const untouchedBalance =
|
||||||
|
previousYearData.untouchedBalance * annualGrowthRate +
|
||||||
|
monthlySavings * 12;
|
||||||
|
const allowance = phase === "retirement" ? inflatedAllowance : 0;
|
||||||
yearlyData.push({
|
yearlyData.push({
|
||||||
age: currentAge,
|
age: currentAge,
|
||||||
year: year,
|
year: year,
|
||||||
balance: newBalance,
|
balance: newBalance,
|
||||||
|
untouchedBalance: untouchedBalance,
|
||||||
phase: phase,
|
phase: phase,
|
||||||
monthlyAllowance: inflatedAllowance,
|
monthlyAllowance: allowance,
|
||||||
|
untouchedMonthlyAllowance: inflatedAllowance,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,9 +204,23 @@ export default function FireCalculatorForm() {
|
|||||||
);
|
);
|
||||||
const retirementData = yearlyData[retirementIndex];
|
const retirementData = yearlyData[retirementIndex];
|
||||||
|
|
||||||
|
const [fireNumber4percent, retirementAge4percent] = (() => {
|
||||||
|
for (const yearData of yearlyData) {
|
||||||
|
if (
|
||||||
|
yearData.untouchedBalance >
|
||||||
|
(yearData.untouchedMonthlyAllowance * 12) / 0.04
|
||||||
|
) {
|
||||||
|
return [yearData.untouchedBalance, yearData.age];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [0, 0];
|
||||||
|
})();
|
||||||
|
|
||||||
if (retirementIndex === -1 || !retirementData) {
|
if (retirementIndex === -1 || !retirementData) {
|
||||||
setResult({
|
setResult({
|
||||||
fireNumber: null,
|
fireNumber: null,
|
||||||
|
fireNumber4percent: null,
|
||||||
|
retirementAge4percent: null,
|
||||||
error: "Could not calculate retirement data",
|
error: "Could not calculate retirement data",
|
||||||
yearlyData: yearlyData,
|
yearlyData: yearlyData,
|
||||||
});
|
});
|
||||||
@ -202,6 +228,8 @@ export default function FireCalculatorForm() {
|
|||||||
// Set the result
|
// Set the result
|
||||||
setResult({
|
setResult({
|
||||||
fireNumber: retirementData.balance,
|
fireNumber: retirementData.balance,
|
||||||
|
fireNumber4percent: fireNumber4percent,
|
||||||
|
retirementAge4percent: retirementAge4percent,
|
||||||
yearlyData: yearlyData,
|
yearlyData: yearlyData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -430,10 +458,10 @@ export default function FireCalculatorForm() {
|
|||||||
offset: -10,
|
offset: -10,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Left Y axis */}
|
{/* Right Y axis */}
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId={"left"}
|
yAxisId={"right"}
|
||||||
orientation="left"
|
orientation="right"
|
||||||
tickFormatter={(value: number) => {
|
tickFormatter={(value: number) => {
|
||||||
if (value >= 1000000) {
|
if (value >= 1000000) {
|
||||||
return `${(value / 1000000).toPrecision(3)}M`;
|
return `${(value / 1000000).toPrecision(3)}M`;
|
||||||
@ -448,10 +476,10 @@ export default function FireCalculatorForm() {
|
|||||||
}}
|
}}
|
||||||
width={30}
|
width={30}
|
||||||
/>
|
/>
|
||||||
{/* Right Y axis */}
|
{/* Left Y axis */}
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="right"
|
yAxisId="left"
|
||||||
orientation="right"
|
orientation="left"
|
||||||
tickFormatter={(value: number) => {
|
tickFormatter={(value: number) => {
|
||||||
if (value >= 1000000) {
|
if (value >= 1000000) {
|
||||||
return `${(value / 1000000).toPrecision(3)}M`;
|
return `${(value / 1000000).toPrecision(3)}M`;
|
||||||
@ -473,12 +501,12 @@ export default function FireCalculatorForm() {
|
|||||||
>
|
>
|
||||||
<stop
|
<stop
|
||||||
offset="5%"
|
offset="5%"
|
||||||
stopColor="var(--chart-1)"
|
stopColor="var(--color-orange-500)"
|
||||||
stopOpacity={0.8}
|
stopOpacity={0.8}
|
||||||
/>
|
/>
|
||||||
<stop
|
<stop
|
||||||
offset="95%"
|
offset="95%"
|
||||||
stopColor="var(--chart-1)"
|
stopColor="var(--color-orange-500)"
|
||||||
stopOpacity={0.1}
|
stopOpacity={0.1}
|
||||||
/>
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
@ -487,34 +515,46 @@ export default function FireCalculatorForm() {
|
|||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="balance"
|
dataKey="balance"
|
||||||
name="balance"
|
name="balance"
|
||||||
stroke="var(--chart-1)"
|
stroke="var(--color-orange-500)"
|
||||||
fill="url(#fillBalance)"
|
fill="url(#fillBalance)"
|
||||||
fillOpacity={0.9}
|
fillOpacity={0.9}
|
||||||
activeDot={{ r: 6 }}
|
activeDot={{ r: 6 }}
|
||||||
yAxisId={"left"}
|
yAxisId={"right"}
|
||||||
stackId={"a"}
|
stackId={"a"}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="step"
|
||||||
dataKey="monthlyAllowance"
|
dataKey="monthlyAllowance"
|
||||||
name="allowance"
|
name="allowance"
|
||||||
stroke="var(--chart-2)"
|
stroke="var(--color-red-600)"
|
||||||
fill="none"
|
fill="none"
|
||||||
activeDot={{ r: 6 }}
|
activeDot={{ r: 6 }}
|
||||||
yAxisId="right"
|
yAxisId="left"
|
||||||
stackId={"a"}
|
|
||||||
/>
|
/>
|
||||||
{result.fireNumber && (
|
{result.fireNumber && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={result.fireNumber}
|
y={result.fireNumber}
|
||||||
stroke="var(--chart-3)"
|
stroke="var(--primary)"
|
||||||
strokeWidth={1}
|
strokeWidth={2}
|
||||||
strokeDasharray="2 2"
|
strokeDasharray="2 1"
|
||||||
label={{
|
label={{
|
||||||
value: "FIRE Number",
|
value: "FIRE Number",
|
||||||
position: "insideBottomRight",
|
position: "insideBottomRight",
|
||||||
}}
|
}}
|
||||||
yAxisId={"left"}
|
yAxisId={"right"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{result.fireNumber4percent && showing4percent && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={result.fireNumber4percent}
|
||||||
|
stroke="var(--secondary)"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="1 1"
|
||||||
|
label={{
|
||||||
|
value: "4%-Rule FIRE Number",
|
||||||
|
position: "insideBottomLeft",
|
||||||
|
}}
|
||||||
|
yAxisId={"right"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
@ -523,7 +563,7 @@ export default function FireCalculatorForm() {
|
|||||||
(form.getValues("retirementAge") -
|
(form.getValues("retirementAge") -
|
||||||
form.getValues("currentAge"))
|
form.getValues("currentAge"))
|
||||||
}
|
}
|
||||||
stroke="var(--chart-2)"
|
stroke="var(--primary)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
label={{
|
label={{
|
||||||
value: "Retirement",
|
value: "Retirement",
|
||||||
@ -531,11 +571,36 @@ export default function FireCalculatorForm() {
|
|||||||
}}
|
}}
|
||||||
yAxisId={"left"}
|
yAxisId={"left"}
|
||||||
/>
|
/>
|
||||||
|
{result.retirementAge4percent && showing4percent && (
|
||||||
|
<ReferenceLine
|
||||||
|
x={
|
||||||
|
irlYear +
|
||||||
|
(result.retirementAge4percent -
|
||||||
|
form.getValues("currentAge"))
|
||||||
|
}
|
||||||
|
stroke="var(--secondary)"
|
||||||
|
strokeWidth={1}
|
||||||
|
label={{
|
||||||
|
value: "4%-Rule Retirement",
|
||||||
|
position: "insideBottomLeft",
|
||||||
|
}}
|
||||||
|
yAxisId={"left"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
{result && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowing4percent(!showing4percent)}
|
||||||
|
variant={showing4percent ? "secondary" : "default"}
|
||||||
|
size={"sm"}
|
||||||
|
>
|
||||||
|
{showing4percent ? "Hide" : "Show"} 4%-Rule
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -579,6 +644,40 @@ export default function FireCalculatorForm() {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{showing4percent && (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>4%-Rule FIRE Number</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Capital needed for 4% of it to be greater than your
|
||||||
|
yearly allowance
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{formatNumber(result.fireNumber4percent)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>4%-Rule Retirement Duration</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Years to enjoy your financial independence if you follow
|
||||||
|
the 4% rule
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{form.getValues("lifeExpectancy") -
|
||||||
|
(result.retirementAge4percent ?? 0)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user