/** * ============================================================================= * QWHPI — Quebec Weekly Housing Price Index * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : web/app/explore/page.tsx * Purpose : Explore — series picker, index chart with CI band, raw overlay * toggle, growth horizons, volume subchart, CSV download. * ============================================================================= */ "use client"; import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { csvUrl, fetchGeographies, fetchSeries, fetchStats, reportUrl, type GeographyNode, type SeriesResponse, type SeriesStats, } from "../../lib/api"; import dynamic from "next/dynamic"; import type { ChartUnit } from "../../components/IndexChart"; import { ChartSkeleton, DashboardSkeleton } from "../../components/Skeleton"; import DataTable from "../../components/DataTable"; import { MARKET_EVENTS } from "../../lib/events"; import { exportChartPng } from "../../lib/exportPng"; const IndexChart = dynamic(() => import("../../components/IndexChart"), { ssr: false, loading: () => , }); import ReliabilityBadge from "../../components/ReliabilityBadge"; const TYPES = ["all", "unifamilial", "condo", "plex"]; const RANGES = ["1y", "3y", "5y", "ytd", "max"] as const; type Range = (typeof RANGES)[number]; function rangeCutoff(range: Range, lastPeriod: string): string | null { if (range === "max") return null; const [y, m] = lastPeriod.split("-").map(Number); if (range === "ytd") return `${y}-01`; const months = range === "1y" ? 12 : range === "3y" ? 36 : 60; const total = y * 12 + (m - 1) - months; const cy = Math.floor(total / 12); const cm = (total % 12) + 1; return `${cy}-${String(cm).padStart(2, "0")}`; } const HORIZONS: { key: keyof HorizonRow; label: string }[] = [ { key: "monthly_pct", label: "1m" }, { key: "three_month_pct", label: "3m" }, { key: "six_month_pct", label: "6m" }, { key: "yoy_pct", label: "YoY" }, ]; type HorizonRow = { monthly_pct: number | null; three_month_pct: number | null; six_month_pct: number | null; yoy_pct: number | null; }; function ExploreInner() { const params = useSearchParams(); const router = useRouter(); const pathname = usePathname(); const [geos, setGeos] = useState([]); const [geo, setGeo] = useState(params.get("geography") ?? "quebec"); const [ptype, setPtype] = useState(params.get("type") ?? "all"); const [series, setSeries] = useState(null); const [stats, setStats] = useState(null); const [showRaw, setShowRaw] = useState(false); const [unit, setUnit] = useState( params.get("unit") === "dollars" ? "dollars" : "points"); const [range, setRange] = useState( (RANGES as readonly string[]).includes(params.get("range") ?? "") ? (params.get("range") as Range) : "max"); const [showEvents, setShowEvents] = useState(params.get("events") === "1"); const [showTable, setShowTable] = useState(false); const chartRef = useRef(null); const [error, setError] = useState(null); // Shareable URL: selection encoded in query params. useEffect(() => { const q = new URLSearchParams({ geography: geo, type: ptype }); if (unit === "dollars") q.set("unit", "dollars"); if (range !== "max") q.set("range", range); if (showEvents) q.set("events", "1"); router.replace(`${pathname}?${q.toString()}`, { scroll: false }); }, [geo, ptype, unit, range, showEvents, router, pathname]); useEffect(() => { fetchGeographies() .then((g) => setGeos(g.published_series)) .catch((e) => setError(String(e))); }, []); useEffect(() => { setSeries(null); setStats(null); fetchSeries(geo, ptype) .then(setSeries) .catch((e) => setError(String(e))); fetchStats(geo, ptype).then(setStats).catch(() => setStats(null)); }, [geo, ptype]); const grouped = useMemo(() => { const by: Record = {}; for (const g of geos) (by[g.geography_level] ??= []).push(g); return by; }, [geos]); if (error) return (
API unreachable. {error}
); const last = series?.observations.filter((o) => !o.is_partial_month).at(-1); const visibleObs = useMemo(() => { if (!series) return []; const obs = series.observations; const cutoff = obs.length ? rangeCutoff(range, obs[obs.length - 1].period) : null; return cutoff ? obs.filter((o) => o.period >= cutoff) : obs; }, [series, range]); return ( <>

Explore a series

{RANGES.map((r) => ( ))}
⤓ CSV ⤓ PDF report
{!series ? ( ) : ( <>
{series.geography_name} · {series.property_type} ·{" "} {unit === "dollars" ? "representative dollar value" : "base 2021 = 100"} {last && }
{last && (last.reliability_grade === "D" || last.reliability_grade === "E") && (

Thin market: monthly movements are heavily shrunk toward the parent trend. Read levels and multi-month changes, not single months.

)}
{showTable && }
{last && ( <>

Growth (month of {last.period})

{HORIZONS.map(({ key, label }) => { const v = last[key]; return (
{label} {v == null ? "—" : `${v > 0 ? "+" : ""}${v.toFixed(2)}%`}
); })}
Representative value {last.representative_value ? `$${Math.round(last.representative_value).toLocaleString()}` : "—"} n={last.transactions} · eff. N≈ {last.effective_sample_size?.toFixed(0) ?? "—"}
{stats && ( <>

Structure & risk

{([ ["Since 2021", `${stats.since_2021_pct > 0 ? "+" : ""}${stats.since_2021_pct.toFixed(1)}%`], ["CAGR", `${stats.cagr_pct > 0 ? "+" : ""}${stats.cagr_pct.toFixed(2)}%`], ["Peak", `${stats.peak_index.toFixed(1)} (${stats.peak_period})`], ["Vs peak", stats.at_record_high ? "★ at peak" : `${stats.drawdown_pct.toFixed(2)}%`], ["Volatility 12m", `${stats.volatility_12m_pct.toFixed(2)}%`], ["Momentum 3m ann.", `${stats.momentum_3m_ann_pct > 0 ? "+" : ""}${stats.momentum_3m_ann_pct.toFixed(1)}%`], ["YoY rank", stats.yoy_rank ? `${stats.yoy_rank}/${stats.yoy_rank_of}` : "—"], ["Sales 12m", stats.volume_12m.toLocaleString()], ["Volume YoY", stats.volume_yoy_pct == null ? "—" : `${stats.volume_yoy_pct > 0 ? "+" : ""}${stats.volume_yoy_pct.toFixed(1)}%`], ["$ volume 12m", `$${(stats.dollar_volume_12m / 1e9).toFixed(2)}B`], ["Assessment gap", stats.assessment_gap == null ? "—" : `${stats.assessment_gap.toFixed(2)}×`], ["Effective N", stats.effective_sample_size?.toFixed(0) ?? "—"], ] as [string, string][]).map(([label, val]) => (
{label} {val}
))}
)} )} )} ); } export default function Explore() { return ( }> ); }