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?.snowball_exempt
|| sourceBill?.current_balance != null || sourceBill?.current_balance != null
|| sourceBill?.minimum_payment != null || sourceBill?.minimum_payment != null
|| sourceBill?.interest_rate != null
); );
const [saveTemplate, setSaveTemplate] = useState(false); const [saveTemplate, setSaveTemplate] = useState(false);
const [templateName, setTemplateName] = useState(''); const [templateName, setTemplateName] = useState('');

View File

@ -44,17 +44,15 @@ function toLine(pts: { x: number; y: number }[]): string {
interface PayoffChartProps { interface PayoffChartProps {
minTrack?: TrackPoint[]; minTrack?: TrackPoint[];
currentTrack?: TrackPoint[];
simTrack?: TrackPoint[]; simTrack?: TrackPoint[];
startBalance?: number; startBalance?: number;
} }
export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack = [], startBalance = 1 }: PayoffChartProps) { export default function PayoffChart({ minTrack = [], simTrack = [], startBalance = 1 }: PayoffChartProps) {
const maxMonths = Math.max(minTrack.length, currentTrack.length, simTrack.length, 12); const maxMonths = Math.max(minTrack.length, simTrack.length, 12);
const bal = Math.max(startBalance, 1); const bal = Math.max(startBalance, 1);
const minPts = buildPoints(minTrack, bal, maxMonths); const minPts = buildPoints(minTrack, bal, maxMonths);
const currentPts = buildPoints(currentTrack, bal, maxMonths);
const simPts = buildPoints(simTrack, bal, maxMonths); const simPts = buildPoints(simTrack, bal, maxMonths);
const xStep = maxMonths <= 24 ? 6 : maxMonths <= 60 ? 12 : 24; 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 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 ( return (
<div className="w-full overflow-hidden rounded-xl border border-border/60 bg-background/60"> <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"> <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) */} {/* Simulation track (amber solid, prominent) */}
{simPts.length > 1 && ( {simPts.length > 1 && (
<> <>
@ -142,19 +129,15 @@ export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack
{/* Legend */} {/* Legend */}
<g transform={`translate(${PAD.left}, ${H - 22})`} fontSize="11" fill="currentColor" opacity="0.7"> <g transform={`translate(${PAD.left}, ${H - 22})`} fontSize="11" fill="currentColor" opacity="0.7">
<line x1="0" x2="16" y1="0" y2="0" stroke="#94a3b8" strokeWidth="1.5" strokeDasharray="4,3" /> {minPts.length > 1 && (
<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" /> <line x1="0" x2="16" y1="0" y2="0" stroke="#94a3b8" strokeWidth="1.5" strokeDasharray="4,3" />
<text x="100" y="4">Snowball plan</text> <text x="20" y="4">Min only</text>
</> </>
)} )}
<line x1={minPts.length > 1 ? 80 : 0} x2={minPts.length > 1 ? 96 : 16} y1="0" y2="0"
<line x1={showCurrent ? 198 : 80} x2={showCurrent ? 214 : 96} y1="0" y2="0"
stroke="#f59e0b" strokeWidth="2.5" /> 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> </g>
</svg> </svg>

View File

@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; 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 { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { import {
Select, SelectContent, SelectGroup, SelectItem, SelectLabel, Select, SelectContent, SelectGroup, SelectItem, SelectLabel,
SelectSeparator, SelectTrigger, SelectValue, SelectSeparator, SelectTrigger, SelectValue,
@ -85,11 +86,30 @@ function numMonths(track: ScheduleMonth[]): string | null {
// ─── Sub-components ─────────────────────────────────────────────────────────── // ─── 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; label: ReactNode;
value: ReactNode; value: ReactNode;
sub?: ReactNode; sub?: ReactNode;
color?: string; color?: string;
help?: string;
}) { }) {
const colors: Record<string, string> = { const colors: Record<string, string> = {
amber: 'bg-amber-500/8 border-amber-400/20 text-amber-500 dark:text-amber-400', 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 ( return (
<div className={cn('rounded-xl border p-4 text-center', colors[color])}> <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> <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>} {sub && <p className="text-[11px] text-muted-foreground mt-0.5">{sub}</p>}
</div> </div>
@ -149,7 +171,6 @@ function NoSelection() {
export default function PayoffPage() { export default function PayoffPage() {
const [bills, setBills] = useState<Bill[]>([]); const [bills, setBills] = useState<Bill[]>([]);
const [extraPayment, setExtraPayment] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<number | 'custom' | null>(null); const [selectedId, setSelectedId] = useState<number | 'custom' | null>(null);
@ -166,34 +187,39 @@ export default function PayoffPage() {
const isCustom = selectedId === 'custom'; const isCustom = selectedId === 'custom';
const loadData = useCallback(() => { // isInitial=true drives the full-page loading/error state (first load + retry).
setLoading(true); // A background refresh (after a save/undo) is "silent": it updates the data
setLoadError(null); // 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) // 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 }>]) (api.bills() as Promise<Bill[]>)
.then(([allBills, settings]) => { .then((allBills) => {
const withBalance = (allBills || []) const withBalance = (allBills || [])
.filter(b => (b.current_balance ?? 0) > 0 && !b.is_subscription) .filter(b => (b.current_balance ?? 0) > 0 && !b.is_subscription)
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
setBills(withBalance); setBills(withBalance);
setExtraPayment(Number(settings?.extra_payment) || 0); // Only auto-pick a bill on the initial load — never yank the user's
if (withBalance.length > 0 && !selectedId) { // current selection on a background refresh (functional update reads the
setSelectedId(withBalance[0]!.id); // 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'))) .catch(err => {
.finally(() => setLoading(false)); if (isInitial) setLoadError(errMessage(err, 'Failed to load data'));
}, []); // eslint-disable-line react-hooks/exhaustive-deps 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( const bill = useMemo(
() => (isCustom ? null : bills.find(b => b.id === selectedId) ?? null), () => (isCustom ? null : bills.find(b => b.id === selectedId) ?? null),
[bills, selectedId, isCustom], [bills, selectedId, isCustom],
); );
const isAttack = !isCustom && bills[0]?.id === selectedId;
// Reset sim inputs whenever selection changes // Reset sim inputs whenever selection changes
useEffect(() => { useEffect(() => {
if (isCustom) { if (isCustom) {
@ -216,19 +242,20 @@ export default function PayoffPage() {
const activeBalance = isCustom ? (parseFloat(customBalance) || 0) : (bill?.current_balance ?? 0); const activeBalance = isCustom ? (parseFloat(customBalance) || 0) : (bill?.current_balance ?? 0);
const activeName = isCustom ? (customName.trim() || 'Custom Loan') : (bill?.name ?? ''); const activeName = isCustom ? (customName.trim() || 'Custom Loan') : (bill?.name ?? '');
const { minTrack, currentTrack, simTrack } = useMemo(() => { // The "vs minimum" baseline only makes sense when the bill actually has a
if (!activeBalance) return { minTrack: [] as ScheduleMonth[], currentTrack: [] as ScheduleMonth[], simTrack: [] as ScheduleMonth[] }; // minimum payment on record; without one we show absolute payoff figures.
const min = !isCustom && minPayment > 0 ? minPayment : 0.01; const hasMinimum = !isCustom && minPayment > 0;
const currentPmt = !isCustom && isAttack ? min + extraPayment : min; const { minTrack, simTrack } = useMemo(() => {
if (!activeBalance) return { minTrack: [] as ScheduleMonth[], simTrack: [] as ScheduleMonth[] };
return { return {
minTrack: isCustom ? [] : buildPayoffSchedule(activeBalance, simRateN, min), minTrack: hasMinimum ? buildPayoffSchedule(activeBalance, simRateN, minPayment) : [],
currentTrack: isCustom ? [] : buildPayoffSchedule(activeBalance, simRateN, currentPmt), simTrack: buildPayoffSchedule(activeBalance, simRateN, simPaymentN, oneTimeExtraN),
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 minInterest = useMemo(() => minTrack.reduce((s, m) => s + m.interest, 0), [minTrack]);
const simInterest = useMemo(() => simTrack.reduce((s, m) => s + m.interest, 0), [simTrack]); 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 interestSavings = Math.max(0, minInterest - simInterest);
const timeSavings = Math.max(0, minTrack.length - simTrack.length); const timeSavings = Math.max(0, minTrack.length - simTrack.length);
const simTotalPaid = simInterest + activeBalance; const simTotalPaid = simInterest + activeBalance;
@ -237,8 +264,12 @@ export default function PayoffPage() {
const minPayoffLabel = payoffLabel(minTrack); const minPayoffLabel = payoffLabel(minTrack);
const simDuration = numMonths(simTrack); 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 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 customNeedsBalance = isCustom && !customBalance;
const defaultSimPayment = bill const defaultSimPayment = bill
@ -256,24 +287,34 @@ export default function PayoffPage() {
const handlePrint = () => window.print(); 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; if (!bill || applying) return;
const prevRate = bill.interest_rate ?? null;
setApplying(true); setApplying(true);
try { try {
await api.updateBill(bill.id, { expected_amount: simPaymentN }); await api.updateBill(bill.id, { interest_rate: simRateN });
toast.success(`"${bill.name}" updated to ${fmt(simPaymentN)}/mo`, { toast.success(`Saved ${simRateN}% APR to "${bill.name}" — payoff estimates now include interest`, {
action: { action: {
label: 'Undo', label: 'Undo',
onClick: async () => { onClick: async () => {
await api.updateBill(bill.id, { expected_amount: bill.expected_amount }); try {
toast.success('Reverted'); await api.updateBill(bill.id, { interest_rate: prevRate });
loadData(); toast.success('Interest rate reverted');
loadData();
} catch (err) {
toast.error(errMessage(err, "Couldn't revert the interest rate"));
}
}, },
}, },
}); });
loadData(); loadData();
} catch { } catch (err) {
toast.error('Failed to update bill'); toast.error(errMessage(err, "Couldn't save the interest rate"));
} finally { } finally {
setApplying(false); setApplying(false);
} }
@ -306,18 +347,22 @@ export default function PayoffPage() {
<AlertCircle className="h-10 w-10 text-destructive mb-3" /> <AlertCircle className="h-10 w-10 text-destructive mb-3" />
<p className="text-sm font-medium">Failed to load data</p> <p className="text-sm font-medium">Failed to load data</p>
<p className="mt-1 text-xs text-muted-foreground">{loadError}</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 <RefreshCw className="h-3 w-3" /> Try again
</Button> </Button>
</div> </div>
); );
} }
const showResults = (isCustom && activeBalance > 0 && simTrack.length > 0) || // Capped (hit the 600-month wall) counts as "won't pay off" — don't present a
(!isCustom && !!bill && simTrack.length > 0); // bogus payoff date / results for it.
const showResults = !simCapped && (
(isCustom && activeBalance > 0 && simTrack.length > 0) ||
(!isCustom && !!bill && simTrack.length > 0)
);
return ( return (
<> <TooltipProvider delayDuration={200}>
<style>{PRINT_STYLES}</style> <style>{PRINT_STYLES}</style>
<div id="payoff-print-area"> <div id="payoff-print-area">
@ -342,7 +387,7 @@ export default function PayoffPage() {
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">Payoff Simulator</h1> <h1 className="text-2xl font-bold tracking-tight">Payoff Simulator</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <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> </p>
</div> </div>
<div className="flex items-center gap-2 shrink-0 mt-1"> <div className="flex items-center gap-2 shrink-0 mt-1">
@ -460,8 +505,8 @@ export default function PayoffPage() {
{/* Bill mode: Required minimum display */} {/* Bill mode: Required minimum display */}
{!isCustom && ( {!isCustom && (
<div className="rounded-lg bg-muted/30 px-4 py-3 flex items-center justify-between"> <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"> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground inline-flex items-center">
Required Minimum Required Minimum<Help label="Required Minimum" text="The smallest payment your lender requires each month." />
</span> </span>
<span className="font-mono text-lg font-bold tabular-nums"> <span className="font-mono text-lg font-bold tabular-nums">
{minPayment > 0 {minPayment > 0
@ -474,12 +519,15 @@ export default function PayoffPage() {
{!isCustom && minPayment <= 0 && ( {!isCustom && minPayment <= 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5 no-print"> <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" /> <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> </p>
)} )}
{/* Interest rate */} {/* 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"> <div className="flex items-center gap-2">
<Input <Input
type="number" min="0" max="99" step="0.01" 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="text-sm text-muted-foreground shrink-0">%</span>
<span className="print-only hidden font-mono text-sm">{simRateN}%</span> <span className="print-only hidden font-mono text-sm">{simRateN}%</span>
</div> </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> </InputRow>
{/* Monthly payment */} {/* Monthly payment */}
@ -510,22 +576,13 @@ export default function PayoffPage() {
</p> </p>
)} )}
{paymentTooLow && !paymentBelowMin && ( {paymentTooLow && !paymentBelowMin && (
<p className="text-[11px] text-destructive flex items-center gap-1 mt-1"> <p className="text-[11px] text-destructive flex items-start gap-1 mt-1">
<AlertCircle className="h-3 w-3 shrink-0" /> <AlertCircle className="h-3 w-3 shrink-0 mt-0.5" />
Payment too low to overcome interest {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> </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> </div>
<p className="print-only hidden font-mono text-sm font-semibold">{fmt(simPaymentN)}/mo</p> <p className="print-only hidden font-mono text-sm font-semibold">{fmt(simPaymentN)}/mo</p>
</InputRow> </InputRow>
@ -599,48 +656,66 @@ export default function PayoffPage() {
<div className="space-y-4"> <div className="space-y-4">
{/* Chart */} {/* Chart */}
{simTrack.length > 0 ? ( {showResults ? (
<PayoffChart <>
minTrack={minTrack} <PayoffChart
currentTrack={currentTrack} minTrack={minTrack}
simTrack={simTrack} simTrack={simTrack}
startBalance={activeBalance} 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 {customNeedsBalance
? 'Enter a balance and payment to see the chart' ? 'Enter a balance and payment to see the chart'
: simPaymentN <= 0 : simPaymentN <= 0
? 'Enter a monthly payment to see the chart' ? 'Enter a monthly payment to see the chart'
: 'Payment too low to pay off this debt'} : simCapped
? 'This payment wont pay the debt off — raise it to see a payoff curve.'
: 'Payment too low to pay off this debt'}
</div> </div>
)} )}
{/* Stats row */} {/* Stats row */}
{showResults && ( {showResults && (
<> <>
<div className={cn('grid gap-3', !isCustom && interestSavings >= 0 ? 'grid-cols-2' : 'grid-cols-1')}> <div className="grid gap-3 grid-cols-2">
{!isCustom && ( {hasBaseline ? (
<StatCard <>
label="Interest Savings" <StatCard
value={fmtShort(interestSavings)} label="Interest Savings"
sub="vs minimum only" help="Interest you avoid at this payment vs paying only the minimum."
color={interestSavings > 0 ? 'teal' : 'slate'} value={fmtShort(interestSavings)}
/> sub="vs minimum only"
)} color={interestSavings > 0 ? 'teal' : 'slate'}
<StatCard />
label="Time Savings" <StatCard
value={timeSavings > 0 ? `${timeSavings} mo` : (isCustom ? numMonths(simTrack) ?? '—' : '—')} label="Time Savings"
sub={isCustom ? 'to pay off' : (timeSavings > 0 ? 'months sooner' : 'same timeline')} help="How many months sooner you're debt-free vs paying only the minimum."
color={timeSavings > 0 ? 'amber' : (isCustom ? 'amber' : 'slate')} value={timeSavings > 0 ? `${timeSavings} mo` : '—'}
/> sub={timeSavings > 0 ? 'months sooner' : 'same timeline'}
{isCustom && ( color={timeSavings > 0 ? 'amber' : 'slate'}
<StatCard />
label="Total Interest" </>
value={fmtShort(simInterest)} ) : (
sub="at this payment" <>
color={simInterest > 0 ? 'teal' : 'slate'} <StatCard
/> label="Time to Payoff"
value={numMonths(simTrack) ?? '—'}
sub={simPayoffLabel ? `by ${simPayoffLabel}` : 'to pay off'}
color="amber"
/>
<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> </div>
@ -667,6 +742,6 @@ export default function PayoffPage() {
)} )}
</div>{/* /payoff-print-area */} </div>{/* /payoff-print-area */}
</> </TooltipProvider>
); );
} }