2026-05-14 21:00:07 -05:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
2026-05-28 02:34:24 -05:00
|
|
|
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff, CheckCircle2, Circle, AlertCircle, RefreshCw } 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';
|
2026-05-16 10:17:24 -05:00
|
|
|
import { Switch } from '@/components/ui/switch';
|
2026-05-14 02:11:54 -05:00
|
|
|
import { Skeleton } from '@/components/ui/Skeleton';
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import BillModal from '@/components/BillModal';
|
2026-05-16 15:38:28 -05:00
|
|
|
import { makeBillDraft } from '@/lib/billDrafts';
|
2026-05-14 02:11:54 -05:00
|
|
|
|
|
|
|
|
// ── 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`;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-16 10:17:24 -05:00
|
|
|
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);
|
|
|
|
|
}
|
2026-05-14 02:11:54 -05:00
|
|
|
|
2026-05-14 21:00:07 -05:00
|
|
|
// ── Client-side snowball simulation (mirrors server snowballService) ───────────
|
2026-05-15 00:03:32 -05:00
|
|
|
// 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) {
|
2026-05-14 21:00:07 -05:00
|
|
|
const extra = Math.max(0, Number(extraPayment) || 0);
|
|
|
|
|
|
2026-05-15 00:03:32 -05:00
|
|
|
const active = [];
|
|
|
|
|
const skipped = [];
|
|
|
|
|
|
2026-05-14 21:00:07 -05:00
|
|
|
for (const d of bills) {
|
|
|
|
|
const bal = Number(d.current_balance);
|
2026-05-15 00:03:32 -05:00
|
|
|
if (d.current_balance == null || !Number.isFinite(bal) || bal <= 0) {
|
|
|
|
|
skipped.push({ id: d.id, name: d.name, reason: 'no_balance' });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-05-14 21:00:07 -05:00
|
|
|
active.push({
|
2026-05-15 00:03:32 -05:00
|
|
|
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,
|
2026-05-14 21:00:07 -05:00
|
|
|
});
|
|
|
|
|
}
|
2026-05-15 00:03:32 -05:00
|
|
|
|
2026-05-14 21:00:07 -05:00
|
|
|
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;
|
2026-05-15 00:03:32 -05:00
|
|
|
const interest = d.balance * d.monthlyRate;
|
|
|
|
|
d.balance += interest;
|
|
|
|
|
d.totalInterest += interest;
|
2026-05-14 21:00:07 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 00:03:32 -05:00
|
|
|
const now = new Date();
|
|
|
|
|
const baseYear = now.getFullYear();
|
|
|
|
|
const baseMo = now.getMonth();
|
2026-05-14 21:00:07 -05:00
|
|
|
|
2026-05-15 00:03:32 -05:00
|
|
|
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,
|
|
|
|
|
};
|
2026-05-14 21:00:07 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
// ── 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 10:17:24 -05:00
|
|
|
// ── 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
// ── Pointer-based drag-and-drop hook (works on touch + mouse) ─────────────────
|
|
|
|
|
function useSortable(items, setItems, setDirty) {
|
2026-05-15 01:36:56 -05:00
|
|
|
const [draggingIdx, setDraggingIdx] = useState(null);
|
|
|
|
|
const [draggingFromIdx, setDraggingFromIdx] = useState(null);
|
2026-05-14 02:11:54 -05:00
|
|
|
|
|
|
|
|
// 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)
|
2026-05-15 04:22:33 -05:00
|
|
|
if (!e.target.closest('[data-grip]')) return;
|
2026-05-14 02:11:54 -05:00
|
|
|
// Ignore right-click
|
|
|
|
|
if (e.button !== undefined && e.button !== 0) return;
|
|
|
|
|
|
2026-05-15 04:22:33 -05:00
|
|
|
const card = e.target.closest('[data-card]');
|
2026-05-14 02:11:54 -05:00
|
|
|
const list = card?.parentElement;
|
|
|
|
|
const rect = card?.getBoundingClientRect();
|
|
|
|
|
|
2026-05-15 04:22:33 -05:00
|
|
|
// 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);
|
|
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
state.current = {
|
|
|
|
|
fromIdx: index,
|
|
|
|
|
currentIdx: index,
|
|
|
|
|
startY: e.clientY,
|
|
|
|
|
itemHeight: rect?.height ?? 80,
|
|
|
|
|
containerEl: list ?? null,
|
|
|
|
|
};
|
|
|
|
|
setDraggingIdx(index);
|
2026-05-15 01:36:56 -05:00
|
|
|
setDraggingFromIdx(index);
|
2026-05-14 02:11:54 -05:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
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);
|
2026-05-15 01:36:56 -05:00
|
|
|
setDraggingFromIdx(null);
|
2026-05-14 02:11:54 -05:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-05-15 01:36:56 -05:00
|
|
|
return { draggingIdx, draggingFromIdx, onPointerDown, onPointerMove, onPointerUp };
|
2026-05-14 02:11:54 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
export default function SnowballPage() {
|
|
|
|
|
const [bills, setBills] = useState([]);
|
|
|
|
|
const [categories, setCategories] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
2026-05-28 02:34:24 -05:00
|
|
|
const [loadError, setLoadError] = useState(null);
|
2026-05-14 02:11:54 -05:00
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
const [dirty, setDirty] = useState(false);
|
|
|
|
|
const [editBill, setEditBill] = useState(null);
|
|
|
|
|
|
|
|
|
|
const [extraPayment, setExtraPayment] = useState('');
|
2026-05-16 10:17:24 -05:00
|
|
|
const [ramseyMode, setRamseyMode] = useState(true);
|
|
|
|
|
const [readyCurrentOnBills, setReadyCurrentOnBills] = useState(false);
|
|
|
|
|
const [readyEmergencyFund, setReadyEmergencyFund] = useState(false);
|
2026-05-14 02:11:54 -05:00
|
|
|
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: '' });
|
|
|
|
|
|
2026-05-15 01:36:56 -05:00
|
|
|
const { draggingIdx, draggingFromIdx, onPointerDown, onPointerMove, onPointerUp } =
|
2026-05-14 02:11:54 -05:00
|
|
|
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);
|
2026-05-28 02:34:24 -05:00
|
|
|
setLoadError(null);
|
2026-05-14 02:11:54 -05:00
|
|
|
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) : '';
|
2026-05-16 10:17:24 -05:00
|
|
|
setRamseyMode(settings.ramsey_mode !== false);
|
|
|
|
|
setReadyCurrentOnBills(!!settings.ready_current_on_bills);
|
|
|
|
|
setReadyEmergencyFund(!!settings.ready_emergency_fund);
|
2026-05-14 02:11:54 -05:00
|
|
|
setExtraPayment(ep);
|
|
|
|
|
extraPaymentRef.current = ep;
|
|
|
|
|
} catch (err) {
|
2026-05-28 02:34:24 -05:00
|
|
|
setLoadError(err.message || 'Failed to load snowball data');
|
2026-05-14 02:11:54 -05:00
|
|
|
} finally { setLoading(false); }
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => { Promise.all([load(), loadProjection()]); }, [load, loadProjection]);
|
|
|
|
|
|
|
|
|
|
// ── auto-arrange ──────────────────────────────────────────────────────────
|
|
|
|
|
const handleAutoArrange = () => {
|
2026-05-16 10:17:24 -05:00
|
|
|
setBills(prev => sortRamseyDebts(prev));
|
2026-05-14 02:11:54 -05:00
|
|
|
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); }
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-16 10:17:24 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
// ── 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);
|
2026-05-16 10:17:24 -05:00
|
|
|
setBills(prev => {
|
|
|
|
|
const next = prev.map(b => b.id === billId ? { ...b, current_balance: num } : b);
|
|
|
|
|
return ramseyMode ? sortRamseyDebts(next) : next;
|
|
|
|
|
});
|
2026-05-14 02:11:54 -05:00
|
|
|
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-15 00:03:32 -05:00
|
|
|
// ── 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),
|
2026-05-14 21:00:07 -05:00
|
|
|
[bills, extraPayment],
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-15 00:03:32 -05:00
|
|
|
// 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;
|
|
|
|
|
|
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;
|
2026-05-16 10:17:24 -05:00
|
|
|
const missingMinCount = bills.filter(b => b.current_balance > 0 && b.minimum_payment == null).length;
|
2026-05-14 02:11:54 -05:00
|
|
|
const extraAmt = parseFloat(extraPayment) || 0;
|
2026-05-16 10:17:24 -05:00
|
|
|
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;
|
2026-05-14 02:11:54 -05:00
|
|
|
|
|
|
|
|
// ── 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 02:34:24 -05:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
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" />
|
2026-05-16 10:17:24 -05:00
|
|
|
<StatCard label="Next Win" value={liveAttackPayoff || 'Add details'}
|
|
|
|
|
sub={attackBill ? `${attackBill.name} · attacking ${fmt(attackAmount)}/mo` : undefined}
|
|
|
|
|
highlight={!!liveAttackPayoff} />
|
2026-05-14 02:11:54 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Toolbar */}
|
|
|
|
|
{bills.length > 0 && (
|
|
|
|
|
<div className="flex flex-wrap items-end gap-3">
|
2026-05-16 10:17:24 -05:00
|
|
|
<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>
|
2026-05-14 02:11:54 -05:00
|
|
|
<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">
|
2026-05-16 10:17:24 -05:00
|
|
|
<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
|
2026-05-14 02:11:54 -05:00
|
|
|
</Button>
|
2026-05-16 10:17:24 -05:00
|
|
|
<Button type="button" size="sm" disabled={ramseyMode || !dirty || saving} onClick={handleSaveOrder} className="gap-2">
|
2026-05-14 02:11:54 -05:00
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-16 10:17:24 -05:00
|
|
|
{bills.length > 0 && (
|
|
|
|
|
<ReadinessStrip
|
|
|
|
|
items={readinessItems}
|
|
|
|
|
readyCount={readinessReadyCount}
|
|
|
|
|
totalCount={readinessItems.length}
|
|
|
|
|
allReady={readinessAllReady}
|
|
|
|
|
onToggle={handleReadinessToggle}
|
|
|
|
|
disabled={savingSettings}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
{/* 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">
|
2026-05-16 10:17:24 -05:00
|
|
|
Bills in Credit Cards, Loans, or Debt categories appear here automatically.
|
2026-05-14 02:11:54 -05:00
|
|
|
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) => {
|
2026-05-15 01:36:56 -05:00
|
|
|
const isAttack = index === 0;
|
|
|
|
|
const isEditingBal = editingBalance.billId === bill.id;
|
|
|
|
|
const isDragging = draggingFromIdx !== null;
|
|
|
|
|
const isDragSource = draggingFromIdx === index;
|
|
|
|
|
const isLandTarget = isDragging && !isDragSource && draggingIdx === index;
|
2026-05-14 19:33:23 -05:00
|
|
|
|
2026-05-15 00:03:32 -05:00
|
|
|
// Pull this debt's payoff info from the live projection (attack card only)
|
2026-05-14 19:33:23 -05:00
|
|
|
const attackProjection = isAttack
|
2026-05-15 00:03:32 -05:00
|
|
|
? displayProjection?.snowball?.debts?.[0]
|
2026-05-14 19:33:23 -05:00
|
|
|
: null;
|
2026-05-14 02:11:54 -05:00
|
|
|
|
|
|
|
|
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(
|
2026-05-15 04:22:33 -05:00
|
|
|
'surface-elevated rounded-xl border select-none touch-none',
|
|
|
|
|
// Only animate when not in a drag gesture — instant feedback on grab
|
|
|
|
|
!isDragging && 'transition-all duration-150',
|
2026-05-14 19:33:23 -05:00
|
|
|
isAttack ? 'border-emerald-500/30 bg-emerald-950/5' : 'border-border/40',
|
2026-05-15 01:36:56 -05:00
|
|
|
// Card being actively dragged — lifted look
|
2026-05-15 04:22:33 -05:00
|
|
|
isDragSource && 'scale-105 shadow-2xl ring-2 ring-primary/60 opacity-75 relative z-10',
|
2026-05-15 01:36:56 -05:00
|
|
|
// Where the card will land — slot highlight
|
2026-05-15 04:22:33 -05:00
|
|
|
isLandTarget && 'ring-2 ring-primary/40 scale-[0.97] opacity-50',
|
2026-05-14 02:11:54 -05:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-stretch">
|
|
|
|
|
|
2026-05-14 19:33:23 -05:00
|
|
|
{/* Grip */}
|
2026-05-14 02:11:54 -05:00
|
|
|
<div
|
|
|
|
|
data-grip
|
2026-05-16 10:17:24 -05:00
|
|
|
onPointerDown={e => { 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'}
|
2026-05-14 02:11:54 -05:00
|
|
|
>
|
|
|
|
|
<GripVertical className="h-5 w-5" />
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-14 19:33:23 -05:00
|
|
|
{/* 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}
|
2026-05-14 02:11:54 -05:00
|
|
|
</span>
|
|
|
|
|
)}
|
2026-05-14 19:33:23 -05:00
|
|
|
<span className="font-semibold text-sm truncate">{bill.name}</span>
|
2026-05-14 02:11:54 -05:00
|
|
|
{bill.category_name && (
|
2026-05-14 19:33:23 -05:00
|
|
|
<span className="text-[10px] text-muted-foreground/60 shrink-0 hidden sm:inline">
|
2026-05-14 02:11:54 -05:00
|
|
|
{bill.category_name}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Stats row */}
|
2026-05-14 19:33:23 -05:00
|
|
|
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-sm items-center">
|
2026-05-14 02:11:54 -05:00
|
|
|
|
|
|
|
|
{/* 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)}
|
2026-05-14 19:33:23 -05:00
|
|
|
title="Click to update balance"
|
2026-05-14 02:11:54 -05:00
|
|
|
className={cn(
|
|
|
|
|
'font-semibold tabular-nums rounded px-1 -mx-1 hover:bg-muted/60 transition-colors',
|
2026-05-14 19:33:23 -05:00
|
|
|
isAttack && bill.current_balance != null && 'text-emerald-400',
|
|
|
|
|
bill.current_balance == null && 'text-muted-foreground/50 italic text-xs',
|
2026-05-14 02:11:54 -05:00
|
|
|
)}
|
|
|
|
|
>
|
2026-05-14 19:33:23 -05:00
|
|
|
{bill.current_balance != null ? fmt(bill.current_balance) : 'add balance'}
|
2026-05-14 02:11:54 -05:00
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-14 19:33:23 -05:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-05-14 02:11:54 -05:00
|
|
|
|
2026-05-16 10:17:24 -05:00
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
{isAttack && extraAmt > 0 && (
|
|
|
|
|
<div>
|
2026-05-14 19:33:23 -05:00
|
|
|
<span className="text-xs text-muted-foreground">Throwing </span>
|
|
|
|
|
<span className="font-semibold tabular-nums text-emerald-400">
|
2026-05-14 02:11:54 -05:00
|
|
|
{fmt((bill.minimum_payment || 0) + extraAmt)}
|
|
|
|
|
</span>
|
2026-05-14 19:33:23 -05:00
|
|
|
<span className="text-xs text-muted-foreground"> /mo</span>
|
2026-05-14 02:11:54 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{bill.interest_rate != null && (
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs text-muted-foreground">APR </span>
|
2026-05-14 19:33:23 -05:00
|
|
|
<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>
|
2026-05-14 02:11:54 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs text-muted-foreground">Due </span>
|
|
|
|
|
<span className="font-medium">{ordinal(bill.due_day)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-14 19:33:23 -05:00
|
|
|
|
2026-05-14 21:00:07 -05:00
|
|
|
{/* Attack payoff line — date is live (updates while typing), interest from server */}
|
|
|
|
|
{isAttack && (liveAttackPayoff || attackProjection?.payoff_display) && (
|
2026-05-14 19:33:23 -05:00
|
|
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-emerald-400/80">
|
2026-05-14 21:00:07 -05:00
|
|
|
<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>
|
2026-05-14 19:33:23 -05:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-05-14 02:11:54 -05:00
|
|
|
</div>
|
2026-05-14 19:33:23 -05:00
|
|
|
|
|
|
|
|
{/* Action icons — fixed right column */}
|
|
|
|
|
<div className="flex flex-col items-center justify-center gap-1 px-3 shrink-0">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-05-16 15:38:28 -05:00
|
|
|
onClick={() => setEditBill({ bill })}
|
2026-05-14 19:33:23 -05:00
|
|
|
title="Edit bill"
|
|
|
|
|
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-muted/60 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<PenLine className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => 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"
|
|
|
|
|
>
|
|
|
|
|
<EyeOff className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
<p className="text-xs text-muted-foreground/50 text-center pt-1">
|
2026-05-16 10:17:24 -05:00
|
|
|
{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'}
|
2026-05-14 02:11:54 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Projection (sticky sidebar on large screens) */}
|
|
|
|
|
<div className="lg:sticky lg:top-24 lg:self-start">
|
|
|
|
|
<ProjectionPanel
|
2026-05-15 00:03:32 -05:00
|
|
|
projection={displayProjection}
|
|
|
|
|
projectionLoading={projectionLoading && !liveSnowball}
|
2026-05-14 02:11:54 -05:00
|
|
|
billCount={bills.length}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Edit modal */}
|
|
|
|
|
{editBill && (
|
|
|
|
|
<BillModal
|
2026-05-16 15:38:28 -05:00
|
|
|
key={editBill.bill?.id ? `edit-${editBill.bill.id}` : `new-${editBill.initialBill?.name || 'blank'}`}
|
|
|
|
|
bill={editBill.bill}
|
|
|
|
|
initialBill={editBill.initialBill}
|
2026-05-14 02:11:54 -05:00
|
|
|
categories={categories}
|
|
|
|
|
onClose={() => setEditBill(null)}
|
|
|
|
|
onSave={() => { setEditBill(null); load(); loadProjection(); }}
|
2026-05-16 15:38:28 -05:00
|
|
|
onDuplicate={bill => setEditBill({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })}
|
2026-05-14 02:11:54 -05:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|