// 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 (