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%
9.0 KB · 239 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/page.tsx7 * Purpose : Overview — ticker tape, animated mega hero on a draw-in chart,8 *           regional podium, metric tiles, pulse heatmap table.9 * =============================================================================10 */11"use client";1213import { useEffect, useState } from "react";14import { useRouter } from "next/navigation";15import {16  fetchOverview,17  fetchSeries,18  fetchStats,19  marketReportUrl,20  type Observation,21  type OverviewRow,22  type SeriesStats,23} from "../lib/api";24import CountUp from "../components/CountUp";25import HeroChart from "../components/HeroChart";26import ReliabilityBadge from "../components/ReliabilityBadge";27import { DashboardSkeleton } from "../components/Skeleton";28import Sparkline from "../components/Sparkline";29import Ticker from "../components/Ticker";3031function Delta({ v, suffix = "%" }: { v: number | null; suffix?: string }) {32  if (v == null) return <span className="delta">—</span>;33  const cls = v >= 0 ? "delta up" : "delta down";34  return (35    <span className={cls}>36      {v >= 0 ? "▲" : "▼"} {Math.abs(v).toFixed(2)}37      {suffix}38    </span>39  );40}4142function heatStyle(v: number | null, lo: number, hi: number) {43  if (v == null) return {};44  if (v < 0) return { background: "rgba(227, 73, 72, 0.18)" };45  const ramp = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf"];46  const t = hi === lo ? 0.5 : Math.max(0, Math.min(0.999, (v - lo) / (hi - lo)));47  const c = ramp[Math.floor(t * ramp.length)];48  return {49    background: c,50    color: c === "#256abf" || c === "#3987e5" ? "#fff" : "#0b0b0b",51  };52}5354const TYPES = ["all", "unifamilial", "condo", "plex"];55const MEDALS = ["🥇", "🥈", "🥉"];5657export default function Overview() {58  const router = useRouter();59  const [stats, setStats] = useState<SeriesStats | null>(null);60  const [rows, setRows] = useState<OverviewRow[]>([]);61  const [series, setSeries] = useState<Observation[]>([]);62  const [ptype, setPtype] = useState("all");63  const [error, setError] = useState<string | null>(null);6465  useEffect(() => {66    Promise.all([67      fetchStats("quebec", ptype),68      fetchOverview("region", ptype),69      fetchSeries("quebec", ptype),70    ])71      .then(([s, o, se]) => {72        setStats(s);73        setRows(o.rows);74        setSeries(se.observations);75        setError(null);76      })77      .catch((e) => setError(String(e)));78  }, [ptype]);7980  if (error)81    return (82      <div className="card" style={{ marginTop: 32 }}>83        <strong>API unreachable.</strong>84        <p className="note">Start it with <code>make api</code> — {error}</p>85      </div>86    );87  if (!stats) return <DashboardSkeleton />;8889  const yoys = rows.map((r) => r.yoy_pct).filter((v): v is number => v != null);90  const lo = Math.min(...yoys, 0);91  const hi = Math.max(...yoys, 1);92  const podium = rows.slice(0, 3);9394  return (95    <>96      <Ticker ptype={ptype} />9798      <div className="hero">99        <div>100          <div className="hero-kicker">Month of {stats.period}</div>101          <h1>Quebec housing prices,<br />measured monthly, robustly.</h1>102          <p className="lede">103            Robust hedonic index (rolling-time-dummy) · base 2021 = 100 ·104            uncertainty always displayed105          </p>106          <div className="controls" style={{ margin: "16px 0 0" }}>107            <div className="seg" role="group" aria-label="Property type">108              {TYPES.map((t) => (109                <button key={t} aria-pressed={ptype === t}110                        onClick={() => setPtype(t)}>111                  {t}112                </button>113              ))}114            </div>115            <a className="primary" href={marketReportUrl(ptype)} target="_blank"116               rel="noreferrer">117              ⤓ Market report (PDF)118            </a>119          </div>120        </div>121        <div className="hero-mega">122          <span className="big">123            <CountUp value={stats.index} decimals={1} />124          </span>125          <span className="sub">126            QHPI-QC · 95% CI {stats.lower_95.toFixed(1)}–{stats.upper_95.toFixed(1)}127            {stats.at_record_high && (128              <span className="record-chip"> · ★ record high</span>129            )}130          </span>131          <span className="sub">132            <Delta v={stats.yoy_pct} /> YoY · <Delta v={stats.since_2021_pct} /> since 2021133          </span>134        </div>135      </div>136137      <HeroChart observations={series} />138139      <h2>Fastest-appreciating regions</h2>140      <div className="podium fade-up">141        {podium.map((r, i) => (142          <div key={r.geography_id}143               className={`card hoverable p${i + 1}`}144               style={{ cursor: "pointer" }}145               onClick={() =>146                 router.push(`/explore?geography=${r.geography_id}&type=${ptype}`)}>147            <div style={{ display: "flex", justifyContent: "space-between",148                          alignItems: "baseline" }}>149              <strong>{MEDALS[i]} {r.geography_name}</strong>150              <ReliabilityBadge grade={r.reliability_grade} />151            </div>152            <div style={{ display: "flex", justifyContent: "space-between",153                          alignItems: "flex-end", marginTop: 8 }}>154              <div>155                <div className="stat-tile">156                  <span className="value" style={{ fontSize: 26 }}>157                    <Delta v={r.yoy_pct} />158                  </span>159                  <span className="note">160                    index {r.index.toFixed(1)}161                    {r.at_record_high ? " · at peak" : ""}162                  </span>163                </div>164              </div>165              <Sparkline values={r.spark} width={110} height={34} />166            </div>167          </div>168        ))}169      </div>170171      <div className="grid cols-8 fade-up d1" style={{ marginTop: 24 }}>172        {[173          ["Dollar value", stats.representative_value174            ? <CountUp key="v" value={stats.representative_value} decimals={0} prefix="$" />175            : <span key="v">—</span>],176          ["1 month", <Delta key="a" v={stats.monthly_pct} />],177          ["3 months", <Delta key="b" v={stats.three_month_pct} />],178          ["6 months", <Delta key="c" v={stats.six_month_pct} />],179          ["CAGR", <Delta key="d" v={stats.cagr_pct} />],180          ["Volatility 12m", <span key="f">{stats.volatility_12m_pct.toFixed(2)}%</span>],181          ["Sales 12m", <CountUp key="g" value={stats.volume_12m} decimals={0} />],182          ["$ volume 12m", <span key="h">${(stats.dollar_volume_12m / 1e9).toFixed(1)}B</span>],183        ].map(([label, val]) => (184          <div className="card hoverable stat-tile mini-tile" key={String(label)}>185            <span className="label">{label}</span>186            <span className="value">{val}</span>187          </div>188        ))}189      </div>190191      <h2>Regional pulse — {ptype} · month of {stats.period}</h2>192      <div className="card fade-up d2" style={{ padding: "8px 12px", overflowX: "auto" }}>193        <table className="pulse">194          <thead>195            <tr>196              <th>Region</th><th>Trend</th><th>Index</th><th>1m</th><th>3m</th>197              <th>6m</th><th>YoY</th><th>Since 2021</th><th>Vs peak</th>198              <th>Sales 12m</th><th>$ value</th><th>Grade</th>199            </tr>200          </thead>201          <tbody>202            {rows.map((r) => (203              <tr key={r.geography_id}204                  onClick={() =>205                    router.push(`/explore?geography=${r.geography_id}&type=${ptype}`)}>206                <td>207                  {r.geography_name}208                  {r.at_record_high && <span className="record-chip"> ★</span>}209                </td>210                <td><Sparkline values={r.spark} width={84} height={22} /></td>211                <td>{r.index.toFixed(1)}</td>212                <td><Delta v={r.monthly_pct} /></td>213                <td><Delta v={r.three_month_pct} /></td>214                <td><Delta v={r.six_month_pct} /></td>215                <td className="heat" style={heatStyle(r.yoy_pct, lo, hi)}>216                  {r.yoy_pct == null ? "—" : `${r.yoy_pct > 0 ? "+" : ""}${r.yoy_pct.toFixed(2)}%`}217                </td>218                <td><Delta v={r.since_2021_pct} /></td>219                <td>{r.drawdown_pct === 0 ? "peak" : `${r.drawdown_pct.toFixed(1)}%`}</td>220                <td>{r.volume_12m.toLocaleString()}</td>221                <td>{r.representative_value222                  ? `$${Math.round(r.representative_value / 1000)}k` : "—"}</td>223                <td>{r.reliability_grade}</td>224              </tr>225            ))}226          </tbody>227        </table>228      </div>229      <p className="note" style={{ marginTop: 12 }}>230        ★ = record high · YoY column shaded by appreciation · click a row to231        explore the series · press <kbd style={{232          background: "var(--surface-2)", border: "1px solid var(--border)",233          borderRadius: 5, padding: "0 5px", fontSize: 11,234        }}>⌘K</kbd> to jump anywhere.235      </p>236    </>237  );238}239