import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { AlertCircle, ArrowRight, Calculator, RefreshCw, RotateCcw, TrendingDown } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import PayoffChart from '@/components/snowball/PayoffChart'; // ─── Helpers ────────────────────────────────────────────────────────────────── function fmt(v) { return (Number(v) || 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2, }); } function fmtShort(v) { const n = Number(v) || 0; return n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }); } function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtra = 0) { if (!balance || balance <= 0 || !monthlyPayment || monthlyPayment <= 0) return []; const rate = (annualRatePct || 0) / 100 / 12; if (rate > 0 && monthlyPayment <= balance * rate) return []; let bal = balance; const months = []; for (let i = 0; i < 600; i++) { const interest = Math.round(bal * rate * 100) / 100; const pmt = Math.min(bal + interest, i === 0 ? monthlyPayment + oneTimeExtra : monthlyPayment); const principal = Math.max(0, pmt - interest); bal = Math.round(Math.max(0, bal - principal) * 100) / 100; months.push({ month: i + 1, balance: bal, interest }); if (bal < 0.01) break; } return months; } function payoffLabel(track, now = new Date()) { if (!track.length) return null; const d = new Date(now.getFullYear(), now.getMonth() + track.length, 1); return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' }); } function numMonths(track) { if (!track.length) return null; const y = Math.floor(track.length / 12); const m = track.length % 12; if (y === 0) return `${m} mo`; if (m === 0) return `${y} yr`; return `${y} yr ${m} mo`; } // ─── Stat card ──────────────────────────────────────────────────────────────── function StatCard({ label, value, sub, color = 'amber' }) { const colors = { amber: 'bg-amber-500/8 border-amber-400/20 text-amber-500 dark:text-amber-400', teal: 'bg-teal-500/8 border-teal-400/20 text-teal-500 dark:text-teal-400', slate: 'bg-muted/40 border-border/60 text-foreground', }; return (

{label}

{value}

{sub &&

{sub}

}
); } // ─── Input row ──────────────────────────────────────────────────────────────── function InputRow({ label, hint, children }) { return (
{hint && {hint}}
{children}
); } // ─── Empty states ───────────────────────────────────────────────────────────── function EmptyDebts() { return (

No debts with a balance found

Add a current balance to your bills on the{' '} Snowball page.

); } function NoSelection() { return (

Select a loan or debt to begin

Choose from the dropdown above to run your simulation.

); } // ─── PayoffPage ─────────────────────────────────────────────────────────────── export default function PayoffPage() { const [bills, setBills] = useState([]); const [extraPayment, setExtraPayment] = useState(0); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [selectedId, setSelectedId] = useState(null); // Per-simulation state (reset when bill changes) const [simPayment, setSimPayment] = useState(''); const [simRate, setSimRate] = useState(''); const [oneTimeExtra, setOneTimeExtra] = useState(''); const [applying, setApplying] = useState(false); const loadData = useCallback(() => { setLoading(true); setLoadError(null); Promise.all([api.snowball(), api.snowballSettings()]) .then(([billData, settings]) => { const debtBills = (billData || []).filter(b => (b.current_balance ?? 0) > 0); setBills(debtBills); setExtraPayment(Number(settings?.extra_payment) || 0); if (debtBills.length > 0 && !selectedId) { setSelectedId(debtBills[0].id); } }) .catch(err => setLoadError(err.message || 'Failed to load data')) .finally(() => setLoading(false)); }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { loadData(); }, [loadData]); const bill = useMemo(() => bills.find(b => b.id === selectedId) ?? null, [bills, selectedId]); const isAttack = bills[0]?.id === selectedId; // Reset sim inputs whenever the selected bill changes useEffect(() => { if (!bill) return; setSimPayment(String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0))); setSimRate(String(bill.interest_rate ?? 0)); setOneTimeExtra(''); }, [bill?.id]); // eslint-disable-line react-hooks/exhaustive-deps // Derived simulation tracks const simPaymentN = Math.max(0, Number(simPayment) || 0); const simRateN = Math.max(0, Number(simRate) || 0); const oneTimeExtraN = Math.max(0, Number(oneTimeExtra) || 0); const minPayment = bill?.minimum_payment ?? 0; const { minTrack, currentTrack, simTrack } = useMemo(() => { if (!bill) return { minTrack: [], currentTrack: [], simTrack: [] }; const b = bill.current_balance; const min = minPayment > 0 ? minPayment : 0.01; const currentPmt = isAttack ? min + extraPayment : min; return { minTrack: buildPayoffSchedule(b, simRateN, min), currentTrack: buildPayoffSchedule(b, simRateN, currentPmt), simTrack: buildPayoffSchedule(b, simRateN, simPaymentN, oneTimeExtraN), }; }, [bill, simRateN, simPaymentN, oneTimeExtraN, minPayment, isAttack, extraPayment]); const minInterest = useMemo(() => minTrack.reduce((s, m) => s + m.interest, 0), [minTrack]); const simInterest = useMemo(() => simTrack.reduce((s, m) => s + m.interest, 0), [simTrack]); const interestSavings = Math.max(0, minInterest - simInterest); const timeSavings = Math.max(0, minTrack.length - simTrack.length); const simTotalPaid = simInterest + (bill?.current_balance ?? 0); const simPayoffLabel = payoffLabel(simTrack); const minPayoffLabel = payoffLabel(minTrack); const simDuration = numMonths(simTrack); const paymentBelowMin = simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0; const paymentTooLow = bill && simPaymentN > 0 && simTrack.length === 0; const defaultSimPayment = bill ? String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0)) : ''; const defaultRate = bill ? String(bill.interest_rate ?? 0) : ''; const isDirty = simPayment !== defaultSimPayment || simRate !== defaultRate || oneTimeExtra !== ''; const handleReset = () => { if (!bill) return; setSimPayment(defaultSimPayment); setSimRate(defaultRate); setOneTimeExtra(''); }; const handleApply = async () => { if (!bill || applying) return; setApplying(true); try { await api.updateBill(bill.id, { expected_amount: simPaymentN }); toast.success(`"${bill.name}" updated to ${fmt(simPaymentN)}/mo`, { action: { label: 'Undo', onClick: async () => { await api.updateBill(bill.id, { expected_amount: bill.expected_amount }); toast.success('Reverted'); loadData(); }, }, }); loadData(); } catch { toast.error('Failed to update bill'); } finally { setApplying(false); } }; // ── Render ────────────────────────────────────────────────────────────────── if (loading) { return (
{[1, 2, 3, 4].map(i => (
))}
); } if (loadError) { return (

Failed to load data

{loadError}

); } return (
{/* Page header */}

Payoff Simulator

Explore how extra payments reduce interest and shorten your payoff timeline.

{isDirty && ( )}
{/* Bill selector */}
{bills.length === 0 ? ( ) : ( )}
{/* Main content: left panel + right panel */} {!bill ? ( bills.length > 0 ? : null ) : (
{/* ── Left panel ── */}
{/* Required minimum */}
Required Minimum {minPayment > 0 ? fmt(minPayment) : Not set}
{minPayment <= 0 && (

Set a minimum payment on the Snowball page for best results.

)} {/* Interest rate */}
setSimRate(e.target.value)} className="font-mono" placeholder="0.00" /> %
{/* Monthly payment */} setSimPayment(e.target.value)} className="font-mono" placeholder="0.00" /> {paymentBelowMin && (

Below minimum payment of {fmt(minPayment)}

)} {paymentTooLow && !paymentBelowMin && (

Payment too low to overcome interest

)} {simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && ( )}
{/* One-time extra */}
setOneTimeExtra(e.target.value)} className="font-mono" placeholder="0.00" />
{/* Divider */}
{/* Payoff date summary */}
{simPayoffLabel ? (
Payoff
{simPayoffLabel} {simDuration && (

{simDuration}

)}
) : (

Enter a payment to see payoff date

)} {minPayoffLabel && simPayoffLabel && minPayoffLabel !== simPayoffLabel && (
Minimum only {minPayoffLabel}
)}
{/* ── Right panel ── */}
{/* Chart */} {simTrack.length > 0 ? ( ) : (
{simPaymentN <= 0 ? 'Enter a monthly payment to see the chart' : 'Payment too low to pay off this debt'}
)} {/* Stats row */} {simTrack.length > 0 && ( <>
0 ? 'teal' : 'slate'} /> 0 ? `${timeSavings} mo` : '—'} sub={timeSavings > 0 ? 'months sooner' : 'same timeline'} color={timeSavings > 0 ? 'amber' : 'slate'} />
{/* Breakdown */}
Balance today {fmt(bill.current_balance)}
Total interest {fmt(simInterest)}
Total paid {fmt(simTotalPaid)}
)}
)}
); }