BillTracker/client/pages/SnowballPage.jsx

636 lines
28 KiB
React
Raw Normal View History

2026-05-14 02:11:54 -05:00
import { useCallback, useEffect, useRef, useState } from 'react';
2026-05-14 03:00:01 -05:00
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, X } from 'lucide-react';
2026-05-14 02:11:54 -05:00
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 (
<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, billCount }) {
if (projectionLoading) {
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 (!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>
{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" />
Payoff exceeds 50 years. 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>
);
}
// ── 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,
});
2026-05-14 03:00:01 -05:00
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;
}, []);
2026-05-14 02:11:54 -05:00
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;
2026-05-14 03:00:01 -05:00
const { containerEl, currentIdx } = state.current;
2026-05-14 02:11:54 -05:00
if (!containerEl) return;
2026-05-14 03:00:01 -05:00
const newIdx = Math.max(0, Math.min(items.length - 1, indexFromPointer(e.clientX, e.clientY)));
2026-05-14 02:11:54 -05:00
if (newIdx !== currentIdx) {
state.current.currentIdx = newIdx;
setDraggingIdx(newIdx); // visual feedback on where card will land
}
2026-05-14 03:00:01 -05:00
}, [indexFromPointer, items.length]);
2026-05-14 02:11:54 -05:00
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'); }
};
2026-05-14 03:00:01 -05:00
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');
}
};
2026-05-14 02:11:54 -05:00
// ── 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 (
<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>
);
}
const inp = 'bg-background/50 border-border/60 h-9 text-sm font-mono';
return (
<div className="space-y-6">
{/* 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 && (
<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="Total Attack" value={fmt(totalMinPayment + extraAmt)}
sub="toward #1 target" highlight={extraAmt > 0} />
</div>
)}
{/* Toolbar */}
{bills.length > 0 && (
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
Extra monthly budget ($)
</Label>
<Input
type="number" min="0" step="1" placeholder="0.00"
value={extraPayment}
onChange={e => setExtraPayment(e.target.value)}
onBlur={handleSaveExtraPayment}
className={cn(inp, 'w-32')}
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">
<Zap className="h-3.5 w-3.5" /> Auto-arrange
</Button>
<Button type="button" size="sm" disabled={!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>
)}
{/* Empty state */}
{bills.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 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 Mortgage categories appear here automatically.
You can also enable "Include in Snowball" when editing any bill.
</p>
</div>
)}
{/* Cards + projection */}
{bills.length > 0 && (
<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
{/* Cards list — pointer events on the whole list so moves are tracked even outside a card */}
<div
className="space-y-2"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{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 (
<div
key={bill.id}
data-card
2026-05-14 03:00:01 -05:00
data-card-index={index}
2026-05-14 02:11:54 -05:00
className={cn(
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
isAttack ? 'border-emerald-500/40' : 'border-border/40',
isTarget && isDragging && 'ring-2 ring-primary/50 scale-[0.99]',
)}
>
<div className="flex items-stretch">
{/* Grip handle — pointer-capture trigger */}
<div
data-grip
onPointerDown={e => 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"
>
<GripVertical className="h-5 w-5" />
</div>
{/* Body */}
<div className="flex-1 py-3.5 pr-4 min-w-0">
{/* Top row */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-xs font-bold text-muted-foreground tabular-nums w-5 shrink-0">
#{index + 1}
</span>
{isAttack && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400 shrink-0">
<Zap className="h-2.5 w-2.5" /> Attack
</span>
)}
<span className="font-semibold truncate">{bill.name}</span>
{bill.category_name && (
<span className="text-[10px] text-muted-foreground border border-border/50 rounded px-1.5 py-0.5 shrink-0">
{bill.category_name}
</span>
)}
{bill.snowball_include === 1 && !bill.category_name && (
<span className="text-[10px] text-violet-400 border border-violet-500/30 rounded px-1.5 py-0.5 shrink-0">
manual
</span>
)}
<button
type="button"
onClick={() => setEditBill(bill)}
className="ml-auto text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
Edit
</button>
2026-05-14 03:00:01 -05:00
<button
type="button"
onClick={() => removeFromSnowball(bill)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-amber-400 transition-colors shrink-0"
title="Remove from Snowball"
>
<X className="h-3.5 w-3.5" />
Remove
</button>
2026-05-14 02:11:54 -05:00
</div>
{/* Stats row */}
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1.5 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)}
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/60 italic text-xs',
)}
title="Click to update balance"
>
{bill.current_balance != null ? fmt(bill.current_balance) : 'enter balance'}
</button>
)}
</div>
<div>
<span className="text-xs text-muted-foreground">Min/mo </span>
<span className="font-medium tabular-nums">
{bill.minimum_payment != null ? fmt(bill.minimum_payment) : '—'}
</span>
</div>
{isAttack && extraAmt > 0 && (
<div>
<span className="text-xs text-muted-foreground">Attack </span>
<span className="font-medium tabular-nums text-emerald-400">
{fmt((bill.minimum_payment || 0) + extraAmt)}
</span>
</div>
)}
{bill.interest_rate != null && (
<div>
<span className="text-xs text-muted-foreground">APR </span>
<span className="font-medium tabular-nums">{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>
</div>
</div>
</div>
);
})}
<p className="text-xs text-muted-foreground/50 text-center pt-1">
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={projection}
projectionLoading={projectionLoading}
billCount={bills.length}
/>
</div>
</div>
)}
{/* Edit modal */}
{editBill && (
<BillModal
bill={editBill}
categories={categories}
onClose={() => setEditBill(null)}
onSave={() => { setEditBill(null); load(); loadProjection(); }}
/>
)}
</div>
);
}