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?.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('');
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 won’t 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 won’t 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue