Skip to content

Instantly share code, notes, and snippets.

@jackyef
Created March 7, 2026 08:01
Show Gist options
  • Select an option

  • Save jackyef/dbca0c66e0619f137c846dc7d710ecbf to your computer and use it in GitHub Desktop.

Select an option

Save jackyef/dbca0c66e0619f137c846dc7d710ecbf to your computer and use it in GitHub Desktop.
FIRE calculator
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FIRE Calculator</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<!-- prop-types required by recharts UMD -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/prop-types/15.8.1/prop-types.min.js"></script>
<!-- recharts UMD depends on d3 sub-packages bundled inside, but needs React on window first -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/recharts/2.5.0/Recharts.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.5/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600&family=Playfair+Display:wght@700;900&display=swap" rel="stylesheet" />
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #080a10; }
input[type=range] { -webkit-appearance: none; appearance: none; background: rgba(255,200,80,0.12); border-radius: 4px; height: 4px; outline: none; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #ffc850; cursor: pointer; box-shadow: 0 0 8px rgba(255,200,80,0.4); }
input[type=range]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: #ffc850; cursor: pointer; box-shadow: 0 0 8px rgba(255,200,80,0.4); border: none; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: #0d0f18; }
::-webkit-scrollbar-thumb { background: #2a2f3d; border-radius: 4px; }
</style>
</head>
<body>
<div id="root"></div>
<script>
// Normalize Recharts global — cdnjs 2.5 exposes it as `Recharts`
// but older bundles sometimes expose individual names; ensure window.Recharts exists
window.Recharts = window.Recharts || {};
</script>
<script type="text/babel">
const {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ReferenceLine, ResponsiveContainer, Legend
} = window.Recharts;
const { useState, useMemo } = React;
const formatB = (v) => {
if (Math.abs(v) >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(1)}B`;
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(0)}M`;
return String(Math.round(v));
};
const formatIDR = (v) => {
if (Math.abs(v) >= 1_000_000_000) return `Rp ${(v / 1_000_000_000).toFixed(2)}B`;
if (Math.abs(v) >= 1_000_000) return `Rp ${(v / 1_000_000).toFixed(0)}M`;
return `Rp ${Math.round(v)}`;
};
const M = 1_000_000;
const B = 1_000_000_000;
const DEFAULTS = {
currentAge: 28,
currentPortfolio: 50 * M,
monthlyContribution: 3 * M,
equityCAGR: 10,
bondCAGR: 7,
equityRatio: 50,
fireSpend: 15 * M,
inflationRate: 4,
swr: 4,
fireAge: 55,
postFireCAGR: 6,
lifeExpectancy: 80,
};
const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<div style={{ background: "rgba(8,10,16,0.97)", border: "1px solid rgba(255,200,80,0.2)", borderRadius: 10, padding: "12px 16px", fontFamily: "'DM Mono', monospace", fontSize: 12 }}>
<div style={{ color: "#ffc850", fontWeight: 700, marginBottom: 6 }}>Age {label}</div>
{payload.map((p) => p.value != null && (
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>
{p.name}: {formatIDR(p.value)}
</div>
))}
</div>
);
};
const Slider = ({ label, value, min, max, step, onChange, format, sublabel }) => (
<div style={{ marginBottom: 20 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 4 }}>
<span style={{ color: "#a0a8b8", fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase", fontFamily: "'DM Mono', monospace" }}>{label}</span>
<span style={{ color: "#ffc850", fontSize: 14, fontWeight: 700, fontFamily: "'DM Mono', monospace" }}>{format(value)}</span>
</div>
{sublabel && <div style={{ color: "#3a4458", fontSize: 10, marginBottom: 5, fontFamily: "'DM Mono', monospace" }}>{sublabel}</div>}
<input type="range" min={min} max={max} step={step} value={value} onChange={e => onChange(Number(e.target.value))}
style={{ width: "100%", accentColor: "#ffc850", cursor: "pointer", height: 4 }} />
<div style={{ display: "flex", justifyContent: "space-between", color: "#2e3545", fontSize: 10, marginTop: 2, fontFamily: "'DM Mono', monospace" }}>
<span>{format(min)}</span><span>{format(max)}</span>
</div>
</div>
);
const Card = ({ label, value, sub, accent }) => (
<div style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.06)", borderTop: `2px solid ${accent}`, borderRadius: 12, padding: "16px 18px" }}>
<div style={{ fontSize: 10, color: "#565e70", letterSpacing: "0.12em", textTransform: "uppercase", fontFamily: "'DM Mono', monospace", marginBottom: 6 }}>{label}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: accent, fontFamily: "'DM Mono', monospace", letterSpacing: "-0.02em" }}>{value}</div>
<div style={{ fontSize: 11, color: "#565e70", marginTop: 3, fontFamily: "'DM Mono', monospace" }}>{sub}</div>
</div>
);
function FIREProjection() {
const [p, setP] = useState(DEFAULTS);
const [tab, setTab] = useState("accumulation");
const set = (k) => (v) => setP(prev => ({ ...prev, [k]: v }));
const blendedCAGR = (p.equityRatio / 100) * p.equityCAGR + ((100 - p.equityRatio) / 100) * p.bondCAGR;
const monthlyReturn = blendedCAGR / 100 / 12;
const { accumData, fireAgeActual } = useMemo(() => {
let portfolio = p.currentPortfolio;
let crossoverAge = null;
for (let age = p.currentAge; age <= 70; age++) {
const yearsFromNow = age - p.currentAge;
const inflatedSpend = p.fireSpend * Math.pow(1 + p.inflationRate / 100, yearsFromNow) * 12;
const target = inflatedSpend / (p.swr / 100);
if (!crossoverAge && portfolio >= target) crossoverAge = age;
for (let m = 0; m < 12; m++) {
portfolio = portfolio * (1 + monthlyReturn) + p.monthlyContribution;
}
}
const endAge = Math.max(p.fireAge, crossoverAge || p.fireAge) + 5;
portfolio = p.currentPortfolio;
const points = [];
for (let age = p.currentAge; age <= endAge; age++) {
const yearsFromNow = age - p.currentAge;
const inflatedSpend = p.fireSpend * Math.pow(1 + p.inflationRate / 100, yearsFromNow) * 12;
const target = inflatedSpend / (p.swr / 100);
points.push({ age, portfolio: Math.round(portfolio), target: Math.round(target) });
for (let m = 0; m < 12; m++) {
portfolio = portfolio * (1 + monthlyReturn) + p.monthlyContribution;
}
}
return { accumData: points, fireAgeActual: crossoverAge || (">" + p.fireAge) };
}, [p, monthlyReturn]);
const atFireAge = accumData.find(d => d.age === p.fireAge);
const onTrack = atFireAge && atFireAge.portfolio >= atFireAge.target;
const gap = atFireAge ? atFireAge.portfolio - atFireAge.target : 0;
const drawdownData = useMemo(() => {
const startPortfolio = atFireAge?.portfolio || p.currentPortfolio;
const postMonthlyReturn = p.postFireCAGR / 100 / 12;
const points = [];
let portfolio = startPortfolio;
for (let age = p.fireAge; age <= p.lifeExpectancy + 1; age++) {
const yearsFromFire = age - p.fireAge;
const inflatedAnnualSpend = p.fireSpend * Math.pow(1 + p.inflationRate / 100, (p.fireAge - p.currentAge) + yearsFromFire) * 12;
points.push({
age,
portfolio: Math.round(Math.max(0, portfolio)),
annualSpend: Math.round(inflatedAnnualSpend),
});
if (portfolio <= 0) break;
for (let m = 0; m < 12; m++) {
portfolio = portfolio * (1 + postMonthlyReturn) - (inflatedAnnualSpend / 12);
}
}
return points;
}, [p, atFireAge]);
const depletionAge = drawdownData.find(d => d.portfolio <= 0)?.age;
const portfolioAtLE = drawdownData.find(d => d.age === p.lifeExpectancy)?.portfolio || 0;
const safeForLife = !depletionAge || depletionAge > p.lifeExpectancy;
return (
<div style={{ minHeight: "100vh", background: "#080a10", color: "#e8eaf0", fontFamily: "'DM Sans', system-ui, sans-serif", paddingBottom: 60 }}>
{/* Header */}
<div style={{ background: "linear-gradient(180deg,#0d1020 0%,#080a10 100%)", borderBottom: "1px solid rgba(255,200,80,0.08)", padding: "28px 40px 24px", position: "relative", overflow: "hidden" }}>
<div style={{ position: "absolute", top: -60, right: -60, width: 300, height: 300, background: "radial-gradient(circle, rgba(255,200,80,0.05) 0%, transparent 70%)", pointerEvents: "none" }} />
<div style={{ fontSize: 10, letterSpacing: "0.2em", color: "#ffc850", textTransform: "uppercase", fontFamily: "'DM Mono', monospace", marginBottom: 6, opacity: 0.75 }}>Financial Independence Projection</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 28, fontWeight: 900, letterSpacing: "-0.02em" }}>FIRE Calculator</div>
<div style={{ color: "#3a4458", fontSize: 12, marginTop: 4, fontFamily: "'DM Mono', monospace" }}>Indonesia · IDR · Age {p.currentAge}</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "300px 1fr", gap: 0, maxWidth: 1200, margin: "0 auto", padding: "28px 24px", alignItems: "start" }}>
{/* Sidebar */}
<div style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.05)", borderRadius: 16, padding: "24px 20px", marginRight: 20, position: "sticky", top: 24 }}>
<div style={{ fontSize: 10, letterSpacing: "0.15em", color: "#ffc850", textTransform: "uppercase", fontFamily: "'DM Mono', monospace", marginBottom: 20, opacity: 0.7 }}>Parameters</div>
<div style={{ borderBottom: "1px solid rgba(255,255,255,0.04)", paddingBottom: 18, marginBottom: 18 }}>
<div style={{ fontSize: 9, color: "#2e3545", letterSpacing: "0.12em", textTransform: "uppercase", fontFamily: "'DM Mono', monospace", marginBottom: 12 }}>Portfolio</div>
<Slider label="Current Age" value={p.currentAge} min={20} max={55} step={1} onChange={set("currentAge")} format={v => `${v}`} />
<Slider label="Current Portfolio" value={p.currentPortfolio / M} min={1} max={20000} step={1} onChange={v => set("currentPortfolio")(v * M)} format={v => v >= 1000 ? `Rp ${(v/1000).toFixed(2)}B` : `Rp ${v}M`} />
<Slider label="Monthly Contribution" value={p.monthlyContribution / M} min={0.5} max={500} step={0.5} onChange={v => set("monthlyContribution")(v * M)} format={v => `Rp ${v}M`} />
<Slider label="Equity / Bond Split" value={p.equityRatio} min={0} max={100} step={5} onChange={set("equityRatio")} format={v => `${v}% / ${100 - v}%`} sublabel="Saham / Obligasi" />
</div>
<div style={{ borderBottom: "1px solid rgba(255,255,255,0.04)", paddingBottom: 18, marginBottom: 18 }}>
<div style={{ fontSize: 9, color: "#2e3545", letterSpacing: "0.12em", textTransform: "uppercase", fontFamily: "'DM Mono', monospace", marginBottom: 12 }}>Returns</div>
<Slider label="Equity CAGR" value={p.equityCAGR} min={4} max={20} step={0.5} onChange={set("equityCAGR")} format={v => `${v}%`} sublabel="RD Saham (pre-FIRE)" />
<Slider label="Bond CAGR" value={p.bondCAGR} min={4} max={12} step={0.5} onChange={set("bondCAGR")} format={v => `${v}%`} sublabel="RD Obligasi (pre-FIRE)" />
<Slider label="Post-FIRE CAGR" value={p.postFireCAGR} min={3} max={12} step={0.5} onChange={set("postFireCAGR")} format={v => `${v}%`} sublabel="Conservative drawdown return" />
</div>
<div>
<div style={{ fontSize: 9, color: "#2e3545", letterSpacing: "0.12em", textTransform: "uppercase", fontFamily: "'DM Mono', monospace", marginBottom: 12 }}>FIRE Target</div>
<Slider label="Monthly Spend at FIRE" value={p.fireSpend / M} min={1} max={150} step={0.5} onChange={v => set("fireSpend")(v * M)} format={v => `Rp ${v}M`} />
<Slider label="Inflation Rate" value={p.inflationRate} min={1} max={6} step={0.5} onChange={set("inflationRate")} format={v => `${v}%`} />
<Slider label="Safe Withdrawal Rate" value={p.swr} min={2} max={6} step={0.25} onChange={set("swr")} format={v => `${v}%`} />
<Slider label="Target FIRE Age" value={p.fireAge} min={33} max={55} step={1} onChange={set("fireAge")} format={v => `${v}`} />
<Slider label="Life Expectancy" value={p.lifeExpectancy} min={70} max={100} step={1} onChange={set("lifeExpectancy")} format={v => `${v}`} sublabel="For drawdown tab" />
</div>
</div>
{/* Main */}
<div>
{/* Tabs */}
<div style={{ display: "flex", gap: 4, marginBottom: 24, background: "rgba(255,255,255,0.02)", borderRadius: 12, padding: 4, width: "fit-content" }}>
{[{ id: "accumulation", label: "Accumulation" }, { id: "drawdown", label: "Post-FIRE Drawdown" }].map(t => (
<button key={t.id} onClick={() => setTab(t.id)} style={{
background: tab === t.id ? "rgba(255,200,80,0.12)" : "transparent",
border: tab === t.id ? "1px solid rgba(255,200,80,0.25)" : "1px solid transparent",
borderRadius: 8, padding: "8px 20px", cursor: "pointer",
color: tab === t.id ? "#ffc850" : "#565e70",
fontSize: 12, fontFamily: "'DM Mono', monospace", letterSpacing: "0.05em", transition: "all 0.15s",
}}>{t.label}</button>
))}
</div>
{/* ACCUMULATION TAB */}
{tab === "accumulation" && (
<>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10, marginBottom: 20 }}>
<Card label="Blended CAGR" value={`${blendedCAGR.toFixed(1)}%`} sub={`${p.equityRatio}% equity · ${100 - p.equityRatio}% bonds`} accent="#7dd3fc" />
<Card label={`Portfolio at ${p.fireAge}`} value={formatIDR(atFireAge?.portfolio || 0)}
sub={onTrack ? `+${formatIDR(Math.abs(gap))} surplus` : `${formatIDR(Math.abs(gap))} short`}
accent={onTrack ? "#4ade80" : "#f87171"} />
<Card label="FIRE Achievable At" value={`Age ${fireAgeActual}`}
sub={typeof fireAgeActual === "number" ? `${fireAgeActual - p.currentAge} yrs from now` : "Push contributions"}
accent={typeof fireAgeActual === "number" && fireAgeActual <= p.fireAge ? "#4ade80" : "#fbbf24"} />
</div>
<div style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.05)", borderRadius: 16, padding: "24px 16px 16px", marginBottom: 16 }}>
<div style={{ paddingLeft: 8, marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Portfolio Growth</div>
<div style={{ fontSize: 11, color: "#565e70", fontFamily: "'DM Mono', monospace" }}>Salary covers living expenses · contributions added from income</div>
</div>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={accumData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="gP" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ffc850" stopOpacity={0.3} /><stop offset="95%" stopColor="#ffc850" stopOpacity={0} />
</linearGradient>
<linearGradient id="gT" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#7dd3fc" stopOpacity={0.12} /><stop offset="95%" stopColor="#7dd3fc" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
<XAxis dataKey="age" stroke="#2e3545" tick={{ fill: "#565e70", fontSize: 11, fontFamily: "'DM Mono', monospace" }} tickLine={false} />
<YAxis tickFormatter={formatB} stroke="#2e3545" tick={{ fill: "#565e70", fontSize: 11, fontFamily: "'DM Mono', monospace" }} tickLine={false} axisLine={false} />
<Tooltip content={<CustomTooltip />} />
{typeof fireAgeActual === "number" && fireAgeActual !== p.fireAge && (
<ReferenceLine x={fireAgeActual} stroke="rgba(74,222,128,0.35)" strokeDasharray="4 4"
label={{ value: `FIRE ${fireAgeActual}`, fill: "#4ade80", fontSize: 10, fontFamily: "'DM Mono', monospace" }} />
)}
<Area type="monotone" dataKey="target" name="FIRE Target" stroke="#7dd3fc" strokeWidth={1.5} fill="url(#gT)" dot={false} strokeDasharray="5 3" />
<Area type="monotone" dataKey="portfolio" name="Portfolio" stroke="#ffc850" strokeWidth={2} fill="url(#gP)" dot={false} />
<Legend wrapperStyle={{ fontSize: 11, fontFamily: "'DM Mono', monospace", paddingTop: 12, color: "#a0a8b8" }} />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.05)", borderRadius: 16, padding: "20px", overflow: "hidden" }}>
<div style={{ fontSize: 10, letterSpacing: "0.15em", color: "#ffc850", textTransform: "uppercase", fontFamily: "'DM Mono', monospace", marginBottom: 16, opacity: 0.7 }}>Milestones</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 1, background: "rgba(255,255,255,0.03)", borderRadius: 8, overflow: "hidden" }}>
{["Age", "Portfolio", "FIRE Target", "Status", "Monthly Passive"].map(h => (
<div key={h} style={{ background: "#0d0f18", padding: "9px 10px", fontSize: 9, color: "#3a4458", fontFamily: "'DM Mono', monospace", letterSpacing: "0.08em", textTransform: "uppercase" }}>{h}</div>
))}
{accumData.filter(d => [35, 37, 40, 43, 45, p.fireAge].includes(d.age))
.filter((d, i, arr) => arr.findIndex(x => x.age === d.age) === i)
.map(pt => {
const ok = pt.portfolio >= pt.target;
const passive = pt.portfolio * (p.swr / 100) / 12;
return [
<div key={`${pt.age}-a`} style={{ background: "#0b0d16", padding: "11px 10px", fontSize: 13, fontWeight: 600, fontFamily: "'DM Mono', monospace", color: pt.age === fireAgeActual ? "#4ade80" : "#e8eaf0" }}>{pt.age}</div>,
<div key={`${pt.age}-p`} style={{ background: "#0b0d16", padding: "11px 10px", fontSize: 11, fontFamily: "'DM Mono', monospace", color: "#ffc850" }}>{formatIDR(pt.portfolio)}</div>,
<div key={`${pt.age}-t`} style={{ background: "#0b0d16", padding: "11px 10px", fontSize: 11, fontFamily: "'DM Mono', monospace", color: "#7dd3fc" }}>{formatIDR(pt.target)}</div>,
<div key={`${pt.age}-s`} style={{ background: "#0b0d16", padding: "11px 10px", fontSize: 10, fontFamily: "'DM Mono', monospace", color: ok ? "#4ade80" : "#f87171" }}>{ok ? "✓ FIRE Ready" : `-${formatIDR(pt.target - pt.portfolio)}`}</div>,
<div key={`${pt.age}-m`} style={{ background: "#0b0d16", padding: "11px 10px", fontSize: 11, fontFamily: "'DM Mono', monospace", color: "#a0a8b8" }}>{formatIDR(passive)}/mo</div>,
];
})}
</div>
</div>
</>
)}
{/* DRAWDOWN TAB */}
{tab === "drawdown" && (
<>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10, marginBottom: 20 }}>
<Card label="Starting Balance" value={formatIDR(atFireAge?.portfolio || 0)} sub={`Portfolio at age ${p.fireAge}`} accent="#ffc850" />
<Card
label={safeForLife ? `Balance at ${p.lifeExpectancy}` : "Portfolio Depletes"}
value={safeForLife ? formatIDR(portfolioAtLE) : `Age ${depletionAge}`}
sub={safeForLife ? "Remaining — leaving a legacy" : `${depletionAge - p.lifeExpectancy < 0 ? p.lifeExpectancy - depletionAge + " yrs short of life exp." : "within life expectancy"}`}
accent={safeForLife ? "#4ade80" : "#f87171"}
/>
<Card label={`Monthly Spend at ${p.fireAge}`}
value={formatIDR(p.fireSpend * Math.pow(1 + p.inflationRate / 100, p.fireAge - p.currentAge))}
sub="Inflation-adjusted from today" accent="#c084fc" />
</div>
<div style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.05)", borderRadius: 16, padding: "24px 16px 16px", marginBottom: 16 }}>
<div style={{ paddingLeft: 8, marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Portfolio Drawdown After FIRE</div>
<div style={{ fontSize: 11, color: "#565e70", fontFamily: "'DM Mono', monospace" }}>
Portfolio earns {p.postFireCAGR}% · spend grows with {p.inflationRate}% inflation · no contributions
</div>
</div>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={drawdownData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="gD" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={safeForLife ? "#4ade80" : "#f87171"} stopOpacity={0.25} />
<stop offset="95%" stopColor={safeForLife ? "#4ade80" : "#f87171"} stopOpacity={0} />
</linearGradient>
<linearGradient id="gS" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#c084fc" stopOpacity={0.15} />
<stop offset="95%" stopColor="#c084fc" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
<XAxis dataKey="age" stroke="#2e3545" tick={{ fill: "#565e70", fontSize: 11, fontFamily: "'DM Mono', monospace" }} tickLine={false} />
<YAxis tickFormatter={formatB} stroke="#2e3545" tick={{ fill: "#565e70", fontSize: 11, fontFamily: "'DM Mono', monospace" }} tickLine={false} axisLine={false} />
<Tooltip content={<CustomTooltip />} />
<ReferenceLine x={p.lifeExpectancy} stroke="rgba(192,132,252,0.3)" strokeDasharray="4 4"
label={{ value: `Life exp. ${p.lifeExpectancy}`, fill: "#c084fc", fontSize: 10, fontFamily: "'DM Mono', monospace" }} />
{depletionAge && (
<ReferenceLine x={depletionAge} stroke="rgba(248,113,113,0.4)" strokeDasharray="4 4"
label={{ value: `Depletes ${depletionAge}`, fill: "#f87171", fontSize: 10, fontFamily: "'DM Mono', monospace" }} />
)}
<Area type="monotone" dataKey="annualSpend" name="Annual Spend" stroke="#c084fc" strokeWidth={1.5} fill="url(#gS)" dot={false} strokeDasharray="5 3" />
<Area type="monotone" dataKey="portfolio" name="Portfolio" stroke={safeForLife ? "#4ade80" : "#f87171"} strokeWidth={2} fill="url(#gD)" dot={false} />
<Legend wrapperStyle={{ fontSize: 11, fontFamily: "'DM Mono', monospace", paddingTop: 12, color: "#a0a8b8" }} />
</AreaChart>
</ResponsiveContainer>
</div>
{/* Drawdown table */}
<div style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.05)", borderRadius: 16, padding: "20px", overflow: "hidden", marginBottom: 14 }}>
<div style={{ fontSize: 10, letterSpacing: "0.15em", color: "#ffc850", textTransform: "uppercase", fontFamily: "'DM Mono', monospace", marginBottom: 16, opacity: 0.7 }}>Drawdown Snapshots</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 1, background: "rgba(255,255,255,0.03)", borderRadius: 8, overflow: "hidden" }}>
{["Age", "Portfolio", "Annual Spend", "Status"].map(h => (
<div key={h} style={{ background: "#0d0f18", padding: "9px 10px", fontSize: 9, color: "#3a4458", fontFamily: "'DM Mono', monospace", letterSpacing: "0.08em", textTransform: "uppercase" }}>{h}</div>
))}
{drawdownData
.filter((d, i) => i % 5 === 0 || d.age === p.lifeExpectancy)
.filter((d, i, arr) => arr.findIndex(x => x.age === d.age) === i)
.map(pt => {
const safe = pt.portfolio > 0;
const yearsLeft = depletionAge ? depletionAge - pt.age : (p.lifeExpectancy - pt.age);
return [
<div key={`${pt.age}-a`} style={{ background: "#0b0d16", padding: "11px 10px", fontSize: 13, fontWeight: 600, fontFamily: "'DM Mono', monospace", color: pt.age === p.lifeExpectancy ? "#c084fc" : "#e8eaf0" }}>{pt.age}</div>,
<div key={`${pt.age}-p`} style={{ background: "#0b0d16", padding: "11px 10px", fontSize: 11, fontFamily: "'DM Mono', monospace", color: safe ? "#4ade80" : "#f87171" }}>{safe ? formatIDR(pt.portfolio) : "Depleted"}</div>,
<div key={`${pt.age}-s`} style={{ background: "#0b0d16", padding: "11px 10px", fontSize: 11, fontFamily: "'DM Mono', monospace", color: "#c084fc" }}>{formatIDR(pt.annualSpend)}/yr</div>,
<div key={`${pt.age}-r`} style={{ background: "#0b0d16", padding: "11px 10px", fontSize: 10, fontFamily: "'DM Mono', monospace", color: !safe ? "#f87171" : yearsLeft > 10 ? "#4ade80" : "#fbbf24" }}>
{safe ? (depletionAge ? `${yearsLeft} yrs runway` : `${yearsLeft}+ yrs safe`) : "—"}
</div>,
];
})}
</div>
</div>
{/* Status banner */}
{!safeForLife ? (
<div style={{ background: "rgba(248,113,113,0.06)", border: "1px solid rgba(248,113,113,0.2)", borderRadius: 12, padding: "14px 18px", fontFamily: "'DM Mono', monospace", fontSize: 12, color: "#f87171" }}>
⚠ Portfolio depletes at age {depletionAge} — {p.lifeExpectancy - depletionAge} years short of life expectancy. Try increasing post-FIRE CAGR, reducing monthly spend, or lowering SWR.
</div>
) : (
<div style={{ background: "rgba(74,222,128,0.05)", border: "1px solid rgba(74,222,128,0.15)", borderRadius: 12, padding: "14px 18px", fontFamily: "'DM Mono', monospace", fontSize: 12, color: "#4ade80" }}>
✓ Portfolio survives to age {p.lifeExpectancy} with {formatIDR(portfolioAtLE)} remaining. You're on track to leave a legacy.
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<FIREProjection />);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment