import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { 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 BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts';
// ── 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);
}
// ── Client-side snowball simulation (mirrors server snowballService) ───────────
// Returns the full projection shape so the panel and attack card both update
// instantly as the user types the extra payment — no network round-trip needed.
function computeLiveProjection(bills, extraPayment) {
const extra = Math.max(0, Number(extraPayment) || 0);
const active = [];
const skipped = [];
for (const d of bills) {
const bal = Number(d.current_balance);
if (d.current_balance == null || !Number.isFinite(bal) || bal <= 0) {
skipped.push({ id: d.id, name: d.name, reason: 'no_balance' });
continue;
}
active.push({
id: d.id,
name: d.name,
balance: bal,
minPayment: Math.max(0, Number(d.minimum_payment) || 0),
monthlyRate: Math.max(0, Number(d.interest_rate) || 0) / 100 / 12,
payoffMonth: null,
totalInterest: 0,
});
}
if (active.length === 0) return null;
let rollingExtra = extra;
let month = 0;
while (active.some(d => d.balance > 0) && month < 600) {
month++;
const targetIdx = active.findIndex(d => d.balance > 0);
for (let i = 0; i < active.length; i++) {
const d = active[i];
if (d.balance <= 0) continue;
const interest = d.balance * d.monthlyRate;
d.balance += interest;
d.totalInterest += interest;
const payment = Math.min(d.balance, i === targetIdx ? d.minPayment + rollingExtra : d.minPayment);
d.balance = Math.max(0, d.balance - payment);
if (d.balance < 0.005) d.balance = 0;
}
for (const d of active) {
if (d.balance === 0 && d.payoffMonth === null) {
d.payoffMonth = month;
rollingExtra += d.minPayment;
}
}
}
const now = new Date();
const baseYear = now.getFullYear();
const baseMo = now.getMonth();
function monthLabel(m) {
const d = new Date(baseYear, baseMo + m, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function monthDisplay(m) {
return new Date(baseYear, baseMo + m, 1)
.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
}
const debts = active.map(d => ({
id: d.id,
name: d.name,
payoff_month: d.payoffMonth,
payoff_date: d.payoffMonth ? monthLabel(d.payoffMonth) : null,
payoff_display: d.payoffMonth ? monthDisplay(d.payoffMonth) : null,
total_interest: Math.round(d.totalInterest * 100) / 100,
months: d.payoffMonth,
}));
const maxMonth = Math.max(0, ...active.map(d => d.payoffMonth || 0));
const totalInterest = active.reduce((s, d) => s + d.totalInterest, 0);
return {
months_to_freedom: maxMonth || null,
total_interest_paid: Math.round(totalInterest * 100) / 100,
payoff_date: maxMonth ? monthLabel(maxMonth) : null,
payoff_display: maxMonth ? monthDisplay(maxMonth) : null,
debts,
skipped,
extra_payment: extra,
capped: month >= 600,
};
}
// ── 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 (
onToggle(item.id, !item.ready)}
disabled={disabled}
title={item.hint}
>
{content}
);
}
return (
{content}
);
})}
);
}
// ── Pointer-based drag-and-drop hook (works on touch + mouse) ─────────────────
function useSortable(items, setItems, setDirty) {
const [draggingIdx, setDraggingIdx] = useState(null);
const [draggingFromIdx, setDraggingFromIdx] = 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.target.closest('[data-grip]')) return;
// Ignore right-click
if (e.button !== undefined && e.button !== 0) return;
const card = e.target.closest('[data-card]');
const list = card?.parentElement;
const rect = card?.getBoundingClientRect();
// Capture on the container so pointermove/pointerup are dispatched
// directly to the element that owns those React handlers — avoids
// relying on bubbling from the grip through React's delegation chain.
list?.setPointerCapture(e.pointerId);
state.current = {
fromIdx: index,
currentIdx: index,
startY: e.clientY,
itemHeight: rect?.height ?? 80,
containerEl: list ?? null,
};
setDraggingIdx(index);
setDraggingFromIdx(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);
setDraggingFromIdx(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, draggingFromIdx, onPointerDown, onPointerMove, onPointerUp };
}
// ── 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 { draggingIdx, draggingFromIdx, 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);
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); }
}, []);
useEffect(() => { Promise.all([load(), loadProjection()]); }, [load, loadProjection]);
// ── auto-arrange ──────────────────────────────────────────────────────────
const handleAutoArrange = () => {
setBills(prev => sortRamseyDebts(prev));
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); }
};
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 (updates as user types extra amount) ─────────────────
// Full simulation runs client-side so both the attack card and the
// projection panel update instantly — no network round-trip.
const liveSnowball = useMemo(
() => computeLiveProjection(bills, extraPayment),
[bills, extraPayment],
);
// Attack card uses the first debt's payoff date from the live simulation
const liveAttackPayoff = liveSnowball?.debts?.[0]?.payoff_display ?? null;
// Panel merges live snowball with server avalanche (avalanche doesn't need to be live)
const displayProjection = liveSnowball
? { snowball: liveSnowball, avalanche: projection?.avalanche }
: projection;
// ── 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}
Try again
);
}
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" />
)}
{/* Toolbar */}
{bills.length > 0 && (
Ramsey Mode
{ramseyMode ? 'Smallest balance first' : 'Custom drag order'}
Extra monthly budget ($)
setExtraPayment(e.target.value)}
onBlur={handleSaveExtraPayment}
className={cn(inp, 'w-32')}
disabled={savingSettings}
/>
Restore Ramsey Order
{saving ? 'Saving…' : 'Save Order'}
{dirty && Unsaved changes }
)}
{bills.length > 0 && (
)}
{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 — 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 = draggingFromIdx !== null;
const isDragSource = draggingFromIdx === index;
const isLandTarget = isDragging && !isDragSource && draggingIdx === index;
// Pull this debt's payoff info from the live projection (attack card only)
const attackProjection = isAttack
? displayProjection?.snowball?.debts?.[0]
: null;
return (
{/* Grip */}
{ if (!ramseyMode) onPointerDown(e, index); }}
className={cn(
'flex items-center px-3 transition-colors touch-none shrink-0',
ramseyMode
? 'text-muted-foreground/10 cursor-not-allowed'
: 'text-muted-foreground/20 hover:text-muted-foreground/60 cursor-grab active:cursor-grabbing',
)}
aria-label={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'}
>
{/* 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')}
/>
) : (
startEditBalance(bill)}
title="Click to update balance"
className={cn(
'font-semibold tabular-nums rounded px-1 -mx-1 hover:bg-muted/60 transition-colors',
isAttack && bill.current_balance != null && 'text-emerald-400',
bill.current_balance == null && 'text-muted-foreground/50 italic text-xs',
)}
>
{bill.current_balance != null ? fmt(bill.current_balance) : 'add balance'}
)}
{bill.minimum_payment != null && (
Min
{fmt(bill.minimum_payment)}
)}
{bill.current_balance > 0 && bill.minimum_payment == null && (
)}
{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 */}
setEditBill({ bill })}
title="Edit bill"
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-muted/60 transition-colors"
>
removeFromSnowball(bill)}
title="Hide from Snowball"
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-amber-400 hover:bg-amber-500/10 transition-colors"
>
);
})}
{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) */}
)}
{/* Edit modal */}
{editBill && (
setEditBill(null)}
onSave={() => { setEditBill(null); load(); loadProjection(); }}
onDuplicate={bill => setEditBill({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })}
/>
)}
);
}