From 4638d24a1d2835f9090cc208ddf9cb39d618009e Mon Sep 17 00:00:00 2001 From: null Date: Sun, 5 Jul 2026 18:09:12 -0500 Subject: [PATCH] fix(payoff): correctness, data-integrity & clarity of the payoff simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data integrity (A): - "Apply … to my budget" overwrote the bill's expected_amount (its real recurring amount, used in budgets + payment recording) with the simulated payoff payment. Replace with "Save APR to this bill" — persists interest_rate, leaves expected_amount untouched. Fixes the corruption AND the interest-free root cause (future runs use the saved APR). BillModal debt section also opens when a bill has an interest_rate. Error handling (B): - Undo now has try/catch + error toast (was an unhandled rejection that left the wrong value applied); Apply uses errMessage. - loadData no longer snaps the selection back to the first bill after a save/undo (was a stale-closure over selectedId); background refreshes are "silent" so a transient failure no longer replaces the whole page. Makes-sense (C): - Distinguish "no APR" from a real 0% and show a notice (was silently interest- free with no indication). - Without a real minimum, show absolute Time-to-Payoff + Total Interest instead of a broken "$0 savings" / "~580 mo sooner". - Remove the arbitrary, mislabeled "Snowball plan" line (was extra applied only to the alphabetically-first bill) from the page + chart. - Detect the 600-month cap (incl. 0% tiny payment) as "won't pay off". - Correct the "set a minimum on the Snowball page" guidance (no such editor). Clarity (D): - Plain-language toasts on every write; targeted HelpCircle tooltips (Interest/ Time Savings, Total Interest, Required Minimum, APR, Save-APR); a clearer page description; states that explain why + what to do. Co-Authored-By: Claude Opus 4.8 --- client/components/BillModal.tsx | 1 + client/components/snowball/PayoffChart.tsx | 31 +-- client/pages/PayoffPage.tsx | 255 +++++++++++++-------- 3 files changed, 173 insertions(+), 114 deletions(-) diff --git a/client/components/BillModal.tsx b/client/components/BillModal.tsx index fad81e5..b19980c 100644 --- a/client/components/BillModal.tsx +++ b/client/components/BillModal.tsx @@ -120,6 +120,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa || !!sourceBill?.snowball_exempt || sourceBill?.current_balance != null || sourceBill?.minimum_payment != null + || sourceBill?.interest_rate != null ); const [saveTemplate, setSaveTemplate] = useState(false); const [templateName, setTemplateName] = useState(''); diff --git a/client/components/snowball/PayoffChart.tsx b/client/components/snowball/PayoffChart.tsx index 1612acf..0ee7a51 100644 --- a/client/components/snowball/PayoffChart.tsx +++ b/client/components/snowball/PayoffChart.tsx @@ -44,17 +44,15 @@ function toLine(pts: { x: number; y: number }[]): string { interface PayoffChartProps { minTrack?: TrackPoint[]; - currentTrack?: TrackPoint[]; simTrack?: TrackPoint[]; startBalance?: number; } -export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack = [], startBalance = 1 }: PayoffChartProps) { - const maxMonths = Math.max(minTrack.length, currentTrack.length, simTrack.length, 12); +export default function PayoffChart({ minTrack = [], simTrack = [], startBalance = 1 }: PayoffChartProps) { + const maxMonths = Math.max(minTrack.length, simTrack.length, 12); const bal = Math.max(startBalance, 1); const minPts = buildPoints(minTrack, bal, maxMonths); - const currentPts = buildPoints(currentTrack, bal, maxMonths); const simPts = buildPoints(simTrack, bal, maxMonths); const xStep = maxMonths <= 24 ? 6 : maxMonths <= 60 ? 12 : 24; @@ -65,9 +63,6 @@ export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack const yTicks = [0, 0.25, 0.5, 0.75, 1]; - const showCurrent = currentTrack.length > 0 && - currentTrack.some((c, i) => (minTrack[i]?.balance ?? null) !== c.balance); - return (
@@ -110,14 +105,6 @@ export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack /> )} - {/* Current snowball plan (indigo dashed) */} - {showCurrent && currentPts.length > 1 && ( - - )} - {/* Simulation track (amber solid, prominent) */} {simPts.length > 1 && ( <> @@ -142,19 +129,15 @@ export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack {/* Legend */} - - Min only - - {showCurrent && ( + {minPts.length > 1 && ( <> - - Snowball plan + + Min only )} - - 1 ? 80 : 0} x2={minPts.length > 1 ? 96 : 16} y1="0" y2="0" stroke="#f59e0b" strokeWidth="2.5" /> - Simulation + 1 ? 100 : 20} y="4">Your plan diff --git a/client/pages/PayoffPage.tsx b/client/pages/PayoffPage.tsx index a32c255..af140c6 100644 --- a/client/pages/PayoffPage.tsx +++ b/client/pages/PayoffPage.tsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; -import { AlertCircle, ArrowRight, Calculator, Printer, RefreshCw, RotateCcw, TrendingDown } from 'lucide-react'; +import { AlertCircle, Calculator, HelpCircle, Info, Printer, RefreshCw, RotateCcw, Save, TrendingDown } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, @@ -85,11 +86,30 @@ function numMonths(track: ScheduleMonth[]): string | null { // ─── Sub-components ─────────────────────────────────────────────────────────── -function StatCard({ label, value, sub, color = 'amber' }: { +// Focusable inline-help tooltip for a non-obvious figure (keyboard + SR accessible). +function Help({ text, label }: { text: string; label: string }) { + return ( + + + + + {text} + + ); +} + +function StatCard({ label, value, sub, color = 'amber', help }: { label: ReactNode; value: ReactNode; sub?: ReactNode; color?: string; + help?: string; }) { const colors: Record = { amber: 'bg-amber-500/8 border-amber-400/20 text-amber-500 dark:text-amber-400', @@ -98,7 +118,9 @@ function StatCard({ label, value, sub, color = 'amber' }: { }; return (
-

{label}

+

+ {label}{help && typeof label === 'string' && } +

{value}

{sub &&

{sub}

}
@@ -149,7 +171,6 @@ function NoSelection() { export default function PayoffPage() { const [bills, setBills] = useState([]); - const [extraPayment, setExtraPayment] = useState(0); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [selectedId, setSelectedId] = useState(null); @@ -166,34 +187,39 @@ export default function PayoffPage() { const isCustom = selectedId === 'custom'; - const loadData = useCallback(() => { - setLoading(true); - setLoadError(null); + // isInitial=true drives the full-page loading/error state (first load + retry). + // A background refresh (after a save/undo) is "silent": it updates the data + // without flashing the skeleton or replacing the page on a transient failure. + const loadData = useCallback((isInitial = false) => { + if (isInitial) { setLoading(true); setLoadError(null); } // Use api.bills() so ALL active bills with a balance appear (not just debt categories) - Promise.all([api.bills() as Promise, api.snowballSettings() as Promise<{ extra_payment?: number }>]) - .then(([allBills, settings]) => { + (api.bills() as Promise) + .then((allBills) => { const withBalance = (allBills || []) .filter(b => (b.current_balance ?? 0) > 0 && !b.is_subscription) .sort((a, b) => a.name.localeCompare(b.name)); setBills(withBalance); - setExtraPayment(Number(settings?.extra_payment) || 0); - if (withBalance.length > 0 && !selectedId) { - setSelectedId(withBalance[0]!.id); + // Only auto-pick a bill on the initial load — never yank the user's + // current selection on a background refresh (functional update reads the + // live value, avoiding the stale-closure snap-back). + if (withBalance.length > 0) { + setSelectedId(prev => (prev == null ? withBalance[0]!.id : prev)); } }) - .catch(err => setLoadError(errMessage(err, 'Failed to load data'))) - .finally(() => setLoading(false)); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + .catch(err => { + if (isInitial) setLoadError(errMessage(err, 'Failed to load data')); + else toast.error(errMessage(err, "Couldn't refresh the debt list")); + }) + .finally(() => { if (isInitial) setLoading(false); }); + }, []); - useEffect(() => { loadData(); }, [loadData]); + useEffect(() => { loadData(true); }, [loadData]); const bill = useMemo( () => (isCustom ? null : bills.find(b => b.id === selectedId) ?? null), [bills, selectedId, isCustom], ); - const isAttack = !isCustom && bills[0]?.id === selectedId; - // Reset sim inputs whenever selection changes useEffect(() => { if (isCustom) { @@ -216,19 +242,20 @@ export default function PayoffPage() { const activeBalance = isCustom ? (parseFloat(customBalance) || 0) : (bill?.current_balance ?? 0); const activeName = isCustom ? (customName.trim() || 'Custom Loan') : (bill?.name ?? ''); - const { minTrack, currentTrack, simTrack } = useMemo(() => { - if (!activeBalance) return { minTrack: [] as ScheduleMonth[], currentTrack: [] as ScheduleMonth[], simTrack: [] as ScheduleMonth[] }; - const min = !isCustom && minPayment > 0 ? minPayment : 0.01; - const currentPmt = !isCustom && isAttack ? min + extraPayment : min; + // The "vs minimum" baseline only makes sense when the bill actually has a + // minimum payment on record; without one we show absolute payoff figures. + const hasMinimum = !isCustom && minPayment > 0; + const { minTrack, simTrack } = useMemo(() => { + if (!activeBalance) return { minTrack: [] as ScheduleMonth[], simTrack: [] as ScheduleMonth[] }; return { - minTrack: isCustom ? [] : buildPayoffSchedule(activeBalance, simRateN, min), - currentTrack: isCustom ? [] : buildPayoffSchedule(activeBalance, simRateN, currentPmt), - simTrack: buildPayoffSchedule(activeBalance, simRateN, simPaymentN, oneTimeExtraN), + minTrack: hasMinimum ? buildPayoffSchedule(activeBalance, simRateN, minPayment) : [], + simTrack: buildPayoffSchedule(activeBalance, simRateN, simPaymentN, oneTimeExtraN), }; - }, [activeBalance, isCustom, simRateN, simPaymentN, oneTimeExtraN, minPayment, isAttack, extraPayment]); + }, [activeBalance, hasMinimum, minPayment, simRateN, simPaymentN, oneTimeExtraN]); const minInterest = useMemo(() => minTrack.reduce((s, m) => s + m.interest, 0), [minTrack]); const simInterest = useMemo(() => simTrack.reduce((s, m) => s + m.interest, 0), [simTrack]); + const hasBaseline = minTrack.length > 0; // a real, amortizing minimum to compare against const interestSavings = Math.max(0, minInterest - simInterest); const timeSavings = Math.max(0, minTrack.length - simTrack.length); const simTotalPaid = simInterest + activeBalance; @@ -237,8 +264,12 @@ export default function PayoffPage() { const minPayoffLabel = payoffLabel(minTrack); const simDuration = numMonths(simTrack); + // No APR on record → the schedule is running interest-free (distinct from a + // genuine 0%). `interest_rate == null` means the field was never set. + const aprMissing = !isCustom && !!bill && bill.interest_rate == null; + const simCapped = simTrack.length >= 600; // hit the 600-month cap → won't realistically pay off const paymentBelowMin = !isCustom && simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0; - const paymentTooLow = activeBalance > 0 && simPaymentN > 0 && simTrack.length === 0; + const paymentTooLow = activeBalance > 0 && simPaymentN > 0 && (simTrack.length === 0 || simCapped); const customNeedsBalance = isCustom && !customBalance; const defaultSimPayment = bill @@ -256,24 +287,34 @@ export default function PayoffPage() { const handlePrint = () => window.print(); - const handleApply = async () => { + // Persist the tuned APR back to the bill (the one durable fact worth keeping — + // it makes every future estimate interest-aware). Deliberately does NOT touch + // expected_amount / the budget. Rate differs from what's stored ⇒ offer it. + const rateChanged = !isCustom && !!bill && simRateN !== (bill.interest_rate ?? 0); + + const handleSaveApr = async () => { if (!bill || applying) return; + const prevRate = bill.interest_rate ?? null; setApplying(true); try { - await api.updateBill(bill.id, { expected_amount: simPaymentN }); - toast.success(`"${bill.name}" updated to ${fmt(simPaymentN)}/mo`, { + await api.updateBill(bill.id, { interest_rate: simRateN }); + toast.success(`Saved ${simRateN}% APR to "${bill.name}" — payoff estimates now include interest`, { action: { label: 'Undo', onClick: async () => { - await api.updateBill(bill.id, { expected_amount: bill.expected_amount }); - toast.success('Reverted'); - loadData(); + try { + await api.updateBill(bill.id, { interest_rate: prevRate }); + toast.success('Interest rate reverted'); + loadData(); + } catch (err) { + toast.error(errMessage(err, "Couldn't revert the interest rate")); + } }, }, }); loadData(); - } catch { - toast.error('Failed to update bill'); + } catch (err) { + toast.error(errMessage(err, "Couldn't save the interest rate")); } finally { setApplying(false); } @@ -306,18 +347,22 @@ export default function PayoffPage() {

Failed to load data

{loadError}

-
); } - const showResults = (isCustom && activeBalance > 0 && simTrack.length > 0) || - (!isCustom && !!bill && simTrack.length > 0); + // Capped (hit the 600-month wall) counts as "won't pay off" — don't present a + // bogus payoff date / results for it. + const showResults = !simCapped && ( + (isCustom && activeBalance > 0 && simTrack.length > 0) || + (!isCustom && !!bill && simTrack.length > 0) + ); return ( - <> +
@@ -342,7 +387,7 @@ export default function PayoffPage() {

Payoff Simulator

- Explore how extra payments reduce interest and shorten your payoff timeline. + A what-if calculator for one debt — see how a bigger monthly payment and your APR change the payoff date and total interest. (For a whole-plan strategy across debts, use the Snowball page.)

@@ -460,8 +505,8 @@ export default function PayoffPage() { {/* Bill mode: Required minimum display */} {!isCustom && (
- - Required Minimum + + Required Minimum {minPayment > 0 @@ -474,12 +519,15 @@ export default function PayoffPage() { {!isCustom && minPayment <= 0 && (

- Set a minimum payment on the Snowball page for best results. + No minimum payment on this bill — edit the bill's Debt Details to add one and unlock the savings-vs-minimum comparison.

)} {/* Interest rate */} - + Interest Rate
} + hint="Override to test scenarios" + >
% {simRateN}%
+ {aprMissing && simRateN <= 0 && ( +

+ + No interest rate on this bill — this estimate assumes 0% (interest-free). Enter your APR, then Save it. +

+ )} + {rateChanged && ( + + )} {/* Monthly payment */} @@ -510,22 +576,13 @@ export default function PayoffPage() {

)} {paymentTooLow && !paymentBelowMin && ( -

- - Payment too low to overcome interest +

+ + {simCapped && simRateN <= 0 + ? 'This debt won’t pay off in a reasonable time at this payment.' + : 'Payment too low to overcome the interest — the balance never shrinks.'}

)} - {!isCustom && simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && ( - - )}

{fmt(simPaymentN)}/mo

@@ -599,48 +656,66 @@ export default function PayoffPage() {
{/* Chart */} - {simTrack.length > 0 ? ( - + {showResults ? ( + <> + +

+ Projected balance over time{hasBaseline ? ' — dashed grey is paying only the minimum' : ''}. +

+ ) : ( -
+
{customNeedsBalance ? 'Enter a balance and payment to see the chart' : simPaymentN <= 0 ? 'Enter a monthly payment to see the chart' - : 'Payment too low to pay off this debt'} + : simCapped + ? 'This payment won’t pay the debt off — raise it to see a payoff curve.' + : 'Payment too low to pay off this debt'}
)} {/* Stats row */} {showResults && ( <> -
= 0 ? 'grid-cols-2' : 'grid-cols-1')}> - {!isCustom && ( - 0 ? 'teal' : 'slate'} - /> - )} - 0 ? `${timeSavings} mo` : (isCustom ? numMonths(simTrack) ?? '—' : '—')} - sub={isCustom ? 'to pay off' : (timeSavings > 0 ? 'months sooner' : 'same timeline')} - color={timeSavings > 0 ? 'amber' : (isCustom ? 'amber' : 'slate')} - /> - {isCustom && ( - 0 ? 'teal' : 'slate'} - /> +
+ {hasBaseline ? ( + <> + 0 ? 'teal' : 'slate'} + /> + 0 ? `${timeSavings} mo` : '—'} + sub={timeSavings > 0 ? 'months sooner' : 'same timeline'} + color={timeSavings > 0 ? 'amber' : 'slate'} + /> + + ) : ( + <> + + 0 ? 'teal' : 'slate'} + /> + )}
@@ -667,6 +742,6 @@ export default function PayoffPage() { )}
{/* /payoff-print-area */} - + ); }