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:
parent
c9d90a125f
commit
4638d24a1d
|
|
@ -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('');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 won’t 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 won’t 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue