diff --git a/client/components/BillModal.tsx b/client/components/BillModal.tsx
index fad81e5..b19980c 100644
--- a/client/components/BillModal.tsx
+++ b/client/components/BillModal.tsx
@@ -120,6 +120,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|| !!sourceBill?.snowball_exempt
|| sourceBill?.current_balance != null
|| sourceBill?.minimum_payment != null
+ || sourceBill?.interest_rate != null
);
const [saveTemplate, setSaveTemplate] = useState(false);
const [templateName, setTemplateName] = useState('');
diff --git a/client/components/snowball/PayoffChart.tsx b/client/components/snowball/PayoffChart.tsx
index 1612acf..0ee7a51 100644
--- a/client/components/snowball/PayoffChart.tsx
+++ b/client/components/snowball/PayoffChart.tsx
@@ -44,17 +44,15 @@ function toLine(pts: { x: number; y: number }[]): string {
interface PayoffChartProps {
minTrack?: TrackPoint[];
- currentTrack?: TrackPoint[];
simTrack?: TrackPoint[];
startBalance?: number;
}
-export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack = [], startBalance = 1 }: PayoffChartProps) {
- const maxMonths = Math.max(minTrack.length, currentTrack.length, simTrack.length, 12);
+export default function PayoffChart({ minTrack = [], simTrack = [], startBalance = 1 }: PayoffChartProps) {
+ const maxMonths = Math.max(minTrack.length, simTrack.length, 12);
const bal = Math.max(startBalance, 1);
const minPts = buildPoints(minTrack, bal, maxMonths);
- const currentPts = buildPoints(currentTrack, bal, maxMonths);
const simPts = buildPoints(simTrack, bal, maxMonths);
const xStep = maxMonths <= 24 ? 6 : maxMonths <= 60 ? 12 : 24;
@@ -65,9 +63,6 @@ export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack
const yTicks = [0, 0.25, 0.5, 0.75, 1];
- const showCurrent = currentTrack.length > 0 &&
- currentTrack.some((c, i) => (minTrack[i]?.balance ?? null) !== c.balance);
-
return (
@@ -110,14 +105,6 @@ export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack
/>
)}
- {/* Current snowball plan (indigo dashed) */}
- {showCurrent && currentPts.length > 1 && (
-
- )}
-
{/* Simulation track (amber solid, prominent) */}
{simPts.length > 1 && (
<>
@@ -142,19 +129,15 @@ export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack
{/* Legend */}
-
- Min only
-
- {showCurrent && (
+ {minPts.length > 1 && (
<>
-
- Snowball plan
+
+ Min only
>
)}
-
- 1 ? 80 : 0} x2={minPts.length > 1 ? 96 : 16} y1="0" y2="0"
stroke="#f59e0b" strokeWidth="2.5" />
- Simulation
+ 1 ? 100 : 20} y="4">Your plan
diff --git a/client/pages/PayoffPage.tsx b/client/pages/PayoffPage.tsx
index a32c255..af140c6 100644
--- a/client/pages/PayoffPage.tsx
+++ b/client/pages/PayoffPage.tsx
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
-import { AlertCircle, ArrowRight, Calculator, Printer, RefreshCw, RotateCcw, TrendingDown } from 'lucide-react';
+import { AlertCircle, Calculator, HelpCircle, Info, Printer, RefreshCw, RotateCcw, Save, TrendingDown } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
Select, SelectContent, SelectGroup, SelectItem, SelectLabel,
SelectSeparator, SelectTrigger, SelectValue,
@@ -85,11 +86,30 @@ function numMonths(track: ScheduleMonth[]): string | null {
// ─── Sub-components ───────────────────────────────────────────────────────────
-function StatCard({ label, value, sub, color = 'amber' }: {
+// Focusable inline-help tooltip for a non-obvious figure (keyboard + SR accessible).
+function Help({ text, label }: { text: string; label: string }) {
+ return (
+
+
+
+
+
+
+ {text}
+
+ );
+}
+
+function StatCard({ label, value, sub, color = 'amber', help }: {
label: ReactNode;
value: ReactNode;
sub?: ReactNode;
color?: string;
+ help?: string;
}) {
const colors: Record
= {
amber: 'bg-amber-500/8 border-amber-400/20 text-amber-500 dark:text-amber-400',
@@ -98,7 +118,9 @@ function StatCard({ label, value, sub, color = 'amber' }: {
};
return (
-
{label}
+
+ {label}{help && typeof label === 'string' && }
+
{value}
{sub &&
{sub}
}
@@ -149,7 +171,6 @@ function NoSelection() {
export default function PayoffPage() {
const [bills, setBills] = useState([]);
- const [extraPayment, setExtraPayment] = useState(0);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [selectedId, setSelectedId] = useState(null);
@@ -166,34 +187,39 @@ export default function PayoffPage() {
const isCustom = selectedId === 'custom';
- const loadData = useCallback(() => {
- setLoading(true);
- setLoadError(null);
+ // isInitial=true drives the full-page loading/error state (first load + retry).
+ // A background refresh (after a save/undo) is "silent": it updates the data
+ // without flashing the skeleton or replacing the page on a transient failure.
+ const loadData = useCallback((isInitial = false) => {
+ if (isInitial) { setLoading(true); setLoadError(null); }
// Use api.bills() so ALL active bills with a balance appear (not just debt categories)
- Promise.all([api.bills() as Promise, api.snowballSettings() as Promise<{ extra_payment?: number }>])
- .then(([allBills, settings]) => {
+ (api.bills() as Promise)
+ .then((allBills) => {
const withBalance = (allBills || [])
.filter(b => (b.current_balance ?? 0) > 0 && !b.is_subscription)
.sort((a, b) => a.name.localeCompare(b.name));
setBills(withBalance);
- setExtraPayment(Number(settings?.extra_payment) || 0);
- if (withBalance.length > 0 && !selectedId) {
- setSelectedId(withBalance[0]!.id);
+ // Only auto-pick a bill on the initial load — never yank the user's
+ // current selection on a background refresh (functional update reads the
+ // live value, avoiding the stale-closure snap-back).
+ if (withBalance.length > 0) {
+ setSelectedId(prev => (prev == null ? withBalance[0]!.id : prev));
}
})
- .catch(err => setLoadError(errMessage(err, 'Failed to load data')))
- .finally(() => setLoading(false));
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
+ .catch(err => {
+ if (isInitial) setLoadError(errMessage(err, 'Failed to load data'));
+ else toast.error(errMessage(err, "Couldn't refresh the debt list"));
+ })
+ .finally(() => { if (isInitial) setLoading(false); });
+ }, []);
- useEffect(() => { loadData(); }, [loadData]);
+ useEffect(() => { loadData(true); }, [loadData]);
const bill = useMemo(
() => (isCustom ? null : bills.find(b => b.id === selectedId) ?? null),
[bills, selectedId, isCustom],
);
- const isAttack = !isCustom && bills[0]?.id === selectedId;
-
// Reset sim inputs whenever selection changes
useEffect(() => {
if (isCustom) {
@@ -216,19 +242,20 @@ export default function PayoffPage() {
const activeBalance = isCustom ? (parseFloat(customBalance) || 0) : (bill?.current_balance ?? 0);
const activeName = isCustom ? (customName.trim() || 'Custom Loan') : (bill?.name ?? '');
- const { minTrack, currentTrack, simTrack } = useMemo(() => {
- if (!activeBalance) return { minTrack: [] as ScheduleMonth[], currentTrack: [] as ScheduleMonth[], simTrack: [] as ScheduleMonth[] };
- const min = !isCustom && minPayment > 0 ? minPayment : 0.01;
- const currentPmt = !isCustom && isAttack ? min + extraPayment : min;
+ // The "vs minimum" baseline only makes sense when the bill actually has a
+ // minimum payment on record; without one we show absolute payoff figures.
+ const hasMinimum = !isCustom && minPayment > 0;
+ const { minTrack, simTrack } = useMemo(() => {
+ if (!activeBalance) return { minTrack: [] as ScheduleMonth[], simTrack: [] as ScheduleMonth[] };
return {
- minTrack: isCustom ? [] : buildPayoffSchedule(activeBalance, simRateN, min),
- currentTrack: isCustom ? [] : buildPayoffSchedule(activeBalance, simRateN, currentPmt),
- simTrack: buildPayoffSchedule(activeBalance, simRateN, simPaymentN, oneTimeExtraN),
+ minTrack: hasMinimum ? buildPayoffSchedule(activeBalance, simRateN, minPayment) : [],
+ simTrack: buildPayoffSchedule(activeBalance, simRateN, simPaymentN, oneTimeExtraN),
};
- }, [activeBalance, isCustom, simRateN, simPaymentN, oneTimeExtraN, minPayment, isAttack, extraPayment]);
+ }, [activeBalance, hasMinimum, minPayment, simRateN, simPaymentN, oneTimeExtraN]);
const minInterest = useMemo(() => minTrack.reduce((s, m) => s + m.interest, 0), [minTrack]);
const simInterest = useMemo(() => simTrack.reduce((s, m) => s + m.interest, 0), [simTrack]);
+ const hasBaseline = minTrack.length > 0; // a real, amortizing minimum to compare against
const interestSavings = Math.max(0, minInterest - simInterest);
const timeSavings = Math.max(0, minTrack.length - simTrack.length);
const simTotalPaid = simInterest + activeBalance;
@@ -237,8 +264,12 @@ export default function PayoffPage() {
const minPayoffLabel = payoffLabel(minTrack);
const simDuration = numMonths(simTrack);
+ // No APR on record → the schedule is running interest-free (distinct from a
+ // genuine 0%). `interest_rate == null` means the field was never set.
+ const aprMissing = !isCustom && !!bill && bill.interest_rate == null;
+ const simCapped = simTrack.length >= 600; // hit the 600-month cap → won't realistically pay off
const paymentBelowMin = !isCustom && simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0;
- const paymentTooLow = activeBalance > 0 && simPaymentN > 0 && simTrack.length === 0;
+ const paymentTooLow = activeBalance > 0 && simPaymentN > 0 && (simTrack.length === 0 || simCapped);
const customNeedsBalance = isCustom && !customBalance;
const defaultSimPayment = bill
@@ -256,24 +287,34 @@ export default function PayoffPage() {
const handlePrint = () => window.print();
- const handleApply = async () => {
+ // Persist the tuned APR back to the bill (the one durable fact worth keeping —
+ // it makes every future estimate interest-aware). Deliberately does NOT touch
+ // expected_amount / the budget. Rate differs from what's stored ⇒ offer it.
+ const rateChanged = !isCustom && !!bill && simRateN !== (bill.interest_rate ?? 0);
+
+ const handleSaveApr = async () => {
if (!bill || applying) return;
+ const prevRate = bill.interest_rate ?? null;
setApplying(true);
try {
- await api.updateBill(bill.id, { expected_amount: simPaymentN });
- toast.success(`"${bill.name}" updated to ${fmt(simPaymentN)}/mo`, {
+ await api.updateBill(bill.id, { interest_rate: simRateN });
+ toast.success(`Saved ${simRateN}% APR to "${bill.name}" — payoff estimates now include interest`, {
action: {
label: 'Undo',
onClick: async () => {
- await api.updateBill(bill.id, { expected_amount: bill.expected_amount });
- toast.success('Reverted');
- loadData();
+ try {
+ await api.updateBill(bill.id, { interest_rate: prevRate });
+ toast.success('Interest rate reverted');
+ loadData();
+ } catch (err) {
+ toast.error(errMessage(err, "Couldn't revert the interest rate"));
+ }
},
},
});
loadData();
- } catch {
- toast.error('Failed to update bill');
+ } catch (err) {
+ toast.error(errMessage(err, "Couldn't save the interest rate"));
} finally {
setApplying(false);
}
@@ -306,18 +347,22 @@ export default function PayoffPage() {
Failed to load data
{loadError}
-
+ loadData(true)} className="mt-4 gap-1.5 text-xs">
Try again
);
}
- const showResults = (isCustom && activeBalance > 0 && simTrack.length > 0) ||
- (!isCustom && !!bill && simTrack.length > 0);
+ // Capped (hit the 600-month wall) counts as "won't pay off" — don't present a
+ // bogus payoff date / results for it.
+ const showResults = !simCapped && (
+ (isCustom && activeBalance > 0 && simTrack.length > 0) ||
+ (!isCustom && !!bill && simTrack.length > 0)
+ );
return (
- <>
+
@@ -342,7 +387,7 @@ export default function PayoffPage() {
Payoff Simulator
- Explore how extra payments reduce interest and shorten your payoff timeline.
+ A what-if calculator for one debt — see how a bigger monthly payment and your APR change the payoff date and total interest. (For a whole-plan strategy across debts, use the Snowball page.)
@@ -460,8 +505,8 @@ export default function PayoffPage() {
{/* Bill mode: Required minimum display */}
{!isCustom && (
-
- Required Minimum
+
+ Required Minimum
{minPayment > 0
@@ -474,12 +519,15 @@ export default function PayoffPage() {
{!isCustom && minPayment <= 0 && (
- Set a minimum payment on the Snowball page for best results.
+ No minimum payment on this bill — edit the bill's Debt Details to add one and unlock the savings-vs-minimum comparison.
)}
{/* Interest rate */}
-
+ Interest Rate }
+ hint="Override to test scenarios"
+ >
%
{simRateN}%
+ {aprMissing && simRateN <= 0 && (
+
+
+ No interest rate on this bill — this estimate assumes 0% (interest-free). Enter your APR, then Save it.
+
+ )}
+ {rateChanged && (
+
+
+ {applying ? 'Saving…' : `Save ${simRateN}% APR to this bill`}
+
+
+ )}
{/* Monthly payment */}
@@ -510,22 +576,13 @@ export default function PayoffPage() {
)}
{paymentTooLow && !paymentBelowMin && (
-
-
- Payment too low to overcome interest
+
+
+ {simCapped && simRateN <= 0
+ ? 'This debt won’t pay off in a reasonable time at this payment.'
+ : 'Payment too low to overcome the interest — the balance never shrinks.'}
)}
- {!isCustom && simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && (
-
-
- {applying ? 'Applying…' : `Apply ${fmt(simPaymentN)}/mo to my budget`}
-
- )}
{fmt(simPaymentN)}/mo
@@ -599,48 +656,66 @@ export default function PayoffPage() {
{/* Chart */}
- {simTrack.length > 0 ? (
-
+ {showResults ? (
+ <>
+
+
+ Projected balance over time{hasBaseline ? ' — dashed grey is paying only the minimum' : ''}.
+
+ >
) : (
-
+
{customNeedsBalance
? 'Enter a balance and payment to see the chart'
: simPaymentN <= 0
? 'Enter a monthly payment to see the chart'
- : 'Payment too low to pay off this debt'}
+ : simCapped
+ ? 'This payment won’t pay the debt off — raise it to see a payoff curve.'
+ : 'Payment too low to pay off this debt'}
)}
{/* Stats row */}
{showResults && (
<>
-
= 0 ? 'grid-cols-2' : 'grid-cols-1')}>
- {!isCustom && (
-
0 ? 'teal' : 'slate'}
- />
- )}
- 0 ? `${timeSavings} mo` : (isCustom ? numMonths(simTrack) ?? '—' : '—')}
- sub={isCustom ? 'to pay off' : (timeSavings > 0 ? 'months sooner' : 'same timeline')}
- color={timeSavings > 0 ? 'amber' : (isCustom ? 'amber' : 'slate')}
- />
- {isCustom && (
- 0 ? 'teal' : 'slate'}
- />
+
+ {hasBaseline ? (
+ <>
+ 0 ? 'teal' : 'slate'}
+ />
+ 0 ? `${timeSavings} mo` : '—'}
+ sub={timeSavings > 0 ? 'months sooner' : 'same timeline'}
+ color={timeSavings > 0 ? 'amber' : 'slate'}
+ />
+ >
+ ) : (
+ <>
+
+ 0 ? 'teal' : 'slate'}
+ />
+ >
)}
@@ -667,6 +742,6 @@ export default function PayoffPage() {
)}
{/* /payoff-print-area */}
- >
+
);
}