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/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