refactor(ts): SnowballPage → TypeScript (debt attack order + projections + plans)

Typed Bill[]/Projection/SnowballSettings; reused SnowballPlan and exported
ActivePlan from the plan components; React Query cache setters typed.
This commit is contained in:
null 2026-07-04 23:12:57 -05:00
parent d559f4b00b
commit 18eaec04e2
2 changed files with 162 additions and 90 deletions

View File

@ -24,7 +24,7 @@ interface SnapshotDebt {
projected_payoff_month?: number;
}
interface ActivePlan {
export interface ActivePlan {
name: string;
status: string;
started_at?: string | null;

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, type DragEvent, type DragEventHandler, type ReactNode } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
useSnowball, useSnowballSettings, useSnowballActivePlan, useSnowballPlans, useCategories,
@ -11,24 +11,25 @@ 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 { cn, errMessage } 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 PlanStatusBanner, { type ActivePlan } from '@/components/snowball/PlanStatusBanner';
import PlanHistoryPanel, { type SnowballPlan } from '@/components/snowball/PlanHistoryPanel';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import { formatUSD, formatUSDWhole } from '@/lib/money';
import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
import type { Bill, Category } from '@/types';
// ── formatters ────────────────────────────────────────────────────────────────
function fmt(val) {
return formatUSD(val, { dash: true });
function fmt(val: number | null | undefined): string {
return formatUSD(val == null ? undefined : asDollars(val), { dash: true });
}
function fmtCompact(val) {
function fmtCompact(val: number | null | undefined): string {
if (val == null || val === 0) return '—';
return formatUSDWhole(val);
return formatUSDWhole(asDollars(val));
}
function ordinal(n) {
function ordinal(n: number | null | undefined): string {
const d = Number(n);
if (!d) return '—';
if (d > 3 && d < 21) return `${d}th`;
@ -36,7 +37,7 @@ function ordinal(n) {
case 1: return `${d}st`; case 2: return `${d}nd`; case 3: return `${d}rd`; default: return `${d}th`;
}
}
function sortRamseyDebts(debts) {
function sortRamseyDebts(debts: Bill[]): Bill[] {
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;
@ -45,14 +46,70 @@ function sortRamseyDebts(debts) {
return diff || a.name.localeCompare(b.name);
});
}
function isRamseyOrdered(debts) {
function isRamseyOrdered(debts: Bill[]): boolean {
const sorted = sortRamseyDebts(debts);
return debts.every((debt, index) => debt.id === sorted[index]?.id);
}
interface DebtProj {
id: number;
name: string;
payoff_display?: string;
months?: number;
total_interest?: number;
current_balance?: number;
}
interface SnowballProj {
debts: DebtProj[];
skipped: { name: string }[];
payoff_display?: string;
months_to_freedom?: number;
total_interest_paid?: number;
capped?: boolean;
}
interface AvalancheProj {
months_to_freedom?: number;
total_interest_paid?: number;
payoff_display?: string;
}
interface Projection {
snowball?: SnowballProj;
avalanche?: AvalancheProj;
}
interface SnowballSettings {
extra_payment?: number;
ramsey_mode?: boolean;
ready_current_on_bills?: boolean;
ready_emergency_fund?: boolean;
}
interface ReadinessItem {
id: string;
label: string;
ready: boolean;
manual?: boolean;
hint?: string;
}
interface DebtDragProps {
draggable: boolean;
isDragging?: boolean;
isDropTarget?: boolean;
onDragStart?: DragEventHandler<HTMLDivElement>;
onDragEnter?: DragEventHandler<HTMLDivElement>;
onDragOver?: DragEventHandler<HTMLDivElement>;
onDragEnd?: DragEventHandler<HTMLDivElement>;
onDrop?: DragEventHandler<HTMLDivElement>;
}
interface EditBillState {
bill: Bill | null;
initialBill?: Partial<Bill>;
}
// ── SectionDivider ────────────────────────────────────────────────────────────
function SectionDivider({ label }) {
function SectionDivider({ label }: { label: ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="shrink-0 text-[10px] font-bold uppercase tracking-[0.12em] text-muted-foreground/50">
@ -64,7 +121,12 @@ function SectionDivider({ label }) {
}
// ── StatCard ──────────────────────────────────────────────────────────────────
function StatCard({ label, value, sub, highlight }) {
function StatCard({ label, value, sub, highlight }: {
label: ReactNode;
value: ReactNode;
sub?: ReactNode;
highlight?: boolean;
}) {
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>
@ -75,10 +137,10 @@ function StatCard({ label, value, sub, highlight }) {
}
// ── Projection panel ──────────────────────────────────────────────────────────
function AvalancheComparison({ snowball, avalanche }) {
function AvalancheComparison({ snowball, avalanche }: { snowball: SnowballProj; avalanche: AvalancheProj }) {
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 interestDiff = Number(snowball.total_interest_paid ?? 0) - Number(avalanche.total_interest_paid ?? 0);
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">
@ -106,7 +168,13 @@ function AvalancheComparison({ snowball, avalanche }) {
);
}
function ProjectionPanel({ projection, projectionLoading, projectionError, onRetry, billCount }) {
function ProjectionPanel({ projection, projectionLoading, projectionError, onRetry, billCount }: {
projection: Projection | null;
projectionLoading: boolean;
projectionError: boolean;
onRetry: () => void;
billCount: number;
}) {
if (projectionLoading && !projection) {
return (
<div className="surface-elevated rounded-xl p-5 space-y-3">
@ -204,7 +272,14 @@ function ProjectionPanel({ projection, projectionLoading, projectionError, onRet
}
// ── Readiness strip ───────────────────────────────────────────────────────────
function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, disabled }) {
function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, disabled }: {
items: ReadinessItem[];
readyCount: number;
totalCount: number;
allReady: boolean;
onToggle: (id: string, ready: boolean) => void;
disabled?: boolean;
}) {
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',
@ -271,24 +346,29 @@ function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, dis
// ── Page ──────────────────────────────────────────────────────────────────────
export default function SnowballPage() {
const queryClient = useQueryClient();
const { data: bills = [], isPending: billsPending, error: snowballError } = useSnowball();
const { data: categories = [] } = useCategories();
const { data: settings } = useSnowballSettings();
const { data: activePlan = null } = useSnowballActivePlan();
const { data: allPlans = [] } = useSnowballPlans();
const { data: billsRaw = [], isPending: billsPending, error: snowballError } = useSnowball();
const bills = (billsRaw ?? []) as Bill[];
const { data: categoriesRaw = [] } = useCategories();
const categories = (categoriesRaw ?? []) as Category[];
const { data: settingsRaw } = useSnowballSettings();
const settings = settingsRaw as SnowballSettings | undefined;
const { data: activePlanRaw = null } = useSnowballActivePlan();
const activePlan = (activePlanRaw ?? null) as SnowballPlan | null;
const { data: allPlansRaw = [] } = useSnowballPlans();
const allPlans = (allPlansRaw ?? []) as SnowballPlan[];
const loading = billsPending;
const loadError = snowballError ? (snowballError.message || 'Failed to load snowball data') : null;
// Optimistic list/plan edits write through the query cache so existing call
// sites (setBills(prev => …), setActivePlan(…), setAllPlans(prev => …)) work.
const setBills = useCallback((u) => queryClient.setQueryData(['snowball'],
prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
const setActivePlan = useCallback((u) => queryClient.setQueryData(['snowball-active-plan'],
prev => (typeof u === 'function' ? u(prev ?? null) : u)), [queryClient]);
const setAllPlans = useCallback((u) => queryClient.setQueryData(['snowball-plans'],
prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
const setBills = useCallback((u: Bill[] | ((prev: Bill[]) => Bill[])) =>
queryClient.setQueryData<Bill[]>(['snowball'], prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
const setActivePlan = useCallback((plan: SnowballPlan | null) =>
queryClient.setQueryData<SnowballPlan | null>(['snowball-active-plan'], plan), [queryClient]);
const setAllPlans = useCallback((u: (prev: SnowballPlan[]) => SnowballPlan[]) =>
queryClient.setQueryData<SnowballPlan[]>(['snowball-plans'], prev => u(prev || [])), [queryClient]);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [editBill, setEditBill] = useState(null);
const [editBill, setEditBill] = useState<EditBillState | null>(null);
const [extraPayment, setExtraPayment] = useState('');
const [ramseyMode, setRamseyMode] = useState(true);
@ -297,28 +377,25 @@ export default function SnowballPage() {
const [savingSettings, setSavingSettings] = useState(false);
const extraPaymentRef = useRef('');
const [projection, setProjection] = useState(null);
const [projection, setProjection] = useState<Projection | null>(null);
const [projectionLoading, setProjectionLoading] = useState(false);
const [projectionError, setProjectionError] = useState(false);
const typedExtraRef = useRef('');
const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' });
const [editingBalance, setEditingBalance] = useState<{ billId: number | null; value: string }>({ billId: null, value: '' });
const [startingPlan, setStartingPlan] = useState(false);
const [readinessWarnOpen, setReadinessWarnOpen] = useState(false);
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const [draggingId, setDraggingId] = useState<number | null>(null);
const [dropTargetId, setDropTargetId] = useState<number | null>(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));
setProjection(await api.snowballProjection(params) as Projection);
setProjectionError(false);
} catch {
setProjectionError(true);
@ -331,12 +408,11 @@ export default function SnowballPage() {
queryClient.invalidateQueries({ queryKey: ['snowball-settings'] }),
]), [queryClient]);
// Seed the editable settings form (extra payment, ramsey mode, ready flags)
// from the loaded settings whenever they change — this is what `load` did inline.
// Seed the editable settings form from the loaded settings whenever they change.
useEffect(() => {
if (!settings) return;
setDirty(false);
const ep = settings.extra_payment > 0 ? String(settings.extra_payment) : '';
const ep = Number(settings.extra_payment) > 0 ? String(settings.extra_payment) : '';
setRamseyMode(settings.ramsey_mode !== false);
setReadyCurrentOnBills(!!settings.ready_current_on_bills);
setReadyEmergencyFund(!!settings.ready_emergency_fund);
@ -354,19 +430,19 @@ export default function SnowballPage() {
toast.success('Arranged smallest-to-largest balance');
};
const moveDebt = (fromIndex, toIndex) => {
const moveDebt = (fromIndex: number, toIndex: number) => {
if (ramseyMode || saving || fromIndex === toIndex) return;
setBills(prev => moveInArray(prev, fromIndex, toIndex));
setDirty(true);
};
const dragPropsFor = (bill, index) => {
const dragPropsFor = (bill: Bill): DebtDragProps => {
if (ramseyMode || saving) return { draggable: false };
return {
draggable: true,
isDragging: draggingId === bill.id,
isDropTarget: dropTargetId === bill.id && draggingId !== bill.id,
onDragStart: (event) => {
onDragStart: (event: DragEvent<HTMLDivElement>) => {
setDraggingId(bill.id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(bill.id));
@ -374,16 +450,16 @@ export default function SnowballPage() {
onDragEnter: () => {
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
},
onDragOver: (event) => {
onDragOver: (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
},
onDrop: (event) => {
onDrop: (event: DragEvent<HTMLDivElement>) => {
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);
if (fromIndex >= 0) moveDebt(fromIndex, bills.findIndex(item => item.id === bill.id));
setDraggingId(null);
setDropTargetId(null);
},
@ -402,7 +478,7 @@ export default function SnowballPage() {
setDirty(false);
toast.success('Order saved');
loadProjection();
} catch (err) { toast.error(err.message || 'Failed to save order'); }
} catch (err) { toast.error(errMessage(err, 'Failed to save order')); }
finally { setSaving(false); }
};
@ -415,20 +491,20 @@ export default function SnowballPage() {
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) : '';
const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) }) as SnowballSettings;
const saved = Number(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'); }
} catch (err) { toast.error(errMessage(err, 'Failed to save')); }
finally { setSavingSettings(false); }
};
const handleRamseyModeChange = async (checked) => {
const handleRamseyModeChange = async (checked: boolean) => {
setSavingSettings(true);
try {
const result = await api.saveSnowballSettings({ ramsey_mode: checked });
const result = await api.saveSnowballSettings({ ramsey_mode: checked }) as SnowballSettings;
const nextMode = result.ramsey_mode !== false;
setRamseyMode(nextMode);
setDirty(false);
@ -437,13 +513,13 @@ export default function SnowballPage() {
loadProjection();
toast.success(nextMode ? 'Ramsey Mode enabled' : 'Custom order enabled');
} catch (err) {
toast.error(err.message || 'Failed to save Snowball mode');
toast.error(errMessage(err, 'Failed to save Snowball mode'));
} finally {
setSavingSettings(false);
}
};
const handleReadinessToggle = async (id, checked) => {
const handleReadinessToggle = async (id: string, checked: boolean) => {
const payload = id === 'current_on_bills'
? { ready_current_on_bills: checked }
: { ready_emergency_fund: checked };
@ -454,40 +530,40 @@ export default function SnowballPage() {
setSavingSettings(true);
try {
const result = await api.saveSnowballSettings(payload);
const result = await api.saveSnowballSettings(payload) as SnowballSettings;
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');
toast.error(errMessage(err, 'Failed to save readiness'));
} finally {
setSavingSettings(false);
}
};
// ── inline balance edit ───────────────────────────────────────────────────
const startEditBalance = (bill) =>
const startEditBalance = (bill: Bill) =>
setEditingBalance({ billId: bill.id, value: bill.current_balance != null ? String(bill.current_balance) : '' });
const commitBalance = async (billId) => {
const commitBalance = async (billId: number) => {
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; }
if (raw !== '' && (num == null || 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);
const next = prev.map(b => b.id === billId ? { ...b, current_balance: (num == null ? null : asDollars(num)) } : b);
return ramseyMode ? sortRamseyDebts(next) : next;
});
setEditingBalance({ billId: null, value: '' });
loadProjection();
} catch (err) { toast.error(err.message || 'Failed to update balance'); }
} catch (err) { toast.error(errMessage(err, 'Failed to update balance')); }
};
const removeFromSnowball = async (bill) => {
const removeFromSnowball = async (bill: Bill) => {
try {
await api.updateBillSnowball(bill.id, { snowball_include: false, snowball_exempt: true });
setBills(prev => prev.filter(b => b.id !== bill.id));
@ -495,14 +571,11 @@ export default function SnowballPage() {
toast.success(`${bill.name} removed from Snowball`);
loadProjection();
} catch (err) {
toast.error(err.message || 'Failed to remove bill from Snowball');
toast.error(errMessage(err, '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 () => {
@ -510,7 +583,7 @@ export default function SnowballPage() {
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
try {
setProjectionLoading(true);
const result = await api.snowballProjection(params);
const result = await api.snowballProjection(params) as Projection;
setProjection(result);
setProjectionError(false);
} catch {
@ -519,7 +592,6 @@ export default function SnowballPage() {
setProjectionLoading(false);
}
}, 220);
liveProjectionRef.current = t;
return () => clearTimeout(t);
}, [extraPayment]);
@ -531,52 +603,52 @@ export default function SnowballPage() {
const handleStartPlan = async () => {
setStartingPlan(true);
try {
const plan = await api.startSnowballPlan({ method: ramseyMode ? 'snowball' : 'custom' });
const plan = await api.startSnowballPlan({ method: ramseyMode ? 'snowball' : 'custom' }) as SnowballPlan;
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'); }
} catch (err) { toast.error(errMessage(err, 'Failed to start plan')); }
finally { setStartingPlan(false); }
};
const handlePausePlan = async () => {
if (!activePlan) return;
try {
const updated = await api.pauseSnowballPlan(activePlan.id);
const updated = await api.pauseSnowballPlan(activePlan.id) as SnowballPlan;
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'); }
} catch (err) { toast.error(errMessage(err, 'Failed to pause')); }
};
const handleResumePlan = async () => {
if (!activePlan) return;
try {
const updated = await api.resumeSnowballPlan(activePlan.id);
const updated = await api.resumeSnowballPlan(activePlan.id) as SnowballPlan;
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'); }
} catch (err) { toast.error(errMessage(err, 'Failed to resume')); }
};
const handleCompletePlan = async () => {
if (!activePlan) return;
try {
const updated = await api.completeSnowballPlan(activePlan.id);
const updated = await api.completeSnowballPlan(activePlan.id) as SnowballPlan;
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'); }
} catch (err) { toast.error(errMessage(err, 'Failed to complete plan')); }
};
const handleAbandonPlan = async () => {
if (!activePlan) return;
try {
const updated = await api.abandonSnowballPlan(activePlan.id);
const updated = await api.abandonSnowballPlan(activePlan.id) as SnowballPlan;
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'); }
} catch (err) { toast.error(errMessage(err, 'Failed to abandon plan')); }
};
const handleStartPlanClick = () => {
@ -588,13 +660,13 @@ export default function SnowballPage() {
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 missingMinCount = bills.filter(b => Number(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 = [
const readinessItems: ReadinessItem[] = [
{
id: 'current_on_bills',
label: 'Current on bills',
@ -671,7 +743,7 @@ export default function SnowballPage() {
{/* Active plan banner */}
{activePlan && (
<PlanStatusBanner
plan={activePlan}
plan={activePlan as unknown as ActivePlan}
onPause={handlePausePlan}
onResume={handleResumePlan}
onComplete={handleCompletePlan}
@ -830,7 +902,7 @@ export default function SnowballPage() {
{bills.map((bill, index) => {
const isAttack = index === 0;
const isEditingBal = editingBalance.billId === bill.id;
const dragProps = dragPropsFor(bill, index);
const dragProps = dragPropsFor(bill);
// Pull this debt's payoff info from the live projection (attack card only)
const attackProjection = isAttack
@ -928,7 +1000,7 @@ export default function SnowballPage() {
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 === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') setEditingBalance({ billId: null, value: '' });
}}
className={cn(inp, 'h-7 w-28 text-xs py-0 px-2')}
@ -956,7 +1028,7 @@ export default function SnowballPage() {
</div>
)}
{bill.current_balance > 0 && bill.minimum_payment == null && (
{Number(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
@ -997,11 +1069,11 @@ export default function SnowballPage() {
{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}
Clears {liveAttackPayoff ?? attackProjection?.payoff_display}
</span>
{attackProjection?.total_interest > 0 && (
{Number(attackProjection?.total_interest) > 0 && (
<span className="text-muted-foreground">
· {fmtCompact(attackProjection.total_interest)} interest
· {fmtCompact(attackProjection?.total_interest)} interest
</span>
)}
</div>
@ -1110,7 +1182,7 @@ export default function SnowballPage() {
categories={categories}
onClose={() => setEditBill(null)}
onSave={() => { setEditBill(null); load(); loadProjection(); }}
onDuplicate={bill => setEditBill({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })}
onDuplicate={bill => setEditBill({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) as Partial<Bill> })}
/>
)}
</div>