BillTracker/client/components/snowball/PayoffChart.tsx

147 lines
4.7 KiB
TypeScript
Raw Normal View History

import { formatUSD, asDollars } from '@/lib/money';
const W = 720;
const H = 300;
const PAD = { left: 68, right: 24, top: 20, bottom: 56 };
const CW = W - PAD.left - PAD.right;
const CH = H - PAD.top - PAD.bottom;
interface TrackPoint {
month: number;
balance: number;
}
interface ChartPoint {
x: number;
y: number;
month: number;
balance: number;
}
function money(v: number | string | null | undefined): string {
const n = Number(v) || 0;
if (n >= 1000) return `$${(n / 1000).toFixed(0)}k`;
return `$${n.toFixed(0)}`;
}
function fullMoney(v: number): string {
return formatUSD(asDollars(v));
}
function buildPoints(track: TrackPoint[], startBalance: number, maxMonths: number): ChartPoint[] {
const all = [{ month: 0, balance: startBalance }, ...track];
return all.map(({ month, balance }) => ({
x: PAD.left + (month / maxMonths) * CW,
y: PAD.top + CH - (balance / startBalance) * CH,
month,
balance,
}));
}
function toLine(pts: { x: number; y: number }[]): string {
return pts.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
}
interface PayoffChartProps {
minTrack?: TrackPoint[];
simTrack?: TrackPoint[];
startBalance?: number;
}
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>
2026-07-05 18:09:12 -05:00
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 simPts = buildPoints(simTrack, bal, maxMonths);
const xStep = maxMonths <= 24 ? 6 : maxMonths <= 60 ? 12 : 24;
const xLabels: number[] = [];
for (let m = xStep; m <= maxMonths; m += xStep) {
xLabels.push(m);
}
const yTicks = [0, 0.25, 0.5, 0.75, 1];
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">
{/* Grid + Y axis */}
{yTicks.map(tick => {
const y = PAD.top + CH - tick * CH;
return (
<g key={tick}>
<line x1={PAD.left} x2={PAD.left + CW} y1={y} y2={y}
stroke="currentColor" opacity="0.08" strokeWidth="1" />
<text x={PAD.left - 6} y={y + 4} fontSize="11" fill="currentColor"
opacity="0.5" textAnchor="end">
{money(bal * tick)}
</text>
</g>
);
})}
{/* X axis labels */}
{xLabels.map(m => {
const x = PAD.left + (m / maxMonths) * CW;
return (
<text key={m} x={x} y={H - 38} fontSize="11" fill="currentColor"
opacity="0.5" textAnchor="middle">
{m}mo
</text>
);
})}
{/* X axis baseline */}
<line x1={PAD.left} x2={PAD.left + CW} y1={PAD.top + CH} y2={PAD.top + CH}
stroke="currentColor" opacity="0.12" strokeWidth="1" />
{/* Min-only track (slate dashed) */}
{minPts.length > 1 && (
<polyline points={toLine(minPts)} fill="none"
stroke="#94a3b8" strokeWidth="1.5"
strokeDasharray="6,4" strokeLinecap="round" strokeLinejoin="round"
/>
)}
{/* Simulation track (amber solid, prominent) */}
{simPts.length > 1 && (
<>
<polyline points={toLine(simPts)} fill="none"
stroke="#f59e0b" strokeWidth="3"
strokeLinecap="round" strokeLinejoin="round"
/>
{/* Endpoint dot */}
{(() => {
const last = simPts[simPts.length - 1]!;
return <circle cx={last.x} cy={last.y} r="4" fill="#f59e0b" />;
})()}
</>
)}
{/* Tooltips at 6-month intervals on sim track */}
{simPts.filter(p => p.month > 0 && p.month % 6 === 0).map(p => (
<circle key={p.month} cx={p.x} cy={p.y} r="3" fill="#f59e0b" opacity="0.7">
<title>{`Month ${p.month}: ${fullMoney(p.balance)} remaining`}</title>
</circle>
))}
{/* Legend */}
<g transform={`translate(${PAD.left}, ${H - 22})`} fontSize="11" fill="currentColor" opacity="0.7">
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>
2026-07-05 18:09:12 -05:00
{minPts.length > 1 && (
<>
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>
2026-07-05 18:09:12 -05:00
<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>
</>
)}
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>
2026-07-05 18:09:12 -05:00
<line x1={minPts.length > 1 ? 80 : 0} x2={minPts.length > 1 ? 96 : 16} y1="0" y2="0"
stroke="#f59e0b" strokeWidth="2.5" />
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>
2026-07-05 18:09:12 -05:00
<text x={minPts.length > 1 ? 100 : 20} y="4">Your plan</text>
</g>
</svg>
</div>
);
}