// screen-fire.jsx — FIRE Calculator (Financial Independence, Retire Early) // Standalone screen, no Supabase dependency. All math is client-side. function ScreenFire({ theme, accent, auth }) { const T = theme; // ── Auto-load from real data ────────────────────────────────── const [loaded, setLoaded] = React.useState(false); const [simMode, setSimMode] = React.useState(false); const [simForm, setSimForm] = React.useState(null); // override values for simulation React.useEffect(function() { if (!auth || !auth.family || !window.SB || loaded) return; var familyId = auth.family.id; Promise.all([ // 1. Last 3 months transactions (expense + investasi) window.SB.from('transactions').select('type,amount,date,account_id,category:categories(name)') .eq('family_id', familyId) .gte('date', new Date(Date.now() - 90*24*3600*1000).toISOString().split('T')[0]), // 2. Accounts (net worth + investment history for return calc) window.SB.from('accounts').select('id,name,balance,type,cost_basis,last_updated,created_at').eq('family_id', familyId).eq('is_active', true), // 3. Profile for birth_date window.SB.from('profiles').select('birth_date').eq('id', auth.user.id).single(), ]).then(function(results) { var txRes = results[0], accRes = results[1], profRes = results[2]; var updates = {}; // Usia dari birth_date if (profRes && !profRes.error && profRes.data && profRes.data.birth_date) { var bd = new Date(profRes.data.birth_date); var today = new Date(); var age = today.getFullYear() - bd.getFullYear() - (today.getMonth() < bd.getMonth() || (today.getMonth() === bd.getMonth() && today.getDate() < bd.getDate()) ? 1 : 0); if (age > 0 && age < 100) updates.usia = age; } // Avg monthly expense + investasi contributions dari transaksi if (!txRes.error && txRes.data && txRes.data.length > 0) { var invAccountIds = (accRes.data||[]) .filter(function(a){return a.type==='investment';}) .map(function(a){return a.id;}); var expenses = txRes.data.filter(function(t){ return t.type==='expense' && !invAccountIds.includes(t.account_id); }); var invTopups = txRes.data.filter(function(t){ return invAccountIds.includes(t.account_id) || (t.category&&t.category.name&&t.category.name.toLowerCase().includes('invest')); }); var totalExp = expenses.reduce(function(s,t){return s+t.amount;},0); var avgExp = Math.round(totalExp / 3); if (avgExp > 0) updates.pengeluaranBulanan = avgExp; if (invTopups.length > 0) { var totalInvTopup = invTopups.reduce(function(s,t){return s+t.amount;},0); var avgInvMonthly = Math.round(totalInvTopup / 3); if (avgInvMonthly > 0) { updates.investasiBulanan = avgInvMonthly; updates._invLocked = true; } } } // Net worth + calculate investment return from cost_basis vs current value if (!accRes.error && accRes.data && accRes.data.length > 0) { var netWorth = accRes.data .filter(function(a){return a.type !== 'credit';}) .reduce(function(s,a){return s+a.balance;},0); if (netWorth > 0) updates.tabunganSaatIni = netWorth; // Hitung estimated annual return dari akun investasi // P&L / cost_basis / years_held → annualized return var invAccounts = accRes.data.filter(function(a){ return a.type === 'investment' && a.cost_basis > 0 && a.balance > 0; }); if (invAccounts.length > 0) { var totalPnlPct = 0, count = 0; invAccounts.forEach(function(a) { var created = new Date(a.created_at); var now = new Date(); var yearsHeld = Math.max(0.5, (now - created) / (365.25 * 24 * 3600 * 1000)); var totalReturn = (a.balance - a.cost_basis) / a.cost_basis; // Annualized: (1 + totalReturn)^(1/years) - 1 var annualized = (Math.pow(1 + totalReturn, 1 / yearsHeld) - 1) * 100; if (annualized > -50 && annualized < 200) { totalPnlPct += annualized; count++; } }); if (count > 0) { var avgReturn = Math.round(totalPnlPct / count * 10) / 10; avgReturn = Math.max(2, Math.min(30, avgReturn)); updates.returnTahunan = avgReturn; updates._returnLocked = true; } } } // Lock usia kalau ada birth_date if (updates.usia) updates._ageLocked = true; if (Object.keys(updates).length > 0) { setForm(function(f){ var merged = Object.assign({}, f, updates); setSimForm(merged); // snapshot real values return merged; }); } setLoaded(true); }); }, [auth]); // ── Inputs state ────────────────────────────────────────────── const [form, setForm] = React.useState({ usia: 30, targetPensiun: 55, pengeluaranBulanan: 15_000_000, tabunganSaatIni: 200_000_000, investasiBulanan: 5_000_000, returnTahunan: 10, // % inflasi: 4, // % safeWithdrawal: 4, // % }); const set = (k, v) => setForm(f => ({ ...f, [k]: v })); const num = (v) => typeof v === 'string' ? parseInt(v.replace(/\D/g, '')) || 0 : v; // ── FIRE Math ───────────────────────────────────────────────── const results = React.useMemo(() => { const { usia, targetPensiun, pengeluaranBulanan, tabunganSaatIni, investasiBulanan, returnTahunan, inflasi, safeWithdrawal, } = form; const tahunHingga = Math.max(targetPensiun - usia, 1); // ── FIRE Best Practice ──────────────────────────────────── // 1. Inflate pengeluaran ke nilai uang saat pensiun const inf = inflasi / 100; const ret = returnTahunan / 100; const pengeluaranFuture = num(pengeluaranBulanan) * 12 * Math.pow(1 + inf, tahunHingga); // 2. Real return (Fisher equation) — lebih akurat dari sekedar ret - inf const realReturn = (1 + ret) / (1 + inf) - 1; // 3. FIRE Number = pengeluaran tahunan future / SWR // Rule of 25 = 4% SWR, Rule of 33 = 3% SWR (Fat FIRE) const swr = safeWithdrawal / 100; const fireNumber = pengeluaranFuture / swr; // = pengeluaranFuture × 25 pada 4% // 4. Lean FIRE (25x) / Regular FIRE (30x) / Fat FIRE (33x) targets const leanFire = pengeluaranFuture * 25; const fatFire = pengeluaranFuture * 33; // 5. Portfolio projection menggunakan real return // FV = PV × (1+r)^n + PMT × [(1+r)^n - 1] / r const r = realReturn / 12; // monthly real return const n = tahunHingga * 12; const fvExisting = num(tabunganSaatIni) * Math.pow(1 + r, n); const fvContrib = r > 0 ? num(investasiBulanan) * ((Math.pow(1 + r, n) - 1) / r) : num(investasiBulanan) * n; const portfolioProjected = fvExisting + fvContrib; // Gap or surplus const gap = fireNumber - portfolioProjected; const achieved = portfolioProjected >= fireNumber; // How many years to FIRE at current savings rate (binary search) let fireYear = null; for (let y = 1; y <= 50; y++) { const nn = y * 12; const fve = num(tabunganSaatIni) * Math.pow(1 + r, nn); const fvc = num(investasiBulanan) * ((Math.pow(1 + r, nn) - 1) / r); const proj = fve + fvc; const fn = num(pengeluaranBulanan) * 12 * Math.pow(1 + inflasi/100, y) / (safeWithdrawal / 100); if (proj >= fn) { fireYear = y; break; } } // Monthly income needed if FIRE at target const monthlyFromPortfolio = portfolioProjected * (safeWithdrawal / 100) / 12; // Savings rate // (assume income = tabungan bulanan + pengeluaran, simplified) const income = num(investasiBulanan) + num(pengeluaranBulanan); const savingsRate = income > 0 ? (num(investasiBulanan) / income * 100) : 0; // Chart: portfolio growth year by year const chartData = []; for (let y = 0; y <= tahunHingga; y++) { const nn = y * 12; const fve = num(tabunganSaatIni) * Math.pow(1 + r, nn); const fvc = num(investasiBulanan) * ((Math.pow(1 + r, nn) - 1) / r); const fn = num(pengeluaranBulanan) * 12 * Math.pow(1 + inflasi/100, y) / (safeWithdrawal / 100); chartData.push({ year: usia + y, portfolio: Math.round(fve + fvc), fireTarget: Math.round(fn), }); } return { fireNumber, portfolioProjected, gap, achieved, fireYear, fireAge: fireYear ? usia + fireYear : null, monthlyFromPortfolio, savingsRate, chartData, tahunHingga, pengeluaranFuture, }; }, [form]); const fmtRp = (v) => { if (v >= 1e12) return `Rp ${(v/1e12).toFixed(1)}T`; if (v >= 1e9) return `Rp ${(v/1e9).toFixed(1)}M`; if (v >= 1e6) return `Rp ${(v/1e6).toFixed(1)}jt`; if (v >= 1e3) return `Rp ${(v/1e3).toFixed(0)}rb`; return `Rp ${Math.round(v)}`; }; const fmtInput = (v) => num(v) > 0 ? num(v).toLocaleString('id-ID') : ''; // Mini bar chart const maxVal = Math.max(...results.chartData.map(d => Math.max(d.portfolio, d.fireTarget))); const pct = (v) => maxVal > 0 ? Math.min(v / maxVal * 100, 100) : 0; // Color logic const statusColor = results.achieved ? accent[500] : '#f59e0b'; const statusBg = results.achieved ? accent.tint : 'rgba(245,158,11,0.08)'; return (
{/* Header */}
FIRE Calculator
Financial Independence · Retire Early
{/* ── Result Card ── */}
{results.achieved ? '🎉 Target tercapai!' : '📊 Proyeksi FIRE'}
{/* FIRE number utama */}
Target FIRE (Regular · 4% SWR · 25×)
{fmtRp(results.fireNumber)}
Proyeksi portfolio
{fmtRp(results.portfolioProjected)}
{/* Lean / Regular / Fat FIRE bars */}
{[ { label:'Lean FIRE', target: results.leanFire, mul:'25×', swr:'4%', color: accent[400] || accent[500] }, { label:'FIRE', target: results.fireNumber, mul:'25×', swr:'4%', color: accent[500], main: true }, { label:'Fat FIRE', target: results.fatFire, mul:'33×', swr:'3%', color: accent[700] }, ].map(function(tier) { var pct = Math.min(100, results.portfolioProjected / tier.target * 100); var reached = pct >= 100; return (
{reached ? '✅' : '○'} {tier.label} {tier.mul} pengeluaran · SWR {tier.swr}
{pct.toFixed(0)}% · {fmtRp(tier.target,{short:true})}
); })}
{[ { label: 'FIRE di usia', value: results.fireAge ? `${results.fireAge} thn` : '>50 thn', sub: results.fireYear ? `${results.fireYear} thn lagi` : '' }, { label: 'Pendapatan/bln', value: fmtRp(results.monthlyFromPortfolio), sub: 'saat pensiun' }, { label: 'Savings rate', value: `${results.savingsRate.toFixed(0)}%`, sub: results.savingsRate >= 50 ? '🔥 Sangat baik' : results.savingsRate >= 30 ? '✅ Baik' : '⚠️ Tingkatkan' }, ].map(({ label, value, sub }) => (
{label}
{value}
{sub &&
{sub}
}
))}
{/* ── Mini Chart ── */}
Pertumbuhan Portfolio
{(() => { const pts = results.chartData.filter((_, i) => i % Math.max(1, Math.floor(results.chartData.length / 20)) === 0); const W = 300, H = 80, GAP = 2; const bw = pts.length > 0 ? Math.max(2, (W - GAP * (pts.length - 1)) / pts.length) : 4; return ( {pts.map((d, i) => { const x = i * (bw + GAP); const hF = maxVal > 0 ? Math.round((d.fireTarget / maxVal) * H) : 0; const hP = maxVal > 0 ? Math.round((d.portfolio / maxVal) * H) : 0; return ( = d.fireTarget ? accent[500] : accent[600]} /> ); })} ); })()}
{[ { color: accent[500], label: 'Portfolio' }, { color: accent[200], label: 'FIRE target' }, ].map(({ color, label }) => (
{label}
))}
{/* ── Inputs ── */}
Data Kamu
{loaded && !simMode && (
✓ data real
)} {loaded && (
{simMode && (
⚠️ Mode Simulasi aktif — angka telah diubah dari data real. Matikan simulasi untuk kembali ke data asli.
)} {/* Usia row */}
Usia saat ini {form._ageLocked && ( 🔒 dari profil )}
{form.usia} thn
!form._ageLocked && set('usia', +e.target.value)} disabled={!!form._ageLocked} style={{ width: '100%', accentColor: form._ageLocked ? accent[200] : accent[500], opacity: form._ageLocked ? 0.6 : 1 }} /> {form._ageLocked && (
Ubah tanggal lahir di Profil untuk mengubah usia
)}
Target usia pensiun {form.targetPensiun} thn
set('targetPensiun', +e.target.value)} style={{ width: '100%', accentColor: accent[500] }} />
{/* Currency inputs */} {[ { key: 'pengeluaranBulanan', label: 'Pengeluaran per bulan', icon: 'wallet', isAuto: loaded }, { key: 'tabunganSaatIni', label: 'Kekayaan bersih saat ini', icon: 'landmark', isAuto: loaded }, { key: 'investasiBulanan', label: 'Investasi rutin / bulan', icon: 'trending-up', isAuto: !!form._invLocked, optional: true }, ].map(({ key, label, icon, isAuto, optional }) => { var locked = isAuto && !simMode; return (
{label} {isAuto && !simMode && ( 🔒 dari app )} {isAuto && simMode && ( ✏️ simulasi )}
Rp !locked && set(key, parseInt(e.target.value.replace(/\D/g,'')) || 0)} style={{ flex: 1, border: 0, outline: 0, background: 'transparent', fontSize: 15, fontWeight: 700, color: T.fg1, fontFamily: 'inherit', cursor: locked ? 'default' : 'text', }} />
); })}
{/* ── Asumsi ── */} {simMode && (
Asumsi
✏️ mode simulasi
{[ { key: 'returnTahunan', label: 'Return investasi / tahun', min: 1, max: 25, step: 0.5, unit: '%', note: 'IHSG historis ~12-15%' }, { key: 'inflasi', label: 'Inflasi / tahun', min: 1, max: 12, step: 0.5, unit: '%', note: 'Indonesia ~3-5%' }, { key: 'safeWithdrawal',label: 'Safe withdrawal rate', min: 2, max: 6, step: 0.5, unit: '%', note: 'Rule of 4%' }, ].map(({ key, label, min, max, step, unit, note }) => { var isReturnLocked = key === 'returnTahunan' && form._returnLocked && !simMode; return (
{label} {isReturnLocked ? 🔒 dari data : {note} }
{form[key]}%
!isReturnLocked && set(key, +e.target.value)} disabled={isReturnLocked} style={{ width: '100%', accentColor: isReturnLocked ? accent[200] : accent[500], opacity: isReturnLocked ? 0.6 : 1 }} /> {isReturnLocked && (
Dihitung dari P&L portofolio investasi kamu. Aktifkan Simulasi untuk mengubah.
)}
); })}
)} {/* ── Tips ── */} {!results.achieved && (
💡 Tips untuk capai FIRE lebih cepat
{[ results.savingsRate < 30 && `Tingkatkan savings rate ke minimal 30% (sekarang ${results.savingsRate.toFixed(0)}%)`, results.fireYear > 20 && `Investasi di instrumen dengan return lebih tinggi (reksa dana saham, ETF)`, num(form.pengeluaranBulanan) > 20_000_000 && `Kurangi pengeluaran bulanan — setiap Rp 1jt/bln = Rp 300jt lebih kecil FIRE number`, true && `Gunakan fitur Tabungan di INDIECASH untuk track goals investasi`, ].filter(Boolean).slice(0, 3).map((tip, i) => (
{tip}
))}
)}
); } Object.assign(window, { ScreenFire });