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/components/DataTable.tsx7 * Purpose : Sortable, filterable observations table under each chart —8 * uncertainty (CI, grade) always shown.9 * =============================================================================10 */11"use client";1213import { useMemo, useState } from "react";14import type { Observation } from "../lib/api";1516type SortKey = "period" | "index_smoothed" | "monthly_pct" | "yoy_pct"17 | "transactions";1819const COLS: { key: SortKey | string; label: string; sortable: boolean }[] = [20 { key: "period", label: "Period", sortable: true },21 { key: "index_smoothed", label: "Index", sortable: true },22 { key: "ci", label: "95% CI", sortable: false },23 { key: "monthly_pct", label: "MoM", sortable: true },24 { key: "yoy_pct", label: "YoY", sortable: true },25 { key: "representative_value", label: "$ value", sortable: false },26 { key: "transactions", label: "Sales", sortable: true },27 { key: "reliability_grade", label: "Grade", sortable: false },28];2930function pct(v: number | null): string {31 return v == null ? "—" : `${v > 0 ? "+" : ""}${v.toFixed(2)}%`;32}3334export default function DataTable({ observations }: { observations: Observation[] }) {35 const [sortKey, setSortKey] = useState<SortKey>("period");36 const [desc, setDesc] = useState(true);37 const [filter, setFilter] = useState("");3839 const rows = useMemo(() => {40 let r = observations;41 if (filter) r = r.filter((o) => o.period.includes(filter));42 return [...r].sort((a, b) => {43 const av = a[sortKey] ?? -Infinity;44 const bv = b[sortKey] ?? -Infinity;45 const cmp = typeof av === "string"46 ? String(av).localeCompare(String(bv))47 : Number(av) - Number(bv);48 return desc ? -cmp : cmp;49 });50 }, [observations, sortKey, desc, filter]);5152 const onSort = (key: string, sortable: boolean) => {53 if (!sortable) return;54 if (sortKey === key) setDesc(!desc);55 else {56 setSortKey(key as SortKey);57 setDesc(true);58 }59 };6061 return (62 <div>63 <div className="controls" style={{ margin: "10px 0" }}>64 <input65 type="text"66 placeholder="Filter period… (e.g. 2024)"67 value={filter}68 aria-label="Filter table by period"69 onChange={(e) => setFilter(e.target.value)}70 style={{71 background: "var(--surface-1)", color: "var(--text-primary)",72 border: "1px solid var(--border-strong)", borderRadius: 10,73 padding: "7px 12px", fontSize: 13.5, fontFamily: "inherit",74 }}75 />76 <span className="note">{rows.length} rows</span>77 </div>78 <div style={{ overflowX: "auto", maxHeight: 420, overflowY: "auto" }}>79 <table className="data" style={{ fontSize: 13 }}>80 <thead>81 <tr>82 {COLS.map((c) => (83 <th key={c.key}84 onClick={() => onSort(c.key, c.sortable)}85 style={{ cursor: c.sortable ? "pointer" : "default",86 userSelect: "none" }}87 aria-sort={sortKey === c.key88 ? (desc ? "descending" : "ascending") : "none"}>89 {c.label}90 {c.sortable && sortKey === c.key && (desc ? " ↓" : " ↑")}91 </th>92 ))}93 </tr>94 </thead>95 <tbody>96 {rows.map((o) => (97 <tr key={o.period}>98 <td>{o.period}{o.is_partial_month ? " *" : ""}</td>99 <td>{o.index_smoothed.toFixed(2)}</td>100 <td className="note">101 {o.lower_95.toFixed(1)}–{o.upper_95.toFixed(1)}102 </td>103 <td className={o.monthly_pct != null && o.monthly_pct < 0104 ? "delta down" : "delta up"}>{pct(o.monthly_pct)}</td>105 <td className={o.yoy_pct != null && o.yoy_pct < 0106 ? "delta down" : "delta up"}>{pct(o.yoy_pct)}</td>107 <td>{o.representative_value108 ? `$${Math.round(o.representative_value).toLocaleString("en-CA")}` : "—"}</td>109 <td>{o.transactions}</td>110 <td>{o.reliability_grade}</td>111 </tr>112 ))}113 </tbody>114 </table>115 </div>116 <p className="note" style={{ marginTop: 8 }}>* partial month (nowcast)</p>117 </div>118 );119}120