import { useCallback, useEffect, useRef, useState } from 'react'; import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, X } 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 { Skeleton } from '@/components/ui/Skeleton'; import { cn } from '@/lib/utils'; import BillModal from '@/components/BillModal'; // ── 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`; } } // ── 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(', ')}
)}
); } // ── Pointer-based drag-and-drop hook (works on touch + mouse) ───────────────── function useSortable(items, setItems, setDirty) { const [draggingIdx, setDraggingIdx] = useState(null); // Refs that live through the entire drag gesture const state = useRef({ fromIdx: null, // card index where the drag started currentIdx: null, // card index currently under the pointer startY: 0, itemHeight: 0, containerEl: null, }); const indexFromPointer = useCallback((clientX, clientY) => { const direct = document.elementFromPoint(clientX, clientY)?.closest?.('[data-card-index]'); if (direct?.dataset?.cardIndex != null) { const idx = Number(direct.dataset.cardIndex); if (Number.isInteger(idx)) return idx; } const cards = [...(state.current.containerEl?.querySelectorAll('[data-card-index]') || [])]; if (cards.length === 0) return state.current.currentIdx; let nearestIdx = state.current.currentIdx; let nearestDistance = Infinity; for (const card of cards) { const rect = card.getBoundingClientRect(); const centerY = rect.top + rect.height / 2; const distance = Math.abs(clientY - centerY); if (distance < nearestDistance) { nearestDistance = distance; nearestIdx = Number(card.dataset.cardIndex); } } return Number.isInteger(nearestIdx) ? nearestIdx : state.current.currentIdx; }, []); const onPointerDown = useCallback((e, index) => { // Only trigger on the grip handle (data-grip attr) if (!e.currentTarget.dataset.grip) return; // Ignore right-click if (e.button !== undefined && e.button !== 0) return; e.currentTarget.setPointerCapture(e.pointerId); const card = e.currentTarget.closest('[data-card]'); const list = card?.parentElement; const rect = card?.getBoundingClientRect(); state.current = { fromIdx: index, currentIdx: index, startY: e.clientY, itemHeight: rect?.height ?? 80, containerEl: list ?? null, }; setDraggingIdx(index); }, []); const onPointerMove = useCallback((e) => { if (state.current.fromIdx === null) return; const { containerEl, currentIdx } = state.current; if (!containerEl) return; const newIdx = Math.max(0, Math.min(items.length - 1, indexFromPointer(e.clientX, e.clientY))); if (newIdx !== currentIdx) { state.current.currentIdx = newIdx; setDraggingIdx(newIdx); // visual feedback on where card will land } }, [indexFromPointer, items.length]); const onPointerUp = useCallback((e) => { const { fromIdx, currentIdx } = state.current; state.current.fromIdx = null; state.current.currentIdx = null; setDraggingIdx(null); if (fromIdx === null || currentIdx === null || fromIdx === currentIdx) return; setItems(prev => { const next = [...prev]; const [moved] = next.splice(fromIdx, 1); next.splice(currentIdx, 0, moved); return next; }); setDirty(true); }, [setItems, setDirty]); return { draggingIdx, onPointerDown, onPointerMove, onPointerUp }; } // ── Page ────────────────────────────────────────────────────────────────────── export default function SnowballPage() { const [bills, setBills] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); const [editBill, setEditBill] = useState(null); const [extraPayment, setExtraPayment] = useState(''); 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 { draggingIdx, onPointerDown, onPointerMove, onPointerUp } = useSortable(bills, setBills, setDirty); // ── loading ─────────────────────────────────────────────────────────────── const loadProjection = useCallback(async () => { setProjectionLoading(true); try { setProjection(await api.snowballProjection()); } catch { /* non-fatal */ } finally { setProjectionLoading(false); } }, []); const load = useCallback(async () => { setLoading(true); 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) : ''; setExtraPayment(ep); extraPaymentRef.current = ep; } catch (err) { toast.error(err.message || 'Failed to load snowball data'); } finally { setLoading(false); } }, []); useEffect(() => { Promise.all([load(), loadProjection()]); }, [load, loadProjection]); // ── auto-arrange ────────────────────────────────────────────────────────── const handleAutoArrange = () => { setBills(prev => [...prev].sort((a, b) => { if (a.current_balance == null && b.current_balance == null) return 0; if (a.current_balance == null) return 1; if (b.current_balance == null) return -1; return a.current_balance - b.current_balance; })); setDirty(true); toast.success('Arranged smallest-to-largest balance'); }; // ── 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); } }; // ── 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 => prev.map(b => b.id === billId ? { ...b, current_balance: num } : b)); 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'); } }; // ── 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 extraAmt = parseFloat(extraPayment) || 0; // ── loading skeleton ────────────────────────────────────────────────────── if (loading) { return (
{[...Array(4)].map((_, i) => )}
{[...Array(3)].map((_, i) => )}
); } const inp = 'bg-background/50 border-border/60 h-9 text-sm font-mono'; return (
{/* 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" /> 0} />
)} {/* Toolbar */} {bills.length > 0 && (
setExtraPayment(e.target.value)} onBlur={handleSaveExtraPayment} className={cn(inp, 'w-32')} disabled={savingSettings} />
{dirty && Unsaved changes}
)} {/* Empty state */} {bills.length === 0 && (

No debt bills found

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

)} {/* Cards + projection */} {bills.length > 0 && (
{/* Cards list — pointer events on the whole list so moves are tracked even outside a card */}
{bills.map((bill, index) => { const isAttack = index === 0; const isEditingBal = editingBalance.billId === bill.id; const isDragging = draggingIdx !== null; const isTarget = draggingIdx === index; // where it will land return (
{/* Grip handle — pointer-capture trigger */}
onPointerDown(e, index)} className="flex items-center px-3 text-muted-foreground/30 hover:text-muted-foreground/70 cursor-grab active:cursor-grabbing transition-colors touch-none" aria-label="Drag to reorder" >
{/* Body */}
{/* Top row */}
#{index + 1} {isAttack && ( Attack )} {bill.name} {bill.category_name && ( {bill.category_name} )} {bill.snowball_include === 1 && !bill.category_name && ( manual )}
{/* 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')} /> ) : ( )}
Min/mo {bill.minimum_payment != null ? fmt(bill.minimum_payment) : '—'}
{isAttack && extraAmt > 0 && (
Attack {fmt((bill.minimum_payment || 0) + extraAmt)}
)} {bill.interest_rate != null && (
APR {bill.interest_rate}%
)}
Due {ordinal(bill.due_day)}
); })}

Drag the grip handle to reorder · Click a balance to update it · Save Order to persist

{/* Projection (sticky sidebar on large screens) */}
)} {/* Edit modal */} {editBill && ( setEditBill(null)} onSave={() => { setEditBill(null); load(); loadProjection(); }} /> )}
); }