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%
4.5 KB · 120 lines tsx
Raw Blame History
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