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:
parent
d559f4b00b
commit
18eaec04e2
|
|
@ -24,7 +24,7 @@ interface SnapshotDebt {
|
|||
projected_payoff_month?: number;
|
||||
}
|
||||
|
||||
interface ActivePlan {
|
||||
export interface ActivePlan {
|
||||
name: string;
|
||||
status: string;
|
||||
started_at?: string | null;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue