// ----------------------------------------------------------------------------- // House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first) // Author: Simon-Pierre Boucher — contact@spboucher.ai // components/RateHistory.tsx : SVG chart of the best observed rate over time // (validity periods valid_from/valid_to rebuilt as a step curve — a rate // stays in force until it changes). // ----------------------------------------------------------------------------- import { useEffect, useMemo, useState } from "react"; import { MortgageHistoryRow, fetchMortgageHistory, fmtRate } from "../api"; /** Best rate (all institutions) at every instant: for each period boundary, * the min of the rates whose period covers that instant. */ function bestCurve(rows: MortgageHistoryRow[], now: number) { const stamps = new Set(); for (const r of rows) { stamps.add(r.valid_from); if (r.valid_to != null) stamps.add(r.valid_to); } stamps.add(now); const ts = [...stamps].sort((a, b) => a - b); const pts: { t: number; rate: number }[] = []; for (const t of ts) { let best: number | null = null; for (const r of rows) { if (r.valid_from <= t && (r.valid_to == null || r.valid_to > t)) best = best == null ? r.rate : Math.min(best, r.rate); } if (best != null) pts.push({ t, rate: best }); } return pts; } export default function RateHistory({ rateType, termMonths, days = 365 }: { rateType: string; termMonths: number; days?: number }) { const [rows, setRows] = useState(null); useEffect(() => { setRows(null); fetchMortgageHistory(rateType, termMonths, days, "special") .then((r) => setRows(r.history)) .catch(() => setRows([])); }, [rateType, termMonths, days]); const now = Math.floor(Date.now() / 1000); const pts = useMemo(() => bestCurve(rows ?? [], now), [rows, now]); if (rows == null) return
Loading the history…
; if (pts.length === 0) return
No history for this product yet.
; const W = 640, H = 180, PAD = { l: 44, r: 10, t: 10, b: 22 }; const t0 = pts[0].t, t1 = now; const rates = pts.map((p) => p.rate); const rMin = Math.floor(Math.min(...rates) * 10) / 10 - 0.1; const rMax = Math.ceil(Math.max(...rates) * 10) / 10 + 0.1; const x = (t: number) => PAD.l + ((t - t0) / Math.max(1, t1 - t0)) * (W - PAD.l - PAD.r); const y = (r: number) => PAD.t + (1 - (r - rMin) / Math.max(0.01, rMax - rMin)) * (H - PAD.t - PAD.b); // step curve: the rate holds until the next change let d = `M ${x(pts[0].t).toFixed(1)} ${y(pts[0].rate).toFixed(1)}`; for (let i = 1; i < pts.length; i++) { d += ` H ${x(pts[i].t).toFixed(1)} V ${y(pts[i].rate).toFixed(1)}`; } d += ` H ${x(t1).toFixed(1)}`; const yTicks: number[] = []; for (let r = Math.ceil(rMin * 4) / 4; r <= rMax + 1e-9; r += 0.25) yTicks.push(Math.round(r * 100) / 100); const fmtD = (t: number) => new Date(t * 1000).toLocaleDateString("en-CA", { month: "short", day: "numeric" }); const last = pts[pts.length - 1]; return (
{yTicks.map((r) => ( {r.toFixed(2)} ))} {fmtD(t0)} today
Best “special offer” rate observed across all institutions — currently {fmtRate(last.rate)}. The history builds up as the collections run (no data is extrapolated).
); }