SPB Git forge

spb/trouve-ka

Public ★ Pinned

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

40commits 1branches 0releases
9.2 MBsize
maindefault branch
23 days agolast push
Python 51.9% TypeScript 32.7% JavaScript 4.8% CSS 4.1% Shell 2.9% HTML 1.8% SQL 1.5%
8.6 KB · 256 lines tsx
Raw Blame History
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&apos;écosystème, crawl, erreurs et112            recherches — calculé sur les données réelles du moteur, rien d&apos;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&apos;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 &amp; 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