Created
March 7, 2026 08:01
-
-
Save jackyef/dbca0c66e0619f137c846dc7d710ecbf to your computer and use it in GitHub Desktop.
FIRE calculator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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