diff --git a/client/components/snowball/PlanStatusBanner.tsx b/client/components/snowball/PlanStatusBanner.tsx index 69b3998..c059115 100644 --- a/client/components/snowball/PlanStatusBanner.tsx +++ b/client/components/snowball/PlanStatusBanner.tsx @@ -24,7 +24,7 @@ interface SnapshotDebt { projected_payoff_month?: number; } -interface ActivePlan { +export interface ActivePlan { name: string; status: string; started_at?: string | null; diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.tsx similarity index 86% rename from client/pages/SnowballPage.jsx rename to client/pages/SnowballPage.tsx index 9e51e74..f694a3b 100644 --- a/client/pages/SnowballPage.jsx +++ b/client/pages/SnowballPage.tsx @@ -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; + onDragEnter?: DragEventHandler; + onDragOver?: DragEventHandler; + onDragEnd?: DragEventHandler; + onDrop?: DragEventHandler; +} + +interface EditBillState { + bill: Bill | null; + initialBill?: Partial; +} // ── SectionDivider ──────────────────────────────────────────────────────────── -function SectionDivider({ label }) { +function SectionDivider({ label }: { label: ReactNode }) { return (
@@ -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 (

{label}

@@ -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 (
@@ -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 (
@@ -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 (
…), 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(['snowball'], prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]); + const setActivePlan = useCallback((plan: SnowballPlan | null) => + queryClient.setQueryData(['snowball-active-plan'], plan), [queryClient]); + const setAllPlans = useCallback((u: (prev: SnowballPlan[]) => SnowballPlan[]) => + queryClient.setQueryData(['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(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(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(null); + const [dropTargetId, setDropTargetId] = useState(null); // ── loading ─────────────────────────────────────────────────────────────── - // Keep the projection in sync with the amount CURRENTLY typed (not just the - // last-saved value), so refreshing after a balance edit doesn't drop an - // in-progress extra payment. Mirrors the debounced live-projection effect. const loadProjection = useCallback(async () => { setProjectionLoading(true); try { const extra = parseFloat(typedExtraRef.current); const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {}; - setProjection(await api.snowballProjection(params)); + 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) => { 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) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id); }, - onDrop: (event) => { + onDrop: (event: DragEvent) => { 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 && ( { 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() {
)} - {bill.current_balance > 0 && bill.minimum_payment == null && ( + {Number(bill.current_balance) > 0 && bill.minimum_payment == null && (
Needs minimum payment @@ -997,11 +1069,11 @@ export default function SnowballPage() { {isAttack && (liveAttackPayoff || attackProjection?.payoff_display) && (
- ↳ Clears {liveAttackPayoff ?? attackProjection.payoff_display} + ↳ Clears {liveAttackPayoff ?? attackProjection?.payoff_display} - {attackProjection?.total_interest > 0 && ( + {Number(attackProjection?.total_interest) > 0 && ( - · {fmtCompact(attackProjection.total_interest)} interest + · {fmtCompact(attackProjection?.total_interest)} interest )}
@@ -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 })} /> )}