import { useCallback, useEffect, useRef, useState } from 'react'; import { ArrowDown, ArrowUp, GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff, CheckCircle2, Circle, AlertCircle, RefreshCw } 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 { Switch } from '@/components/ui/switch'; import { Skeleton } from '@/components/ui/Skeleton'; import { cn } from '@/lib/utils'; import { moveInArray } from '@/lib/reorder'; import BillModal from '@/components/BillModal'; import { makeBillDraft } from '@/lib/billDrafts'; import PlanStatusBanner from '@/components/snowball/PlanStatusBanner'; import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel'; import * as AlertDialog from '@radix-ui/react-alert-dialog'; // ── formatters ──────────────────────────────────────────────────────────────── function fmt(val) { if (val == null) return '—'; return Number(val).toLocaleString(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2, }); } function fmtCompact(val) { if (val == null || val === 0) return '—'; return Number(val).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0, }); } function ordinal(n) { const d = Number(n); if (!d) return '—'; if (d > 3 && d < 21) return `${d}th`; switch (d % 10) { case 1: return `${d}st`; case 2: return `${d}nd`; case 3: return `${d}rd`; default: return `${d}th`; } } function sortRamseyDebts(debts) { return [...debts].sort((a, b) => { if (a.current_balance == null && b.current_balance == null) return a.name.localeCompare(b.name); if (a.current_balance == null) return 1; if (b.current_balance == null) return -1; const diff = Number(a.current_balance) - Number(b.current_balance); return diff || a.name.localeCompare(b.name); }); } function isRamseyOrdered(debts) { const sorted = sortRamseyDebts(debts); return debts.every((debt, index) => debt.id === sorted[index]?.id); } // ── SectionDivider ──────────────────────────────────────────────────────────── function SectionDivider({ label }) { return (
{label}
); } // ── StatCard ────────────────────────────────────────────────────────────────── function StatCard({ label, value, sub, highlight }) { return (

{label}

{value}

{sub &&

{sub}

}
); } // ── Projection panel ────────────────────────────────────────────────────────── function AvalancheComparison({ snowball, avalanche }) { if (!snowball.months_to_freedom || !avalanche.months_to_freedom) return null; const monthDiff = snowball.months_to_freedom - avalanche.months_to_freedom; const interestDiff = snowball.total_interest_paid - avalanche.total_interest_paid; const same = Math.abs(monthDiff) < 1 && Math.abs(interestDiff) < 1; return (

vs. Avalanche (highest rate first)

{avalanche.payoff_display} {fmt(avalanche.total_interest_paid)} interest
{same ? (

Same result — your debts have similar rates.

) : interestDiff > 0 ? (

Avalanche saves {fmt(interestDiff)} interest {monthDiff > 0 ? ` · ${monthDiff} month${monthDiff > 1 ? 's' : ''} faster` : ''}

) : (

Snowball finishes {Math.abs(monthDiff)} month{Math.abs(monthDiff) > 1 ? 's' : ''} faster · Avalanche costs {fmt(Math.abs(interestDiff))} more

)}
); } function ProjectionPanel({ projection, projectionLoading, billCount }) { if (projectionLoading) { return (
{[...Array(3)].map((_, i) => )}
); } if (!projection) return null; const sb = projection.snowball; const av = projection.avalanche; if (!sb) return null; const hasProjection = sb.debts.length > 0; const needsBalances = billCount > 0 && !hasProjection && sb.skipped.length > 0; return (
Payoff Projection
{sb.payoff_display && (

Snowball · Debt-Free

{sb.payoff_display}

)}
{sb.capped && (
Payoff exceeds 50 years. Add extra monthly budget or increase minimum payments.
)} {needsBalances && (
Click any balance to enter it and see your payoff timeline.
)} {hasProjection && (
{sb.debts.map((d, i) => (
#{i + 1} {d.name}
{d.payoff_display ? ( <>

{d.payoff_display}

{d.months} mo · {fmtCompact(d.total_interest)} interest

) : (

unknown balance

)}
))}
)} {hasProjection && (
Total interest paid {fmt(sb.total_interest_paid)}
)} {hasProjection && av && } {sb.skipped.length > 0 && hasProjection && (
{sb.skipped.length} bill{sb.skipped.length > 1 ? 's' : ''} excluded (no balance): {' '}{sb.skipped.map(s => s.name).join(', ')}
)}
); } // ── Readiness strip ─────────────────────────────────────────────────────────── function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, disabled }) { return (
{allReady ? : }

Snowball Readiness

{allReady ? 'Ready to roll: attack the smallest balance first.' : `${readyCount} of ${totalCount} ready`}

{items.map(item => { const Icon = item.ready ? CheckCircle2 : Circle; const content = ( <> {item.label} ); const className = cn( 'inline-flex h-8 max-w-full items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition-colors', item.ready ? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-400' : 'border-border/60 bg-muted/30 text-muted-foreground', item.manual && !disabled && 'hover:bg-muted/60 cursor-pointer', item.manual && disabled && 'opacity-60 cursor-not-allowed', ); if (item.manual) { return ( ); } return ( {content} ); })}
); } // ── Page ────────────────────────────────────────────────────────────────────── export default function SnowballPage() { const [bills, setBills] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); const [editBill, setEditBill] = useState(null); const [extraPayment, setExtraPayment] = useState(''); const [ramseyMode, setRamseyMode] = useState(true); const [readyCurrentOnBills, setReadyCurrentOnBills] = useState(false); const [readyEmergencyFund, setReadyEmergencyFund] = useState(false); const [savingSettings, setSavingSettings] = useState(false); const extraPaymentRef = useRef(''); const [projection, setProjection] = useState(null); const [projectionLoading, setProjectionLoading] = useState(false); const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' }); const [activePlan, setActivePlan] = useState(null); const [allPlans, setAllPlans] = useState([]); const [startingPlan, setStartingPlan] = useState(false); const [readinessWarnOpen, setReadinessWarnOpen] = useState(false); const [draggingId, setDraggingId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); // ── loading ─────────────────────────────────────────────────────────────── const loadProjection = useCallback(async () => { setProjectionLoading(true); try { setProjection(await api.snowballProjection()); } catch { /* non-fatal */ } finally { setProjectionLoading(false); } }, []); const load = useCallback(async () => { setLoading(true); setLoadError(null); try { const [billsArr, catsArr, settings] = await Promise.all([ api.snowball(), api.categories(), api.snowballSettings(), ]); setCategories(catsArr); setBills(billsArr); setDirty(false); const ep = settings.extra_payment > 0 ? String(settings.extra_payment) : ''; setRamseyMode(settings.ramsey_mode !== false); setReadyCurrentOnBills(!!settings.ready_current_on_bills); setReadyEmergencyFund(!!settings.ready_emergency_fund); setExtraPayment(ep); extraPaymentRef.current = ep; } catch (err) { setLoadError(err.message || 'Failed to load snowball data'); } finally { setLoading(false); } }, []); const loadPlans = useCallback(() => { api.snowballActivePlan().then(p => setActivePlan(p)).catch(() => setActivePlan(null)); api.snowballPlans().then(d => setAllPlans(d?.plans ?? [])).catch(err => console.error('[SnowballPage] failed to load plan history', err)); }, []); useEffect(() => { Promise.all([load(), loadProjection()]); loadPlans(); }, [load, loadProjection, loadPlans]); // ── auto-arrange ────────────────────────────────────────────────────────── const handleAutoArrange = () => { setBills(prev => sortRamseyDebts(prev)); setDirty(true); toast.success('Arranged smallest-to-largest balance'); }; const moveDebt = (fromIndex, toIndex) => { if (ramseyMode || saving || fromIndex === toIndex) return; setBills(prev => moveInArray(prev, fromIndex, toIndex)); setDirty(true); }; const dragPropsFor = (bill, index) => { if (ramseyMode || saving) return { draggable: false }; return { draggable: true, isDragging: draggingId === bill.id, isDropTarget: dropTargetId === bill.id && draggingId !== bill.id, onDragStart: (event) => { setDraggingId(bill.id); event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(bill.id)); }, onDragEnter: () => { if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id); }, onDragOver: (event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id); }, onDrop: (event) => { event.preventDefault(); const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId); const fromIndex = bills.findIndex(item => item.id === sourceId); if (fromIndex >= 0) moveDebt(fromIndex, index); setDraggingId(null); setDropTargetId(null); }, onDragEnd: () => { setDraggingId(null); setDropTargetId(null); }, }; }; // ── save order ──────────────────────────────────────────────────────────── const handleSaveOrder = async () => { setSaving(true); try { await api.saveSnowballOrder(bills.map((b, i) => ({ id: b.id, snowball_order: i }))); setDirty(false); toast.success('Order saved'); loadProjection(); } catch (err) { toast.error(err.message || 'Failed to save order'); } finally { setSaving(false); } }; // ── extra payment ───────────────────────────────────────────────────────── const handleSaveExtraPayment = async () => { const val = extraPayment.trim(); if (val !== '' && (isNaN(parseFloat(val)) || parseFloat(val) < 0)) { toast.error('Extra payment must be a positive number'); return; } if (val === extraPaymentRef.current) return; setSavingSettings(true); try { const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) }); const saved = result.extra_payment > 0 ? String(result.extra_payment) : ''; extraPaymentRef.current = saved; setExtraPayment(saved); toast.success('Extra payment saved'); loadProjection(); } catch (err) { toast.error(err.message || 'Failed to save'); } finally { setSavingSettings(false); } }; const handleRamseyModeChange = async (checked) => { setSavingSettings(true); try { const result = await api.saveSnowballSettings({ ramsey_mode: checked }); const nextMode = result.ramsey_mode !== false; setRamseyMode(nextMode); setDirty(false); if (nextMode) setBills(prev => sortRamseyDebts(prev)); else load(); loadProjection(); toast.success(nextMode ? 'Ramsey Mode enabled' : 'Custom order enabled'); } catch (err) { toast.error(err.message || 'Failed to save Snowball mode'); } finally { setSavingSettings(false); } }; const handleReadinessToggle = async (id, checked) => { const payload = id === 'current_on_bills' ? { ready_current_on_bills: checked } : { ready_emergency_fund: checked }; const previous = id === 'current_on_bills' ? readyCurrentOnBills : readyEmergencyFund; if (id === 'current_on_bills') setReadyCurrentOnBills(checked); else setReadyEmergencyFund(checked); setSavingSettings(true); try { const result = await api.saveSnowballSettings(payload); setReadyCurrentOnBills(!!result.ready_current_on_bills); setReadyEmergencyFund(!!result.ready_emergency_fund); } catch (err) { if (id === 'current_on_bills') setReadyCurrentOnBills(previous); else setReadyEmergencyFund(previous); toast.error(err.message || 'Failed to save readiness'); } finally { setSavingSettings(false); } }; // ── inline balance edit ─────────────────────────────────────────────────── const startEditBalance = (bill) => setEditingBalance({ billId: bill.id, value: bill.current_balance != null ? String(bill.current_balance) : '' }); const commitBalance = async (billId) => { const raw = editingBalance.value.trim(); const num = raw === '' ? null : parseFloat(raw); if (raw !== '' && (isNaN(num) || num < 0)) { toast.error('Balance must be a non-negative number'); return; } const current = bills.find(b => b.id === billId); if (num === current?.current_balance) { setEditingBalance({ billId: null, value: '' }); return; } try { await api.updateBillBalance(billId, num); setBills(prev => { const next = prev.map(b => b.id === billId ? { ...b, current_balance: num } : b); return ramseyMode ? sortRamseyDebts(next) : next; }); setEditingBalance({ billId: null, value: '' }); loadProjection(); } catch (err) { toast.error(err.message || 'Failed to update balance'); } }; const removeFromSnowball = async (bill) => { try { await api.updateBillSnowball(bill.id, { snowball_include: false, snowball_exempt: true }); setBills(prev => prev.filter(b => b.id !== bill.id)); setDirty(true); toast.success(`${bill.name} removed from Snowball`); loadProjection(); } catch (err) { toast.error(err.message || 'Failed to remove bill from Snowball'); } }; // ── live projection (debounced server call as user types extra amount) ────── // Passes ?extra=N to the projection endpoint so the server simulation runs // with the current input — no client-side duplicate of snowballService logic. const liveProjectionRef = useRef(null); useEffect(() => { const t = setTimeout(async () => { const extra = parseFloat(extraPayment); const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {}; try { setProjectionLoading(true); const result = await api.snowballProjection(params); setProjection(result); } catch { // non-fatal — keep showing last known projection } finally { setProjectionLoading(false); } }, 220); liveProjectionRef.current = t; return () => clearTimeout(t); }, [extraPayment]); // Attack card payoff date and display projection come directly from server result const liveAttackPayoff = projection?.snowball?.debts?.[0]?.payoff_display ?? null; const displayProjection = projection; // ── plan lifecycle ──────────────────────────────────────────────────────── const handleStartPlan = async () => { setStartingPlan(true); try { const plan = await api.startSnowballPlan({ method: ramseyMode ? 'snowball' : 'custom' }); setActivePlan(plan); setAllPlans(prev => [plan, ...prev.filter(p => !['active', 'paused'].includes(p.status))]); toast.success('Snowball plan started!'); } catch (err) { toast.error(err.message || 'Failed to start plan'); } finally { setStartingPlan(false); } }; const handlePausePlan = async () => { if (!activePlan) return; try { const updated = await api.pauseSnowballPlan(activePlan.id); setActivePlan(updated); setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p)); toast.success('Plan paused'); } catch (err) { toast.error(err.message || 'Failed to pause'); } }; const handleResumePlan = async () => { if (!activePlan) return; try { const updated = await api.resumeSnowballPlan(activePlan.id); setActivePlan(updated); setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p)); toast.success('Plan resumed'); } catch (err) { toast.error(err.message || 'Failed to resume'); } }; const handleCompletePlan = async () => { if (!activePlan) return; try { const updated = await api.completeSnowballPlan(activePlan.id); setActivePlan(null); setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p)); toast.success('Plan marked as complete!'); } catch (err) { toast.error(err.message || 'Failed to complete plan'); } }; const handleAbandonPlan = async () => { if (!activePlan) return; try { const updated = await api.abandonSnowballPlan(activePlan.id); setActivePlan(null); setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p)); toast.success('Plan abandoned'); } catch (err) { toast.error(err.message || 'Failed to abandon plan'); } }; const handleStartPlanClick = () => { if (readinessAllReady) { handleStartPlan(); } else { setReadinessWarnOpen(true); } }; // ── stats ───────────────────────────────────────────────────────────────── const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0); const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0); const unknownCount = bills.filter(b => b.current_balance == null).length; const missingMinCount = bills.filter(b => b.current_balance > 0 && b.minimum_payment == null).length; const extraAmt = parseFloat(extraPayment) || 0; const attackBill = bills[0] ?? null; const attackAmount = attackBill ? (attackBill.minimum_payment || 0) + extraAmt : 0; const customOrderDrift = !ramseyMode && bills.length > 1 && !isRamseyOrdered(bills); const mortgageIncluded = bills.some(b => /mortgage|housing/i.test(b.category_name || b.name || '')); const readinessItems = [ { id: 'current_on_bills', label: 'Current on bills', ready: readyCurrentOnBills, manual: true, hint: 'Confirm all bills are current before starting the snowball.', }, { id: 'emergency_fund', label: '$1,000 emergency fund', ready: readyEmergencyFund, manual: true, hint: 'Confirm the starter emergency fund is set aside.', }, { id: 'debts_entered', label: 'Debts entered', ready: bills.length > 0 && !mortgageIncluded, hint: mortgageIncluded ? 'Mortgage or housing debt is included; Ramsey Baby Step 2 excludes the house.' : 'Enter consumer debts and leave the mortgage out of this snowball.', }, { id: 'minimums_entered', label: 'Minimums entered', ready: bills.length > 0 && missingMinCount === 0, hint: 'Every debt with a balance needs a minimum payment.', }, { id: 'extra_set', label: 'Extra amount set', ready: extraAmt > 0, hint: 'Set the extra monthly budget you can throw at the current target.', }, ]; const readinessReadyCount = readinessItems.filter(item => item.ready).length; const readinessAllReady = readinessReadyCount === readinessItems.length; // ── loading skeleton ────────────────────────────────────────────────────── if (loading) { return (
{[...Array(4)].map((_, i) => )}
{[...Array(3)].map((_, i) => )}
); } if (loadError) { return (

Failed to load snowball data

{loadError}

); } const inp = 'bg-background/50 border-border/60 h-9 text-sm font-mono'; return (
{/* Active plan banner */} {activePlan && ( )} {/* Header */}

Debt Snowball

Dave Ramsey method — attack the smallest balance first, roll payments as each debt clears. Marking a payment automatically reduces the outstanding balance.

{/* Stats */} {bills.length > 0 && ( <>
0 ? `+ ${unknownCount} unknown` : undefined} /> 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" />
)} {/* Settings + Readiness */} {bills.length > 0 && ( <>

{ramseyMode ? 'Smallest balance first' : 'Custom drag order'}

Added to the current target debt.

{extraAmt > 0 ? fmt(extraAmt) : '$0'}

per month

setExtraPayment(e.target.value)} onBlur={handleSaveExtraPayment} className={cn(inp, 'mt-2 w-full border-primary/25 bg-background/75')} disabled={savingSettings} />
{dirty && Unsaved changes}
{!activePlan && readinessReadyCount >= 3 && (
)}
)} {bills.length > 0 && (customOrderDrift || missingMinCount > 0) && (
{customOrderDrift && (
Custom order is active, so the payoff list no longer strictly follows smallest-balance-first.
)} {missingMinCount > 0 && (
{missingMinCount} debt{missingMinCount > 1 ? 's need' : ' needs'} a minimum payment before the projection can model the snowball cleanly.
)}
)} {/* Empty state */} {bills.length === 0 && (

No debt bills found

Bills in Credit Cards, Loans, or Debt categories appear here automatically. You can also enable "Include in Snowball" when editing any bill.

)} {/* Cards + projection */} {bills.length > 0 && ( <>
{/* Cards list */}
{bills.map((bill, index) => { const isAttack = index === 0; const isEditingBal = editingBalance.billId === bill.id; const dragProps = dragPropsFor(bill, index); // Pull this debt's payoff info from the live projection (attack card only) const attackProjection = isAttack ? displayProjection?.snowball?.debts?.[0] : null; return (
{/* Grip */}
{/* Main content */}
{/* Name row */}
{isAttack ? ( Now ) : ( #{index + 1} )} {bill.name} {bill.category_name && ( {bill.category_name} )}
{/* Stats row */}
{/* Balance — inline editable */}
Balance {isEditingBal ? ( setEditingBalance(p => ({ ...p, value: e.target.value }))} onBlur={() => commitBalance(bill.id)} onKeyDown={e => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') setEditingBalance({ billId: null, value: '' }); }} className={cn(inp, 'h-7 w-28 text-xs py-0 px-2')} /> ) : ( )}
{bill.minimum_payment != null && (
Min {fmt(bill.minimum_payment)}
)} {bill.current_balance > 0 && bill.minimum_payment == null && (
Needs minimum payment
)} {isAttack && extraAmt > 0 && (
Throwing {fmt((bill.minimum_payment || 0) + extraAmt)} /mo
)} {bill.interest_rate != null && (
APR = 25 ? 'text-rose-400' : bill.interest_rate >= 15 ? 'text-amber-400' : 'text-muted-foreground', )}> {bill.interest_rate}%
)}
Due {ordinal(bill.due_day)}
{/* Attack payoff line — date is live (updates while typing), interest from server */} {isAttack && (liveAttackPayoff || attackProjection?.payoff_display) && (
↳ Clears {liveAttackPayoff ?? attackProjection.payoff_display} {attackProjection?.total_interest > 0 && ( · {fmtCompact(attackProjection.total_interest)} interest )}
)}
{/* Action icons — fixed right column */}
); })}

{ramseyMode ? 'Ramsey Mode keeps debts sorted by smallest balance · Click a balance to update it' : 'Drag the grip handle to reorder · Click a balance to update it · Save Order to persist'}

{/* Projection (sticky sidebar on large screens) */}
)} {/* Plan history */} {/* Readiness warning dialog */}
Checklist not complete

The following readiness items are still pending:

    {readinessItems.filter(i => !i.ready).map(item => (
  • {item.label}
  • ))}

Starting now may affect your plan's accuracy. You can still proceed.

{/* Edit modal */} {editBill && ( setEditBill(null)} onSave={() => { setEditBill(null); load(); loadProjection(); }} onDuplicate={bill => setEditBill({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })} /> )}
); }