SPB Git

spb/forma-ka Public

Python 65.1% TypeScript 17.9% CSS 16.4% HTML 0.5%
5.1 KB · 134 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Forma-Ka — Agrégateur de formations (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Stats.tsx : portrait de l'offre de formation — totaux, répartition par5//   type (barres), santé des synchronisations récentes.6// -----------------------------------------------------------------------------7import { useEffect, useState } from "react";8import { Link } from "react-router-dom";9import { Stats, fetchSources, fetchStats, registerSourceNames, sourceName } from "../api";1011export default function StatsPage() {12  const [stats, setStats] = useState<Stats | null>(null);13  const [error, setError] = useState<string | null>(null);1415  useEffect(() => {16    fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});17    fetchStats().then(setStats).catch((e) => setError(String(e)));18  }, []);1920  if (error)21    return (22      <div className="notice container">23        <div className="big">⚠️</div>24        <h2>Impossible de charger les statistiques</h2>25        <p>{error}</p>26      </div>27    );2829  if (!stats)30    return (31      <div className="container">32        <div className="notice">Chargement…</div>33      </div>34    );3536  const maxType = Math.max(1, ...stats.par_type.map((t) => t.n));3738  return (39    <div className="container sources">40      <span className="kicker">Portrait — l'offre de formation au Québec</span>41      <h1>Statistiques</h1>42      <p className="sub">43        Portrait en direct des formations actives agrégées par Forma-Ka, tous44        établissements confondus.45      </p>4647      <div className="stat-row" style={{ marginBottom: 28 }}>48        <span className="stat-chip"><b>{stats.total}</b> formations actives</span>49        <span className="stat-chip"><b>{stats.sources}</b> établissements</span>50        {(stats.gratuites ?? 0) > 0 && (51          <span className="stat-chip"><b>{stats.gratuites}</b> gratuites</span>52        )}53        {(stats.en_ligne ?? 0) > 0 && (54          <span className="stat-chip"><b>{stats.en_ligne}</b> en ligne</span>55        )}56        {stats.avg_price != null && (57          <span className="stat-chip">58            prix moyen (affiché) <b>{Math.round(stats.avg_price).toLocaleString("fr-CA")} $</b>59          </span>60        )}61        {stats.avg_hours != null && (62          <span className="stat-chip">63            durée moyenne <b>{Math.round(stats.avg_hours)} h</b>64          </span>65        )}66      </div>6768      <h2 style={{ fontFamily: "var(--font-display)" }}>Par type de formation</h2>69      <div style={{ display: "grid", gap: 8, marginBottom: 36 }}>70        {stats.par_type.map((t) => (71          <Link72            key={t.t || "(sans type)"}73            to={`/?training_type=${encodeURIComponent(t.t)}`}74            style={{75              display: "grid", gridTemplateColumns: "180px 1fr 56px",76              alignItems: "center", gap: 12, color: "var(--ink)",77            }}78          >79            <span style={{ fontWeight: 600, fontSize: ".92rem" }}>{t.t || "Autre"}</span>80            <span style={{ background: "var(--surface-2)", border: "1px solid var(--line)", borderRadius: 6, overflow: "hidden", height: 18 }}>81              <span style={{82                display: "block", height: "100%",83                width: `${Math.max(2, Math.round((t.n / maxType) * 100))}%`,84                background: "var(--lime)", borderRight: "1px solid var(--ink)",85              }} />86            </span>87            <span style={{ fontFamily: "var(--font-mono)", fontSize: ".85rem", textAlign: "right" }}>88              {t.n}89            </span>90          </Link>91        ))}92      </div>9394      <h2 style={{ fontFamily: "var(--font-display)" }}>Synchronisations récentes</h2>95      <div className="src-wrap">96        <table className="src-table">97          <thead>98            <tr>99              <th>Source</th>100              <th>Quand</th>101              <th style={{ textAlign: "right" }}>Trouvées</th>102              <th style={{ textAlign: "right" }}>+ / ~ / −</th>103              <th>État</th>104            </tr>105          </thead>106          <tbody>107            {stats.recent_syncs.map((s, i) => (108              <tr key={i}>109                <td>{sourceName(s.source)}</td>110                <td style={{ color: "var(--ink-3)" }}>111                  {new Date(s.ts * 1000).toLocaleString("fr-CA")}112                </td>113                <td style={{ textAlign: "right" }}>{s.found}</td>114                <td style={{ textAlign: "right", fontFamily: "var(--font-mono)", fontSize: ".85rem" }}>115                  {s.added} / {s.updated} / {s.removed}116                </td>117                <td>118                  {s.ok ? (119                    s.message === "ok"120                      ? <span className="pill ok">ok</span>121                      : <span className="pill todo" title={s.message}>alerte</span>122                  ) : (123                    <span className="pill todo" title={s.message}>échec</span>124                  )}125                </td>126              </tr>127            ))}128          </tbody>129        </table>130      </div>131    </div>132  );133}134