spb/qwhpi Public
QHPI — Quebec Housing Price Index: quality-adjusted, hierarchically pooled housing price indexes.
Python 63.9%
TypeScript 25.4%
CSS 5.5%
TeX 3.5%
SQL 0.8%
Makefile 0.5%
Dockerfile 0.5%
1/**2 * =============================================================================3 * QWHPI — Quebec Weekly Housing Price Index4 * Author : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * File : web/app/explore/page.tsx7 * Purpose : Explore — series picker, index chart with CI band, raw overlay8 * toggle, growth horizons, volume subchart, CSV download.9 * =============================================================================10 */11"use client";1213import { Suspense, useEffect, useMemo, useRef, useState } from "react";14import { usePathname, useRouter, useSearchParams } from "next/navigation";15import {16 csvUrl,17 fetchGeographies,18 fetchSeries,19 fetchStats,20 reportUrl,21 type GeographyNode,22 type SeriesResponse,23 type SeriesStats,24} from "../../lib/api";25import dynamic from "next/dynamic";26import type { ChartUnit } from "../../components/IndexChart";27import { ChartSkeleton, DashboardSkeleton } from "../../components/Skeleton";28import DataTable from "../../components/DataTable";29import { MARKET_EVENTS } from "../../lib/events";30import { exportChartPng } from "../../lib/exportPng";3132const IndexChart = dynamic(() => import("../../components/IndexChart"), {33 ssr: false,34 loading: () => <ChartSkeleton height={430} />,35});36import ReliabilityBadge from "../../components/ReliabilityBadge";3738const TYPES = ["all", "unifamilial", "condo", "plex"];39const RANGES = ["1y", "3y", "5y", "ytd", "max"] as const;40type Range = (typeof RANGES)[number];4142function rangeCutoff(range: Range, lastPeriod: string): string | null {43 if (range === "max") return null;44 const [y, m] = lastPeriod.split("-").map(Number);45 if (range === "ytd") return `${y}-01`;46 const months = range === "1y" ? 12 : range === "3y" ? 36 : 60;47 const total = y * 12 + (m - 1) - months;48 const cy = Math.floor(total / 12);49 const cm = (total % 12) + 1;50 return `${cy}-${String(cm).padStart(2, "0")}`;51}52const HORIZONS: { key: keyof HorizonRow; label: string }[] = [53 { key: "monthly_pct", label: "1m" },54 { key: "three_month_pct", label: "3m" },55 { key: "six_month_pct", label: "6m" },56 { key: "yoy_pct", label: "YoY" },57];58type HorizonRow = {59 monthly_pct: number | null;60 three_month_pct: number | null;61 six_month_pct: number | null;62 yoy_pct: number | null;63};6465function ExploreInner() {66 const params = useSearchParams();67 const router = useRouter();68 const pathname = usePathname();69 const [geos, setGeos] = useState<GeographyNode[]>([]);70 const [geo, setGeo] = useState(params.get("geography") ?? "quebec");71 const [ptype, setPtype] = useState(params.get("type") ?? "all");72 const [series, setSeries] = useState<SeriesResponse | null>(null);73 const [stats, setStats] = useState<SeriesStats | null>(null);74 const [showRaw, setShowRaw] = useState(false);75 const [unit, setUnit] = useState<ChartUnit>(76 params.get("unit") === "dollars" ? "dollars" : "points");77 const [range, setRange] = useState<Range>(78 (RANGES as readonly string[]).includes(params.get("range") ?? "")79 ? (params.get("range") as Range) : "max");80 const [showEvents, setShowEvents] = useState(params.get("events") === "1");81 const [showTable, setShowTable] = useState(false);82 const chartRef = useRef<HTMLDivElement>(null);83 const [error, setError] = useState<string | null>(null);8485 // Shareable URL: selection encoded in query params.86 useEffect(() => {87 const q = new URLSearchParams({ geography: geo, type: ptype });88 if (unit === "dollars") q.set("unit", "dollars");89 if (range !== "max") q.set("range", range);90 if (showEvents) q.set("events", "1");91 router.replace(`${pathname}?${q.toString()}`, { scroll: false });92 }, [geo, ptype, unit, range, showEvents, router, pathname]);9394 useEffect(() => {95 fetchGeographies()96 .then((g) => setGeos(g.published_series))97 .catch((e) => setError(String(e)));98 }, []);99100 useEffect(() => {101 setSeries(null);102 setStats(null);103 fetchSeries(geo, ptype)104 .then(setSeries)105 .catch((e) => setError(String(e)));106 fetchStats(geo, ptype).then(setStats).catch(() => setStats(null));107 }, [geo, ptype]);108109 const grouped = useMemo(() => {110 const by: Record<string, GeographyNode[]> = {};111 for (const g of geos) (by[g.geography_level] ??= []).push(g);112 return by;113 }, [geos]);114115 if (error)116 return (117 <div className="card" style={{ marginTop: 32 }}>118 <strong>API unreachable.</strong> <span className="note">{error}</span>119 </div>120 );121122 const last = series?.observations.filter((o) => !o.is_partial_month).at(-1);123124 const visibleObs = useMemo(() => {125 if (!series) return [];126 const obs = series.observations;127 const cutoff = obs.length128 ? rangeCutoff(range, obs[obs.length - 1].period) : null;129 return cutoff ? obs.filter((o) => o.period >= cutoff) : obs;130 }, [series, range]);131132 return (133 <>134 <h1>Explore a series</h1>135 <div className="controls">136 <label htmlFor="geo">Geography</label>137 <select id="geo" value={geo} onChange={(e) => setGeo(e.target.value)}>138 {(["province", "region", "municipality"] as const).map((lvl) => (139 <optgroup key={lvl} label={lvl}>140 {(grouped[lvl] ?? []).map((g) => (141 <option key={g.geography_id} value={g.geography_id}>142 {g.geography_name}143 </option>144 ))}145 </optgroup>146 ))}147 </select>148 <label htmlFor="ptype">Type</label>149 <select id="ptype" value={ptype} onChange={(e) => setPtype(e.target.value)}>150 {TYPES.map((t) => (151 <option key={t} value={t}>{t}</option>152 ))}153 </select>154 <div className="seg" role="group" aria-label="Chart unit">155 <button aria-pressed={unit === "points"} onClick={() => setUnit("points")}>156 Index157 </button>158 <button aria-pressed={unit === "dollars"} onClick={() => setUnit("dollars")}>159 $ value160 </button>161 </div>162 <div className="seg" role="group" aria-label="Period range">163 {RANGES.map((r) => (164 <button key={r} aria-pressed={range === r} onClick={() => setRange(r)}>165 {r.toUpperCase()}166 </button>167 ))}168 </div>169 <button170 className="ctrl"171 aria-pressed={showRaw}172 onClick={() => setShowRaw((v) => !v)}173 >174 Raw monthly overlay175 </button>176 <button177 className="ctrl"178 aria-pressed={showEvents}179 onClick={() => setShowEvents((v) => !v)}180 >181 Events182 </button>183 <button184 className="ctrl"185 onClick={() =>186 chartRef.current &&187 exportChartPng(chartRef.current, `qhpi_${geo}_${ptype}.png`)}188 >189 ⤓ PNG190 </button>191 <a className="ctrl" href={csvUrl(geo, ptype)}>192 ⤓ CSV193 </a>194 <a className="primary" href={reportUrl([`${geo}:${ptype}`])}195 target="_blank" rel="noreferrer">196 ⤓ PDF report197 </a>198 </div>199200 {!series ? (201 <DashboardSkeleton />202 ) : (203 <>204 <div className="card fade-up">205 <div style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>206 <strong>207 {series.geography_name} · {series.property_type} ·{" "}208 {unit === "dollars"209 ? "representative dollar value"210 : "base 2021 = 100"}211 </strong>212 {last && <ReliabilityBadge grade={last.reliability_grade} />}213 </div>214 {last && (last.reliability_grade === "D" || last.reliability_grade === "E") && (215 <p className="low-reliability-note">216 Thin market: monthly movements are heavily shrunk toward the217 parent trend. Read levels and multi-month changes, not218 single months.219 </p>220 )}221 <div ref={chartRef}>222 <IndexChart observations={visibleObs} showRaw={showRaw}223 unit={unit}224 annotations={showEvents ? MARKET_EVENTS : []}225 showBrush={range === "max"} />226 </div>227 <div className="controls" style={{ margin: "8px 0 0" }}>228 <button className="ctrl" aria-pressed={showTable}229 aria-expanded={showTable}230 onClick={() => setShowTable((v) => !v)}>231 {showTable ? "Hide data table" : "Data table"}232 </button>233 </div>234 {showTable && <DataTable observations={visibleObs} />}235 </div>236237 {last && (238 <>239 <h2>Growth (month of {last.period})</h2>240 <div className="grid cols-4">241 {HORIZONS.map(({ key, label }) => {242 const v = last[key];243 return (244 <div className="card stat-tile" key={key}>245 <span className="label">{label}</span>246 <span247 className={248 "value " + (v != null && v < 0 ? "delta down" : "delta up")249 }250 style={{ fontSize: 22 }}251 >252 {v == null ? "—" : `${v > 0 ? "+" : ""}${v.toFixed(2)}%`}253 </span>254 </div>255 );256 })}257 <div className="card stat-tile">258 <span className="label">Representative value</span>259 <span className="value" style={{ fontSize: 22 }}>260 {last.representative_value261 ? `$${Math.round(last.representative_value).toLocaleString()}`262 : "—"}263 </span>264 <span className="note">265 n={last.transactions} · eff. N≈266 {last.effective_sample_size?.toFixed(0) ?? "—"}267 </span>268 </div>269 </div>270 {stats && (271 <>272 <h2>Structure & risk</h2>273 <div className="grid cols-8">274 {([275 ["Since 2021", `${stats.since_2021_pct > 0 ? "+" : ""}${stats.since_2021_pct.toFixed(1)}%`],276 ["CAGR", `${stats.cagr_pct > 0 ? "+" : ""}${stats.cagr_pct.toFixed(2)}%`],277 ["Peak", `${stats.peak_index.toFixed(1)} (${stats.peak_period})`],278 ["Vs peak", stats.at_record_high ? "★ at peak" : `${stats.drawdown_pct.toFixed(2)}%`],279 ["Volatility 12m", `${stats.volatility_12m_pct.toFixed(2)}%`],280 ["Momentum 3m ann.", `${stats.momentum_3m_ann_pct > 0 ? "+" : ""}${stats.momentum_3m_ann_pct.toFixed(1)}%`],281 ["YoY rank", stats.yoy_rank ? `${stats.yoy_rank}/${stats.yoy_rank_of}` : "—"],282 ["Sales 12m", stats.volume_12m.toLocaleString()],283 ["Volume YoY", stats.volume_yoy_pct == null ? "—" : `${stats.volume_yoy_pct > 0 ? "+" : ""}${stats.volume_yoy_pct.toFixed(1)}%`],284 ["$ volume 12m", `$${(stats.dollar_volume_12m / 1e9).toFixed(2)}B`],285 ["Assessment gap", stats.assessment_gap == null ? "—" : `${stats.assessment_gap.toFixed(2)}×`],286 ["Effective N", stats.effective_sample_size?.toFixed(0) ?? "—"],287 ] as [string, string][]).map(([label, val]) => (288 <div className="card stat-tile mini-tile" key={label}>289 <span className="label">{label}</span>290 <span className="value" style={{ fontSize: 17 }}>{val}</span>291 </div>292 ))}293 </div>294 </>295 )}296 </>297 )}298 </>299 )}300 </>301 );302}303304export default function Explore() {305 return (306 <Suspense fallback={<DashboardSkeleton />}>307 <ExploreInner />308 </Suspense>309 );310}311