Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
1// ==============================================================================2// Author: Simon-Pierre Boucher <contact@spboucher.ai>3// File: pages/Stats.tsx4// Desc: Tableau de bord analytique — module Stats commun Groupe KA v25// (ka-ui/stats/SPEC.md §1) : bandeau KPI avec sparklines, sélecteur6// de période, jauges de complétude, courbes/aires/barres + stats de7// séries, multi-courbes (prix par ville), barres empilées (ajouts par8// source), répartitions, distributions, géographie, heatmaps9// calendrier + horaire, tableaux, records, export PDF (5 rapports).10// Données 100 % réelles servies par /api/stats/dashboard (cache 5 min).11// ==============================================================================12import { useCallback, useEffect, useState } from "react";13import {14 BarChart, BreakItem, CalendarHeatmap, DataTable, Distribution, Donut,15 EmptyBlock, Fraicheur, Gauge, GaugeCard, Histogram, HourCell, HourHeatmap,16 Kpi, KpiCard, LineChart, MultiLineChart, MultiSerie, PdfButton,17 PeriodSelector, RecordCard, RecordFact, Serie, StackedBarChart,18 StackedSerie, StatSummary, TableSpec,19} from "../ka/stats/kacharts";2021/* ---------- contrat /api/stats/dashboard (SPEC.md v2 §2) ---------- */22type Breakdown = { id: string; title: string; kind?: "donut" | "bar"; items: BreakItem[] };23type Dashboard = {24 updated: string;25 period: { from: string; to: string; label: string };26 kpis?: Kpi[];27 gauges?: Gauge[];28 series?: Serie[];29 multiseries?: MultiSerie[];30 stacked?: StackedSerie[];31 breakdowns?: Breakdown[];32 distributions?: Distribution[];33 geo?: { title: string; items: BreakItem[] };34 heatmap?: { title: string; cells: { date: string; value: number }[] };35 hourly?: { title: string; cells: HourCell[] };36 tables?: TableSpec[];37 records?: RecordFact[];38};3940async function fetchDashboard(period: string, from?: string, to?: string): Promise<Dashboard> {41 const p = new URLSearchParams({ period });42 if (from && to) { p.set("from", from); p.set("to", to); }43 const res = await fetch(`/api/stats/dashboard?${p}`);44 if (!res.ok) throw new Error(`HTTP ${res.status}`);45 return res.json();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 [error, setError] = useState(false);53 const [loading, setLoading] = useState(true);5455 const load = useCallback(() => {56 setLoading(true);57 const useCustom = custom.from && custom.to;58 fetchDashboard(period, useCustom ? custom.from : undefined, useCustom ? custom.to : undefined)59 .then((d) => { setDash(d); setError(false); })60 .catch(() => setError(true))61 .finally(() => setLoading(false));62 }, [period, custom]);6364 useEffect(() => { load(); }, [load]);6566 if (error) {67 return (68 <div className="notice container">69 <h2>Stats indisponibles</h2>70 <p>Le tableau de bord n'a pas pu être chargé. Réessayez dans un instant.</p>71 </div>72 );73 }74 if (!dash) return <div className="notice container"><p>Chargement…</p></div>;7576 const donuts = (dash.breakdowns ?? []).filter((b) => b.kind === "donut");77 const bars = (dash.breakdowns ?? []).filter((b) => b.kind !== "donut");78 // séries clés (volumes quotidiens) : stats min/max/moy/méd/σ dessous79 const withStats = new Set(["new_restos", "releves"]);8081 return (82 <div className="container stats-page" style={{ opacity: loading ? 0.55 : 1, transition: "opacity 0.15s" }}>83 {/* ---- en-tête : titre + PDF (5 rapports) + fraîcheur (SPEC §1.10-11) ---- */}84 <div className="stats-head">85 <div>86 <span className="kicker">Statistiques · {dash.period.label}</span>87 <h1 className="stats-title">La table du Québec, en chiffres</h1>88 <p className="sub">89 Tableau de bord analytique en direct — restos référencés, menus,90 plats & prix suivis. Données réelles, rien d'inventé.91 </p>92 </div>93 <div className="stats-actions">94 <PdfButton period={period}95 from={custom.from && custom.to ? custom.from : undefined}96 to={custom.from && custom.to ? custom.to : undefined} />97 <Fraicheur updated={dash.updated} onRefresh={load} />98 </div>99 </div>100101 {/* ---- 1. bandeau KPI (sparklines incluses) ---- */}102 {dash.kpis?.length ? (103 <div className="kpi-grid">104 {dash.kpis.map((k) => <KpiCard key={k.id} k={k} />)}105 </div>106 ) : <EmptyBlock title="Indicateurs" />}107108 {/* ---- 2. sélecteur de période global ---- */}109 <div className="card period-bar">110 <PeriodSelector111 value={period}112 onChange={(p) => { setPeriod(p); setCustom({ from: "", to: "" }); }}113 custom={custom}114 onCustom={(from, to) => setCustom({ from, to })}115 />116 </div>117118 {/* ---- 3. jauges : couverture & complétude des fiches ---- */}119 {dash.gauges?.length ? (120 <section>121 <span className="kicker">Couverture & complétude</span>122 <div className="kpi-grid">123 {dash.gauges.map((g) => <GaugeCard key={g.id} g={g} />)}124 </div>125 </section>126 ) : null}127128 {/* ---- 4. évolution : courbes / aires / barres + stats de séries ---- */}129 <div className="stats-grid2">130 {(dash.series ?? []).map((s) => (131 <div key={s.id}>132 <LineChart serie={s} />133 {withStats.has(s.id) && <StatSummary serie={s} />}134 </div>135 ))}136 </div>137138 {/* ---- 4b. multi-courbes (≤ 4 séries, motifs distincts) + empilées ---- */}139 {(dash.multiseries?.length || dash.stacked?.length) ? (140 <div className="stats-grid2">141 {(dash.multiseries ?? []).map((ms) => <MultiLineChart key={ms.id} ms={ms} />)}142 {(dash.stacked ?? []).map((st) => <StackedBarChart key={st.id} st={st} />)}143 </div>144 ) : null}145146 {/* ---- 5. répartitions : anneaux + barres + distributions ---- */}147 <div className="stats-grid2">148 {donuts.map((b) => <Donut key={b.id} title={b.title} items={b.items} />)}149 {bars.map((b) => <BarChart key={b.id} title={b.title} items={b.items} />)}150 {(dash.distributions ?? []).map((d) => <Histogram key={d.id} dist={d} />)}151 {/* ---- 6. répartition géographique ---- */}152 {dash.geo ? <BarChart title={dash.geo.title} items={dash.geo.items} unit="restos" />153 : <EmptyBlock title="Répartition géographique" />}154 </div>155156 {/* ---- 7. calendriers : chaleur 26 semaines + activité horaire 7×24 ---- */}157 {dash.heatmap && <CalendarHeatmap title={dash.heatmap.title} cells={dash.heatmap.cells} />}158 {dash.hourly && <HourHeatmap title={dash.hourly.title} cells={dash.hourly.cells} />}159160 {/* ---- 8. tableaux détaillés ---- */}161 {(dash.tables ?? []).map((t) => <DataTable key={t.id} spec={t} />)}162163 {/* ---- 9. records & faits marquants ---- */}164 {dash.records?.length ? (165 <section>166 <span className="kicker">Records & faits marquants</span>167 <div className="records-grid">168 {dash.records.map((r) => <RecordCard key={r.label} r={r} />)}169 </div>170 </section>171 ) : null}172173 <div className="stats-foot">174 Les prix appartiennent aux restaurants et à leurs plateformes ;175 Resto-Ka les agrège pour la découverte et renvoie toujours à la source.176 Rapports PDF estampillés Groupe-KA (complet, synthèse, tendances,177 répartitions, données) disponibles en haut de page.178 </div>179 </div>180 );181}182