Python 67%
TypeScript 18.2%
CSS 14.4%
1// -----------------------------------------------------------------------------2// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// components/RateHistory.tsx : SVG chart of the best observed rate over time5// (validity periods valid_from/valid_to rebuilt as a step curve — a rate6// stays in force until it changes).7// -----------------------------------------------------------------------------8import { useEffect, useMemo, useState } from "react";9import { MortgageHistoryRow, fetchMortgageHistory, fmtRate } from "../api";1011/** Best rate (all institutions) at every instant: for each period boundary,12 * the min of the rates whose period covers that instant. */13function bestCurve(rows: MortgageHistoryRow[], now: number) {14 const stamps = new Set<number>();15 for (const r of rows) {16 stamps.add(r.valid_from);17 if (r.valid_to != null) stamps.add(r.valid_to);18 }19 stamps.add(now);20 const ts = [...stamps].sort((a, b) => a - b);21 const pts: { t: number; rate: number }[] = [];22 for (const t of ts) {23 let best: number | null = null;24 for (const r of rows) {25 if (r.valid_from <= t && (r.valid_to == null || r.valid_to > t))26 best = best == null ? r.rate : Math.min(best, r.rate);27 }28 if (best != null) pts.push({ t, rate: best });29 }30 return pts;31}3233export default function RateHistory({ rateType, termMonths, days = 365 }:34 { rateType: string; termMonths: number; days?: number }) {35 const [rows, setRows] = useState<MortgageHistoryRow[] | null>(null);3637 useEffect(() => {38 setRows(null);39 fetchMortgageHistory(rateType, termMonths, days, "special")40 .then((r) => setRows(r.history))41 .catch(() => setRows([]));42 }, [rateType, termMonths, days]);4344 const now = Math.floor(Date.now() / 1000);45 const pts = useMemo(() => bestCurve(rows ?? [], now), [rows, now]);4647 if (rows == null) return <div className="fine">Loading the history…</div>;48 if (pts.length === 0)49 return <div className="fine">No history for this product yet.</div>;5051 const W = 640, H = 180, PAD = { l: 44, r: 10, t: 10, b: 22 };52 const t0 = pts[0].t, t1 = now;53 const rates = pts.map((p) => p.rate);54 const rMin = Math.floor(Math.min(...rates) * 10) / 10 - 0.1;55 const rMax = Math.ceil(Math.max(...rates) * 10) / 10 + 0.1;56 const x = (t: number) =>57 PAD.l + ((t - t0) / Math.max(1, t1 - t0)) * (W - PAD.l - PAD.r);58 const y = (r: number) =>59 PAD.t + (1 - (r - rMin) / Math.max(0.01, rMax - rMin)) * (H - PAD.t - PAD.b);6061 // step curve: the rate holds until the next change62 let d = `M ${x(pts[0].t).toFixed(1)} ${y(pts[0].rate).toFixed(1)}`;63 for (let i = 1; i < pts.length; i++) {64 d += ` H ${x(pts[i].t).toFixed(1)} V ${y(pts[i].rate).toFixed(1)}`;65 }66 d += ` H ${x(t1).toFixed(1)}`;6768 const yTicks: number[] = [];69 for (let r = Math.ceil(rMin * 4) / 4; r <= rMax + 1e-9; r += 0.25)70 yTicks.push(Math.round(r * 100) / 100);71 const fmtD = (t: number) =>72 new Date(t * 1000).toLocaleDateString("en-CA", { month: "short", day: "numeric" });73 const last = pts[pts.length - 1];7475 return (76 <div className="mtg-chart">77 <svg viewBox={`0 0 ${W} ${H}`} role="img"78 aria-label={`Best ${rateType} ${termMonths}-month rate over time`}>79 {yTicks.map((r) => (80 <g key={r}>81 <line x1={PAD.l} x2={W - PAD.r} y1={y(r)} y2={y(r)} className="mtg-grid" />82 <text x={PAD.l - 6} y={y(r) + 3} className="mtg-tick" textAnchor="end">83 {r.toFixed(2)}84 </text>85 </g>86 ))}87 <text x={x(t0)} y={H - 6} className="mtg-tick">{fmtD(t0)}</text>88 <text x={W - PAD.r} y={H - 6} className="mtg-tick" textAnchor="end">89 today90 </text>91 <path d={d} className="mtg-line" />92 <circle cx={x(t1)} cy={y(last.rate)} r={3.5} className="mtg-dot" />93 </svg>94 <div className="fine">95 Best “special offer” rate observed across all institutions —96 currently <b>{fmtRate(last.rate)}</b>. The history builds up as the97 collections run (no data is extrapolated).98 </div>99 </div>100 );101}102