Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com
Python 51.9%
TypeScript 32.7%
JavaScript 4.8%
CSS 4.1%
Shell 2.9%
HTML 1.8%
SQL 1.5%
1/**2 * Trouve-KA — panneau client /stats (kit ka-ui/stats v2, contrat SPEC.md)3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 *6 * Ordre des sections (SPEC §1) : KPI → période → jauges → évolutions7 * (séries + stats, multi-courbes, empilées) → répartitions → distributions →8 * calendriers (heatmap 26 sem. + horaire 7×24) → tableaux → records →9 * fraîcheur. PdfButton v2 = menu des 5 rapports.10 */1112"use client";1314import { useCallback, useEffect, useState } from "react";15import {16 BarChart,17 CalendarHeatmap,18 DataTable,19 Donut,20 EmptyBlock,21 Fraicheur,22 GaugeCard,23 Histogram,24 HourHeatmap,25 KpiCard,26 LineChart,27 MultiLineChart,28 PdfButton,29 PeriodSelector,30 RecordCard,31 StackedBarChart,32 StatSummary,33 type BreakItem,34 type Distribution,35 type Gauge,36 type HourCell,37 type Kpi,38 type MultiSerie,39 type RecordFact,40 type Serie,41 type StackedSerie,42 type TableSpec,43} from "@/ka/stats/kacharts";4445type Breakdown = { id: string; title: string; kind?: "donut" | "bar" | "bars"; items: BreakItem[] };4647type Dashboard = {48 updated: string;49 period: { from: string; to: string; label: string };50 kpis: Kpi[];51 gauges?: Gauge[];52 series: Serie[];53 multiseries?: MultiSerie[];54 stacked?: StackedSerie[];55 breakdowns: Breakdown[];56 distributions?: Distribution[];57 heatmap?: { title: string; cells: { date: string; value: number }[] };58 hourly?: { title: string; cells: HourCell[] };59 tables: TableSpec[];60 records: RecordFact[];61};6263/** Les séries clés reçoivent un résumé min/max/moy/méd/σ sous la courbe. */64const STAT_SERIES = new Set(["cumul", "crawlees", "indexees", "recherches"]);6566export function StatsPanel() {67 const [period, setPeriod] = useState("30j");68 const [custom, setCustom] = useState<{ from: string; to: string }>({ from: "", to: "" });69 const [data, setData] = useState<Dashboard | null>(null);70 const [loading, setLoading] = useState(true);71 const [error, setError] = useState(false);7273 const hasCustom = Boolean(custom.from && custom.to);7475 const load = useCallback(async () => {76 setLoading(true);77 setError(false);78 try {79 const p = new URLSearchParams({ period });80 if (hasCustom) {81 p.set("from", custom.from);82 p.set("to", custom.to);83 }84 const res = await fetch(`/api/stats/dashboard?${p}`);85 if (!res.ok) throw new Error(`Erreur ${res.status}`);86 setData((await res.json()) as Dashboard);87 } catch {88 setError(true);89 } finally {90 setLoading(false);91 }92 }, [period, custom.from, custom.to, hasCustom]);9394 useEffect(() => {95 void load();96 }, [load]);9798 const topQueries = data?.tables.find((t) => t.id === "topq");99 const otherTables = data?.tables.filter((t) => t.id !== "topq") ?? [];100101 return (102 <div>103 <p className="kicker">Transparence</p>104 <div className="mt-3 flex flex-wrap items-start justify-between gap-4">105 <div>106 <h1 className="text-3xl font-bold tracking-[-0.03em] text-ink">107 Statistiques du moteur108 </h1>109 <p className="mt-2 max-w-xl text-sm text-ink-2">110 Le tableau de bord complet du moteur de recherche du Groupe KA :111 indexation des sites de l'écosystème, crawl, erreurs et112 recherches — calculé sur les données réelles du moteur, rien d'inventé.113 </p>114 </div>115 <div className="flex flex-col items-start gap-3 sm:items-end">116 <PdfButton period={period} from={hasCustom ? custom.from : undefined} to={hasCustom ? custom.to : undefined} />117 {data && <Fraicheur updated={data.updated} onRefresh={() => void load()} />}118 </div>119 </div>120121 <div className="mt-8">122 <PeriodSelector123 value={period}124 onChange={(p) => {125 setCustom({ from: "", to: "" });126 setPeriod(p);127 }}128 custom={custom}129 onCustom={(from, to) => setCustom({ from, to })}130 />131 </div>132133 {error && (134 <div className="card mt-8" style={{ padding: 20 }}>135 <b style={{ fontFamily: "var(--font-display)" }}>Statistiques indisponibles</b>136 <p className="klabel" style={{ margin: "6px 0 12px" }}>137 Le moteur n'a pas pu répondre. Réessayez dans un instant.138 </p>139 <button type="button" className="btn btn-primary" onClick={() => void load()}>140 Réessayer141 </button>142 </div>143 )}144145 {!error && !data && loading && (146 <p className="klabel mt-10" role="status">147 Chargement des statistiques…148 </p>149 )}150151 {data && (152 <div style={{ opacity: loading ? 0.55 : 1, transition: "opacity 0.15s" }}>153 {/* 1. Bandeau KPI (+ sparklines) */}154 <section aria-label="Indicateurs clés" className="mt-6 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">155 {data.kpis.map((k) => (156 <KpiCard key={k.id} k={k} />157 ))}158 </section>159160 {/* 3. Jauges : taux & couvertures */}161 {data.gauges && data.gauges.length > 0 && (162 <section aria-label="Taux et couvertures" className="mt-8 grid grid-cols-2 gap-3 md:grid-cols-4">163 {data.gauges.map((g) => (164 <GaugeCard key={g.id} g={g} />165 ))}166 </section>167 )}168169 {/* 4. Évolutions : séries, multi-courbes, empilées */}170 <section aria-label="Évolution" className="mt-8 grid grid-cols-1 gap-4 xl:grid-cols-2">171 {data.series.map((s, i) => (172 <div key={s.id} className={i === 0 ? "xl:col-span-2" : undefined}>173 <LineChart serie={s} />174 {STAT_SERIES.has(s.id) && <StatSummary serie={s} />}175 </div>176 ))}177 {(data.multiseries ?? []).map((ms) => (178 <div key={ms.id} className="xl:col-span-2">179 <MultiLineChart ms={ms} />180 </div>181 ))}182 {(data.stacked ?? []).map((st) => (183 <div key={st.id} className="xl:col-span-2">184 <StackedBarChart st={st} />185 </div>186 ))}187 </section>188189 {/* 5. Répartitions */}190 <section aria-label="Répartitions" className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-2">191 {data.breakdowns.map((b) =>192 b.kind === "bar" || b.kind === "bars" ? (193 <BarChart key={b.id} title={b.title} items={b.items} unit="pages" />194 ) : (195 <Donut key={b.id} title={b.title} items={b.items} />196 ),197 )}198 </section>199200 {/* Distributions / histogrammes */}201 {data.distributions && data.distributions.length > 0 && (202 <section aria-label="Distributions" className="mt-8 grid grid-cols-1 gap-4 xl:grid-cols-2">203 {data.distributions.map((d) => (204 <Histogram key={d.id} dist={d} />205 ))}206 </section>207 )}208209 {/* 7. Calendriers : 26 semaines + activité horaire 7×24 */}210 {data.heatmap && (211 <section aria-label="Calendrier d'activité" className="mt-8">212 <CalendarHeatmap title={data.heatmap.title} cells={data.heatmap.cells} />213 </section>214 )}215 {data.hourly && (216 <section aria-label="Activité horaire" className="mt-4">217 <HourHeatmap title={data.hourly.title} cells={data.hourly.cells} />218 </section>219 )}220221 {/* 8. Tableaux détaillés */}222 <section aria-label="Tableaux détaillés" className="mt-8 grid grid-cols-1 gap-4">223 {topQueries ? (224 <DataTable spec={topQueries} />225 ) : (226 <EmptyBlock title="Top requêtes de recherche" />227 )}228 {otherTables.map((t) => (229 <DataTable key={t.id} spec={t} />230 ))}231 </section>232233 {/* 9. Records & faits marquants */}234 {data.records.length > 0 && (235 <section aria-label="Records et faits marquants" className="mt-8">236 <h2 className="text-lg font-bold tracking-[-0.02em] text-ink">237 Records & faits marquants238 </h2>239 <div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">240 {data.records.map((r) => (241 <RecordCard key={r.label} r={r} />242 ))}243 </div>244 </section>245 )}246247 {/* 10. Fraîcheur (rappel en bas de page) */}248 <div className="mt-10">249 <Fraicheur updated={data.updated} onRefresh={() => void load()} />250 </div>251 </div>252 )}253 </div>254 );255}256