add optional "show 4%-rule" button with extra cards and reference lines

This commit is contained in:
Felix Schulze 2025-05-04 19:44:18 +02:00
parent 090cbcaecd
commit bb5d17b9cf

View File

@ -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>