SPB Git

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%
11.9 KB · 311 lines tsx
Raw Blame History
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 &amp; 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