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/api-docs/page.tsx7 * Purpose : API docs — copyable examples per endpoint + live playground.8 * =============================================================================9 */10"use client";1112import { useState } from "react";13import { API_BASE } from "../../lib/api";1415interface Endpoint {16 path: string;17 desc: string;18 example: string;19}2021const ENDPOINTS: Endpoint[] = [22 { path: "/v1/index", desc: "Full monthly history for one series (CSV via &format=csv)",23 example: "/v1/index?geography=quebec-city&type=condo&from=2023-01&to=latest" },24 { path: "/v1/index/latest", desc: "Latest complete month for one series",25 example: "/v1/index/latest?geography=montreal&type=condo" },26 { path: "/v1/stats", desc: "Derived metrics: peak, drawdown, CAGR, volatility, momentum, ranks, volumes",27 example: "/v1/stats?geography=quebec&type=all" },28 { path: "/v1/stats/overview", desc: "One rich row per geography (heatmap data)",29 example: "/v1/stats/overview?level=region&type=all" },30 { path: "/v1/compare", desc: "Aligned multi-series comparison, optional rebasing",31 example: "/v1/compare?series=montreal:condo,quebec-city:condo&rebase_period=2022-01" },32 { path: "/v1/map", desc: "One metric per geography for a chosen month",33 example: "/v1/map?metric=yoy&level=region&period=2025-06" },34 { path: "/v1/report", desc: "Automatic PDF report (1-5 series, &format=json for data)",35 example: "/v1/report?series=montreal:condo&format=json" },36 { path: "/v1/vintages", desc: "First release vs current vintage (revision tracking)",37 example: "/v1/vintages?geography=quebec&type=all" },38 { path: "/v1/geographies", desc: "Published hierarchy + coverage matrix",39 example: "/v1/geographies" },40 { path: "/v1/meta", desc: "Model version, vintage, methodology summary",41 example: "/v1/meta" },42];4344function CopyButton({ text }: { text: string }) {45 const [copied, setCopied] = useState(false);46 return (47 <button48 className="ctrl"49 style={{ padding: "4px 10px", fontSize: 12.5 }}50 aria-label="Copy request"51 onClick={() => {52 navigator.clipboard.writeText(text);53 setCopied(true);54 setTimeout(() => setCopied(false), 1500);55 }}56 >57 {copied ? "✓ Copied" : "Copy"}58 </button>59 );60}6162export default function ApiDocs() {63 const [query, setQuery] = useState(ENDPOINTS[0].example);64 const [result, setResult] = useState<string>("");65 const [status, setStatus] = useState<string>("");66 const [running, setRunning] = useState(false);6768 const run = async () => {69 setRunning(true);70 setStatus("");71 const t0 = performance.now();72 try {73 const res = await fetch(`${API_BASE}${query}`, {74 headers: { Accept: "application/json" },75 });76 const ms = Math.round(performance.now() - t0);77 const type = res.headers.get("content-type") ?? "";78 setStatus(`${res.status} ${res.statusText} · ${ms} ms · ${type.split(";")[0]}`);79 if (type.includes("json")) {80 const body = await res.json();81 setResult(JSON.stringify(body, null, 2).slice(0, 20000));82 } else {83 const body = await res.text();84 setResult(body.slice(0, 20000));85 }86 } catch (e) {87 setStatus("network error");88 setResult(String(e));89 }90 setRunning(false);91 };9293 return (94 <>95 <h1>API</h1>96 <p className="lede">97 Every published series, statistic and report is available98 programmatically — confidence intervals and reliability grades on every99 observation, never hidden.{" "}100 <a href={`${API_BASE}/docs`} target="_blank" rel="noreferrer">101 Full OpenAPI reference ↗102 </a>103 </p>104105 <h2>Playground</h2>106 <div className="card fade-up">107 <div className="controls" style={{ margin: 0 }}>108 <select109 aria-label="Endpoint template"110 value=""111 onChange={(e) => {112 if (e.target.value) setQuery(e.target.value);113 }}114 >115 <option value="">Insert an example…</option>116 {ENDPOINTS.map((ep) => (117 <option key={ep.path} value={ep.example}>{ep.path}</option>118 ))}119 </select>120 <input121 type="text"122 value={query}123 aria-label="Request path"124 onChange={(e) => setQuery(e.target.value)}125 onKeyDown={(e) => e.key === "Enter" && run()}126 style={{127 flex: "1 1 340px", background: "var(--surface-1)",128 color: "var(--text-primary)",129 border: "1px solid var(--border-strong)", borderRadius: 10,130 padding: "8px 12px", fontSize: 13.5,131 fontFamily: "ui-monospace, monospace",132 }}133 />134 <button className="primary" onClick={run} disabled={running}>135 {running ? "Running…" : "Run ▸"}136 </button>137 </div>138 {status && (139 <p className="note" style={{ margin: "10px 0 6px" }}>{status}</p>140 )}141 {result && (142 <pre style={{143 background: "var(--surface-2)", borderRadius: 10, padding: 14,144 fontSize: 12, maxHeight: 380, overflow: "auto", margin: "8px 0 0",145 }}>{result}</pre>146 )}147 </div>148149 <h2>Endpoints</h2>150 <div className="grid" style={{ gap: 12 }}>151 {ENDPOINTS.map((ep) => {152 const curl = `curl "${API_BASE}${ep.example}"`;153 return (154 <div className="card hoverable" key={ep.path}155 style={{ padding: "14px 18px" }}>156 <div style={{ display: "flex", justifyContent: "space-between",157 alignItems: "baseline", gap: 10, flexWrap: "wrap" }}>158 <code style={{ fontSize: 14, fontWeight: 700 }}>{ep.path}</code>159 <span style={{ display: "flex", gap: 8 }}>160 <button className="ctrl" style={{ padding: "4px 10px", fontSize: 12.5 }}161 onClick={() => setQuery(ep.example)}>162 Try ↑163 </button>164 <CopyButton text={curl} />165 </span>166 </div>167 <p className="note" style={{ margin: "6px 0" }}>{ep.desc}</p>168 <code style={{ fontSize: 12, color: "var(--text-secondary)",169 display: "block", overflowX: "auto" }}>170 {curl}171 </code>172 </div>173 );174 })}175 </div>176 </>177 );178}179