diff --git a/client/components/snowball/PayoffChart.tsx b/client/components/snowball/PayoffChart.tsx new file mode 100644 index 0000000..1612acf --- /dev/null +++ b/client/components/snowball/PayoffChart.tsx @@ -0,0 +1,163 @@ +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[]; + 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); + 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; + const xLabels: number[] = []; + for (let m = xStep; m <= maxMonths; m += xStep) { + xLabels.push(m); + } + + 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 ( +
{snapshot.projected_payoff_date ?? '—'}
)} - {snapshot.interest_saved > 0 && ( + {(snapshot.interest_saved ?? 0) > 0 && (Interest saved
{fmt(snapshot.interest_saved)}
@@ -74,7 +111,7 @@ function PlanDetail({ plan }) {{snapshot.minimum_only_months}
Extra/mo
{fmtFull(plan.extra_payment)}
@@ -137,7 +174,7 @@ function PlanDetail({ plan }) { // ─── Single plan row ────────────────────────────────────────────────────────── -function PlanRow({ plan }) { +function PlanRow({ plan }: { plan: SnowballPlan }) { const [expanded, setExpanded] = useState(false); const snapshot = plan.plan_snapshot ?? {}; const debtCount = (snapshot.debts ?? []).length; @@ -155,7 +192,7 @@ function PlanRow({ plan }) {{dateRange(plan)} {debtCount > 0 && ` · ${debtCount} debt${debtCount !== 1 ? 's' : ''}`} - {snapshot.interest_saved > 0 && ` · ${fmt(snapshot.interest_saved)} interest saved`} + {(snapshot.interest_saved ?? 0) > 0 && ` · ${fmt(snapshot.interest_saved)} interest saved`}
No bills found.
{onCreateBill && (() => { - const af = transaction?.advisory_filter; const label = query.trim() ? `Create "${query.trim()}" as a new bill` : 'Create a new bill'; @@ -158,7 +169,6 @@ export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConf