spb/forma-ka Public
Python 65.1%
TypeScript 17.9%
CSS 16.4%
HTML 0.5%
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