Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
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