BillTracker/client/pages/SnowballPage.jsx

1111 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import { formatUSD, formatUSDWhole } from '@/lib/money';
// ── formatters ────────────────────────────────────────────────────────────────
function fmt(val) {
return formatUSD(val, { dash: true });
}
function fmtCompact(val) {
if (val == null || val === 0) return '—';
return formatUSDWhole(val);
}
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 (
<div className="flex items-center gap-3">
<span className="shrink-0 text-[10px] font-bold uppercase tracking-[0.12em] text-muted-foreground/50">
{label}
</span>
<div className="h-px flex-1 bg-border/50" />
</div>
);
}
// ── StatCard ──────────────────────────────────────────────────────────────────
function StatCard({ label, value, sub, highlight }) {
return (
<div className={cn('surface-elevated rounded-xl px-5 py-4 space-y-0.5', highlight && 'border border-emerald-500/30')}>
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
<p className={cn('text-2xl font-semibold tabular-nums', highlight && 'text-emerald-400')}>{value}</p>
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
</div>
);
}
// ── 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 (
<div className="border-t border-border/40 px-5 py-3 space-y-1.5">
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
vs. Avalanche (highest rate first)
</p>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm text-muted-foreground">{avalanche.payoff_display}</span>
<span className="text-xs tabular-nums text-muted-foreground">{fmt(avalanche.total_interest_paid)} interest</span>
</div>
{same ? (
<p className="text-xs text-muted-foreground/70">Same result your debts have similar rates.</p>
) : interestDiff > 0 ? (
<p className="text-xs text-emerald-400">
Avalanche saves {fmt(interestDiff)} interest
{monthDiff > 0 ? ` · ${monthDiff} month${monthDiff > 1 ? 's' : ''} faster` : ''}
</p>
) : (
<p className="text-xs text-violet-400">
Snowball finishes {Math.abs(monthDiff)} month{Math.abs(monthDiff) > 1 ? 's' : ''} faster ·
Avalanche costs {fmt(Math.abs(interestDiff))} more
</p>
)}
</div>
);
}
function ProjectionPanel({ projection, projectionLoading, projectionError, onRetry, billCount }) {
if (projectionLoading && !projection) {
return (
<div className="surface-elevated rounded-xl p-5 space-y-3">
<Skeleton className="h-5 w-36" />
<div className="space-y-2">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10" />)}</div>
</div>
);
}
if (projectionError && !projection) {
return (
<div className="surface-elevated rounded-xl p-5 text-center">
<AlertCircle className="mx-auto h-6 w-6 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">Couldnt load the payoff projection.</p>
<Button size="sm" variant="outline" className="mt-3 gap-1.5" onClick={onRetry}>
<RefreshCw className="h-3.5 w-3.5" /> Try again
</Button>
</div>
);
}
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 (
<div className="surface-elevated rounded-xl overflow-hidden">
<div className="flex items-start justify-between gap-4 px-5 py-4 border-b border-border/40">
<div className="flex items-center gap-2">
<CalendarCheck className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm font-semibold">Payoff Projection</span>
</div>
{sb.payoff_display && (
<div className="text-right shrink-0">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Snowball · Debt-Free</p>
<p className="text-base font-semibold text-emerald-400">{sb.payoff_display}</p>
</div>
)}
</div>
{projectionError && (
<div className="flex items-center gap-2 px-5 py-2 bg-muted/40 border-b border-border/40 text-xs text-muted-foreground">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
Couldnt refresh showing the last result.
<button type="button" onClick={onRetry} className="ml-auto underline hover:text-foreground">Retry</button>
</div>
)}
{sb.capped && (
<div className="flex items-start gap-2 px-5 py-3 bg-amber-500/10 border-b border-amber-500/20 text-xs text-amber-400">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
One or more debts wont pay off at this rate. Add extra monthly budget or increase minimum payments.
</div>
)}
{needsBalances && (
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
Click any balance to enter it and see your payoff timeline.
</div>
)}
{hasProjection && (
<div className="divide-y divide-border/30">
{sb.debts.map((d, i) => (
<div key={d.id} className="flex items-center gap-3 px-5 py-3">
<span className="text-xs font-bold text-muted-foreground w-5 shrink-0 tabular-nums">#{i + 1}</span>
<span className="flex-1 text-sm font-medium truncate min-w-0">{d.name}</span>
<div className="text-right shrink-0 space-y-0.5">
{d.payoff_display ? (
<>
<p className="text-sm font-semibold">{d.payoff_display}</p>
<p className="text-[10px] text-muted-foreground">
{d.months} mo · {fmtCompact(d.total_interest)} interest
</p>
</>
) : (
<p className="text-xs text-muted-foreground">unknown balance</p>
)}
</div>
</div>
))}
</div>
)}
{hasProjection && (
<div className="flex items-center justify-between px-5 py-3 border-t border-border/40 bg-muted/20">
<span className="text-xs text-muted-foreground">Total interest paid</span>
<span className="text-sm font-semibold tabular-nums">{fmt(sb.total_interest_paid)}</span>
</div>
)}
{hasProjection && av && <AvalancheComparison snowball={sb} avalanche={av} />}
{sb.skipped.length > 0 && hasProjection && (
<div className="px-5 pb-3 text-[10px] text-muted-foreground/60">
{sb.skipped.length} bill{sb.skipped.length > 1 ? 's' : ''} excluded (no balance):
{' '}{sb.skipped.map(s => s.name).join(', ')}
</div>
)}
</div>
);
}
// ── Readiness strip ───────────────────────────────────────────────────────────
function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, disabled }) {
return (
<div className={cn(
'surface-elevated rounded-xl px-4 py-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between',
allReady && 'border border-emerald-500/30',
)}>
<div className="flex items-center gap-3 shrink-0">
<div className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg border',
allReady ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' : 'border-border/60 bg-muted/30 text-muted-foreground',
)}>
{allReady ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
</div>
<div>
<p className="text-sm font-semibold">Snowball Readiness</p>
<p className={cn('text-xs', allReady ? 'text-emerald-400' : 'text-muted-foreground')}>
{allReady ? 'Ready to roll: attack the smallest balance first.' : `${readyCount} of ${totalCount} ready`}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{items.map(item => {
const Icon = item.ready ? CheckCircle2 : Circle;
const content = (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{item.label}</span>
</>
);
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 (
<button
key={item.id}
type="button"
className={className}
onClick={() => onToggle(item.id, !item.ready)}
disabled={disabled}
title={item.hint}
>
{content}
</button>
);
}
return (
<span key={item.id} className={className} title={item.hint}>
{content}
</span>
);
})}
</div>
</div>
);
}
// ── 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 [projectionError, setProjectionError] = useState(false);
const typedExtraRef = useRef('');
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 ───────────────────────────────────────────────────────────────
// Keep the projection in sync with the amount CURRENTLY typed (not just the
// last-saved value), so refreshing after a balance edit doesn't drop an
// in-progress extra payment. Mirrors the debounced live-projection effect.
const loadProjection = useCallback(async () => {
setProjectionLoading(true);
try {
const extra = parseFloat(typedExtraRef.current);
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
setProjection(await api.snowballProjection(params));
setProjectionError(false);
} catch {
setProjectionError(true);
} 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 non-negative 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(() => {
typedExtraRef.current = extraPayment; // keep loadProjection() in sync with the input
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);
setProjectionError(false);
} catch {
setProjectionError(true); // keep last projection, but surface the failure
} 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 (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
</div>
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 rounded-xl" />)}
</div>
</div>
);
}
if (loadError) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center rounded-xl border border-destructive/20 bg-destructive/5">
<AlertCircle className="h-10 w-10 text-destructive mb-3" />
<p className="text-sm font-medium text-foreground">Failed to load snowball data</p>
<p className="mt-1 text-xs text-muted-foreground">{loadError}</p>
<Button size="sm" variant="outline" onClick={load}
className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50">
<RefreshCw className="h-3 w-3" />
Try again
</Button>
</div>
);
}
const inp = 'bg-background/50 border-border/60 h-9 text-sm font-mono';
return (
<div className="space-y-6">
{/* Active plan banner */}
{activePlan && (
<PlanStatusBanner
plan={activePlan}
onPause={handlePausePlan}
onResume={handleResumePlan}
onComplete={handleCompletePlan}
onAbandon={handleAbandonPlan}
onNewPlan={handleStartPlan}
/>
)}
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<TrendingDown className="h-6 w-6 text-primary" />
Debt Snowball
</h1>
<p className="text-sm text-muted-foreground mt-1">
Dave Ramsey method attack the smallest balance first, roll payments as each debt clears.
Marking a payment automatically reduces the outstanding balance.
</p>
</div>
{/* Stats */}
{bills.length > 0 && (
<>
<SectionDivider label="Overview" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Total Debt" value={fmt(totalBalance)}
sub={unknownCount > 0 ? `+ ${unknownCount} unknown` : undefined} />
<StatCard label="Monthly Minimums" value={fmt(totalMinPayment)} />
<StatCard label="Extra / Month" value={extraAmt > 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" />
<StatCard label="Next Win" value={liveAttackPayoff || 'Add details'}
sub={attackBill ? `${attackBill.name} · attacking ${fmt(attackAmount)}/mo` : undefined}
highlight={!!liveAttackPayoff} />
</div>
</>
)}
{/* Settings + Readiness */}
{bills.length > 0 && (
<>
<SectionDivider label="Configuration" />
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<div className="surface-elevated rounded-xl px-4 py-3 flex items-center gap-3 min-h-[58px]">
<Switch
id="ramsey-mode"
checked={ramseyMode}
onCheckedChange={handleRamseyModeChange}
disabled={savingSettings}
/>
<div>
<Label htmlFor="ramsey-mode" className="text-xs font-semibold cursor-pointer">Ramsey Mode</Label>
<p className="text-[10px] text-muted-foreground">
{ramseyMode ? 'Smallest balance first' : 'Custom drag order'}
</p>
</div>
</div>
<div className="surface-elevated min-h-[58px] rounded-xl border border-primary/25 bg-primary/[0.04] px-4 py-3 shadow-sm shadow-primary/5">
<div className="flex items-center justify-between gap-4">
<div>
<Label htmlFor="extra-payment" className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-primary">
<Zap className="h-3.5 w-3.5" />
Extra applied monthly
</Label>
<p className="mt-0.5 text-[10px] text-muted-foreground">Added to the current target debt.</p>
</div>
<div className="text-right">
<p className="tracker-number text-base font-bold text-primary">{extraAmt > 0 ? fmt(extraAmt) : '$0'}</p>
<p className="text-[10px] text-muted-foreground">per month</p>
</div>
</div>
<Input
id="extra-payment"
type="number" min="0" step="1" placeholder="0.00"
value={extraPayment}
onChange={e => setExtraPayment(e.target.value)}
onBlur={handleSaveExtraPayment}
className={cn(inp, 'mt-2 w-full border-primary/25 bg-background/75')}
disabled={savingSettings}
/>
</div>
<div className="flex items-center gap-2 pb-0.5">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAutoArrange}
className="gap-2"
disabled={ramseyMode}
>
<Zap className="h-3.5 w-3.5" /> Restore Ramsey Order
</Button>
<Button type="button" size="sm" disabled={ramseyMode || !dirty || saving} onClick={handleSaveOrder} className="gap-2">
<Save className="h-3.5 w-3.5" /> {saving ? 'Saving…' : 'Save Order'}
</Button>
{dirty && <span className="text-xs text-amber-400">Unsaved changes</span>}
</div>
</div>
<ReadinessStrip
items={readinessItems}
readyCount={readinessReadyCount}
totalCount={readinessItems.length}
allReady={readinessAllReady}
onToggle={handleReadinessToggle}
disabled={savingSettings}
/>
{!activePlan && readinessReadyCount >= 3 && (
<div className="flex justify-end">
<Button size="sm" onClick={handleStartPlanClick} disabled={startingPlan} className="gap-1.5">
<Zap className="h-3.5 w-3.5" />
{startingPlan ? 'Starting…' : 'Start Snowball Plan'}
</Button>
</div>
)}
</div>
</>
)}
{bills.length > 0 && (customOrderDrift || missingMinCount > 0) && (
<div className="surface-elevated rounded-xl border border-amber-500/25 bg-amber-500/10 px-4 py-3 space-y-2">
{customOrderDrift && (
<div className="flex items-start gap-2 text-xs text-amber-300">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
Custom order is active, so the payoff list no longer strictly follows smallest-balance-first.
</div>
)}
{missingMinCount > 0 && (
<div className="flex items-start gap-2 text-xs text-amber-300">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
{missingMinCount} debt{missingMinCount > 1 ? 's need' : ' needs'} a minimum payment before the projection can model the snowball cleanly.
</div>
)}
</div>
)}
{/* Empty state */}
{bills.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/20 py-20 text-center gap-3">
<TrendingDown className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">No debt bills found</p>
<p className="text-xs text-muted-foreground/70 max-w-sm">
Bills in Credit Cards, Loans, or Debt categories appear here automatically.
You can also enable "Include in Snowball" when editing any bill.
</p>
</div>
)}
{/* Cards + projection */}
{bills.length > 0 && (
<>
<SectionDivider label="Attack Order" />
<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
{/* Cards list */}
<div className="space-y-2">
{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 (
<div
key={bill.id}
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'surface-elevated rounded-xl border select-none transition-colors duration-150',
isAttack ? 'border-emerald-500/30 bg-emerald-950/5' : 'border-border/40',
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
)}
>
<div className="flex items-stretch">
{/* Grip */}
<div className="flex items-center gap-0.5 px-3 transition-colors touch-none shrink-0">
<div
className={cn(
'transition-colors',
ramseyMode
? 'text-muted-foreground/10 cursor-not-allowed'
: 'text-muted-foreground/55 hover:text-muted-foreground/80 cursor-grab active:cursor-grabbing',
)}
title={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'}
>
<GripVertical className="h-5 w-5" />
</div>
<div className="flex flex-col">
<button
type="button"
onClick={() => moveDebt(index, index - 1)}
disabled={ramseyMode || saving || index === 0}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move debt up"
aria-label={`Move ${bill.name} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => moveDebt(index, index + 1)}
disabled={ramseyMode || saving || index === bills.length - 1}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move debt down"
aria-label={`Move ${bill.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
{/* Main content */}
<div className="flex-1 py-3 min-w-0">
{/* Name row */}
<div className="flex items-center gap-2 min-w-0">
{isAttack ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-400 shrink-0">
<Zap className="h-2.5 w-2.5" /> Now
</span>
) : (
<span className="text-xs font-semibold text-muted-foreground/50 tabular-nums shrink-0 w-5">
#{index + 1}
</span>
)}
<span className="font-semibold text-sm truncate">{bill.name}</span>
{bill.category_name && (
<span className="text-[10px] text-muted-foreground/60 shrink-0 hidden sm:inline">
{bill.category_name}
</span>
)}
</div>
{/* Stats row */}
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-sm items-center">
{/* Balance — inline editable */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">Balance</span>
{isEditingBal ? (
<Input
autoFocus
type="number" min="0" step="0.01"
value={editingBalance.value}
onChange={e => 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')}
/>
) : (
<button
type="button"
onClick={() => 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'}
</button>
)}
</div>
{bill.minimum_payment != null && (
<div>
<span className="text-xs text-muted-foreground">Min </span>
<span className="font-medium tabular-nums">{fmt(bill.minimum_payment)}</span>
</div>
)}
{bill.current_balance > 0 && bill.minimum_payment == null && (
<div className="inline-flex items-center gap-1 text-xs text-amber-400">
<AlertTriangle className="h-3 w-3" />
Needs minimum payment
</div>
)}
{isAttack && extraAmt > 0 && (
<div>
<span className="text-xs text-muted-foreground">Throwing </span>
<span className="font-semibold tabular-nums text-emerald-400">
{fmt((bill.minimum_payment || 0) + extraAmt)}
</span>
<span className="text-xs text-muted-foreground"> /mo</span>
</div>
)}
{bill.interest_rate != null && (
<div>
<span className="text-xs text-muted-foreground">APR </span>
<span className={cn(
'font-medium tabular-nums',
bill.interest_rate >= 25 ? 'text-rose-400' :
bill.interest_rate >= 15 ? 'text-amber-400' :
'text-muted-foreground',
)}>
{bill.interest_rate}%
</span>
</div>
)}
<div>
<span className="text-xs text-muted-foreground">Due </span>
<span className="font-medium">{ordinal(bill.due_day)}</span>
</div>
</div>
{/* Attack payoff line — date is live (updates while typing), interest from server */}
{isAttack && (liveAttackPayoff || attackProjection?.payoff_display) && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-emerald-400/80">
<span className="font-medium">
Clears {liveAttackPayoff ?? attackProjection.payoff_display}
</span>
{attackProjection?.total_interest > 0 && (
<span className="text-muted-foreground">
· {fmtCompact(attackProjection.total_interest)} interest
</span>
)}
</div>
)}
</div>
{/* Action icons — fixed right column */}
<div className="flex flex-col items-center justify-center gap-1 px-3 shrink-0">
<button
type="button"
onClick={() => setEditBill({ bill })}
title="Edit bill"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground"
>
<PenLine className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => removeFromSnowball(bill)}
title="Hide from Snowball"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-amber-500/10 hover:text-amber-400"
>
<EyeOff className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
})}
<p className="pt-1 text-center text-xs font-medium text-muted-foreground">
{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'}
</p>
</div>
{/* Projection (sticky sidebar on large screens) */}
<div className="lg:sticky lg:top-24 lg:self-start">
<ProjectionPanel
projection={displayProjection}
projectionLoading={projectionLoading}
projectionError={projectionError}
onRetry={loadProjection}
billCount={bills.length}
/>
</div>
</div>
</>
)}
{/* Plan history */}
<PlanHistoryPanel plans={allPlans} />
{/* Readiness warning dialog */}
<AlertDialog.Root open={readinessWarnOpen} onOpenChange={setReadinessWarnOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm" />
<AlertDialog.Content className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-xl border border-border bg-popover p-6 shadow-2xl space-y-4 focus:outline-none">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" />
<AlertDialog.Title className="text-base font-semibold">
Checklist not complete
</AlertDialog.Title>
</div>
<AlertDialog.Description asChild>
<div className="text-sm text-muted-foreground space-y-3">
<p>The following readiness items are still pending:</p>
<ul className="space-y-1.5">
{readinessItems.filter(i => !i.ready).map(item => (
<li key={item.id} className="flex items-center gap-2 text-amber-400 text-xs">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{item.label}
</li>
))}
</ul>
<p>Starting now may affect your plan&apos;s accuracy. You can still proceed.</p>
</div>
</AlertDialog.Description>
<div className="flex gap-2 justify-end pt-1">
<AlertDialog.Cancel asChild>
<Button variant="outline" size="sm">Go back</Button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Button
size="sm"
className="gap-1.5"
onClick={() => { setReadinessWarnOpen(false); handleStartPlan(); }}
>
<Zap className="h-3.5 w-3.5" />
Start anyway
</Button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
{/* Edit modal */}
{editBill && (
<BillModal
key={editBill.bill?.id ? `edit-${editBill.bill.id}` : `new-${editBill.initialBill?.name || 'blank'}`}
bill={editBill.bill}
initialBill={editBill.initialBill}
categories={categories}
onClose={() => setEditBill(null)}
onSave={() => { setEditBill(null); load(); loadProjection(); }}
onDuplicate={bill => setEditBill({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })}
/>
)}
</div>
);
}