fix(payoff): correctness, data-integrity & clarity of the payoff simulator

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 18:09:12 -05:00
parent c9d90a125f
commit 4638d24a1d
3 changed files with 173 additions and 114 deletions

View File

@ -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('');

View File

@ -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 (
<div className="w-full overflow-hidden rounded-xl border border-border/60 bg-background/60">
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label="Loan payoff chart" className="h-auto w-full">
@ -110,14 +105,6 @@ export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack
/>
)}
{/* Current snowball plan (indigo dashed) */}
{showCurrent && currentPts.length > 1 && (
<polyline points={toLine(currentPts)} fill="none"
stroke="#818cf8" strokeWidth="1.5"
strokeDasharray="9,5" strokeLinecap="round" strokeLinejoin="round"
/>
)}
{/* Simulation track (amber solid, prominent) */}
{simPts.length > 1 && (
<>
@ -142,19 +129,15 @@ export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack
{/* Legend */}
<g transform={`translate(${PAD.left}, ${H - 22})`} fontSize="11" fill="currentColor" opacity="0.7">
{minPts.length > 1 && (
<>
<line x1="0" x2="16" y1="0" y2="0" stroke="#94a3b8" strokeWidth="1.5" strokeDasharray="4,3" />
<text x="20" y="4">Min only</text>
{showCurrent && (
<>
<line x1="80" x2="96" y1="0" y2="0" stroke="#818cf8" strokeWidth="1.5" strokeDasharray="6,4" />
<text x="100" y="4">Snowball plan</text>
</>
)}
<line x1={showCurrent ? 198 : 80} x2={showCurrent ? 214 : 96} y1="0" y2="0"
<line x1={minPts.length > 1 ? 80 : 0} x2={minPts.length > 1 ? 96 : 16} y1="0" y2="0"
stroke="#f59e0b" strokeWidth="2.5" />
<text x={showCurrent ? 218 : 100} y="4">Simulation</text>
<text x={minPts.length > 1 ? 100 : 20} y="4">Your plan</text>
</g>
</svg>

View File

@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${label}: help`}
className="ml-1 inline-flex h-3.5 w-3.5 items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring no-print"
>
<HelpCircle className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-[240px] text-xs leading-relaxed">{text}</TooltipContent>
</Tooltip>
);
}
function StatCard({ label, value, sub, color = 'amber', help }: {
label: ReactNode;
value: ReactNode;
sub?: ReactNode;
color?: string;
help?: string;
}) {
const colors: Record<string, string> = {
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 (
<div className={cn('rounded-xl border p-4 text-center', colors[color])}>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-1">{label}</p>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-1 inline-flex items-center justify-center">
{label}{help && typeof label === 'string' && <Help text={help} label={label} />}
</p>
<p className={cn('text-2xl font-bold font-mono tabular-nums', colors[color])}>{value}</p>
{sub && <p className="text-[11px] text-muted-foreground mt-0.5">{sub}</p>}
</div>
@ -149,7 +171,6 @@ function NoSelection() {
export default function PayoffPage() {
const [bills, setBills] = useState<Bill[]>([]);
const [extraPayment, setExtraPayment] = useState(0);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<number | 'custom' | null>(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<Bill[]>, api.snowballSettings() as Promise<{ extra_payment?: number }>])
.then(([allBills, settings]) => {
(api.bills() as Promise<Bill[]>)
.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),
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');
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() {
<AlertCircle className="h-10 w-10 text-destructive mb-3" />
<p className="text-sm font-medium">Failed to load data</p>
<p className="mt-1 text-xs text-muted-foreground">{loadError}</p>
<Button size="sm" variant="outline" onClick={loadData} className="mt-4 gap-1.5 text-xs">
<Button size="sm" variant="outline" onClick={() => loadData(true)} className="mt-4 gap-1.5 text-xs">
<RefreshCw className="h-3 w-3" /> Try again
</Button>
</div>
);
}
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 (
<>
<TooltipProvider delayDuration={200}>
<style>{PRINT_STYLES}</style>
<div id="payoff-print-area">
@ -342,7 +387,7 @@ export default function PayoffPage() {
<div>
<h1 className="text-2xl font-bold tracking-tight">Payoff Simulator</h1>
<p className="text-sm text-muted-foreground mt-0.5">
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.)
</p>
</div>
<div className="flex items-center gap-2 shrink-0 mt-1">
@ -460,8 +505,8 @@ export default function PayoffPage() {
{/* Bill mode: Required minimum display */}
{!isCustom && (
<div className="rounded-lg bg-muted/30 px-4 py-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Required Minimum
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground inline-flex items-center">
Required Minimum<Help label="Required Minimum" text="The smallest payment your lender requires each month." />
</span>
<span className="font-mono text-lg font-bold tabular-nums">
{minPayment > 0
@ -474,12 +519,15 @@ export default function PayoffPage() {
{!isCustom && minPayment <= 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5 no-print">
<AlertCircle className="h-3.5 w-3.5 shrink-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.
</p>
)}
{/* Interest rate */}
<InputRow label="Interest Rate" hint="Override to test scenarios">
<InputRow
label={<span className="inline-flex items-center">Interest Rate<Help label="Interest Rate" text="Your annual rate (APR). Save it to the bill so every future estimate includes interest — otherwise payoff is calculated interest-free." /></span>}
hint="Override to test scenarios"
>
<div className="flex items-center gap-2">
<Input
type="number" min="0" max="99" step="0.01"
@ -491,6 +539,24 @@ export default function PayoffPage() {
<span className="text-sm text-muted-foreground shrink-0">%</span>
<span className="print-only hidden font-mono text-sm">{simRateN}%</span>
</div>
{aprMissing && simRateN <= 0 && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 flex items-start gap-1 mt-1.5 no-print">
<Info className="h-3 w-3 shrink-0 mt-0.5" />
No interest rate on this bill this estimate assumes 0% (interest-free). Enter your APR, then Save it.
</p>
)}
{rateChanged && (
<button
type="button"
onClick={handleSaveApr}
disabled={applying}
className="mt-1.5 inline-flex items-center gap-1 text-[11px] text-primary hover:underline disabled:opacity-50 no-print"
>
<Save className="h-3 w-3 shrink-0" />
{applying ? 'Saving…' : `Save ${simRateN}% APR to this bill`}
<Help label="Save APR" text="Saves this rate to the bill. Doesn't change the bill's amount or your budget." />
</button>
)}
</InputRow>
{/* Monthly payment */}
@ -510,22 +576,13 @@ export default function PayoffPage() {
</p>
)}
{paymentTooLow && !paymentBelowMin && (
<p className="text-[11px] text-destructive flex items-center gap-1 mt-1">
<AlertCircle className="h-3 w-3 shrink-0" />
Payment too low to overcome interest
<p className="text-[11px] text-destructive flex items-start gap-1 mt-1">
<AlertCircle className="h-3 w-3 shrink-0 mt-0.5" />
{simCapped && simRateN <= 0
? 'This debt wont pay off in a reasonable time at this payment.'
: 'Payment too low to overcome the interest — the balance never shrinks.'}
</p>
)}
{!isCustom && simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && (
<button
type="button"
onClick={handleApply}
disabled={applying}
className="mt-1.5 text-[11px] text-primary hover:underline flex items-center gap-1 disabled:opacity-50"
>
<ArrowRight className="h-3 w-3" />
{applying ? 'Applying…' : `Apply ${fmt(simPaymentN)}/mo to my budget`}
</button>
)}
</div>
<p className="print-only hidden font-mono text-sm font-semibold">{fmt(simPaymentN)}/mo</p>
</InputRow>
@ -599,19 +656,25 @@ export default function PayoffPage() {
<div className="space-y-4">
{/* Chart */}
{simTrack.length > 0 ? (
{showResults ? (
<>
<PayoffChart
minTrack={minTrack}
currentTrack={currentTrack}
simTrack={simTrack}
startBalance={activeBalance}
/>
<p className="text-[11px] text-muted-foreground text-center -mt-2 no-print">
Projected balance over time{hasBaseline ? ' — dashed grey is paying only the minimum' : ''}.
</p>
</>
) : (
<div className="flex items-center justify-center rounded-xl bg-muted/20 h-[300px] text-sm text-muted-foreground">
<div className="flex items-center justify-center rounded-xl bg-muted/20 h-[300px] text-center text-sm text-muted-foreground px-6">
{customNeedsBalance
? 'Enter a balance and payment to see the chart'
: simPaymentN <= 0
? 'Enter a monthly payment to see the chart'
: simCapped
? 'This payment wont pay the debt off — raise it to see a payoff curve.'
: 'Payment too low to pay off this debt'}
</div>
)}
@ -619,28 +682,40 @@ export default function PayoffPage() {
{/* Stats row */}
{showResults && (
<>
<div className={cn('grid gap-3', !isCustom && interestSavings >= 0 ? 'grid-cols-2' : 'grid-cols-1')}>
{!isCustom && (
<div className="grid gap-3 grid-cols-2">
{hasBaseline ? (
<>
<StatCard
label="Interest Savings"
help="Interest you avoid at this payment vs paying only the minimum."
value={fmtShort(interestSavings)}
sub="vs minimum only"
color={interestSavings > 0 ? 'teal' : 'slate'}
/>
)}
<StatCard
label="Time Savings"
value={timeSavings > 0 ? `${timeSavings} mo` : (isCustom ? numMonths(simTrack) ?? '—' : '—')}
sub={isCustom ? 'to pay off' : (timeSavings > 0 ? 'months sooner' : 'same timeline')}
color={timeSavings > 0 ? 'amber' : (isCustom ? 'amber' : 'slate')}
help="How many months sooner you're debt-free vs paying only the minimum."
value={timeSavings > 0 ? `${timeSavings} mo` : '—'}
sub={timeSavings > 0 ? 'months sooner' : 'same timeline'}
color={timeSavings > 0 ? 'amber' : 'slate'}
/>
</>
) : (
<>
<StatCard
label="Time to Payoff"
value={numMonths(simTrack) ?? '—'}
sub={simPayoffLabel ? `by ${simPayoffLabel}` : 'to pay off'}
color="amber"
/>
{isCustom && (
<StatCard
label="Total Interest"
help="Total interest over the life of the debt at this payment and APR."
value={fmtShort(simInterest)}
sub="at this payment"
color={simInterest > 0 ? 'teal' : 'slate'}
/>
</>
)}
</div>
@ -667,6 +742,6 @@ export default function PayoffPage() {
)}
</div>{/* /payoff-print-area */}
</>
</TooltipProvider>
);
}