SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
10.4 KB · 224 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Rent-Ka — Rental listings aggregator (Canada, outside Québec)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Stats.tsx: analytics dashboard — shared Groupe KA Stats module5//   v2 (contrat ../ka/stats/SPEC.md, composants ../ka/stats/kacharts.tsx).6//   KPI + sparklines + jauges + sélecteur de période + courbes (compare N-1,7//   stats de séries) + multi-courbes + barres empilées + répartitions +8//   distributions + géographie + calendrier & heatmap horaire + tableaux +9//   records + export PDF (5 rapports).10// -----------------------------------------------------------------------------11import { useCallback, useEffect, useState } from "react";12import {13  BarChart, BreakItem, CalendarHeatmap, DataTable, Distribution, Donut,14  EmptyBlock, Fraicheur, Gauge, GaugeCard, Histogram, HourCell, HourHeatmap,15  Kpi, KpiCard, LineChart, MultiLineChart, MultiSerie, PdfButton,16  PeriodSelector, RecordCard, RecordFact, Serie, StackedBarChart,17  StackedSerie, StatSummary, TableSpec,18} from "../ka/stats/kacharts";1920interface Dashboard {21  updated: string;22  period: { from: string | null; to: string | null; label: string };23  kpis: Kpi[];24  gauges?: Gauge[];25  series: Serie[];26  multiseries?: MultiSerie[];27  stacked?: StackedSerie[];28  breakdowns: { id: string; title: string; kind?: string; items: BreakItem[] }[];29  distributions?: Distribution[];30  geo?: { title: string; items: BreakItem[] };31  heatmap?: { title: string; cells: { date: string; value: number }[] };32  hourly?: { title: string; cells: HourCell[] };33  tables: TableSpec[];34  records: RecordFact[];35  panels?: Panel[];36}3738interface Panel {39  id: string;40  title: string;41  subtitle?: string;42  kpis?: Kpi[];43  breakdowns?: { id: string; title: string; kind?: string; items: BreakItem[] }[];44  distributions?: Distribution[];45  tables?: TableSpec[];46}4748export default function StatsPage() {49  const [period, setPeriod] = useState("30j");50  const [custom, setCustom] = useState<{ from: string; to: string }>({ from: "", to: "" });51  const [dash, setDash] = useState<Dashboard | null>(null);52  const [loading, setLoading] = useState(true);53  const [err, setErr] = useState<string | null>(null);5455  const customOk = Boolean(custom.from && custom.to);5657  const load = useCallback(() => {58    setLoading(true);59    setErr(null);60    const p = new URLSearchParams({ period });61    if (customOk) {62      p.set("from", custom.from);63      p.set("to", custom.to);64    }65    fetch(`/api/stats/dashboard?${p}`)66      .then((r) => {67        if (!r.ok) throw new Error(`API ${r.status}`);68        return r.json();69      })70      .then((d: Dashboard) => setDash(d))71      .catch((e) => setErr(String(e?.message ?? e)))72      .finally(() => setLoading(false));73  }, [period, custom.from, custom.to, customOk]);7475  useEffect(() => {76    document.title = "Rental market statistics — Rent·Ka";77    load();78  }, [load]);7980  const donuts = dash?.breakdowns?.filter((b) => b.kind === "donut") ?? [];81  const barBreaks = dash?.breakdowns?.filter((b) => b.kind !== "donut") ?? [];8283  return (84    <div className="container" style={{ display: "grid", gap: 18, paddingBottom: 40 }}>85      {/* --- en-tête : titre + PDF (5 rapports) + fraîcheur ------------------ */}86      <header style={{ display: "flex", flexWrap: "wrap", gap: 14, alignItems: "flex-end", justifyContent: "space-between", marginTop: 8 }}>87        <div style={{ minWidth: 0 }}>88          <p className="klabel" style={{ margin: 0 }}>Groupe KA · Rent·Ka</p>89          <h1 style={{ margin: "4px 0 0", fontFamily: "var(--font-display)", fontSize: "clamp(24px,3.4vw,34px)", letterSpacing: "-0.02em" }}>90            Rental market statistics91          </h1>92          {dash && (93            <p className="klabel" style={{ margin: "6px 0 0" }}>94              Period: {dash.period.label}95              {dash.period.from ? ` (${dash.period.from} → ${dash.period.to})` : ""}96            </p>97          )}98        </div>99        <div style={{ display: "grid", gap: 8, justifyItems: "end" }}>100          <PdfButton period={period} from={customOk ? custom.from : undefined} to={customOk ? custom.to : undefined} />101          {dash && <Fraicheur updated={dash.updated} onRefresh={load} />}102        </div>103      </header>104105      {err && (106        <div className="card" style={{ padding: 18, borderColor: "var(--danger)" }}>107          <b>Could not load the statistics.</b>108          <p className="klabel" style={{ margin: "6px 0 10px" }}>{err}</p>109          <button type="button" className="btn btn-primary" onClick={load}>Try again</button>110        </div>111      )}112113      {/* --- bandeau KPI (sparklines) ---------------------------------------- */}114      {dash && dash.kpis.length > 0 && (115        <section aria-label="Key indicators" style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))", opacity: loading ? 0.55 : 1, transition: "opacity .2s" }}>116          {dash.kpis.map((k) => <KpiCard key={k.id} k={k} />)}117        </section>118      )}119      {!dash && loading && (120        <div className="card" style={{ padding: 24, textAlign: "center" }}>121          <span className="klabel">Loading statistics…</span>122        </div>123      )}124125      {/* --- sélecteur de période -------------------------------------------- */}126      <section className="card" style={{ padding: "14px 16px" }} aria-label="Period">127        <PeriodSelector128          value={period}129          onChange={(p) => { setPeriod(p); setCustom({ from: "", to: "" }); }}130          custom={custom}131          onCustom={(from, to) => setCustom({ from, to })}132        />133      </section>134135      {dash && (136        <div style={{ display: "grid", gap: 18, opacity: loading ? 0.55 : 1, transition: "opacity .2s" }}>137          {/* --- jauges : couverture & complétude ----------------------------- */}138          {dash.gauges && dash.gauges.length > 0 && (139            <section aria-label="Gauges" style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(min(190px, 100%), 1fr))" }}>140              {dash.gauges.map((g) => <GaugeCard key={g.id} g={g} />)}141            </section>142          )}143144          {/* --- évolution : courbes / barres + stats de séries --------------- */}145          {dash.series?.length ? (146            dash.series.map((s) => (147              <div key={s.id} style={{ display: "grid", gap: 0 }}>148                <LineChart serie={s} />149                {s.kind !== "bar" && <StatSummary serie={s} />}150              </div>151            ))152          ) : (153            <EmptyBlock title="Daily evolution" />154          )}155156          {/* --- multi-courbes (loyer médian par taille…) ---------------------- */}157          {dash.multiseries?.map((ms) => <MultiLineChart key={ms.id} ms={ms} />)}158159          {/* --- barres empilées (composition par source) ---------------------- */}160          {dash.stacked?.map((st) => <StackedBarChart key={st.id} st={st} />)}161162          {/* --- répartitions (anneaux + barres) ------------------------------ */}163          {(donuts.length > 0 || barBreaks.length > 0) && (164            <div style={{ display: "grid", gap: 18, gridTemplateColumns: "repeat(auto-fit, minmax(min(340px, 100%), 1fr))" }}>165              {donuts.map((b) => <Donut key={b.id} title={b.title} items={b.items} />)}166              {barBreaks.map((b) => <BarChart key={b.id} title={b.title} items={b.items} unit="listings" />)}167            </div>168          )}169170          {/* --- distributions (histogrammes) ---------------------------------- */}171          {dash.distributions?.map((d) => <Histogram key={d.id} dist={d} />)}172173          {/* --- géographie --------------------------------------------------- */}174          {dash.geo && <BarChart title={dash.geo.title} items={dash.geo.items} unit="listings" />}175176          {/* --- calendriers : 26 semaines + heatmap horaire 7×24 -------------- */}177          {dash.heatmap && <CalendarHeatmap title={dash.heatmap.title} cells={dash.heatmap.cells} />}178          {dash.hourly && <HourHeatmap title={dash.hourly.title} cells={dash.hourly.cells} />}179180          {/* --- tableaux détaillés ------------------------------------------- */}181          {dash.tables?.map((t) => <DataTable key={t.id} spec={t} />)}182183          {/* --- panneaux « données du territoire » --------------------------- */}184          {dash.panels?.map((p) => {185            const pDonuts = p.breakdowns?.filter((b) => b.kind === "donut") ?? [];186            const pBars = p.breakdowns?.filter((b) => b.kind !== "donut") ?? [];187            return (188              <section key={p.id} aria-label={p.title} style={{ display: "grid", gap: 12, marginTop: 6 }}>189                <div>190                  <h2 style={{ margin: 0, fontFamily: "var(--font-display)", fontSize: 20 }}>{p.title}</h2>191                  {p.subtitle && <p className="klabel" style={{ margin: "4px 0 0" }}>{p.subtitle}</p>}192                </div>193                {p.kpis && p.kpis.length > 0 && (194                  <div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))" }}>195                    {p.kpis.map((k) => <KpiCard key={k.id} k={k} />)}196                  </div>197                )}198                {(pDonuts.length > 0 || pBars.length > 0 || (p.distributions?.length ?? 0) > 0) && (199                  <div style={{ display: "grid", gap: 18, gridTemplateColumns: "repeat(auto-fit, minmax(min(340px, 100%), 1fr))" }}>200                    {pDonuts.map((b) => <Donut key={b.id} title={b.title} items={b.items} />)}201                    {pBars.map((b) => <BarChart key={b.id} title={b.title} items={b.items} />)}202                    {p.distributions?.map((d) => <Histogram key={d.id} dist={d} />)}203                  </div>204                )}205                {p.tables?.map((tb) => <DataTable key={tb.id} spec={tb} />)}206              </section>207            );208          })}209210          {/* --- records & faits marquants ------------------------------------ */}211          {dash.records?.length > 0 && (212            <section aria-label="Records and highlights" style={{ display: "grid", gap: 10 }}>213              <h2 style={{ margin: 0, fontFamily: "var(--font-display)", fontSize: 19 }}>Records & highlights</h2>214              <div style={{ display: "grid", gap: 10, gridTemplateColumns: "repeat(auto-fit, minmax(min(280px, 100%), 1fr))" }}>215                {dash.records.map((r) => <RecordCard key={r.label} r={r} />)}216              </div>217            </section>218          )}219        </div>220      )}221    </div>222  );223}224