/** * ============================================================================= * QWHPI — Quebec Weekly Housing Price Index * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : web/components/DataTable.tsx * Purpose : Sortable, filterable observations table under each chart — * uncertainty (CI, grade) always shown. * ============================================================================= */ "use client"; import { useMemo, useState } from "react"; import type { Observation } from "../lib/api"; type SortKey = "period" | "index_smoothed" | "monthly_pct" | "yoy_pct" | "transactions"; const COLS: { key: SortKey | string; label: string; sortable: boolean }[] = [ { key: "period", label: "Period", sortable: true }, { key: "index_smoothed", label: "Index", sortable: true }, { key: "ci", label: "95% CI", sortable: false }, { key: "monthly_pct", label: "MoM", sortable: true }, { key: "yoy_pct", label: "YoY", sortable: true }, { key: "representative_value", label: "$ value", sortable: false }, { key: "transactions", label: "Sales", sortable: true }, { key: "reliability_grade", label: "Grade", sortable: false }, ]; function pct(v: number | null): string { return v == null ? "—" : `${v > 0 ? "+" : ""}${v.toFixed(2)}%`; } export default function DataTable({ observations }: { observations: Observation[] }) { const [sortKey, setSortKey] = useState("period"); const [desc, setDesc] = useState(true); const [filter, setFilter] = useState(""); const rows = useMemo(() => { let r = observations; if (filter) r = r.filter((o) => o.period.includes(filter)); return [...r].sort((a, b) => { const av = a[sortKey] ?? -Infinity; const bv = b[sortKey] ?? -Infinity; const cmp = typeof av === "string" ? String(av).localeCompare(String(bv)) : Number(av) - Number(bv); return desc ? -cmp : cmp; }); }, [observations, sortKey, desc, filter]); const onSort = (key: string, sortable: boolean) => { if (!sortable) return; if (sortKey === key) setDesc(!desc); else { setSortKey(key as SortKey); setDesc(true); } }; return (
setFilter(e.target.value)} style={{ background: "var(--surface-1)", color: "var(--text-primary)", border: "1px solid var(--border-strong)", borderRadius: 10, padding: "7px 12px", fontSize: 13.5, fontFamily: "inherit", }} /> {rows.length} rows
{COLS.map((c) => ( ))} {rows.map((o) => ( ))}
onSort(c.key, c.sortable)} style={{ cursor: c.sortable ? "pointer" : "default", userSelect: "none" }} aria-sort={sortKey === c.key ? (desc ? "descending" : "ascending") : "none"}> {c.label} {c.sortable && sortKey === c.key && (desc ? " ↓" : " ↑")}
{o.period}{o.is_partial_month ? " *" : ""} {o.index_smoothed.toFixed(2)} {o.lower_95.toFixed(1)}–{o.upper_95.toFixed(1)} {pct(o.monthly_pct)} {pct(o.yoy_pct)} {o.representative_value ? `$${Math.round(o.representative_value).toLocaleString("en-CA")}` : "—"} {o.transactions} {o.reliability_grade}

* partial month (nowcast)

); }