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; projected_payoff_month?: number;
} }
interface ActivePlan { export interface ActivePlan {
name: string; name: string;
status: string; status: string;
started_at?: string | null; 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 { useQueryClient } from '@tanstack/react-query';
import { import {
useSnowball, useSnowballSettings, useSnowballActivePlan, useSnowballPlans, useCategories, useSnowball, useSnowballSettings, useSnowballActivePlan, useSnowballPlans, useCategories,
@ -11,24 +11,25 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/Skeleton'; import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/utils'; import { cn, errMessage } from '@/lib/utils';
import { moveInArray } from '@/lib/reorder'; import { moveInArray } from '@/lib/reorder';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
import PlanStatusBanner from '@/components/snowball/PlanStatusBanner'; import PlanStatusBanner, { type ActivePlan } from '@/components/snowball/PlanStatusBanner';
import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel'; import PlanHistoryPanel, { type SnowballPlan } from '@/components/snowball/PlanHistoryPanel';
import * as AlertDialog from '@radix-ui/react-alert-dialog'; 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 ──────────────────────────────────────────────────────────────── // ── formatters ────────────────────────────────────────────────────────────────
function fmt(val) { function fmt(val: number | null | undefined): string {
return formatUSD(val, { dash: true }); 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 '—'; 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); const d = Number(n);
if (!d) return '—'; if (!d) return '—';
if (d > 3 && d < 21) return `${d}th`; 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`; 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) => { 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 && b.current_balance == null) return a.name.localeCompare(b.name);
if (a.current_balance == null) return 1; if (a.current_balance == null) return 1;
@ -45,14 +46,70 @@ function sortRamseyDebts(debts) {
return diff || a.name.localeCompare(b.name); return diff || a.name.localeCompare(b.name);
}); });
} }
function isRamseyOrdered(debts) { function isRamseyOrdered(debts: Bill[]): boolean {
const sorted = sortRamseyDebts(debts); const sorted = sortRamseyDebts(debts);
return debts.every((debt, index) => debt.id === sorted[index]?.id); 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 ──────────────────────────────────────────────────────────── // ── SectionDivider ────────────────────────────────────────────────────────────
function SectionDivider({ label }) { function SectionDivider({ label }: { label: ReactNode }) {
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="shrink-0 text-[10px] font-bold uppercase tracking-[0.12em] text-muted-foreground/50"> <span className="shrink-0 text-[10px] font-bold uppercase tracking-[0.12em] text-muted-foreground/50">
@ -64,7 +121,12 @@ function SectionDivider({ label }) {
} }
// ── StatCard ────────────────────────────────────────────────────────────────── // ── StatCard ──────────────────────────────────────────────────────────────────
function StatCard({ label, value, sub, highlight }) { function StatCard({ label, value, sub, highlight }: {
label: ReactNode;
value: ReactNode;
sub?: ReactNode;
highlight?: boolean;
}) {
return ( return (
<div className={cn('surface-elevated rounded-xl px-5 py-4 space-y-0.5', highlight && 'border border-emerald-500/30')}> <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="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
@ -75,10 +137,10 @@ function StatCard({ label, value, sub, highlight }) {
} }
// ── Projection panel ────────────────────────────────────────────────────────── // ── 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; if (!snowball.months_to_freedom || !avalanche.months_to_freedom) return null;
const monthDiff = snowball.months_to_freedom - avalanche.months_to_freedom; 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; const same = Math.abs(monthDiff) < 1 && Math.abs(interestDiff) < 1;
return ( return (
<div className="border-t border-border/40 px-5 py-3 space-y-1.5"> <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) { if (projectionLoading && !projection) {
return ( return (
<div className="surface-elevated rounded-xl p-5 space-y-3"> <div className="surface-elevated rounded-xl p-5 space-y-3">
@ -204,7 +272,14 @@ function ProjectionPanel({ projection, projectionLoading, projectionError, onRet
} }
// ── Readiness strip ─────────────────────────────────────────────────────────── // ── 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 ( return (
<div className={cn( <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', '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 ────────────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────────────
export default function SnowballPage() { export default function SnowballPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: bills = [], isPending: billsPending, error: snowballError } = useSnowball(); const { data: billsRaw = [], isPending: billsPending, error: snowballError } = useSnowball();
const { data: categories = [] } = useCategories(); const bills = (billsRaw ?? []) as Bill[];
const { data: settings } = useSnowballSettings(); const { data: categoriesRaw = [] } = useCategories();
const { data: activePlan = null } = useSnowballActivePlan(); const categories = (categoriesRaw ?? []) as Category[];
const { data: allPlans = [] } = useSnowballPlans(); 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 loading = billsPending;
const loadError = snowballError ? (snowballError.message || 'Failed to load snowball data') : null; const loadError = snowballError ? (snowballError.message || 'Failed to load snowball data') : null;
// Optimistic list/plan edits write through the query cache so existing call // Optimistic list/plan edits write through the query cache so existing call
// sites (setBills(prev => …), setActivePlan(…), setAllPlans(prev => …)) work. // sites (setBills(prev => …), setActivePlan(…), setAllPlans(prev => …)) work.
const setBills = useCallback((u) => queryClient.setQueryData(['snowball'], const setBills = useCallback((u: Bill[] | ((prev: Bill[]) => Bill[])) =>
prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]); queryClient.setQueryData<Bill[]>(['snowball'], prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
const setActivePlan = useCallback((u) => queryClient.setQueryData(['snowball-active-plan'], const setActivePlan = useCallback((plan: SnowballPlan | null) =>
prev => (typeof u === 'function' ? u(prev ?? null) : u)), [queryClient]); queryClient.setQueryData<SnowballPlan | null>(['snowball-active-plan'], plan), [queryClient]);
const setAllPlans = useCallback((u) => queryClient.setQueryData(['snowball-plans'], const setAllPlans = useCallback((u: (prev: SnowballPlan[]) => SnowballPlan[]) =>
prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]); queryClient.setQueryData<SnowballPlan[]>(['snowball-plans'], prev => u(prev || [])), [queryClient]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [editBill, setEditBill] = useState(null); const [editBill, setEditBill] = useState<EditBillState | null>(null);
const [extraPayment, setExtraPayment] = useState(''); const [extraPayment, setExtraPayment] = useState('');
const [ramseyMode, setRamseyMode] = useState(true); const [ramseyMode, setRamseyMode] = useState(true);
@ -297,28 +377,25 @@ export default function SnowballPage() {
const [savingSettings, setSavingSettings] = useState(false); const [savingSettings, setSavingSettings] = useState(false);
const extraPaymentRef = useRef(''); const extraPaymentRef = useRef('');
const [projection, setProjection] = useState(null); const [projection, setProjection] = useState<Projection | null>(null);
const [projectionLoading, setProjectionLoading] = useState(false); const [projectionLoading, setProjectionLoading] = useState(false);
const [projectionError, setProjectionError] = useState(false); const [projectionError, setProjectionError] = useState(false);
const typedExtraRef = useRef(''); 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 [startingPlan, setStartingPlan] = useState(false);
const [readinessWarnOpen, setReadinessWarnOpen] = useState(false); const [readinessWarnOpen, setReadinessWarnOpen] = useState(false);
const [draggingId, setDraggingId] = useState(null); const [draggingId, setDraggingId] = useState<number | null>(null);
const [dropTargetId, setDropTargetId] = useState(null); const [dropTargetId, setDropTargetId] = useState<number | null>(null);
// ── loading ─────────────────────────────────────────────────────────────── // ── 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 () => { const loadProjection = useCallback(async () => {
setProjectionLoading(true); setProjectionLoading(true);
try { try {
const extra = parseFloat(typedExtraRef.current); const extra = parseFloat(typedExtraRef.current);
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {}; const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
setProjection(await api.snowballProjection(params)); setProjection(await api.snowballProjection(params) as Projection);
setProjectionError(false); setProjectionError(false);
} catch { } catch {
setProjectionError(true); setProjectionError(true);
@ -331,12 +408,11 @@ export default function SnowballPage() {
queryClient.invalidateQueries({ queryKey: ['snowball-settings'] }), queryClient.invalidateQueries({ queryKey: ['snowball-settings'] }),
]), [queryClient]); ]), [queryClient]);
// Seed the editable settings form (extra payment, ramsey mode, ready flags) // Seed the editable settings form from the loaded settings whenever they change.
// from the loaded settings whenever they change — this is what `load` did inline.
useEffect(() => { useEffect(() => {
if (!settings) return; if (!settings) return;
setDirty(false); 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); setRamseyMode(settings.ramsey_mode !== false);
setReadyCurrentOnBills(!!settings.ready_current_on_bills); setReadyCurrentOnBills(!!settings.ready_current_on_bills);
setReadyEmergencyFund(!!settings.ready_emergency_fund); setReadyEmergencyFund(!!settings.ready_emergency_fund);
@ -354,19 +430,19 @@ export default function SnowballPage() {
toast.success('Arranged smallest-to-largest balance'); toast.success('Arranged smallest-to-largest balance');
}; };
const moveDebt = (fromIndex, toIndex) => { const moveDebt = (fromIndex: number, toIndex: number) => {
if (ramseyMode || saving || fromIndex === toIndex) return; if (ramseyMode || saving || fromIndex === toIndex) return;
setBills(prev => moveInArray(prev, fromIndex, toIndex)); setBills(prev => moveInArray(prev, fromIndex, toIndex));
setDirty(true); setDirty(true);
}; };
const dragPropsFor = (bill, index) => { const dragPropsFor = (bill: Bill): DebtDragProps => {
if (ramseyMode || saving) return { draggable: false }; if (ramseyMode || saving) return { draggable: false };
return { return {
draggable: true, draggable: true,
isDragging: draggingId === bill.id, isDragging: draggingId === bill.id,
isDropTarget: dropTargetId === bill.id && draggingId !== bill.id, isDropTarget: dropTargetId === bill.id && draggingId !== bill.id,
onDragStart: (event) => { onDragStart: (event: DragEvent<HTMLDivElement>) => {
setDraggingId(bill.id); setDraggingId(bill.id);
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(bill.id)); event.dataTransfer.setData('text/plain', String(bill.id));
@ -374,16 +450,16 @@ export default function SnowballPage() {
onDragEnter: () => { onDragEnter: () => {
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id); if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
}, },
onDragOver: (event) => { onDragOver: (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = 'move'; event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id); if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
}, },
onDrop: (event) => { onDrop: (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId); const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
const fromIndex = bills.findIndex(item => item.id === sourceId); 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); setDraggingId(null);
setDropTargetId(null); setDropTargetId(null);
}, },
@ -402,7 +478,7 @@ export default function SnowballPage() {
setDirty(false); setDirty(false);
toast.success('Order saved'); toast.success('Order saved');
loadProjection(); loadProjection();
} catch (err) { toast.error(err.message || 'Failed to save order'); } } catch (err) { toast.error(errMessage(err, 'Failed to save order')); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
@ -415,20 +491,20 @@ export default function SnowballPage() {
if (val === extraPaymentRef.current) return; if (val === extraPaymentRef.current) return;
setSavingSettings(true); setSavingSettings(true);
try { try {
const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) }); const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) }) as SnowballSettings;
const saved = result.extra_payment > 0 ? String(result.extra_payment) : ''; const saved = Number(result.extra_payment) > 0 ? String(result.extra_payment) : '';
extraPaymentRef.current = saved; extraPaymentRef.current = saved;
setExtraPayment(saved); setExtraPayment(saved);
toast.success('Extra payment saved'); toast.success('Extra payment saved');
loadProjection(); loadProjection();
} catch (err) { toast.error(err.message || 'Failed to save'); } } catch (err) { toast.error(errMessage(err, 'Failed to save')); }
finally { setSavingSettings(false); } finally { setSavingSettings(false); }
}; };
const handleRamseyModeChange = async (checked) => { const handleRamseyModeChange = async (checked: boolean) => {
setSavingSettings(true); setSavingSettings(true);
try { 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; const nextMode = result.ramsey_mode !== false;
setRamseyMode(nextMode); setRamseyMode(nextMode);
setDirty(false); setDirty(false);
@ -437,13 +513,13 @@ export default function SnowballPage() {
loadProjection(); loadProjection();
toast.success(nextMode ? 'Ramsey Mode enabled' : 'Custom order enabled'); toast.success(nextMode ? 'Ramsey Mode enabled' : 'Custom order enabled');
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to save Snowball mode'); toast.error(errMessage(err, 'Failed to save Snowball mode'));
} finally { } finally {
setSavingSettings(false); setSavingSettings(false);
} }
}; };
const handleReadinessToggle = async (id, checked) => { const handleReadinessToggle = async (id: string, checked: boolean) => {
const payload = id === 'current_on_bills' const payload = id === 'current_on_bills'
? { ready_current_on_bills: checked } ? { ready_current_on_bills: checked }
: { ready_emergency_fund: checked }; : { ready_emergency_fund: checked };
@ -454,40 +530,40 @@ export default function SnowballPage() {
setSavingSettings(true); setSavingSettings(true);
try { try {
const result = await api.saveSnowballSettings(payload); const result = await api.saveSnowballSettings(payload) as SnowballSettings;
setReadyCurrentOnBills(!!result.ready_current_on_bills); setReadyCurrentOnBills(!!result.ready_current_on_bills);
setReadyEmergencyFund(!!result.ready_emergency_fund); setReadyEmergencyFund(!!result.ready_emergency_fund);
} catch (err) { } catch (err) {
if (id === 'current_on_bills') setReadyCurrentOnBills(previous); if (id === 'current_on_bills') setReadyCurrentOnBills(previous);
else setReadyEmergencyFund(previous); else setReadyEmergencyFund(previous);
toast.error(err.message || 'Failed to save readiness'); toast.error(errMessage(err, 'Failed to save readiness'));
} finally { } finally {
setSavingSettings(false); setSavingSettings(false);
} }
}; };
// ── inline balance edit ─────────────────────────────────────────────────── // ── inline balance edit ───────────────────────────────────────────────────
const startEditBalance = (bill) => const startEditBalance = (bill: Bill) =>
setEditingBalance({ billId: bill.id, value: bill.current_balance != null ? String(bill.current_balance) : '' }); 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 raw = editingBalance.value.trim();
const num = raw === '' ? null : parseFloat(raw); 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); const current = bills.find(b => b.id === billId);
if (num === current?.current_balance) { setEditingBalance({ billId: null, value: '' }); return; } if (num === current?.current_balance) { setEditingBalance({ billId: null, value: '' }); return; }
try { try {
await api.updateBillBalance(billId, num); await api.updateBillBalance(billId, num);
setBills(prev => { 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; return ramseyMode ? sortRamseyDebts(next) : next;
}); });
setEditingBalance({ billId: null, value: '' }); setEditingBalance({ billId: null, value: '' });
loadProjection(); 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 { try {
await api.updateBillSnowball(bill.id, { snowball_include: false, snowball_exempt: true }); await api.updateBillSnowball(bill.id, { snowball_include: false, snowball_exempt: true });
setBills(prev => prev.filter(b => b.id !== bill.id)); setBills(prev => prev.filter(b => b.id !== bill.id));
@ -495,14 +571,11 @@ export default function SnowballPage() {
toast.success(`${bill.name} removed from Snowball`); toast.success(`${bill.name} removed from Snowball`);
loadProjection(); loadProjection();
} catch (err) { } 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) ────── // ── 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(() => { useEffect(() => {
typedExtraRef.current = extraPayment; // keep loadProjection() in sync with the input typedExtraRef.current = extraPayment; // keep loadProjection() in sync with the input
const t = setTimeout(async () => { const t = setTimeout(async () => {
@ -510,7 +583,7 @@ export default function SnowballPage() {
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {}; const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
try { try {
setProjectionLoading(true); setProjectionLoading(true);
const result = await api.snowballProjection(params); const result = await api.snowballProjection(params) as Projection;
setProjection(result); setProjection(result);
setProjectionError(false); setProjectionError(false);
} catch { } catch {
@ -519,7 +592,6 @@ export default function SnowballPage() {
setProjectionLoading(false); setProjectionLoading(false);
} }
}, 220); }, 220);
liveProjectionRef.current = t;
return () => clearTimeout(t); return () => clearTimeout(t);
}, [extraPayment]); }, [extraPayment]);
@ -531,52 +603,52 @@ export default function SnowballPage() {
const handleStartPlan = async () => { const handleStartPlan = async () => {
setStartingPlan(true); setStartingPlan(true);
try { try {
const plan = await api.startSnowballPlan({ method: ramseyMode ? 'snowball' : 'custom' }); const plan = await api.startSnowballPlan({ method: ramseyMode ? 'snowball' : 'custom' }) as SnowballPlan;
setActivePlan(plan); setActivePlan(plan);
setAllPlans(prev => [plan, ...prev.filter(p => !['active', 'paused'].includes(p.status))]); setAllPlans(prev => [plan, ...prev.filter(p => !['active', 'paused'].includes(p.status))]);
toast.success('Snowball plan started!'); 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); } finally { setStartingPlan(false); }
}; };
const handlePausePlan = async () => { const handlePausePlan = async () => {
if (!activePlan) return; if (!activePlan) return;
try { try {
const updated = await api.pauseSnowballPlan(activePlan.id); const updated = await api.pauseSnowballPlan(activePlan.id) as SnowballPlan;
setActivePlan(updated); setActivePlan(updated);
setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p)); setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p));
toast.success('Plan paused'); 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 () => { const handleResumePlan = async () => {
if (!activePlan) return; if (!activePlan) return;
try { try {
const updated = await api.resumeSnowballPlan(activePlan.id); const updated = await api.resumeSnowballPlan(activePlan.id) as SnowballPlan;
setActivePlan(updated); setActivePlan(updated);
setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p)); setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p));
toast.success('Plan resumed'); 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 () => { const handleCompletePlan = async () => {
if (!activePlan) return; if (!activePlan) return;
try { try {
const updated = await api.completeSnowballPlan(activePlan.id); const updated = await api.completeSnowballPlan(activePlan.id) as SnowballPlan;
setActivePlan(null); setActivePlan(null);
setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p)); setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p));
toast.success('Plan marked as complete!'); 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 () => { const handleAbandonPlan = async () => {
if (!activePlan) return; if (!activePlan) return;
try { try {
const updated = await api.abandonSnowballPlan(activePlan.id); const updated = await api.abandonSnowballPlan(activePlan.id) as SnowballPlan;
setActivePlan(null); setActivePlan(null);
setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p)); setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p));
toast.success('Plan abandoned'); 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 = () => { const handleStartPlanClick = () => {
@ -588,13 +660,13 @@ export default function SnowballPage() {
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0); 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 totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
const unknownCount = bills.filter(b => b.current_balance == null).length; 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 extraAmt = parseFloat(extraPayment) || 0;
const attackBill = bills[0] ?? null; const attackBill = bills[0] ?? null;
const attackAmount = attackBill ? (attackBill.minimum_payment || 0) + extraAmt : 0; const attackAmount = attackBill ? (attackBill.minimum_payment || 0) + extraAmt : 0;
const customOrderDrift = !ramseyMode && bills.length > 1 && !isRamseyOrdered(bills); const customOrderDrift = !ramseyMode && bills.length > 1 && !isRamseyOrdered(bills);
const mortgageIncluded = bills.some(b => /mortgage|housing/i.test(b.category_name || b.name || '')); const mortgageIncluded = bills.some(b => /mortgage|housing/i.test(b.category_name || b.name || ''));
const readinessItems = [ const readinessItems: ReadinessItem[] = [
{ {
id: 'current_on_bills', id: 'current_on_bills',
label: 'Current on bills', label: 'Current on bills',
@ -671,7 +743,7 @@ export default function SnowballPage() {
{/* Active plan banner */} {/* Active plan banner */}
{activePlan && ( {activePlan && (
<PlanStatusBanner <PlanStatusBanner
plan={activePlan} plan={activePlan as unknown as ActivePlan}
onPause={handlePausePlan} onPause={handlePausePlan}
onResume={handleResumePlan} onResume={handleResumePlan}
onComplete={handleCompletePlan} onComplete={handleCompletePlan}
@ -830,7 +902,7 @@ export default function SnowballPage() {
{bills.map((bill, index) => { {bills.map((bill, index) => {
const isAttack = index === 0; const isAttack = index === 0;
const isEditingBal = editingBalance.billId === bill.id; 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) // Pull this debt's payoff info from the live projection (attack card only)
const attackProjection = isAttack const attackProjection = isAttack
@ -928,7 +1000,7 @@ export default function SnowballPage() {
onChange={e => setEditingBalance(p => ({ ...p, value: e.target.value }))} onChange={e => setEditingBalance(p => ({ ...p, value: e.target.value }))}
onBlur={() => commitBalance(bill.id)} onBlur={() => commitBalance(bill.id)}
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'Enter') e.target.blur(); if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') setEditingBalance({ billId: null, value: '' }); if (e.key === 'Escape') setEditingBalance({ billId: null, value: '' });
}} }}
className={cn(inp, 'h-7 w-28 text-xs py-0 px-2')} className={cn(inp, 'h-7 w-28 text-xs py-0 px-2')}
@ -956,7 +1028,7 @@ export default function SnowballPage() {
</div> </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"> <div className="inline-flex items-center gap-1 text-xs text-amber-400">
<AlertTriangle className="h-3 w-3" /> <AlertTriangle className="h-3 w-3" />
Needs minimum payment Needs minimum payment
@ -997,11 +1069,11 @@ export default function SnowballPage() {
{isAttack && (liveAttackPayoff || attackProjection?.payoff_display) && ( {isAttack && (liveAttackPayoff || attackProjection?.payoff_display) && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-emerald-400/80"> <div className="mt-1.5 flex items-center gap-1.5 text-xs text-emerald-400/80">
<span className="font-medium"> <span className="font-medium">
Clears {liveAttackPayoff ?? attackProjection.payoff_display} Clears {liveAttackPayoff ?? attackProjection?.payoff_display}
</span> </span>
{attackProjection?.total_interest > 0 && ( {Number(attackProjection?.total_interest) > 0 && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
· {fmtCompact(attackProjection.total_interest)} interest · {fmtCompact(attackProjection?.total_interest)} interest
</span> </span>
)} )}
</div> </div>
@ -1110,7 +1182,7 @@ export default function SnowballPage() {
categories={categories} categories={categories}
onClose={() => setEditBill(null)} onClose={() => setEditBill(null)}
onSave={() => { setEditBill(null); load(); loadProjection(); }} 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> </div>