SPB Git

spb/job-ka

Public

Intégration complète à l'écosystème Groupe KA

- Design : ka-ui vendoré (tokens.css importé avant le CSS local), accent
  officiel cyan #0c8599 (fin du lime hérité), socle dédupliqué, badge
  « Un service Groupe KA » au header (desktop + mobile, zéro débordement
  à 360/768/1440), prefooter local + KaFooter commun, page /contact
  (ecosystem.json), title « Job-Ka — Un service Groupe KA », assets
  og.png/favicon.svg/apple-touch-icon + meta OG/Twitter absolues.
- SSO KA ID : jobka/auth.py (client job-ka, cookie jobka_session, table
  users), bouton « Se connecter avec KA » au header, testé bout-en-bout
  jusqu'au hub (307 → /connexion, client reconnu).
- Stats : /api/stats/dashboard (contrat SPEC, cache 5 min, salaires
  bornés 25–400 k$, série date_posted réelle) + /api/stats/report (PDF
  fpdf2 via jobka/kapdf.py, complet + synthèse), page /stats refaite sur
  ka/stats/kacharts.tsx ; /api/stats historique conservé pour le hub.
- KA Agent : widget ka-agent.js câblé (site job-ka).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 18, 2026) parent 4657429 Browse files

24 changed files +3,546 −132

modified frontend/index.html +17 −4
@@ -13,15 +13,28 @@ Créé : 2026-08-17 Modifié : 2026-08-17
13 <head> 13 <head>
14 <meta charset="UTF-8" /> 14 <meta charset="UTF-8" />
15 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 15 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
16− <meta name="description" content="Job·Ka — toutes les offres d'emploi des employeurs québécois, directement à la source. Salaires transparents, zéro boîte noire." /> 16+ <meta name="description" content="Job·Ka — toutes les offres d'emploi des employeurs québécois, directement à la source. Salaires transparents, zéro boîte noire. Un service Groupe KA." />
17− <title>Job·Ka — L'emploi au Québec, à la source</title> 17+ <title>Job-Ka — Un service Groupe KA</title>
18− <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23141814'/%3E%3Ctext x='32' y='42' font-family='Inter,sans-serif' font-size='28' font-weight='800' fill='%23d9f26b' text-anchor='middle'%3EJ·K%3C/text%3E%3C/svg%3E" /> 18+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
19+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
20+ <meta property="og:title" content="Job-Ka — Un service Groupe KA" />
21+ <meta property="og:description" content="Toutes les offres d'emploi des employeurs québécois, directement à la source. Salaires transparents, zéro boîte noire." />
22+ <meta property="og:type" content="website" />
23+ <meta property="og:url" content="https://www.job-ka.com/" />
24+ <meta property="og:image" content="https://www.job-ka.com/og.png" />
25+ <meta property="og:image:width" content="1200" />
26+ <meta property="og:image:height" content="630" />
27+ <meta name="twitter:card" content="summary_large_image" />
28+ <meta name="twitter:image" content="https://www.job-ka.com/og.png" />
19 <link rel="preconnect" href="https://fonts.googleapis.com" /> 29 <link rel="preconnect" href="https://fonts.googleapis.com" />
20 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 30 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
21− <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> 31+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
22 </head> 32 </head>
23 <body> 33 <body>
24 <div id="root"></div> 34 <div id="root"></div>
25 <script type="module" src="/src/main.tsx"></script> 35 <script type="module" src="/src/main.tsx"></script>
36+ <!-- KA Agent : assistant IA commun de l'écosystème Groupe KA -->
37+ <script>window.KA_AGENT={site:"job-ka"}</script>
38+ <script src="/ka-agent.js" defer></script>
26 </body> 39 </body>
27 </html> 40 </html>
added frontend/public/apple-touch-icon.png +0 −0

Binary file not shown.

added frontend/public/favicon.svg +5 −0
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
2+<rect width="64" height="64" rx="14" fill="#141814"/>
3+<rect x="4.5" y="4.5" width="55" height="55" rx="11" fill="none" stroke="#0c8599" stroke-opacity="0.35" stroke-width="2"/>
4+<text x="32" y="43" text-anchor="middle" font-family="'Space Grotesk','Arial Black',sans-serif" font-size="30" font-weight="700" letter-spacing="-1" fill="#0c8599" transform="rotate(-4 32 32)">Ka</text>
5+</svg>
\ No newline at end of file
added frontend/public/ka-agent.js +233 −0
@@ -0,0 +1,233 @@
1+/* Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+ * ka-ui/agent/ka-agent.js — KA AGENT : widget de chat commun de l'écosystème
3+ * Groupe KA. Vanilla JS, zéro dépendance, injecté sur les 13 sites.
4+ * <script>window.KA_AGENT={site:"lou-ka"}</script>
5+ * <script src="/ka-agent.js" defer></script>
6+ * Bulle flottante en bas à droite → panneau de chat (bulle) → plein écran (⛶).
7+ * Flux SSE en direct depuis le backend central (texte token par token +
8+ * indicateur d'outil). Historique conservé en localStorage (par site).
9+ */
10+(function () {
11+ "use strict";
12+ var CFG = window.KA_AGENT || {};
13+ var SITE = CFG.site || "groupe-ka";
14+ var ENDPOINT = CFG.endpoint || "https://www.api-ka.com/api/agent/chat";
15+ var KEY = "ka-agent-" + SITE;
16+ var MAXLOG = 20;
17+
18+ /* ---------- styles ---------- */
19+ var css = `
20+ .kaa-btn{position:fixed;right:18px;bottom:18px;z-index:2147483000;width:58px;height:58px;
21+ border-radius:50%;border:2px solid var(--ink,#141814);background:var(--accent,#d9f26b);
22+ color:var(--on-accent,#141814);font:700 17px/1 "Space Grotesk",system-ui,sans-serif;
23+ cursor:pointer;box-shadow:4px 4px 0 rgba(20,24,20,.85);display:flex;align-items:center;
24+ justify-content:center;transition:transform .15s;padding-bottom:env(safe-area-inset-bottom,0)}
25+ .kaa-btn:hover{transform:translate(-2px,-2px)}
26+ .kaa-panel{position:fixed;right:16px;bottom:86px;z-index:2147483001;width:min(392px,calc(100vw - 24px));
27+ height:min(580px,calc(100dvh - 110px));display:none;flex-direction:column;background:var(--paper,#f5f3ee);
28+ border:2px solid var(--ink,#141814);border-radius:14px;box-shadow:8px 8px 0 rgba(20,24,20,.85);
29+ overflow:hidden;font-family:Inter,system-ui,sans-serif}
30+ .kaa-panel.kaa-open{display:flex}
31+ .kaa-panel.kaa-full{right:0;bottom:0;width:100vw;height:100dvh;border-radius:0;border:0;box-shadow:none}
32+ .kaa-head{display:flex;align-items:center;gap:9px;padding:11px 14px;background:var(--ink,#141814);
33+ color:var(--paper,#f5f3ee);flex:none}
34+ .kaa-head b{font:700 15px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}
35+ .kaa-head .kaa-ka{display:inline-block;background:var(--accent,#d9f26b);color:var(--on-accent,#141814);
36+ border-radius:6px;padding:1px 7px 2px;transform:rotate(-2deg);font-weight:700}
37+ .kaa-head small{font:700 9.5px "JetBrains Mono",monospace;letter-spacing:.09em;text-transform:uppercase;opacity:.65}
38+ .kaa-head .kaa-sp{flex:1}
39+ .kaa-hbtn{width:34px;height:34px;border:1.5px solid rgba(245,243,238,.4);border-radius:8px;background:none;
40+ color:var(--paper,#f5f3ee);font-size:15px;cursor:pointer;line-height:1}
41+ .kaa-hbtn:hover{border-color:var(--accent,#d9f26b);color:var(--accent,#d9f26b)}
42+ .kaa-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px;
43+ overscroll-behavior:contain;-webkit-overflow-scrolling:touch}
44+ .kaa-msg{max-width:86%;padding:9px 13px;border:1.5px solid var(--ink,#141814);border-radius:12px;
45+ font-size:13.5px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
46+ .kaa-msg.kaa-u{align-self:flex-end;background:var(--accent,#d9f26b);color:var(--on-accent,#141814);
47+ border-bottom-right-radius:4px}
48+ .kaa-msg.kaa-a{align-self:flex-start;background:#fff;border-bottom-left-radius:4px;box-shadow:3px 3px 0 rgba(20,24,20,.12)}
49+ .kaa-msg.kaa-a a{color:inherit;text-decoration:underline;text-underline-offset:3px}
50+ .kaa-msg.kaa-a b{font-weight:700}
51+ .kaa-tool{align-self:flex-start;font:700 10px "JetBrains Mono",monospace;letter-spacing:.07em;
52+ text-transform:uppercase;color:var(--ink-2,#4d5551);padding:3px 10px;border:1.5px dashed var(--ink-2,#4d5551);
53+ border-radius:999px;background:var(--surface-2,#faf9f5)}
54+ .kaa-in{display:flex;gap:8px;padding:11px;border-top:2px solid var(--ink,#141814);background:#fff;flex:none;
55+ padding-bottom:calc(11px + env(safe-area-inset-bottom,0))}
56+ .kaa-in textarea{flex:1;resize:none;min-height:44px;max-height:110px;padding:11px 12px;font:13.5px/1.4 Inter,system-ui,sans-serif;
57+ border:1.5px solid var(--ink,#141814);border-radius:9px;background:var(--paper,#f5f3ee);outline:none}
58+ .kaa-in button{min-width:52px;min-height:44px;border:1.5px solid var(--ink,#141814);border-radius:9px;
59+ background:var(--ink,#141814);color:var(--accent,#d9f26b);font:700 16px "Space Grotesk",system-ui;cursor:pointer}
60+ .kaa-in button:disabled{opacity:.45}
61+ .kaa-hello{font-size:12.5px;color:var(--ink-2,#4d5551);padding:4px 2px}
62+ .kaa-dots::after{content:"…";animation:kaa-b 1.2s infinite}
63+ @keyframes kaa-b{0%{opacity:.2}50%{opacity:1}100%{opacity:.2}}
64+ @media (max-width:560px){.kaa-panel{right:8px;left:8px;width:auto}}
65+ `;
66+
67+ /* ---------- markdown minimal (gras, liens, puces) — texte échappé d'abord ---------- */
68+ function esc(s) {
69+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
70+ }
71+ function md(s) {
72+ return esc(s)
73+ .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
74+ .replace(/(https?:\/\/[^\s<)"']+)/g, function (u) {
75+ return '<a href="' + u + '" target="_blank" rel="noopener noreferrer">' + u.replace(/^https?:\/\/(www\.)?/, "").slice(0, 40) + "</a>";
76+ });
77+ }
78+
79+ /* ---------- état ---------- */
80+ var log = [];
81+ try { log = JSON.parse(localStorage.getItem(KEY) || "[]"); } catch (e) {}
82+ function save() { try { localStorage.setItem(KEY, JSON.stringify(log.slice(-MAXLOG))); } catch (e) {} }
83+
84+ /* ---------- DOM ---------- */
85+ var style = document.createElement("style");
86+ style.textContent = css;
87+ document.head.appendChild(style);
88+
89+ var btn = document.createElement("button");
90+ btn.className = "kaa-btn";
91+ btn.setAttribute("aria-label", "Ouvrir KA Agent — assistant de l'écosystème Groupe KA");
92+ btn.innerHTML = "Ka";
93+
94+ var panel = document.createElement("div");
95+ panel.className = "kaa-panel";
96+ panel.setAttribute("role", "dialog");
97+ panel.setAttribute("aria-label", "KA Agent");
98+ panel.innerHTML =
99+ '<div class="kaa-head"><b>KA<span class="kaa-ka">Agent</span></b><small>Groupe KA · IA</small><span class="kaa-sp"></span>' +
100+ '<button class="kaa-hbtn kaa-x1" title="Plein écran" aria-label="Plein écran">⛶</button>' +
101+ '<button class="kaa-hbtn kaa-x2" title="Fermer" aria-label="Fermer">✕</button></div>' +
102+ '<div class="kaa-log"></div>' +
103+ '<div class="kaa-in"><textarea rows="1" placeholder="Posez votre question…" aria-label="Votre question"></textarea>' +
104+ "<button aria-label=\"Envoyer\">➤</button></div>";
105+ document.body.appendChild(btn);
106+ document.body.appendChild(panel);
107+
108+ var logEl = panel.querySelector(".kaa-log");
109+ var ta = panel.querySelector("textarea");
110+ var send = panel.querySelector(".kaa-in button");
111+
112+ function scroll() { logEl.scrollTop = logEl.scrollHeight; }
113+ function bubble(cls, html) {
114+ var d = document.createElement("div");
115+ d.className = "kaa-msg " + cls;
116+ d.innerHTML = html;
117+ logEl.appendChild(d);
118+ scroll();
119+ return d;
120+ }
121+ function toolChip(name) {
122+ var d = document.createElement("div");
123+ d.className = "kaa-tool kaa-dots";
124+ d.textContent = "🔎 " + name.replace(/_/g, " ");
125+ logEl.appendChild(d);
126+ scroll();
127+ return d;
128+ }
129+
130+ function hello() {
131+ if (log.length) return;
132+ var d = document.createElement("div");
133+ d.className = "kaa-hello";
134+ d.textContent = "👋 Je suis KA Agent, l'assistant de l'écosystème Groupe KA. Posez-moi une question sur ce site, ses données (prix, annonces, stats…) ou sur le groupe.";
135+ logEl.appendChild(d);
136+ }
137+ function render() {
138+ logEl.innerHTML = "";
139+ hello();
140+ log.forEach(function (m) { bubble(m.role === "user" ? "kaa-u" : "kaa-a", md(m.content)); });
141+ scroll();
142+ }
143+
144+ /* ---------- ouverture / plein écran ---------- */
145+ btn.addEventListener("click", function () {
146+ panel.classList.toggle("kaa-open");
147+ if (panel.classList.contains("kaa-open")) { render(); ta.focus(); }
148+ });
149+ panel.querySelector(".kaa-x2").addEventListener("click", function () {
150+ panel.classList.remove("kaa-open", "kaa-full");
151+ });
152+ panel.querySelector(".kaa-x1").addEventListener("click", function () {
153+ panel.classList.toggle("kaa-full");
154+ scroll();
155+ });
156+
157+ /* ---------- envoi + flux SSE ---------- */
158+ var busy = false;
159+ function ask() {
160+ var q = ta.value.trim();
161+ if (!q || busy) return;
162+ busy = true;
163+ send.disabled = true;
164+ ta.value = "";
165+ log.push({ role: "user", content: q });
166+ save();
167+ bubble("kaa-u", md(q));
168+ var out = bubble("kaa-a", '<span class="kaa-dots"></span>');
169+ var acc = "";
170+ var chips = [];
171+
172+ fetch(ENDPOINT, {
173+ method: "POST",
174+ headers: { "Content-Type": "application/json" },
175+ body: JSON.stringify({ site: SITE, messages: log.slice(-MAXLOG) }),
176+ })
177+ .then(function (res) {
178+ if (!res.ok || !res.body) throw new Error("HTTP " + res.status);
179+ var reader = res.body.getReader();
180+ var dec = new TextDecoder();
181+ var buf = "";
182+ function pump() {
183+ return reader.read().then(function (r) {
184+ if (r.done) return finish();
185+ buf += dec.decode(r.value, { stream: true });
186+ var blocks = buf.split("\n\n");
187+ buf = blocks.pop();
188+ blocks.forEach(function (b) {
189+ var ev = (b.match(/^event: (.+)$/m) || [])[1];
190+ var dm = (b.match(/^data: (.+)$/m) || [])[1];
191+ if (!ev || !dm) return;
192+ var data = {};
193+ try { data = JSON.parse(dm); } catch (e) {}
194+ if (ev === "delta" && data.text) {
195+ acc += data.text;
196+ out.innerHTML = md(acc);
197+ scroll();
198+ } else if (ev === "tool") {
199+ chips.push(toolChip(data.name || "recherche"));
200+ logEl.appendChild(out); // la bulle reste après le chip
201+ scroll();
202+ } else if (ev === "error") {
203+ acc = acc || "Désolé, une erreur est survenue (" + (data.message || "inconnue") + "). Réessayez.";
204+ out.innerHTML = md(acc);
205+ }
206+ });
207+ return pump();
208+ });
209+ }
210+ return pump();
211+ })
212+ .catch(function (e) {
213+ acc = acc || "Impossible de joindre KA Agent (" + e.message + "). Réessayez dans un instant.";
214+ out.innerHTML = md(acc);
215+ })
216+ .then(function () { finish(); });
217+
218+ function finish() {
219+ if (busy === false) return;
220+ busy = false;
221+ send.disabled = false;
222+ chips.forEach(function (c) { c.classList.remove("kaa-dots"); });
223+ if (!acc) { acc = "Je n'ai pas pu formuler de réponse — réessayez."; out.innerHTML = md(acc); }
224+ log.push({ role: "assistant", content: acc });
225+ save();
226+ scroll();
227+ }
228+ }
229+ send.addEventListener("click", ask);
230+ ta.addEventListener("keydown", function (e) {
231+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); }
232+ });
233+})();
added frontend/public/og.png +0 −0

Binary file not shown.

modified frontend/src/App.tsx +66 −11
@@ -4,32 +4,82 @@
4 * Auteur : Simon-Pierre Boucher 4 * Auteur : Simon-Pierre Boucher
5 * Contact : contact@spboucher.ai 5 * Contact : contact@spboucher.ai
6 * Fichier : frontend/src/App.tsx 6 * Fichier : frontend/src/App.tsx
7− * Rôle : Gabarit global (en-tête, navigation, routes, pied de page) 7+ * Rôle : Gabarit global (en-tête + badge Groupe KA + connexion KA ID,
8+ * navigation, routes, pied de page commun Groupe KA)
8 * Créé : 2026-08-17 Modifié : 2026-08-17 9 * Créé : 2026-08-17 Modifié : 2026-08-17
9 * ============================================================================= 10 * =============================================================================
10 */ 11 */
11−import { lazy, Suspense } from "react"; 12+import { lazy, Suspense, useEffect, useState } from "react";
12 import { NavLink, Route, Routes } from "react-router-dom"; 13 import { NavLink, Route, Routes } from "react-router-dom";
13 import Home from "./pages/Home"; 14 import Home from "./pages/Home";
14 import JobPage from "./pages/Job"; 15 import JobPage from "./pages/Job";
15 import Sources from "./pages/Sources"; 16 import Sources from "./pages/Sources";
16 import Stats from "./pages/Stats"; 17 import Stats from "./pages/Stats";
18+import Contact from "./pages/Contact";
19+import GroupeKaBadge from "./ka/GroupeKaBadge";
20+import KaFooter from "./ka/KaFooter";
17 21
18 // MapLibre pèse ~700 kB : chargé uniquement quand on visite /carte 22 // MapLibre pèse ~700 kB : chargé uniquement quand on visite /carte
19 const MapPage = lazy(() => import("./pages/MapPage")); 23 const MapPage = lazy(() => import("./pages/MapPage"));
20 24
25+interface Me { name: string; email: string; picture: string; ka_id: string }
26+
27+/** Connexion KA ID (hub groupe-ka.com) — bouton ou profil connecté. */
28+function KaAuth() {
29+ const [enabled, setEnabled] = useState(false);
30+ const [me, setMe] = useState<Me | null>(null);
31+
32+ useEffect(() => {
33+ fetch("/api/auth/config")
34+ .then((r) => r.json())
35+ .then((c) => setEnabled(Boolean(c.ka)))
36+ .catch(() => {});
37+ fetch("/api/me")
38+ .then((r) => (r.ok ? r.json() : null))
39+ .then(setMe)
40+ .catch(() => {});
41+ }, []);
42+
43+ const logout = () => {
44+ fetch("/api/auth/logout", { method: "POST" })
45+ .then(() => setMe(null))
46+ .catch(() => {});
47+ };
48+
49+ if (!enabled) return null;
50+ if (me) {
51+ return (
52+ <span className="auth-user">
53+ {me.picture ? <img src={me.picture} alt="" referrerPolicy="no-referrer" /> : null}
54+ <span className="name">{me.name || me.email}</span>
55+ <button type="button" onClick={logout}>Déconnexion</button>
56+ </span>
57+ );
58+ }
59+ return (
60+ <a className="btn-ka" href="/api/auth/ka/login">
61+ Se connecter avec <b>KA</b>
62+ </a>
63+ );
64+}
65+
21 export default function App() { 66 export default function App() {
22 return ( 67 return (
23 <> 68 <>
24 <header className="site"> 69 <header className="site">
25 <div className="container"> 70 <div className="container">
26 <NavLink to="/" className="logo">Job<em>·</em>Ka</NavLink> 71 <NavLink to="/" className="logo">Job<em>·</em>Ka</NavLink>
72+ <GroupeKaBadge dark />
27 <nav className="main"> 73 <nav className="main">
28 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>Offres</NavLink> 74 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>Offres</NavLink>
29 <NavLink to="/carte" className={({ isActive }) => (isActive ? "active" : "")}>Carte</NavLink> 75 <NavLink to="/carte" className={({ isActive }) => (isActive ? "active" : "")}>Carte</NavLink>
30 <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>Employeurs</NavLink> 76 <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>Employeurs</NavLink>
31− <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>Statistiques</NavLink> 77+ <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>Stats</NavLink>
78+ <NavLink to="/contact" className={({ isActive }) => (isActive ? "active" : "")}>Contact</NavLink>
32 </nav> 79 </nav>
80+ <div className="auth-box">
81+ <KaAuth />
82+ </div>
33 </div> 83 </div>
34 </header> 84 </header>
35 <Routes> 85 <Routes>
@@ -42,19 +92,24 @@ export default function App() {
42 <Route path="/emploi/:uid" element={<JobPage />} /> 92 <Route path="/emploi/:uid" element={<JobPage />} />
43 <Route path="/sources" element={<Sources />} /> 93 <Route path="/sources" element={<Sources />} />
44 <Route path="/stats" element={<Stats />} /> 94 <Route path="/stats" element={<Stats />} />
95+ <Route path="/contact" element={<Contact />} />
45 <Route path="*" element={<div className="container empty"><h2>Page introuvable</h2></div>} /> 96 <Route path="*" element={<div className="container empty"><h2>Page introuvable</h2></div>} />
46 </Routes> 97 </Routes>
47− <footer className="site"> 98+ <div className="prefooter">
48 <div className="container"> 99 <div className="container">
49− <span> 100+ <span className="baseline">
50− Une plateforme du <a href="https://www.groupe-ka.com">Groupe KA</a> —{" "} 101+ <b>Job-Ka</b> — Tous les emplois des employeurs québécois, directement
51− <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a> 102+ à la source. Chaque offre est traçable à sa source originale, zéro
52− </span> 103+ boîte noire.
53− <span>
54− Chaque offre est traçable à sa source originale. Zéro boîte noire.
55 </span> 104 </span>
105+ <NavLink to="/">Offres</NavLink>
106+ <NavLink to="/carte">Carte</NavLink>
107+ <NavLink to="/sources">Employeurs</NavLink>
108+ <NavLink to="/stats">Stats</NavLink>
109+ <NavLink to="/contact">Contact</NavLink>
56 </div> 110 </div>
57− </footer> 111+ </div>
112+ <KaFooter siteId="job-ka" />
58 </> 113 </>
59 ); 114 );
60 } 115 }
added frontend/src/ka/GroupeKaBadge.tsx +21 −0
@@ -0,0 +1,21 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// ka-ui/react/GroupeKaBadge.tsx — badge « Un service Groupe KA », cliquable
3+// vers le hub. À placer dans le header de chaque site (à côté du logo de la
4+// marque). Variante dark pour les surfaces encre. Zone de protection : le
5+// padding interne du badge suffit ; ne rien coller à moins de 8 px du badge.
6+export default function GroupeKaBadge({ dark = false }: { dark?: boolean }) {
7+ return (
8+ <a
9+ className={`gk-badge${dark ? " gk-badge--dark" : ""}`}
10+ href="https://www.groupe-ka.com"
11+ target="_blank"
12+ rel="noopener noreferrer"
13+ aria-label="Un service Groupe KA — visiter le portail de l'écosystème"
14+ >
15+ Un service
16+ <b>
17+ Groupe<span className="ka">KA</span>
18+ </b>
19+ </a>
20+ );
21+}
added frontend/src/ka/KaFooter.tsx +91 −0
@@ -0,0 +1,91 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// ka-ui/react/KaFooter.tsx — footer commun Groupe KA (fond encre), à vendorer
3+// dans chaque app Vite/React sous frontend/src/ka/. Consomme ecosystem.json :
4+// toute modification des infos de contact se fait dans ka-ui/ecosystem.json
5+// puis se resynchronise sur les 12 sites (voir ka-ui/README.md).
6+import eco from "./ecosystem.json";
7+
8+type LocalLink = { label: string; href: string };
9+
10+export default function KaFooter({
11+ siteId,
12+ localLegal = [],
13+}: {
14+ /** id du site courant dans ecosystem.json (ex. "resto-ka") */
15+ siteId: string;
16+ /** pages légales PROPRES au site (ex. /conditions), affichées avant celles du hub */
17+ localLegal?: LocalLink[];
18+}) {
19+ const year = new Date().getFullYear();
20+ const site = eco.sites.find((s) => s.id === siteId);
21+ const others = eco.sites.filter((s) => s.id !== siteId);
22+ return (
23+ <footer className="ka-footer" id="contact">
24+ <div className="container">
25+ <a
26+ className="wordmark"
27+ href={eco.hub.url}
28+ target={siteId === "groupe-ka" ? undefined : "_blank"}
29+ rel="noopener noreferrer"
30+ aria-label="Groupe KA — le portail de l'écosystème"
31+ >
32+ Groupe <span className="ka">KA</span>
33+ </a>
34+ <p className="desc">
35+ {eco.org.tagline} Des centaines de connecteurs lisent les sites à la
36+ source, normalisent la donnée et se resynchronisent seuls.{" "}
37+ {site ? (
38+ <>
39+ <b style={{ color: "var(--paper)" }}>{site.wordmark}</b> —{" "}
40+ {site.tagline} — est un service Groupe KA.
41+ </>
42+ ) : null}
43+ </p>
44+ <p className="notice">
45+ <b>{eco.org.disclaimer}</b>
46+ </p>
47+ <ul className="sites">
48+ {others.map((s) => (
49+ <li key={s.id}>
50+ <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer">
51+ {s.wordmark}
52+ </a>
53+ </li>
54+ ))}
55+ {eco.extraFooterLinks.map((l) => (
56+ <li key={l.href}>
57+ <a href={l.href} target="_blank" rel="noopener noreferrer">
58+ {l.label}
59+ </a>
60+ </li>
61+ ))}
62+ </ul>
63+ <div className="contacts">
64+ {eco.contacts.map((c) => (
65+ <p key={c.email} style={{ margin: 0 }}>
66+ <a href={`mailto:${c.email}`}>{c.email}</a>
67+ <span>{c.role}</span>
68+ </p>
69+ ))}
70+ </div>
71+ <p className="legal">
72+ © {year} {eco.org.copyrightHolder}
73+ {localLegal.map((l) => (
74+ <span key={l.href}>
75+ {" · "}
76+ <a href={l.href}>{l.label}</a>
77+ </span>
78+ ))}
79+ {eco.legal.map((l) => (
80+ <span key={l.href}>
81+ {" · "}
82+ <a href={l.href} target="_blank" rel="noopener noreferrer">
83+ {l.label}
84+ </a>
85+ </span>
86+ ))}
87+ </p>
88+ </div>
89+ </footer>
90+ );
91+}
added frontend/src/ka/ecosystem.json +196 −0
@@ -0,0 +1,196 @@
1+{
2+ "org": {
3+ "name": "Groupe KA",
4+ "legalName": "Groupe KA — Simon-Pierre Boucher",
5+ "tagline": "Holding québécois d'agrégateurs de produits et services entièrement automatisés.",
6+ "disclaimer": "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction.",
7+ "copyrightHolder": "Groupe KA — Simon-Pierre Boucher"
8+ },
9+ "hub": {
10+ "url": "https://www.groupe-ka.com",
11+ "loginPath": "/connexion",
12+ "signupNote": "La création de compte KA ID se fait sur le hub groupe-ka.com ; chaque site délègue sa connexion via /api/auth/ka/login."
13+ },
14+ "contacts": [
15+ {
16+ "email": "contact@groupe-ka.com",
17+ "role": "Projets, partenariats & données"
18+ },
19+ {
20+ "email": "info@groupe-ka.com",
21+ "role": "Médias & questions générales"
22+ },
23+ {
24+ "email": "admin@groupe-ka.com",
25+ "role": "Légal, vie privée & Loi 25"
26+ }
27+ ],
28+ "legal": [
29+ {
30+ "label": "Conditions d'utilisation",
31+ "href": "https://www.groupe-ka.com/conditions"
32+ },
33+ {
34+ "label": "Politique de confidentialité",
35+ "href": "https://www.groupe-ka.com/confidentialite"
36+ },
37+ {
38+ "label": "Protection des renseignements personnels (Loi 25)",
39+ "href": "https://www.groupe-ka.com/loi-25"
40+ },
41+ {
42+ "label": "Transparence des robots d'indexation",
43+ "href": "https://www.groupe-ka.com/bots"
44+ }
45+ ],
46+ "sites": [
47+ {
48+ "id": "groupe-ka",
49+ "wordmark": "Groupe KA",
50+ "domain": "www.groupe-ka.com",
51+ "accent": "#d9f26b",
52+ "accentSoft": "#f0f9d2",
53+ "accentDeep": "#123f2e",
54+ "onAccent": "#141814",
55+ "tagline": "Le portail de l'écosystème ·Ka"
56+ },
57+ {
58+ "id": "trouve-ka",
59+ "wordmark": "Trouve·Ka",
60+ "domain": "www.trouve-ka.com",
61+ "accent": "#1c7ed6",
62+ "accentSoft": "#e7f2fd",
63+ "accentDeep": "#14508f",
64+ "onAccent": "#ffffff",
65+ "tagline": "Le moteur de recherche du web québécois"
66+ },
67+ {
68+ "id": "lou-ka",
69+ "wordmark": "Lou·Ka",
70+ "domain": "www.lou-ka.com",
71+ "accent": "#ff6a00",
72+ "accentSoft": "#fff1e6",
73+ "accentDeep": "#cc5500",
74+ "onAccent": "#ffffff",
75+ "tagline": "Tous les logements à louer"
76+ },
77+ {
78+ "id": "immo-ka",
79+ "wordmark": "Immo·Ka",
80+ "domain": "www.immo-ka.com",
81+ "accent": "#e23744",
82+ "accentSoft": "#fbe0e2",
83+ "accentDeep": "#a8232e",
84+ "onAccent": "#ffffff",
85+ "tagline": "Toutes les propriétés à vendre"
86+ },
87+ {
88+ "id": "vrai-prix",
89+ "wordmark": "Vrai-Prix",
90+ "domain": "www.vrai-prix.com",
91+ "accent": "#ff5148",
92+ "accentSoft": "#ffe3e0",
93+ "accentDeep": "#9e2a25",
94+ "onAccent": "#ffffff",
95+ "tagline": "La valeur réelle de chaque propriété"
96+ },
97+ {
98+ "id": "auto-ka",
99+ "wordmark": "Auto·Ka",
100+ "domain": "www.auto-ka.com",
101+ "accent": "#ff5a2a",
102+ "accentSoft": "#ffe8de",
103+ "accentDeep": "#cc3f16",
104+ "onAccent": "#ffffff",
105+ "tagline": "Les voitures usagées du Québec"
106+ },
107+ {
108+ "id": "fabri-ka",
109+ "wordmark": "Fabri·Ka",
110+ "domain": "www.fabri-ka.com",
111+ "accent": "#c4532e",
112+ "accentSoft": "#f7e3da",
113+ "accentDeep": "#a94525",
114+ "onAccent": "#ffffff",
115+ "tagline": "Les produits fabriqués au Québec"
116+ },
117+ {
118+ "id": "food-ka",
119+ "wordmark": "Food·Ka",
120+ "domain": "www.food-ka.com",
121+ "accent": "#1f9d55",
122+ "accentSoft": "#e2f5ea",
123+ "accentDeep": "#157a40",
124+ "onAccent": "#ffffff",
125+ "tagline": "Les prix d'épicerie, suivis à la source"
126+ },
127+ {
128+ "id": "resto-ka",
129+ "wordmark": "Resto·Ka",
130+ "domain": "www.resto-ka.com",
131+ "accent": "#f08c00",
132+ "accentSoft": "#fdeed7",
133+ "accentDeep": "#b96a00",
134+ "onAccent": "#141814",
135+ "tagline": "Chaque resto, chaque plat, chaque prix"
136+ },
137+ {
138+ "id": "sorti-ka",
139+ "wordmark": "Sorti·Ka",
140+ "domain": "www.sorti-ka.com",
141+ "accent": "#d6336c",
142+ "accentSoft": "#fbe0eb",
143+ "accentDeep": "#a12551",
144+ "onAccent": "#ffffff",
145+ "tagline": "Toutes les sorties, dans les 17 régions"
146+ },
147+ {
148+ "id": "crea-ka",
149+ "wordmark": "Créa·Ka",
150+ "domain": "www.crea-ka.com",
151+ "accent": "#7048e8",
152+ "accentSoft": "#ece5fc",
153+ "accentDeep": "#5433b8",
154+ "onAccent": "#ffffff",
155+ "tagline": "Les créateurs d'ici, tous leurs liens"
156+ },
157+ {
158+ "id": "api-ka",
159+ "wordmark": "API·Ka",
160+ "domain": "www.api-ka.com",
161+ "accent": "#3b5bdb",
162+ "accentSoft": "#e4eafb",
163+ "accentDeep": "#2b44a8",
164+ "onAccent": "#ffffff",
165+ "tagline": "La donnée de l'écosystème, par API"
166+ },
167+ {
168+ "id": "job-ka",
169+ "wordmark": "Job·Ka",
170+ "domain": "www.job-ka.com",
171+ "accent": "#0c8599",
172+ "accentSoft": "#def0f4",
173+ "accentDeep": "#095c6b",
174+ "onAccent": "#ffffff",
175+ "tagline": "Tous les emplois des employeurs québécois"
176+ }
177+ ],
178+ "extraFooterLinks": [
179+ {
180+ "label": "ValoPlex",
181+ "href": "https://www.valoplex.com"
182+ },
183+ {
184+ "label": "Ka2",
185+ "href": "https://www.ka2.bot"
186+ },
187+ {
188+ "label": "Ka4",
189+ "href": "https://www.ka4.bot"
190+ },
191+ {
192+ "label": "Ka6",
193+ "href": "https://www.ka6.bot"
194+ }
195+ ]
196+}
\ No newline at end of file
added frontend/src/ka/stats/SPEC.md +123 −0
@@ -0,0 +1,123 @@
1+# ka-stats — module Stats commun Groupe KA (spec v1)
2+
3+Contrat partagé par les 12 plateformes pour leurs pages **/stats** (tableau de
4+bord analytique) et l'**export PDF** estampillé Groupe-KA. Le visuel suit le
5+design system ka-ui (tokens.css) avec l'accent de la marque.
6+
7+## 1. Page /stats — structure obligatoire (dans cet ordre)
8+
9+1. **Bandeau KPI** : 4–6 grandes cartes (`KpiCard`) — valeur, libellé,
10+ variation vs période précédente (▲/▼ + %, vert `--green` / rouge `--danger`).
11+2. **Sélecteur de période global** (`PeriodSelector`) : `aujourd'hui · 7 j ·
12+ 30 j · 3 m · 6 m · 12 m · année en cours · tout` + plage personnalisée
13+ (2 champs date). Toute la page se recalcule (state → refetch dashboard).
14+3. **Graphiques** : courbes d'évolution (`LineChart`, survol = infobulle,
15+ légende cliquable pour masquer une série, comparaison N vs N-1 en
16+ pointillé), barres (`BarChart`), anneaux (`Donut`), calendrier de chaleur
17+ (`CalendarHeatmap`) quand pertinent.
18+4. **Répartition géographique** (par ville/région) quand pertinent — barres
19+ horizontales triées (pas besoin de vraie carte).
20+5. **Tableaux détaillés** (`DataTable`) : tri par colonne, recherche interne,
21+ pagination (25/pg), débordement horizontal propre sur mobile (.tbl-wrap).
22+6. **Records & faits marquants** : générés depuis les données (jour record,
23+ plus forte croissance, meilleure entrée…) — cartes compactes.
24+7. **Fraîcheur** : « Mis à jour le {date heure} » + bouton Rafraîchir.
25+8. **Bouton PDF** bien visible en haut : « Télécharger le rapport PDF » avec
26+ deux choix (Rapport complet / Synthèse 2 pages). Indicateur de progression
27+ si > 2 s.
28+
29+Responsive : KPI empilés < 768 px, graphiques pleine largeur redimensionnés
30+(SVG viewBox), tableaux en défilement horizontal contenu, tactile ≥ 44 px.
31+AUCUNE donnée inventée : une stat indisponible = bloc « Pas encore mesuré »
32+(carte grise propre), jamais un faux chiffre.
33+
34+## 2. API — contrat commun
35+
36+`GET /api/stats/dashboard?period=7j|30j|3m|6m|12m|annee|tout|auj&from=YYYY-MM-DD&to=YYYY-MM-DD`
37+
38+```jsonc
39+{
40+ "updated": "2026-08-17T21:04:00-04:00",
41+ "period": { "from": "2026-07-18", "to": "2026-08-17", "label": "30 jours" },
42+ "kpis": [ { "id": "total", "label": "Annonces actives", "value": 33744,
43+ "unit": "", "delta_pct": 4.2, "direction": "up" } ],
44+ "series": [ { "id": "vol", "title": "Annonces actives par jour", "unit": "annonces",
45+ "kind": "line", "points": [{ "t": "2026-07-18", "v": 31200 }],
46+ "compare": [{ "t": "2025-07-18", "v": 24100 }] } ],
47+ "breakdowns": [ { "id": "types", "title": "Par type", "kind": "donut",
48+ "items": [{ "label": "4½", "value": 9120 }] } ],
49+ "geo": { "title": "Par région", "items": [{ "label": "Montréal", "value": 15680 }] },
50+ "heatmap": { "title": "Activité", "cells": [{ "date": "2026-08-01", "value": 210 }] },
51+ "tables": [ { "id": "top", "title": "Top villes", "columns": ["Ville", "Annonces", "Δ 30 j"],
52+ "rows": [["Montréal", 15680, "+3,1 %"]] } ],
53+ "records": [ { "label": "Jour record d'ajouts", "value": "412 annonces", "date": "2026-08-09" } ]
54+}
55+```
56+
57+Champs absents = section masquée. Cache serveur recommandé (≥ 5 min par
58+période). Les valeurs proviennent des données réelles (DB de la plateforme,
59+journaux de sync des connecteurs, /api/v1/runs d'API-KA…).
60+
61+`GET /api/stats/report?period=…&from=&to=&mode=complet|synthese`
62+→ `application/pdf`, en-tête `Content-Disposition: attachment; filename=
63+groupe-ka_<plateforme>_stats_<periode>_<YYYY-MM-DD>.pdf`.
64+
65+## 3. PDF — gabarit Groupe-KA (implémentations : `kapdf.py` fpdf2 pour les
66+apps Python ; les apps Next portent le même gabarit en pdfkit)
67+
68+- **Couverture** : cadre encre, kicker « GROUPE KA · RAPPORT STATISTIQUE »,
69+ wordmark de la plateforme (boîte encre + accent), sous-titre, période
70+ couverte, date/heure de génération, bande encre au pied avec
71+ « par Groupe KA — groupe-ka.com ».
72+- **Sommaire** avec numéros de pages (mode complet).
73+- **KPI** : grille de cartes (bordure encre, valeur en gros, delta coloré).
74+- **Graphiques VECTORIELS** (dessinés en primitives, jamais de capture) :
75+ courbes, barres, anneaux — accent de la plateforme, axes/graduations encre.
76+- **Tableaux** paginés proprement (lignes zébrées `--surface-2`, jamais
77+ coupés en deux à cheval sur une ligne).
78+- **Records** puis **page de fin** : coordonnées Groupe KA (3 courriels +
79+ rôles d'ecosystem.json, groupe-ka.com), avertissement d'agrégateur,
80+ mentions légales courtes.
81+- **Chaque page** : en-tête discret (« Groupe KA · {Plateforme} », filet
82+ encre) + pied (« © Groupe-KA — {année} — groupe-ka.com · {période} · p. X/Y »).
83+- A4 portrait, marges 18 mm, typo : Helvetica (fallback sûr) ou fonts TTF du
84+ DS si présentes. Mode « synthese » = couverture + 1 page KPI/records.
85+
86+## 4. Spécifique par plateforme (sections métier attendues)
87+
88+- **groupe-ka** : tableau de bord maître — consolidation des 12 (volume total,
89+ croissance), classement des plateformes, bloc résumé par plateforme + lien
90+ vers sa page /stats ; « Rapport écosystème complet » = PDF consolidé.
91+- **lou-ka** : annonces actives/nouvelles/retirées, loyers moyens/médians par
92+ ville & taille, évolution, répartition par type, top villes.
93+- **immo-ka** : annonces actives/nouvelles/vendues-retirées, prix moyen/médian
94+ par ville/région/type, délai de présence, top villes, tension du marché.
95+- **vrai-prix** : couverture du rôle (unités, valeur totale), estimations
96+ servies si journalisées, répartitions par municipalité/type, indices marché.
97+- **auto-ka** : volume par marque/modèle/année/carburant/boîte, prix moyens et
98+ km moyens par segment, top marques/modèles.
99+- **fabri-ka** : produits par catégorie/région/boutique, fourchettes de prix,
100+ nouveautés par période, top catégories.
101+- **food-ka** : produits suivis, relevés de prix, soldes détectés (baisses/
102+ hausses, amplitude), top produits en solde, prix moyens par catégorie.
103+- **resto-ka** : restos par cuisine/ville/gamme, menus & plats, nouveautés/
104+ fermetures détectées, top établissements.
105+- **sorti-ka** : événements à venir/passés par catégorie/ville, gratuits vs
106+ payants, heatmap calendrier, top lieux.
107+- **crea-ka** : créateurs par plateforme/niche/tier, comptes reliés, top
108+ créateurs, croissance du répertoire.
109+- **trouve-ka** : pages indexées, domaines, rythme de crawl (indexées/h),
110+ erreurs, file frontier, tendances si les requêtes sont journalisées.
111+- **api-ka** : appels par endpoint/jour/heure, latences moyennes + p95, taux
112+ d'erreur, top endpoints, uptime (données des middlewares de logging + runs).
113+- **Transverse (tous)** : volume total agrégé + croissance, connecteurs actifs
114+ et éléments ajoutés/mis à jour par période (journaux de sync), complétude/
115+ fraîcheur moyenne des fiches quand mesurable. Trafic web : seulement si des
116+ journaux d'accès existent — sinon état vide propre.
117+
118+## 5. Ajouter une métrique / un graphique / une plateforme
119+
120+1 métrique = 1 entrée `kpis[]` ou `series[]` côté API (requête SQL agrégée +
121+cache) — le front la rend automatiquement. 1 plateforme = implémenter les 2
122+endpoints du contrat + une page /stats montée sur les composants du kit +
123+`kapdf.py` (ou gabarit pdfkit) branché sur le même JSON de dashboard.
added frontend/src/ka/stats/kacharts.tsx +389 −0
@@ -0,0 +1,389 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// ka-ui/stats/kacharts.tsx — kit de graphiques SVG du module Stats commun
3+// Groupe KA (zéro dépendance, React 18+). Style : design system ka-ui
4+// (bordures encre, accent de la plateforme via var(--accent)).
5+// Composants : KpiCard, PeriodSelector, LineChart (infobulle + légende
6+// cliquable + comparaison N-1), BarChart, Donut, CalendarHeatmap, DataTable
7+// (tri/recherche/pagination), RecordCard, PdfButton, EmptyBlock, Fraicheur.
8+import { useMemo, useState } from "react";
9+
10+/* ---------- types (contrat SPEC.md) ---------- */
11+export type Kpi = {
12+ id: string; label: string; value: number | string; unit?: string;
13+ delta_pct?: number | null; direction?: "up" | "down";
14+};
15+export type Point = { t: string; v: number };
16+export type Serie = {
17+ id: string; title: string; unit?: string; kind?: "line" | "bar";
18+ points: Point[]; compare?: Point[];
19+};
20+export type BreakItem = { label: string; value: number };
21+export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] };
22+export type RecordFact = { label: string; value: string; date?: string };
23+
24+export const PERIODS: { id: string; label: string }[] = [
25+ { id: "auj", label: "Aujourd'hui" },
26+ { id: "7j", label: "7 jours" },
27+ { id: "30j", label: "30 jours" },
28+ { id: "3m", label: "3 mois" },
29+ { id: "6m", label: "6 mois" },
30+ { id: "12m", label: "12 mois" },
31+ { id: "annee", label: "Année en cours" },
32+ { id: "tout", label: "Tout" },
33+];
34+
35+export const fmtInt = (n: number) => n.toLocaleString("fr-CA");
36+export const fmtNum = (n: number) =>
37+ Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("fr-CA", { maximumFractionDigits: 2 });
38+
39+/* ---------- KPI ---------- */
40+export function KpiCard({ k }: { k: Kpi }) {
41+ const up = (k.direction ?? ((k.delta_pct ?? 0) >= 0 ? "up" : "down")) === "up";
42+ return (
43+ <article className="card" style={{ padding: "14px 16px", minWidth: 0 }}>
44+ <p style={{ margin: 0, fontFamily: "var(--font-display)", fontWeight: 700, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em" }}>
45+ {typeof k.value === "number" ? fmtNum(k.value) : k.value}
46+ {k.unit ? <span style={{ fontSize: "0.6em", color: "var(--ink-2)" }}> {k.unit}</span> : null}
47+ </p>
48+ <p className="klabel" style={{ margin: "6px 0 0" }}>{k.label}</p>
49+ {k.delta_pct !== undefined && k.delta_pct !== null && (
50+ <p style={{ margin: "8px 0 0", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, color: up ? "var(--green)" : "var(--danger)" }}>
51+ {up ? "▲" : "▼"} {k.delta_pct >= 0 ? "+" : ""}{fmtNum(k.delta_pct)} % <span style={{ color: "var(--ink-3)", fontWeight: 500 }}>vs période préc.</span>
52+ </p>
53+ )}
54+ </article>
55+ );
56+}
57+
58+/* ---------- Sélecteur de période ---------- */
59+export function PeriodSelector({
60+ value, onChange, custom, onCustom,
61+}: {
62+ value: string; onChange: (p: string) => void;
63+ custom?: { from: string; to: string }; onCustom?: (from: string, to: string) => void;
64+}) {
65+ return (
66+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
67+ {PERIODS.map((p) => (
68+ <button key={p.id} type="button" onClick={() => onChange(p.id)}
69+ className="chip" aria-pressed={value === p.id}
70+ style={{ cursor: "pointer", minHeight: 44, background: value === p.id ? "var(--accent)" : "var(--surface)", color: value === p.id ? "var(--on-accent)" : "var(--ink)" }}>
71+ {p.label}
72+ </button>
73+ ))}
74+ {onCustom && (
75+ <span style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>
76+ <input className="input" type="date" style={{ width: 150 }} value={custom?.from ?? ""} aria-label="Du"
77+ onChange={(e) => onCustom(e.target.value, custom?.to ?? "")} />
78+ <span className="klabel">au</span>
79+ <input className="input" type="date" style={{ width: 150 }} value={custom?.to ?? ""} aria-label="Au"
80+ onChange={(e) => onCustom(custom?.from ?? "", e.target.value)} />
81+ </span>
82+ )}
83+ </div>
84+ );
85+}
86+
87+/* ---------- Courbe ---------- */
88+export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) {
89+ const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false });
90+ const [hover, setHover] = useState<number | null>(null);
91+ const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
92+ const pts = serie.points ?? [];
93+ if (pts.length < 2) return <EmptyBlock title={serie.title} />;
94+ const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])];
95+ const vmax = Math.max(...all.map((p) => p.v), 1);
96+ const vmin = Math.min(0, ...all.map((p) => p.v));
97+ const X = (i: number, n: number) => PL + ((W - PL - PR) * i) / (n - 1);
98+ const Y = (v: number) => PT + (H - PT - PB) * (1 - (v - vmin) / (vmax - vmin || 1));
99+ const path = (s: Point[]) => s.map((p, i) => `${i ? "L" : "M"}${X(i, s.length)},${Y(p.v)}`).join("");
100+ const hi = hover !== null ? Math.min(pts.length - 1, Math.max(0, hover)) : null;
101+ return (
102+ <figure className="card" style={{ margin: 0, padding: 16 }}>
103+ <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
104+ <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{serie.title}</b>
105+ <span style={{ display: "flex", gap: 10 }}>
106+ <LegendChip label="Période courante" color="var(--accent)" off={hide.cur} onClick={() => setHide((h) => ({ ...h, cur: !h.cur }))} />
107+ {serie.compare && <LegendChip label="Période comparée" color="var(--ink-3)" dashed off={hide.cmp} onClick={() => setHide((h) => ({ ...h, cmp: !h.cmp }))} />}
108+ </span>
109+ </figcaption>
110+ <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10, touchAction: "pan-y" }} role="img" aria-label={serie.title}
111+ onMouseMove={(e) => {
112+ const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
113+ const fx = ((e.clientX - r.left) / r.width) * W;
114+ setHover(Math.round(((fx - PL) / (W - PL - PR)) * (pts.length - 1)));
115+ }}
116+ onMouseLeave={() => setHover(null)}>
117+ {[0, 1, 2, 3, 4].map((g) => {
118+ const y = PT + ((H - PT - PB) * g) / 4;
119+ const v = vmax - ((vmax - vmin) * g) / 4;
120+ return (
121+ <g key={g}>
122+ <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
123+ <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>
124+ </g>
125+ );
126+ })}
127+ {[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => (
128+ <text key={i} x={X(i, pts.length)} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
129+ ))}
130+ {!hide.cmp && serie.compare && serie.compare.length > 1 && (
131+ <path d={path(serie.compare)} fill="none" stroke="var(--ink-3)" strokeWidth={1.4} strokeDasharray="4 4" />
132+ )}
133+ {!hide.cur && <path d={path(pts)} fill="none" stroke="var(--accent)" strokeWidth={2.4} />}
134+ {hi !== null && (
135+ <g>
136+ <line x1={X(hi, pts.length)} x2={X(hi, pts.length)} y1={PT} y2={H - PB} stroke="var(--ink)" strokeWidth={1} strokeDasharray="2 3" />
137+ <circle cx={X(hi, pts.length)} cy={Y(pts[hi].v)} r={4} fill="var(--accent)" stroke="var(--ink)" strokeWidth={1.5} />
138+ </g>
139+ )}
140+ </svg>
141+ {hi !== null && (
142+ <p className="chip" style={{ marginTop: 8 }}>
143+ {pts[hi].t} — <b>{fmtNum(pts[hi].v)}{serie.unit ? ` ${serie.unit}` : ""}</b>
144+ {serie.compare?.[hi] && !hide.cmp ? <span style={{ color: "var(--ink-3)" }}> · N-1 : {fmtNum(serie.compare[hi].v)}</span> : null}
145+ </p>
146+ )}
147+ </figure>
148+ );
149+}
150+
151+function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off: boolean; dashed?: boolean; onClick: () => void }) {
152+ return (
153+ <button type="button" onClick={onClick} aria-pressed={!off}
154+ style={{ display: "inline-flex", alignItems: "center", gap: 6, border: 0, background: "none", cursor: "pointer", opacity: off ? 0.4 : 1, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", minHeight: 44 }}>
155+ <span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} />
156+ {label}
157+ </button>
158+ );
159+}
160+
161+/* ---------- Barres horizontales (répartitions, géo) ---------- */
162+export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) {
163+ const rows = (items ?? []).slice(0, 14);
164+ if (!rows.length) return <EmptyBlock title={title} />;
165+ const max = Math.max(...rows.map((r) => r.value), 1);
166+ return (
167+ <figure className="card" style={{ margin: 0, padding: 16 }}>
168+ <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption>
169+ <div style={{ marginTop: 12, display: "grid", gap: 9 }}>
170+ {rows.map((r) => (
171+ <div key={r.label} title={`${r.label} — ${fmtNum(r.value)}${unit ? ` ${unit}` : ""}`}>
172+ <div style={{ display: "flex", justifyContent: "space-between", fontSize: 12.5 }}>
173+ <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
174+ <b style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}>{fmtNum(r.value)}{unit ? ` ${unit}` : ""}</b>
175+ </div>
176+ <div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}>
177+ <div style={{ height: "100%", width: `${Math.max((r.value / max) * 100, 1)}%`, background: "var(--accent)", border: "1px solid var(--ink)", borderRadius: "0 3px 3px 0", boxSizing: "border-box" }} />
178+ </div>
179+ </div>
180+ ))}
181+ </div>
182+ </figure>
183+ );
184+}
185+
186+/* ---------- Anneau ---------- */
187+export function Donut({ title, items }: { title: string; items: BreakItem[] }) {
188+ const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8);
189+ const total = rows.reduce((s, r) => s + r.value, 0);
190+ if (!total) return <EmptyBlock title={title} />;
191+ const R = 74, C = 2 * Math.PI * R;
192+ let acc = 0;
193+ const shades = [1, 0.78, 0.58, 0.42, 0.3, 0.22, 0.15, 0.1];
194+ return (
195+ <figure className="card" style={{ margin: 0, padding: 16 }}>
196+ <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption>
197+ <div style={{ display: "flex", flexWrap: "wrap", gap: 18, alignItems: "center", marginTop: 12 }}>
198+ <svg viewBox="0 0 200 200" style={{ width: 180, maxWidth: "100%" }} role="img" aria-label={title}>
199+ {rows.map((r, i) => {
200+ const frac = r.value / total;
201+ const off = acc; acc += frac;
202+ return (
203+ <circle key={r.label} cx={100} cy={100} r={R} fill="none"
204+ stroke="var(--accent)" strokeOpacity={shades[i % shades.length]}
205+ strokeWidth={30} strokeDasharray={`${frac * C} ${C}`} strokeDashoffset={-off * C}
206+ transform="rotate(-90 100 100)">
207+ <title>{`${r.label} — ${fmtNum(r.value)} (${((100 * r.value) / total).toFixed(1)} %)`}</title>
208+ </circle>
209+ );
210+ })}
211+ <circle cx={100} cy={100} r={R} fill="none" stroke="var(--ink)" strokeWidth={1} opacity={0.5} />
212+ </svg>
213+ <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 6, minWidth: 200, flex: 1 }}>
214+ {rows.map((r, i) => (
215+ <li key={r.label} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5 }}>
216+ <span style={{ width: 11, height: 11, borderRadius: 3, border: "1px solid var(--ink)", background: "var(--accent)", opacity: shades[i % shades.length] }} />
217+ <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
218+ <b style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}>{((100 * r.value) / total).toFixed(1)} %</b>
219+ </li>
220+ ))}
221+ </ul>
222+ </div>
223+ </figure>
224+ );
225+}
226+
227+/* ---------- Calendrier de chaleur ---------- */
228+export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) {
229+ if (!cells?.length) return <EmptyBlock title={title} />;
230+ const byDate = new Map(cells.map((c) => [c.date, c.value]));
231+ const dates = cells.map((c) => c.date).sort();
232+ const end = new Date(dates[dates.length - 1] + "T12:00:00");
233+ const max = Math.max(...cells.map((c) => c.value), 1);
234+ const weeks = 26, cols: { date: string; v: number }[][] = [];
235+ const cur = new Date(end);
236+ cur.setDate(cur.getDate() - (weeks * 7 - 1));
237+ for (let w = 0; w < weeks; w++) {
238+ const col: { date: string; v: number }[] = [];
239+ for (let d = 0; d < 7; d++) {
240+ const iso = cur.toISOString().slice(0, 10);
241+ col.push({ date: iso, v: byDate.get(iso) ?? 0 });
242+ cur.setDate(cur.getDate() + 1);
243+ }
244+ cols.push(col);
245+ }
246+ return (
247+ <figure className="card" style={{ margin: 0, padding: 16 }}>
248+ <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b> <span className="klabel">26 dernières semaines</span></figcaption>
249+ <div className="tbl-wrap" style={{ marginTop: 12 }}>
250+ <svg viewBox={`0 0 ${weeks * 14} ${7 * 14}`} style={{ minWidth: 480, width: "100%", height: "auto" }} role="img" aria-label={title}>
251+ {cols.map((col, w) => col.map((c, d) => (
252+ <rect key={c.date} x={w * 14} y={d * 14} width={12} height={12} rx={2.5}
253+ fill={c.v ? "var(--accent)" : "rgba(20,24,20,0.07)"} fillOpacity={c.v ? 0.25 + 0.75 * (c.v / max) : 1}
254+ stroke="rgba(20,24,20,0.15)" strokeWidth={0.5}>
255+ <title>{`${c.date} — ${fmtNum(c.v)}`}</title>
256+ </rect>
257+ )))}
258+ </svg>
259+ </div>
260+ </figure>
261+ );
262+}
263+
264+/* ---------- Tableau : tri, recherche, pagination ---------- */
265+export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) {
266+ const [q, setQ] = useState("");
267+ const [sort, setSort] = useState<{ col: number; dir: 1 | -1 } | null>(null);
268+ const [page, setPage] = useState(0);
269+ const rows = useMemo(() => {
270+ let r = spec.rows ?? [];
271+ if (q) r = r.filter((row) => row.some((c) => String(c).toLowerCase().includes(q.toLowerCase())));
272+ if (sort) r = [...r].sort((a, b) => {
273+ const x = a[sort.col], y = b[sort.col];
274+ const nx = typeof x === "number" ? x : parseFloat(String(x).replace(/[^\d.,-]/g, "").replace(",", "."));
275+ const ny = typeof y === "number" ? y : parseFloat(String(y).replace(/[^\d.,-]/g, "").replace(",", "."));
276+ if (!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx - ny) * sort.dir;
277+ return String(x).localeCompare(String(y), "fr") * sort.dir;
278+ });
279+ return r;
280+ }, [spec.rows, q, sort]);
281+ const pages = Math.max(1, Math.ceil(rows.length / pageSize));
282+ const cur = Math.min(page, pages - 1);
283+ return (
284+ <section className="card" style={{ padding: 16 }}>
285+ <div style={{ display: "flex", flexWrap: "wrap", gap: 10, justifyContent: "space-between", alignItems: "center" }}>
286+ <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{spec.title}</b>
287+ <input className="input" style={{ maxWidth: 240 }} placeholder="Rechercher…" value={q}
288+ onChange={(e) => { setQ(e.target.value); setPage(0); }} aria-label={`Rechercher dans ${spec.title}`} />
289+ </div>
290+ <div className="tbl-wrap" style={{ marginTop: 10 }}>
291+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
292+ <thead>
293+ <tr>
294+ {spec.columns.map((c, i) => (
295+ <th key={c} onClick={() => setSort((s) => ({ col: i, dir: s?.col === i && s.dir === 1 ? -1 : 1 }))}
296+ style={{ cursor: "pointer", textAlign: "left", padding: "8px 10px", background: "var(--ink)", color: "var(--paper)", fontFamily: "var(--font-mono)", fontSize: 10.5, textTransform: "uppercase", letterSpacing: "0.06em", whiteSpace: "nowrap", userSelect: "none" }}
297+ aria-sort={sort?.col === i ? (sort.dir === 1 ? "ascending" : "descending") : "none"}>
298+ {c} {sort?.col === i ? (sort.dir === 1 ? "▲" : "▼") : "↕"}
299+ </th>
300+ ))}
301+ </tr>
302+ </thead>
303+ <tbody>
304+ {rows.slice(cur * pageSize, (cur + 1) * pageSize).map((row, ri) => (
305+ <tr key={ri} style={{ background: ri % 2 ? "var(--surface-2)" : "var(--surface)" }}>
306+ {row.map((c, ci) => (
307+ <td key={ci} style={{ padding: "7px 10px", borderBottom: "1px solid var(--line)", whiteSpace: "nowrap" }}>
308+ {typeof c === "number" ? fmtNum(c) : c}
309+ </td>
310+ ))}
311+ </tr>
312+ ))}
313+ </tbody>
314+ </table>
315+ </div>
316+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, flexWrap: "wrap", gap: 8 }}>
317+ <span className="klabel">{fmtInt(rows.length)} lignes</span>
318+ <span style={{ display: "flex", gap: 6 }}>
319+ <button type="button" className="btn btn-ghost" disabled={cur === 0} onClick={() => setPage(cur - 1)}>←</button>
320+ <span className="chip">{cur + 1} / {pages}</span>
321+ <button type="button" className="btn btn-ghost" disabled={cur >= pages - 1} onClick={() => setPage(cur + 1)}>→</button>
322+ </span>
323+ </div>
324+ </section>
325+ );
326+}
327+
328+/* ---------- Records / faits marquants ---------- */
329+export function RecordCard({ r }: { r: RecordFact }) {
330+ return (
331+ <article className="card" style={{ padding: "12px 16px", display: "flex", justifyContent: "space-between", gap: 12, alignItems: "baseline", background: "var(--surface-2)" }}>
332+ <span style={{ fontSize: 13, color: "var(--ink-2)" }}>{r.label}</span>
333+ <span style={{ textAlign: "right" }}>
334+ <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{r.value}</b>
335+ {r.date && <span className="klabel" style={{ display: "block" }}>{r.date}</span>}
336+ </span>
337+ </article>
338+ );
339+}
340+
341+/* ---------- Bouton PDF ---------- */
342+export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) {
343+ const [busy, setBusy] = useState(false);
344+ const url = (mode: string) => {
345+ const p = new URLSearchParams({ period, mode });
346+ if (from) p.set("from", from);
347+ if (to) p.set("to", to);
348+ return `${endpoint}?${p}`;
349+ };
350+ const dl = (mode: string) => {
351+ setBusy(true);
352+ const a = document.createElement("a");
353+ a.href = url(mode);
354+ a.download = "";
355+ document.body.appendChild(a);
356+ a.click();
357+ a.remove();
358+ setTimeout(() => setBusy(false), 2500);
359+ };
360+ return (
361+ <span style={{ display: "inline-flex", gap: 8, flexWrap: "wrap" }}>
362+ <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={busy}>
363+ {busy ? "Génération…" : "⬇ Télécharger le rapport PDF"}
364+ </button>
365+ <button type="button" className="btn btn-ghost" onClick={() => dl("synthese")} disabled={busy}>
366+ Synthèse (2 p.)
367+ </button>
368+ </span>
369+ );
370+}
371+
372+/* ---------- États ---------- */
373+export function EmptyBlock({ title }: { title: string }) {
374+ return (
375+ <div className="card" style={{ padding: 20, background: "var(--surface-2)", borderStyle: "dashed" }}>
376+ <b style={{ fontFamily: "var(--font-display)", fontSize: 14 }}>{title}</b>
377+ <p className="klabel" style={{ margin: "6px 0 0" }}>Pas encore mesuré — aucune donnée disponible pour cette période.</p>
378+ </div>
379+ );
380+}
381+
382+export function Fraicheur({ updated, onRefresh }: { updated: string; onRefresh: () => void }) {
383+ return (
384+ <p style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap", margin: 0 }}>
385+ <span className="klabel">Mis à jour le {new Date(updated).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" })}</span>
386+ <button type="button" className="btn btn-ghost" onClick={onRefresh}>↻ Rafraîchir</button>
387+ </p>
388+ );
389+}
added frontend/src/ka/stats/kapdf.py +560 −0
@@ -0,0 +1,560 @@
1+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2).
3+# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4+# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques
5+# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.
6+# Usage :
7+# from kapdf import GroupeKAReport
8+# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
9+# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
10+# mode="complet").build()
11+# Dépendance : pip install fpdf2 (aucune autre)
12+from __future__ import annotations
13+
14+import math
15+from datetime import datetime
16+from zoneinfo import ZoneInfo
17+
18+from fpdf import FPDF
19+
20+INK = (20, 24, 20)
21+INK2 = (77, 85, 81)
22+INK3 = (139, 146, 140)
23+PAPER = (245, 243, 238)
24+SURFACE2 = (250, 249, 245)
25+GREEN = (28, 92, 65)
26+DANGER = (179, 66, 58)
27+WHITE = (255, 255, 255)
28+
29+EMAILS = [
30+ ("contact@groupe-ka.com", "Projets, partenariats & données"),
31+ ("info@groupe-ka.com", "Médias & questions générales"),
32+ ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),
33+]
34+DISCLAIMER = (
35+ "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "
36+ "rien et ne sommes partie à aucune transaction. Données lues à la source, "
37+ "rien d'inventé, tout est traçable."
38+)
39+
40+
41+def _hex(c: str) -> tuple[int, int, int]:
42+ c = c.lstrip("#")
43+ return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore
44+
45+
46+def _fr(n) -> str:
47+ if isinstance(n, float) and not n.is_integer():
48+ return f"{n:,.2f}".replace(",", " ").replace(".", ",")
49+ return f"{int(n):,}".replace(",", " ")
50+
51+
52+_SUBST = {
53+ "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
54+ "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
55+ "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
56+}
57+
58+
59+def _latin1(s: str) -> str:
60+ for k, v in _SUBST.items():
61+ s = s.replace(k, v)
62+ return s.encode("latin-1", "replace").decode("latin-1")
63+
64+
65+class _PDF(FPDF):
66+ """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
67+ Les polices core sont latin-1 : normalize_text sanitise en amont."""
68+
69+ def normalize_text(self, text):
70+ return super().normalize_text(_latin1(text))
71+
72+ def __init__(self, brand: str, accent: tuple, period_label: str):
73+ super().__init__(orientation="P", unit="mm", format="A4")
74+ self.brand = brand
75+ self.accent = accent
76+ self.period_label = period_label
77+ self.cover_mode = False
78+ self.set_margins(18, 20, 18)
79+ self.set_auto_page_break(True, margin=22)
80+
81+ def header(self):
82+ if self.cover_mode or self.page_no() == 1:
83+ return
84+ self.set_font("helvetica", "B", 8.5)
85+ self.set_text_color(*INK)
86+ self.set_xy(18, 9)
87+ self.cell(0, 5, f"Groupe KA · {self.brand}")
88+ self.set_font("helvetica", "", 8)
89+ self.set_text_color(*INK3)
90+ self.set_xy(18, 9)
91+ self.cell(0, 5, "Rapport statistique", align="R")
92+ self.set_draw_color(*INK)
93+ self.set_line_width(0.5)
94+ self.line(18, 15.5, 192, 15.5)
95+ self.set_y(20)
96+
97+ def footer(self):
98+ # page 1 = couverture (le flag cover_mode est déjà retombé quand
99+ # add_page() clôt la page 1 → tester aussi le numéro de page)
100+ if self.cover_mode or self.page_no() == 1:
101+ return
102+ self.set_y(-15)
103+ self.set_draw_color(*INK3)
104+ self.set_line_width(0.2)
105+ self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)
106+ self.set_font("helvetica", "", 7.5)
107+ self.set_text_color(*INK3)
108+ year = datetime.now(ZoneInfo("America/Toronto")).year
109+ self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")
110+ self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")
111+
112+
113+class GroupeKAReport:
114+ def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
115+ self.site = site
116+ self.d = dashboard
117+ self.mode = mode
118+ self.accent = _hex(site.get("accent", "#d9f26b"))
119+ period = dashboard.get("period", {}) or {}
120+ self.period_label = period.get("label") or "toute la période"
121+ self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
122+ self.toc: list[tuple[str, int]] = []
123+
124+ # ---------- primitives ----------
125+ def _card(self, x, y, w, h, fill=WHITE):
126+ p = self.pdf
127+ p.set_draw_color(*INK)
128+ p.set_line_width(0.45)
129+ p.set_fill_color(*fill)
130+ p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
131+
132+ def _kicker(self, text):
133+ p = self.pdf
134+ p.set_font("helvetica", "B", 8)
135+ p.set_text_color(*GREEN)
136+ p.set_draw_color(*GREEN)
137+ p.set_line_width(0.6)
138+ y = p.get_y() + 2
139+ p.line(p.l_margin, y, p.l_margin + 7, y)
140+ p.set_xy(p.l_margin + 9, y - 2.5)
141+ p.cell(0, 5, text.upper())
142+ p.ln(8)
143+
144+ def _section_title(self, title):
145+ if self.pdf.get_y() > 240:
146+ self.pdf.add_page()
147+ self._kicker("Groupe KA · " + self.site.get("wordmark", ""))
148+ self.pdf.set_font("helvetica", "B", 15)
149+ self.pdf.set_text_color(*INK)
150+ self.pdf.set_x(self.pdf.l_margin)
151+ self.pdf.cell(0, 8, title)
152+ self.toc.append((title, self.pdf.page_no()))
153+ self.pdf.ln(11)
154+
155+ # ---------- pages ----------
156+ def _cover(self):
157+ p = self.pdf
158+ p.cover_mode = True
159+ p.set_auto_page_break(False)
160+ p.add_page()
161+ p.set_fill_color(*PAPER)
162+ p.rect(0, 0, 210, 297, style="F")
163+ p.set_draw_color(*INK)
164+ p.set_line_width(1.0)
165+ p.rect(10, 10, 190, 277)
166+ # kicker
167+ p.set_font("helvetica", "B", 10)
168+ p.set_text_color(*GREEN)
169+ p.set_xy(24, 34)
170+ p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
171+ # wordmark : partie gauche + boîte encre/accent
172+ wm = self.site.get("wordmark", "")
173+ left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
174+ p.set_xy(24, 70)
175+ p.set_font("helvetica", "B", 40)
176+ p.set_text_color(*INK)
177+ p.cell(p.get_string_width(left) + 2, 20, left)
178+ if boxed:
179+ bw = p.get_string_width(boxed) + 12
180+ x = p.get_x() + 2
181+ p.set_fill_color(*INK)
182+ p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)
183+ p.set_text_color(*self.accent)
184+ p.set_xy(x + 6, 70)
185+ p.cell(bw - 12, 18, boxed)
186+ p.set_xy(24, 100)
187+ p.set_font("helvetica", "", 13)
188+ p.set_text_color(*INK2)
189+ p.multi_cell(150, 7, f"Rapport statistique — {wm}")
190+ now = datetime.now(ZoneInfo("America/Toronto"))
191+ per = self.d.get("period", {}) or {}
192+ p.set_xy(24, 125)
193+ p.set_font("helvetica", "", 10.5)
194+ rows = [
195+ ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
196+ ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
197+ ("Plateforme", "https://" + self.site.get("domain", "")),
198+ ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),
199+ ]
200+ y = 128
201+ for k, v in rows:
202+ p.set_xy(24, y)
203+ p.set_text_color(*INK3)
204+ p.cell(40, 6, k)
205+ p.set_text_color(*INK)
206+ p.set_font("helvetica", "B", 10.5)
207+ p.cell(0, 6, str(v))
208+ p.set_font("helvetica", "", 10.5)
209+ y += 8
210+ # bande encre au pied
211+ p.set_fill_color(*INK)
212+ p.rect(10, 262, 190, 25, style="F")
213+ p.set_xy(24, 270)
214+ p.set_font("helvetica", "B", 12)
215+ p.set_text_color(*WHITE)
216+ p.cell(60, 8, "par Groupe ")
217+ p.set_text_color(*self.accent)
218+ p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)
219+ p.cell(20, 8, "KA")
220+ p.set_font("helvetica", "B", 10)
221+ p.set_xy(24, 270)
222+ p.set_text_color(*self.accent)
223+ p.cell(162, 8, "groupe-ka.com", align="R")
224+ p.set_auto_page_break(True, margin=22)
225+ p.cover_mode = False
226+
227+ def _kpis(self):
228+ kpis = self.d.get("kpis") or []
229+ if not kpis:
230+ return
231+ self._section_title("Synthèse des indicateurs")
232+ p = self.pdf
233+ cols, gw, gh, gap = 3, 56, 26, 3
234+ x0, y = p.l_margin, p.get_y()
235+ for i, k in enumerate(kpis[:9]):
236+ x = x0 + (i % cols) * (gw + gap)
237+ if i and i % cols == 0:
238+ y += gh + gap
239+ if y > 250:
240+ p.add_page(); y = p.get_y()
241+ self._card(x, y, gw, gh)
242+ p.set_xy(x + 4, y + 4)
243+ p.set_font("helvetica", "B", 14)
244+ p.set_text_color(*INK)
245+ val = k.get("value")
246+ p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))
247+ p.set_xy(x + 4, y + 12)
248+ p.set_font("helvetica", "", 7.6)
249+ p.set_text_color(*INK2)
250+ p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])
251+ if k.get("delta_pct") is not None:
252+ up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"
253+ p.set_xy(x + 4, y + gh - 6.5)
254+ p.set_font("helvetica", "B", 8)
255+ p.set_text_color(*(GREEN if up else DANGER))
256+ arrow = "+" if k["delta_pct"] >= 0 else ""
257+ p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")
258+ p.set_y(y + gh + 8)
259+
260+ def _line_chart(self, s):
261+ p = self.pdf
262+ pts = s.get("points") or []
263+ if len(pts) < 2:
264+ return
265+ if p.get_y() > 200:
266+ p.add_page()
267+ p.set_font("helvetica", "B", 10)
268+ p.set_text_color(*INK)
269+ p.cell(0, 6, s.get("title", ""))
270+ p.ln(7)
271+ x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
272+ self._card(x0, y0, w, h, fill=WHITE)
273+ cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
274+ vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]
275+ vmax = max(vals) or 1
276+ vmin = min(0, min(vals))
277+ rng = (vmax - vmin) or 1
278+ # grille + graduations
279+ p.set_font("helvetica", "", 6.3)
280+ p.set_text_color(*INK3)
281+ p.set_draw_color(200, 200, 195)
282+ p.set_line_width(0.15)
283+ for g in range(5):
284+ gy = cy + ch - ch * g / 4
285+ p.line(cx, gy, cx + cw, gy)
286+ p.set_xy(x0 + 1, gy - 1.6)
287+ p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
288+
289+ def draw(series, color, width, dash=None):
290+ n = len(series)
291+ p.set_draw_color(*color)
292+ p.set_line_width(width)
293+ if dash:
294+ p.set_dash_pattern(dash=1.2, gap=1.2)
295+ last = None
296+ for i, pt in enumerate(series):
297+ px = cx + cw * (i / (n - 1))
298+ py = cy + ch - ch * ((pt["v"] - vmin) / rng)
299+ if last:
300+ p.line(last[0], last[1], px, py)
301+ last = (px, py)
302+ p.set_dash_pattern()
303+
304+ if s.get("compare"):
305+ draw(s["compare"], INK3, 0.35, dash=True)
306+ draw(pts, self.accent, 0.7)
307+ # libellés d'axe X (premier / milieu / dernier)
308+ p.set_text_color(*INK3)
309+ for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
310+ p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
311+ p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")
312+ p.set_y(y0 + h + 4)
313+ if s.get("compare"):
314+ p.set_font("helvetica", "", 6.8)
315+ p.set_text_color(*INK3)
316+ p.cell(0, 4, "— période courante (accent) · ---- période comparée")
317+ p.ln(6)
318+ else:
319+ p.ln(2)
320+
321+ def _bars(self, title, items, unit=""):
322+ p = self.pdf
323+ items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]
324+ if not items:
325+ return
326+ need = 10 + len(items) * 7
327+ if p.get_y() + need > 265:
328+ p.add_page()
329+ p.set_font("helvetica", "B", 10)
330+ p.set_text_color(*INK)
331+ p.cell(0, 6, title)
332+ p.ln(8)
333+ vmax = max(it["value"] for it in items) or 1
334+ for it in items:
335+ y = p.get_y()
336+ p.set_font("helvetica", "", 7.6)
337+ p.set_text_color(*INK)
338+ p.set_x(p.l_margin)
339+ p.cell(46, 5, str(it["label"])[:34])
340+ bw = 96 * (it["value"] / vmax)
341+ p.set_fill_color(*self.accent)
342+ p.set_draw_color(*INK)
343+ p.set_line_width(0.25)
344+ p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
345+ p.set_xy(p.l_margin + 148, y)
346+ p.set_font("helvetica", "B", 7.6)
347+ p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
348+ p.ln(6.4)
349+ p.ln(3)
350+
351+ def _donut(self, b):
352+ # anneau vectoriel simple (arcs) + légende
353+ p = self.pdf
354+ items = [it for it in (b.get("items") or []) if it.get("value")][:8]
355+ total = sum(it["value"] for it in items)
356+ if not items or not total:
357+ return
358+ if p.get_y() > 210:
359+ p.add_page()
360+ p.set_font("helvetica", "B", 10)
361+ p.set_text_color(*INK)
362+ p.cell(0, 6, b.get("title", ""))
363+ p.ln(8)
364+ cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
365+ shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
366+ start = -90.0
367+ for i, it in enumerate(items):
368+ frac = it["value"] / total
369+ f = shades[i % len(shades)]
370+ col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
371+ steps = max(2, int(72 * frac))
372+ p.set_fill_color(*col)
373+ p.set_draw_color(*col)
374+ for st in range(steps):
375+ a0 = math.radians(start + 360 * frac * st / steps)
376+ a1 = math.radians(start + 360 * frac * (st + 1) / steps)
377+ p.polygon(
378+ [(cx, cy),
379+ (cx + r * math.cos(a0), cy + r * math.sin(a0)),
380+ (cx + r * math.cos(a1), cy + r * math.sin(a1))],
381+ style="DF",
382+ )
383+ start += 360 * frac
384+ p.set_fill_color(*WHITE)
385+ p.set_draw_color(*INK)
386+ p.set_line_width(0.4)
387+ p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
388+ p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
389+ # légende
390+ ly = cy - 22
391+ for i, it in enumerate(items):
392+ f = shades[i % len(shades)]
393+ col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
394+ p.set_fill_color(*col)
395+ p.set_draw_color(*INK)
396+ p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
397+ p.set_xy(p.l_margin + 66, ly)
398+ p.set_font("helvetica", "", 7.6)
399+ p.set_text_color(*INK)
400+ pct = 100 * it["value"] / total
401+ p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))
402+ ly += 5.6
403+ p.set_y(max(cy + r, ly) + 6)
404+
405+ def _table(self, t):
406+ p = self.pdf
407+ cols = t.get("columns") or []
408+ rows = t.get("rows") or []
409+ if not cols or not rows:
410+ return
411+ self._section_title(t.get("title", "Tableau"))
412+ w = 174 / len(cols)
413+ def head():
414+ p.set_font("helvetica", "B", 7.6)
415+ p.set_fill_color(*INK)
416+ p.set_text_color(*WHITE)
417+ for c in cols:
418+ p.cell(w, 6, " " + str(c)[:30], fill=True)
419+ p.ln(6)
420+ head()
421+ p.set_text_color(*INK)
422+ for i, row in enumerate(rows[:200]):
423+ if p.get_y() > 262:
424+ p.add_page()
425+ head()
426+ p.set_text_color(*INK)
427+ p.set_font("helvetica", "", 7.4)
428+ p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))
429+ for cell in row:
430+ txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
431+ p.cell(w, 5.4, " " + txt[:34], fill=True)
432+ p.ln(5.4)
433+ if len(rows) > 200:
434+ p.set_font("helvetica", "", 7)
435+ p.set_text_color(*INK3)
436+ p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")
437+ p.ln(6)
438+
439+ def _records(self):
440+ recs = self.d.get("records") or []
441+ if not recs:
442+ return
443+ self._section_title("Records & faits marquants")
444+ p = self.pdf
445+ for r in recs[:10]:
446+ if p.get_y() > 258:
447+ p.add_page()
448+ y = p.get_y()
449+ self._card(p.l_margin, y, 174, 11, fill=SURFACE2)
450+ p.set_xy(p.l_margin + 4, y + 2)
451+ p.set_font("helvetica", "", 8.6)
452+ p.set_text_color(*INK2)
453+ p.cell(96, 7, str(r.get("label", ""))[:70])
454+ p.set_font("helvetica", "B", 9)
455+ p.set_text_color(*INK)
456+ p.cell(52, 7, str(r.get("value", ""))[:36], align="R")
457+ p.set_font("helvetica", "", 7.6)
458+ p.set_text_color(*INK3)
459+ p.cell(20, 7, str(r.get("date", "") or ""), align="R")
460+ p.set_y(y + 13.5)
461+ p.ln(4)
462+
463+ def _final_page(self):
464+ p = self.pdf
465+ p.add_page()
466+ self._kicker("Groupe KA · contact")
467+ p.set_font("helvetica", "B", 15)
468+ p.set_text_color(*INK)
469+ p.cell(0, 8, "Coordonnées du Groupe KA")
470+ p.ln(12)
471+ for email, role in EMAILS:
472+ p.set_font("helvetica", "B", 10.5)
473+ p.set_text_color(*INK)
474+ p.cell(0, 6, email)
475+ p.ln(5.5)
476+ p.set_font("helvetica", "", 8.6)
477+ p.set_text_color(*INK3)
478+ p.cell(0, 5, role)
479+ p.ln(8)
480+ p.ln(2)
481+ p.set_font("helvetica", "B", 10)
482+ p.set_text_color(*GREEN)
483+ p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")
484+ p.ln(10)
485+ p.set_draw_color(*self.accent)
486+ p.set_line_width(0.8)
487+ p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())
488+ p.ln(4)
489+ p.set_font("helvetica", "", 8.6)
490+ p.set_text_color(*INK2)
491+ p.multi_cell(160, 4.6, DISCLAIMER)
492+ p.ln(4)
493+ p.set_font("helvetica", "", 7.6)
494+ p.set_text_color(*INK3)
495+ p.multi_cell(
496+ 160, 4.2,
497+ "Mentions : rapport généré automatiquement à partir des données réelles de la "
498+ "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "
499+ "de confidentialité et protection des renseignements personnels (Loi 25) : "
500+ "groupe-ka.com/conditions · /confidentialite · /loi-25.",
501+ )
502+
503+ def _toc_page(self):
504+ # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en
505+ # page 2 en réservant la page lors du build (voir build()).
506+ pass
507+
508+ def build(self) -> bytes:
509+ p = self.pdf
510+ p.alias_nb_pages()
511+ self._cover()
512+ if self.mode == "synthese":
513+ p.add_page()
514+ self._kpis()
515+ self._records()
516+ self._final_page()
517+ else:
518+ p.add_page()
519+ toc_page_no = p.page_no()
520+ p.add_page()
521+ self._kpis()
522+ for s in self.d.get("series") or []:
523+ if s.get("kind") == "bar":
524+ self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))
525+ else:
526+ self._line_chart(s)
527+ for b in self.d.get("breakdowns") or []:
528+ if b.get("kind") == "donut":
529+ self._donut(b)
530+ else:
531+ self._bars(b.get("title", ""), b.get("items"))
532+ geo = self.d.get("geo")
533+ if geo:
534+ self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
535+ for t in self.d.get("tables") or []:
536+ self._table(t)
537+ self._records()
538+ self._final_page()
539+ # sommaire écrit sur la page réservée (page 2)
540+ last_page = p.page
541+ p.page = toc_page_no
542+ p.set_y(22)
543+ p.set_font("helvetica", "B", 15)
544+ p.set_text_color(*INK)
545+ p.cell(0, 8, "Sommaire")
546+ p.ln(12)
547+ p.set_font("helvetica", "", 9.5)
548+ for title, page_no in self.toc:
549+ p.set_text_color(*INK)
550+ p.cell(140, 6.5, title[:80])
551+ p.set_text_color(*INK3)
552+ p.cell(0, 6.5, str(page_no), align="R")
553+ p.ln(6.5)
554+ p.page = last_page
555+ return bytes(p.output())
556+
557+
558+def filename(platform_id: str, period: str) -> str:
559+ today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
560+ return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"
added frontend/src/ka/tokens.css +229 −0
@@ -0,0 +1,229 @@
1+/* -----------------------------------------------------------------------------
2+ Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3+ Fichier : ka-ui/tokens.css — SOURCE CANONIQUE (repo ka-ui sur spbgit)
4+ Desc. : Design system commun GROUPE KA « éditorial sharp » — tokens + socle
5+ de composants partagés par les 12 plateformes. Chaque site importe ce
6+ fichier PUIS surcharge uniquement son accent (voir accents.css).
7+
8+ · Typo : display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
9+ · Palette: papier #f5f3ee · encre #141814 · vert profond #1c5c41 + ACCENT du site
10+ · Signature : bordures encre 1,5–2 px + ombres décalées dures, grain de film,
11+ surlignés d'accent inclinés (néo-brutalisme raffiné)
12+ · Breakpoints communs : 360 / 768 / 1024 / 1440 px (mobile-first)
13+ · Zones tactiles ≥ 44×44 px, safe-areas iOS, typographie fluide (clamp)
14+----------------------------------------------------------------------------- */
15+
16+:root {
17+ /* ---- Palette fixe Groupe KA (identique sur les 12 sites) ---- */
18+ --paper: #f5f3ee;
19+ --surface: #ffffff;
20+ --surface-2: #faf9f5;
21+ --ink: #141814;
22+ --ink-2: #4d5551;
23+ --ink-3: #8b928c;
24+ --line: rgba(20, 24, 20, 0.14);
25+ --line-strong: rgba(20, 24, 20, 0.85);
26+ --green: #1c5c41;
27+ --green-deep: #123f2e;
28+ --amber: #e8a33d;
29+ --amber-soft: #fdf3e2;
30+ --danger: #b3423a;
31+ --danger-soft: #fbe9e7;
32+
33+ /* ---- ACCENT du site — SEULES variables surchargées par marque ---- */
34+ --accent: #d9f26b; /* défaut : lime Groupe KA */
35+ --accent-soft: #f0f9d2;
36+ --accent-deep: #123f2e;
37+ --on-accent: var(--ink); /* couleur du texte posé SUR l'accent */
38+
39+ /* alias rétro-compatibles (les satellites historiques utilisent --lime) */
40+ --lime: var(--accent);
41+ --lime-soft: var(--accent-soft);
42+
43+ /* ---- Géométrie sharp ---- */
44+ --r-card: 10px;
45+ --r-ctl: 6px;
46+ --r-pill: 999px;
47+
48+ /* ---- Ombres décalées — la signature du groupe ---- */
49+ --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
50+ --shadow-off: 6px 6px 0 var(--ink);
51+ --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
52+ --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18);
53+
54+ /* ---- Typographie ---- */
55+ --font-display: "Space Grotesk", system-ui, sans-serif;
56+ --font-body: "Inter", system-ui, sans-serif;
57+ --font-mono: "JetBrains Mono", ui-monospace, monospace;
58+
59+ /* Échelle fluide (mobile-first, clamp) */
60+ --fs-h1: clamp(30px, 3.2vw + 18px, 54px);
61+ --fs-h2: clamp(23px, 1.8vw + 14px, 34px);
62+ --fs-h3: clamp(17px, 0.9vw + 12px, 22px);
63+ --fs-body: 15px;
64+ --fs-small: 13px;
65+
66+ /* Espacements */
67+ --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
68+ --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px;
69+
70+ /* Cible tactile minimale */
71+ --touch: 44px;
72+}
73+
74+/* ---- Socle ---- */
75+* { box-sizing: border-box; }
76+html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
77+body {
78+ margin: 0;
79+ background: var(--paper);
80+ color: var(--ink);
81+ font-family: var(--font-body);
82+ font-size: var(--fs-body);
83+ line-height: 1.55;
84+ -webkit-font-smoothing: antialiased;
85+ padding-bottom: env(safe-area-inset-bottom);
86+ overflow-x: clip; /* jamais de débordement horizontal */
87+}
88+img, svg, video { max-width: 100%; height: auto; display: block; }
89+h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0 0 0.5em; }
90+h1 { font-size: var(--fs-h1); line-height: 1.06; }
91+h2 { font-size: var(--fs-h2); line-height: 1.15; }
92+h3 { font-size: var(--fs-h3); line-height: 1.25; }
93+a { color: inherit; }
94+::selection { background: var(--accent); color: var(--on-accent); }
95+:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
96+
97+/* Grain de film pleine page — ⚠️ jamais de z-index sur body > * (position:
98+ relative seulement), sinon les utilitaires z-* sont écrasés. */
99+body::before {
100+ content: "";
101+ position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999;
102+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
103+}
104+body > * { position: relative; }
105+
106+/* ---- Conteneur commun ---- */
107+.container { max-width: 1152px; margin: 0 auto; padding: 0 var(--sp-4); }
108+@media (min-width: 768px) { .container { padding: 0 var(--sp-5); } }
109+
110+/* ---- Micro-typographie signature ---- */
111+.kicker {
112+ display: inline-flex; align-items: center; gap: 10px;
113+ font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
114+ text-transform: uppercase; letter-spacing: 0.12em; color: var(--green);
115+}
116+.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
117+.klabel {
118+ font-family: var(--font-mono); font-size: 10px; font-weight: 700;
119+ text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
120+}
121+.hl {
122+ display: inline-block; background: var(--accent); color: var(--on-accent);
123+ border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg);
124+}
125+.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); }
126+
127+/* ---- Boutons (cible ≥ 44 px) ---- */
128+.btn {
129+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
130+ min-height: var(--touch); padding: 10px 18px;
131+ font-family: var(--font-display); font-weight: 700; font-size: 14px;
132+ border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
133+ background: var(--surface); color: var(--ink);
134+ cursor: pointer; text-decoration: none;
135+ transition: transform 0.15s, box-shadow 0.15s, background 0.15s, color 0.15s;
136+}
137+.btn:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }
138+.btn:active { transform: translate(0, 0); box-shadow: none; }
139+.btn:disabled { opacity: 0.45; pointer-events: none; }
140+.btn-primary { background: var(--ink); color: var(--accent); }
141+.btn-primary:hover { background: var(--accent-deep); }
142+.btn-accent { background: var(--accent); color: var(--on-accent); }
143+.btn-ghost { background: transparent; border-color: var(--line); }
144+.btn-ghost:hover { border-color: var(--ink); }
145+
146+/* ---- Cartes ---- */
147+.card {
148+ background: var(--surface); border: 1.5px solid var(--ink);
149+ border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
150+ overflow: hidden;
151+}
152+.card-hover { transition: transform 0.15s, box-shadow 0.15s; }
153+.card-hover:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }
154+
155+/* ---- Chips / badges ---- */
156+.chip {
157+ display: inline-flex; align-items: center; gap: 6px;
158+ padding: 4px 11px; font-family: var(--font-mono); font-size: 11px; font-weight: 700;
159+ text-transform: uppercase; letter-spacing: 0.06em;
160+ border: 1.5px solid var(--ink); border-radius: var(--r-pill);
161+ background: var(--surface); color: var(--ink);
162+}
163+.chip-accent { background: var(--accent); color: var(--on-accent); }
164+.chip-soft { background: var(--accent-soft); border-color: var(--line); }
165+
166+/* ---- Champs ---- */
167+.input, .select, .textarea {
168+ width: 100%; min-height: var(--touch); padding: 10px 14px;
169+ font: inherit; color: var(--ink); background: var(--surface);
170+ border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
171+}
172+.input:focus, .select:focus, .textarea:focus {
173+ outline: none; box-shadow: 3px 3px 0 var(--accent); border-color: var(--ink);
174+}
175+.input::placeholder { color: var(--ink-3); }
176+
177+/* ---- Badge « Un service Groupe KA » (header, cliquable → hub) ---- */
178+.gk-badge {
179+ display: inline-flex; align-items: center; gap: 7px;
180+ min-height: 30px; padding: 3px 10px 4px;
181+ font-family: var(--font-mono); font-size: 10px; font-weight: 700;
182+ letter-spacing: 0.08em; text-transform: uppercase; text-decoration: none;
183+ border: 1.5px solid var(--ink); border-radius: var(--r-pill);
184+ background: var(--surface); color: var(--ink-2); white-space: nowrap;
185+}
186+.gk-badge b {
187+ font-family: var(--font-display); font-size: 12px; letter-spacing: -0.02em;
188+ text-transform: none; color: var(--ink);
189+}
190+.gk-badge b .ka {
191+ display: inline-block; background: var(--ink); color: var(--accent);
192+ border-radius: 5px; padding: 0 5px 1px; margin-left: 3px; transform: rotate(-2deg);
193+}
194+.gk-badge:hover .ka { transform: rotate(0); }
195+.gk-badge--dark { background: transparent; border-color: rgba(245,243,238,0.4); color: rgba(245,243,238,0.75); }
196+.gk-badge--dark b { color: var(--paper); }
197+
198+/* ---- Footer commun (fond encre — voir react/KaFooter.tsx) ---- */
199+.ka-footer { margin-top: var(--sp-8); background: var(--ink); padding: 44px 0; font-size: 13px; color: rgba(245,243,238,0.75); }
200+.ka-footer a { text-decoration: none; }
201+.ka-footer .wordmark { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.04em; color: var(--paper); text-decoration: none; }
202+.ka-footer .wordmark .ka { color: var(--accent); }
203+.ka-footer .desc { max-width: 640px; margin-top: var(--sp-4); }
204+.ka-footer .notice { max-width: 640px; margin-top: var(--sp-4); border-left: 2px solid var(--accent); padding-left: var(--sp-4); }
205+.ka-footer .notice b { color: var(--paper); }
206+.ka-footer .sites { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 26px 0 0; padding: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
207+.ka-footer .sites a { color: rgba(245,243,238,0.65); }
208+.ka-footer .sites a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
209+.ka-footer .contacts { display: grid; gap: 14px 32px; border-top: 1px solid rgba(245,243,238,0.15); margin-top: 30px; padding-top: 26px; }
210+@media (min-width: 768px) { .ka-footer .contacts { grid-template-columns: repeat(3, 1fr); } }
211+.ka-footer .contacts a { font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: rgba(245,243,238,0.85); }
212+.ka-footer .contacts a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
213+.ka-footer .contacts span { display: block; margin-top: 3px; font-size: 11px; color: rgba(245,243,238,0.5); }
214+.ka-footer .legal { margin-top: 30px; font-family: var(--font-mono); font-size: 11px; color: rgba(245,243,238,0.45); }
215+.ka-footer .legal a { color: inherit; }
216+.ka-footer .legal a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
217+
218+/* ---- Tableaux → cartes empilées sous 768 px ---- */
219+.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
220+@media (max-width: 767px) {
221+ .tbl-stack thead { display: none; }
222+ .tbl-stack tr { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-card); margin-bottom: var(--sp-3); background: var(--surface); }
223+ .tbl-stack td { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 12px; border: 0; }
224+ .tbl-stack td::before { content: attr(data-label); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }
225+}
226+
227+/* ---- Utilitaires responsive ---- */
228+.only-mobile { display: initial; } .only-desktop { display: none; }
229+@media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } }
modified frontend/src/main.tsx +1 −0
@@ -12,6 +12,7 @@ import React from "react";
12 import ReactDOM from "react-dom/client"; 12 import ReactDOM from "react-dom/client";
13 import { BrowserRouter } from "react-router-dom"; 13 import { BrowserRouter } from "react-router-dom";
14 import App from "./App"; 14 import App from "./App";
15+import "./ka/tokens.css"; // design system commun Groupe KA — AVANT le CSS local
15 import "./styles.css"; 16 import "./styles.css";
16 17
17 ReactDOM.createRoot(document.getElementById("root")!).render( 18 ReactDOM.createRoot(document.getElementById("root")!).render(
added frontend/src/pages/Contact.tsx +72 −0
@@ -0,0 +1,72 @@
1+/**
2+ * =============================================================================
3+ * Job·Ka — Groupe KA
4+ * Auteur : Simon-Pierre Boucher
5+ * Contact : contact@spboucher.ai
6+ * Fichier : frontend/src/pages/Contact.tsx
7+ * Rôle : Page Contact commune Groupe KA (données ka/ecosystem.json)
8+ * Créé : 2026-08-17 Modifié : 2026-08-17
9+ * =============================================================================
10+ */
11+import { useEffect } from "react";
12+import eco from "../ka/ecosystem.json";
13+
14+export default function ContactPage() {
15+ useEffect(() => {
16+ document.title = "Contact | Job-Ka — Un service Groupe KA";
17+ window.scrollTo(0, 0);
18+ }, []);
19+
20+ const others = eco.sites.filter((s) => s.id !== "job-ka");
21+
22+ return (
23+ <div className="container contact-page">
24+ <span className="kicker">Contact</span>
25+ <h1>
26+ Écrire au <span className="hl">Groupe KA</span>
27+ </h1>
28+ <p className="lede">
29+ Job-Ka — {eco.sites.find((s) => s.id === "job-ka")?.tagline?.toLowerCase()} —
30+ est un service du {eco.org.name}. {eco.org.tagline} Toutes les demandes
31+ passent par les adresses ci-dessous.
32+ </p>
33+
34+ <div className="contact-grid">
35+ {eco.contacts.map((c) => (
36+ <div className="card contact-card" key={c.email}>
37+ <span className="klabel">{c.role}</span>
38+ <a className="mail" href={`mailto:${c.email}`}>{c.email}</a>
39+ </div>
40+ ))}
41+ </div>
42+
43+ <p className="contact-note">
44+ <b>{eco.org.disclaimer}</b> Les offres affichées sur Job-Ka proviennent
45+ des pages carrières publiques des employeurs québécois ; chaque fiche
46+ renvoie vers l'offre originale. Pour postuler ou poser une question sur
47+ un poste, adressez-vous directement à l'employeur indiqué sur la fiche.
48+ </p>
49+
50+ <div className="contact-hub">
51+ <span className="klabel">L'écosystème ·Ka</span>
52+ <ul className="contact-sites">
53+ {others.map((s) => (
54+ <li key={s.id}>
55+ <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer">
56+ {s.wordmark}
57+ </a>
58+ </li>
59+ ))}
60+ </ul>
61+ <p style={{ marginTop: 18, display: "flex", flexWrap: "wrap", gap: 10 }}>
62+ <a className="btn btn-primary" href={eco.hub.url} target="_blank" rel="noopener noreferrer">
63+ Visiter le portail groupe-ka.com →
64+ </a>
65+ <a className="btn" href={`${eco.hub.url}${eco.hub.loginPath}`} target="_blank" rel="noopener noreferrer">
66+ Créer un compte KA ID
67+ </a>
68+ </p>
69+ </div>
70+ </div>
71+ );
72+}
modified frontend/src/pages/MapPage.tsx +2 −2
@@ -39,7 +39,7 @@ export default function MapPage() {
39 map.addLayer({ 39 map.addLayer({
40 id: "clusters", type: "circle", source: "jobs", filter: ["has", "point_count"], 40 id: "clusters", type: "circle", source: "jobs", filter: ["has", "point_count"],
41 paint: { 41 paint: {
42− "circle-color": "#1c5c41", 42+ "circle-color": "#0c8599",
43 "circle-radius": ["step", ["get", "point_count"], 16, 25, 22, 100, 28], 43 "circle-radius": ["step", ["get", "point_count"], 16, 25, 22, 100, 28],
44 "circle-stroke-width": 2, "circle-stroke-color": "#ffffff", 44 "circle-stroke-width": 2, "circle-stroke-color": "#ffffff",
45 }, 45 },
@@ -52,7 +52,7 @@ export default function MapPage() {
52 map.addLayer({ 52 map.addLayer({
53 id: "points", type: "circle", source: "jobs", filter: ["!", ["has", "point_count"]], 53 id: "points", type: "circle", source: "jobs", filter: ["!", ["has", "point_count"]],
54 paint: { 54 paint: {
55− "circle-color": "#1c5c41", "circle-radius": 7, 55+ "circle-color": "#0c8599", "circle-radius": 7,
56 "circle-stroke-width": 2, "circle-stroke-color": "#ffffff", 56 "circle-stroke-width": 2, "circle-stroke-color": "#ffffff",
57 }, 57 },
58 }); 58 });
modified frontend/src/pages/Stats.tsx +139 −59
@@ -4,76 +4,156 @@
4 * Auteur : Simon-Pierre Boucher 4 * Auteur : Simon-Pierre Boucher
5 * Contact : contact@spboucher.ai 5 * Contact : contact@spboucher.ai
6 * Fichier : frontend/src/pages/Stats.tsx 6 * Fichier : frontend/src/pages/Stats.tsx
7− * Rôle : Statistiques du marché — tuiles + barres à teinte unique 7+ * Rôle : Tableau de bord analytique — module Stats commun Groupe KA
8− * (magnitude portée par la longueur, étiquettes en encre de texte) 8+ * (contrat ../ka/stats/SPEC.md, composants ../ka/stats/kacharts.tsx).
9+ * PDF + fraîcheur, KPI, sélecteur de période, courbes (N-1),
10+ * répartitions, géographie, calendrier de chaleur, tableaux, records.
9 * Créé : 2026-08-17 Modifié : 2026-08-17 11 * Créé : 2026-08-17 Modifié : 2026-08-17
10 * ============================================================================= 12 * =============================================================================
11 */ 13 */
12−import { useEffect, useState } from "react"; 14+import { useCallback, useEffect, useState } from "react";
13−import { fetchStats, Stats as StatsData } from "../api"; 15+import {
16+ BarChart, BreakItem, CalendarHeatmap, DataTable, Donut, EmptyBlock,
17+ Fraicheur, Kpi, KpiCard, LineChart, PdfButton, PeriodSelector, RecordCard,
18+ RecordFact, Serie, TableSpec,
19+} from "../ka/stats/kacharts";
14 20
15−function Bars({ rows }: { rows: { name: string; n: number }[] }) { 21+interface Dashboard {
16− const max = Math.max(1, ...rows.map((r) => r.n)); 22+ updated: string;
17− return ( 23+ period: { from: string | null; to: string | null; label: string };
18− <div className="bars"> 24+ kpis: Kpi[];
19− {rows.map((r) => ( 25+ series: Serie[];
20− <div className="bar-row" key={r.name} title={`${r.name} : ${r.n} offres`}> 26+ breakdowns: { id: string; title: string; kind?: string; items: BreakItem[] }[];
21− <span className="name">{r.name}</span> 27+ geo?: { title: string; items: BreakItem[] };
22− <span className="track"><span className="fill" style={{ width: `${(r.n / max) * 100}%` }} /></span> 28+ heatmap?: { title: string; cells: { date: string; value: number }[] };
23− <span className="n">{r.n.toLocaleString("fr-CA")}</span> 29+ tables: TableSpec[];
24− </div> 30+ records: RecordFact[];
25− ))}
26− </div>
27− );
28 } 31 }
29 32
30−export default function Stats() { 33+export default function StatsPage() {
31− const [stats, setStats] = useState<StatsData | null>(null); 34+ const [period, setPeriod] = useState("30j");
35+ const [custom, setCustom] = useState<{ from: string; to: string }>({ from: "", to: "" });
36+ const [dash, setDash] = useState<Dashboard | null>(null);
37+ const [loading, setLoading] = useState(true);
38+ const [err, setErr] = useState<string | null>(null);
39+
40+ const customOk = Boolean(custom.from && custom.to);
32 41
33− useEffect(() => { fetchStats().then(setStats).catch(() => {}); }, []); 42+ const load = useCallback(() => {
43+ setLoading(true);
44+ setErr(null);
45+ const p = new URLSearchParams({ period });
46+ if (customOk) {
47+ p.set("from", custom.from);
48+ p.set("to", custom.to);
49+ }
50+ fetch(`/api/stats/dashboard?${p}`)
51+ .then((r) => {
52+ if (!r.ok) throw new Error(`API ${r.status}`);
53+ return r.json();
54+ })
55+ .then((d: Dashboard) => setDash(d))
56+ .catch((e) => setErr(String(e?.message ?? e)))
57+ .finally(() => setLoading(false));
58+ }, [period, custom.from, custom.to, customOk]);
34 59
35− if (!stats) return <div className="container empty">Chargement…</div>; 60+ useEffect(() => {
61+ document.title = "Statistiques de l'emploi — Job-Ka · Un service Groupe KA";
62+ load();
63+ }, [load]);
36 64
37− const pctSalary = stats.total ? Math.round((stats.with_salary / stats.total) * 100) : 0; 65+ const donuts = dash?.breakdowns?.filter((b) => b.kind === "donut") ?? [];
66+ const barBreaks = dash?.breakdowns?.filter((b) => b.kind !== "donut") ?? [];
38 67
39 return ( 68 return (
40− <main className="container"> 69+ <div className="container" style={{ display: "grid", gap: 18, paddingBottom: 40 }}>
41− <h1 className="section-title" style={{ fontSize: 24, marginTop: 28 }}>Le marché en chiffres</h1> 70+ {/* --- en-tête : titre + PDF + fraîcheur ------------------------------ */}
42− <div className="tiles"> 71+ <header style={{ display: "flex", flexWrap: "wrap", gap: 14, alignItems: "flex-end", justifyContent: "space-between", marginTop: 24 }}>
43− <div className="tile"><div className="value">{stats.total.toLocaleString("fr-CA")}</div><div className="label">Offres actives</div></div> 72+ <div style={{ minWidth: 0 }}>
44− <div className="tile"><div className="value">{stats.employers.toLocaleString("fr-CA")}</div><div className="label">Employeurs</div></div> 73+ <p className="klabel" style={{ margin: 0 }}>Groupe KA · Job·Ka</p>
45− <div className="tile"><div className="value">{pctSalary} %</div><div className="label">Offres avec salaire affiché</div></div> 74+ <h1 style={{ margin: "4px 0 0", fontFamily: "var(--font-display)", fontSize: "clamp(24px,3.4vw,34px)", letterSpacing: "-0.02em" }}>
46− <div className="tile"> 75+ Statistiques du marché de l'emploi
47− <div className="value"> 76+ </h1>
48− {stats.avg_salary_year ? `${Math.round(stats.avg_salary_year / 1000)} k$` : "—"} 77+ {dash && (
49− </div> 78+ <p className="klabel" style={{ margin: "6px 0 0" }}>
50− <div className="label">Salaire annuel moyen (offres affichées)</div> 79+ Période : {dash.period.label}
80+ {dash.period.from ? ` (${dash.period.from} → ${dash.period.to})` : ""}
81+ </p>
82+ )}
83+ </div>
84+ <div style={{ display: "grid", gap: 8, justifyItems: "end" }}>
85+ <PdfButton period={period} from={customOk ? custom.from : undefined} to={customOk ? custom.to : undefined} />
86+ {dash && <Fraicheur updated={dash.updated} onRefresh={load} />}
87+ </div>
88+ </header>
89+
90+ {err && (
91+ <div className="card" style={{ padding: 18, borderColor: "var(--danger)" }}>
92+ <b>Impossible de charger les statistiques.</b>
93+ <p className="klabel" style={{ margin: "6px 0 10px" }}>{err}</p>
94+ <button type="button" className="btn btn-primary" onClick={load}>Réessayer</button>
95+ </div>
96+ )}
97+
98+ {/* --- bandeau KPI ----------------------------------------------------- */}
99+ {dash && dash.kpis.length > 0 && (
100+ <section aria-label="Indicateurs clés" style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(170px, 1fr))", opacity: loading ? 0.55 : 1, transition: "opacity .2s" }}>
101+ {dash.kpis.map((k) => <KpiCard key={k.id} k={k} />)}
102+ </section>
103+ )}
104+ {!dash && loading && (
105+ <div className="card" style={{ padding: 24, textAlign: "center" }}>
106+ <span className="klabel">Chargement des statistiques…</span>
51 </div> 107 </div>
52− <div className="tile"><div className="value">{stats.remote.toLocaleString("fr-CA")}</div><div className="label">Postes en télétravail</div></div> 108+ )}
53− </div> 109+
54− 110+ {/* --- sélecteur de période -------------------------------------------- */}
55− <h2 className="section-title">Offres par catégorie</h2> 111+ <section className="card" style={{ padding: "14px 16px" }} aria-label="Période">
56− <Bars rows={stats.categories.map((c) => ({ name: c.category, n: c.n }))} /> 112+ <PeriodSelector
57− 113+ value={period}
58− <h2 className="section-title">Offres par ville</h2> 114+ onChange={(p) => { setPeriod(p); setCustom({ from: "", to: "" }); }}
59− <Bars rows={stats.top_cities.map((c) => ({ name: c.city, n: c.n }))} /> 115+ custom={custom}
60− 116+ onCustom={(from, to) => setCustom({ from, to })}
61− <h2 className="section-title">Dernières synchronisations</h2> 117+ />
62− <table className="data" style={{ marginBottom: 40 }}> 118+ </section>
63− <thead> 119+
64− <tr><th>Source</th><th>Quand</th><th>Trouvées</th><th>Ajouts</th><th>MAJ</th><th>Retraits</th><th>État</th></tr> 120+ {dash && (
65− </thead> 121+ <div style={{ display: "grid", gap: 18, opacity: loading ? 0.55 : 1, transition: "opacity .2s" }}>
66− <tbody> 122+ {/* --- courbes ----------------------------------------------------- */}
67− {stats.recent_syncs.map((s, i) => ( 123+ {dash.series?.length ? (
68− <tr key={i}> 124+ dash.series.map((s) => <LineChart key={s.id} serie={s} />)
69− <td>{s.source}</td> 125+ ) : (
70− <td>{new Date(s.ts * 1000).toLocaleString("fr-CA", { dateStyle: "short", timeStyle: "short" })}</td> 126+ <EmptyBlock title="Évolution quotidienne" />
71− <td>{s.found}</td><td>{s.added}</td><td>{s.updated}</td><td>{s.removed}</td> 127+ )}
72− <td>{s.ok ? (s.message === "ok" ? "✓" : `⚠ ${s.message}`) : `✗ ${s.message}`}</td> 128+
73− </tr> 129+ {/* --- répartitions (anneaux + barres) ------------------------------ */}
74− ))} 130+ {(donuts.length > 0 || barBreaks.length > 0) && (
75− </tbody> 131+ <div style={{ display: "grid", gap: 18, gridTemplateColumns: "repeat(auto-fit, minmax(min(340px, 100%), 1fr))" }}>
76− </table> 132+ {donuts.map((b) => <Donut key={b.id} title={b.title} items={b.items} />)}
77− </main> 133+ {barBreaks.map((b) => <BarChart key={b.id} title={b.title} items={b.items} unit="offres" />)}
134+ </div>
135+ )}
136+
137+ {/* --- géographie --------------------------------------------------- */}
138+ {dash.geo && <BarChart title={dash.geo.title} items={dash.geo.items} unit="offres" />}
139+
140+ {/* --- calendrier de chaleur ---------------------------------------- */}
141+ {dash.heatmap && <CalendarHeatmap title={dash.heatmap.title} cells={dash.heatmap.cells} />}
142+
143+ {/* --- tableaux détaillés ------------------------------------------- */}
144+ {dash.tables?.map((t) => <DataTable key={t.id} spec={t} />)}
145+
146+ {/* --- records & faits marquants ------------------------------------ */}
147+ {dash.records?.length > 0 && (
148+ <section aria-label="Records et faits marquants" style={{ display: "grid", gap: 10 }}>
149+ <h2 style={{ margin: 0, fontFamily: "var(--font-display)", fontSize: 19 }}>Records & faits marquants</h2>
150+ <div style={{ display: "grid", gap: 10, gridTemplateColumns: "repeat(auto-fit, minmax(min(280px, 100%), 1fr))" }}>
151+ {dash.records.map((r) => <RecordCard key={r.label} r={r} />)}
152+ </div>
153+ </section>
154+ )}
155+ </div>
156+ )}
157+ </div>
78 ); 158 );
79 } 159 }
modified frontend/src/styles.css +121 −53
@@ -4,42 +4,35 @@ Job·Ka — Groupe KA
4 Auteur : Simon-Pierre Boucher 4 Auteur : Simon-Pierre Boucher
5 Contact : contact@spboucher.ai 5 Contact : contact@spboucher.ai
6 Fichier : frontend/src/styles.css 6 Fichier : frontend/src/styles.css
7−Rôle : Système de design unique de l'application (clair, style Groupe KA) 7+Rôle : CSS local Job·Ka — s'appuie sur le design system commun Groupe KA
8+ (ka/tokens.css, importé AVANT ce fichier dans main.tsx). Ici :
9+ seulement l'accent de la marque + les classes métier (offres,
10+ filtres, carte). Le socle (body, grain, sélection, .container,
11+ .btn, .card, .chip, ka-footer…) vit dans tokens.css.
8 Créé : 2026-08-17 Modifié : 2026-08-17 12 Créé : 2026-08-17 Modifié : 2026-08-17
9 ============================================================================= 13 =============================================================================
10 */ 14 */
11 15
12 :root { 16 :root {
13− --ink: #14181a; /* encre principale */ 17+ /* ---- ACCENT Job·Ka (cyan officiel — voir ka/ecosystem.json) ---- */
14− --ink-2: #4a5459; /* encre secondaire */ 18+ --accent: #0c8599;
15− --ink-3: #7d8a90; /* encre atténuée */ 19+ --accent-soft: #def0f4;
16− --surface: #ffffff; 20+ --accent-deep: #095c6b;
17− --surface-2: #f4f6f5; 21+ --on-accent: #ffffff;
18− --border: #e3e8e6; 22+ --lime: var(--accent);
19− --brand: #141814; /* noir-vert Groupe KA */ 23+ --lime-soft: var(--accent-soft);
20− --accent: #d9f26b; /* lime Groupe KA (sur fond sombre uniquement) */ 24+
21− --green: #1c5c41; /* vert profond — liens, barres, accents sur clair */ 25+ /* ---- alias hérités du CSS v1 (classes métier ci-dessous) ---- */
26+ --brand: var(--ink);
27+ --border: var(--line);
22 --green-soft: #e7f2ec; 28 --green-soft: #e7f2ec;
23− --radius: 12px; 29+ --radius: var(--r-card);
24− --shadow: 0 1px 2px rgb(20 24 20 / 6%), 0 4px 14px rgb(20 24 20 / 6%); 30+ --shadow: var(--shadow-flat), 0 4px 14px rgb(20 24 20 / 6%);
25−}
26−
27−* { box-sizing: border-box; }
28−
29−body {
30− margin: 0;
31− font-family: "Inter", system-ui, -apple-system, sans-serif;
32− color: var(--ink);
33− background: var(--surface-2);
34− font-size: 15px;
35− line-height: 1.55;
36 } 31 }
37 32
38 a { color: var(--green); text-decoration: none; } 33 a { color: var(--green); text-decoration: none; }
39 a:hover { text-decoration: underline; } 34 a:hover { text-decoration: underline; }
40 35
41−.container { max-width: 1080px; margin: 0 auto; padding: 0 20px; }
42−
43 /* --- en-tête ---------------------------------------------------------------- */ 36 /* --- en-tête ---------------------------------------------------------------- */
44 header.site { 37 header.site {
45 background: var(--brand); 38 background: var(--brand);
@@ -47,21 +40,66 @@ header.site {
47 position: sticky; top: 0; z-index: 50; 40 position: sticky; top: 0; z-index: 50;
48 } 41 }
49 header.site .container { 42 header.site .container {
50− display: flex; align-items: center; gap: 24px; height: 58px; 43+ display: flex; align-items: center; flex-wrap: wrap;
44+ gap: 4px 14px; min-height: 58px; padding-top: 8px; padding-bottom: 8px;
51 } 45 }
52−.logo { font-weight: 800; font-size: 20px; color: #fff; letter-spacing: -0.3px; } 46+.logo { font-family: var(--font-display); font-weight: 700; font-size: 20px; color: #fff; letter-spacing: -0.3px; white-space: nowrap; }
53−.logo em { color: var(--accent); font-style: normal; } 47+.logo em { color: var(--accent-soft); font-style: normal; }
48+header.site .gk-badge { flex-shrink: 0; }
54 nav.main { display: flex; gap: 4px; margin-left: auto; } 49 nav.main { display: flex; gap: 4px; margin-left: auto; }
55 nav.main a { 50 nav.main a {
56 color: #d7ddda; padding: 7px 12px; border-radius: 8px; font-weight: 500; 51 color: #d7ddda; padding: 7px 12px; border-radius: 8px; font-weight: 500;
52+ white-space: nowrap;
57 } 53 }
58 nav.main a:hover { color: #fff; background: rgb(255 255 255 / 8%); text-decoration: none; } 54 nav.main a:hover { color: #fff; background: rgb(255 255 255 / 8%); text-decoration: none; }
59−nav.main a.active { color: var(--accent); background: rgb(255 255 255 / 10%); } 55+nav.main a.active { color: var(--accent-soft); background: rgb(255 255 255 / 10%); }
56+
57+/* connexion KA ID (droite du header) */
58+.auth-box { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
59+.btn-ka {
60+ display: inline-flex; align-items: center; gap: 7px; min-height: 34px;
61+ padding: 4px 13px; border: 1.5px solid rgb(245 243 238 / 45%);
62+ border-radius: var(--r-pill); background: transparent; color: #f5f3ee;
63+ font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;
64+ letter-spacing: 0.07em; text-transform: uppercase; cursor: pointer;
65+ white-space: nowrap; text-decoration: none;
66+}
67+.btn-ka b {
68+ font-family: var(--font-display); font-size: 12px; text-transform: none;
69+ letter-spacing: -0.02em; background: var(--accent); color: var(--on-accent);
70+ border-radius: 5px; padding: 0 6px 1px;
71+}
72+.btn-ka:hover { border-color: var(--accent-soft); text-decoration: none; }
73+.auth-user { display: inline-flex; align-items: center; gap: 8px; color: #d7ddda; font-size: 13px; white-space: nowrap; }
74+.auth-user img { width: 26px; height: 26px; border-radius: 50%; border: 1.5px solid rgb(245 243 238 / 45%); }
75+.auth-user button {
76+ border: 0; background: none; color: #9aa5a0; font: inherit; font-size: 12px;
77+ cursor: pointer; padding: 6px 0; min-height: 34px;
78+}
79+.auth-user button:hover { color: #fff; text-decoration: underline; }
80+
81+/* bascule mobile du header : sous 980 px la nav passe sur sa propre rangée
82+ (défilement horizontal contenu — jamais de débordement de page) */
83+@media (max-width: 979px) {
84+ nav.main {
85+ order: 10; flex-basis: 100%; margin-left: 0;
86+ overflow-x: auto; -webkit-overflow-scrolling: touch;
87+ scrollbar-width: none; padding-bottom: 2px;
88+ }
89+ nav.main::-webkit-scrollbar { display: none; }
90+ nav.main a { padding: 7px 9px; font-size: 13.5px; }
91+ .auth-box { margin-left: auto; }
92+}
93+@media (max-width: 560px) {
94+ header.site .gk-badge { padding: 2px 8px 3px; font-size: 9px; }
95+ header.site .gk-badge b { font-size: 11px; }
96+ .auth-user span.name { display: none; }
97+}
60 98
61 /* --- héros ------------------------------------------------------------------ */ 99 /* --- héros ------------------------------------------------------------------ */
62 .hero { background: var(--brand); color: #fff; padding: 40px 0 34px; } 100 .hero { background: var(--brand); color: #fff; padding: 40px 0 34px; }
63−.hero h1 { margin: 0 0 6px; font-size: 30px; font-weight: 800; letter-spacing: -0.5px; } 101+.hero h1 { margin: 0 0 6px; font-size: 30px; font-weight: 700; letter-spacing: -0.5px; color: #fff; }
64−.hero h1 em { color: var(--accent); font-style: normal; } 102+.hero h1 em { color: var(--accent-soft); font-style: normal; }
65 .hero p { margin: 0 0 20px; color: #b9c2be; max-width: 640px; } 103 .hero p { margin: 0 0 20px; color: #b9c2be; max-width: 640px; }
66 104
67 /* --- filtres ------------------------------------------------------------------ */ 105 /* --- filtres ------------------------------------------------------------------ */
@@ -73,6 +111,7 @@ nav.main a.active { color: var(--accent); background: rgb(255 255 255 / 10%); }
73 .filters input[type="search"] { flex: 1 1 220px; } 111 .filters input[type="search"] { flex: 1 1 220px; }
74 .filters label.check { 112 .filters label.check {
75 display: inline-flex; align-items: center; gap: 7px; background: #fff; 113 display: inline-flex; align-items: center; gap: 7px; background: #fff;
114+ color: var(--ink); /* le héros est blanc sur encre — ne pas hériter */
76 border: 1px solid var(--border); border-radius: 10px; padding: 0 14px; 115 border: 1px solid var(--border); border-radius: 10px; padding: 0 14px;
77 height: 42px; cursor: pointer; user-select: none; white-space: nowrap; 116 height: 42px; cursor: pointer; user-select: none; white-space: nowrap;
78 } 117 }
@@ -84,9 +123,9 @@ nav.main a.active { color: var(--accent); background: rgb(255 255 255 / 10%); }
84 border-radius: var(--radius); padding: 18px 20px; color: inherit; 123 border-radius: var(--radius); padding: 18px 20px; color: inherit;
85 transition: border-color 0.12s, box-shadow 0.12s; 124 transition: border-color 0.12s, box-shadow 0.12s;
86 } 125 }
87−.job-card:hover { border-color: var(--green); box-shadow: var(--shadow); text-decoration: none; } 126+.job-card:hover { border-color: var(--accent); box-shadow: var(--shadow); text-decoration: none; }
88 .job-card h3 { margin: 0 0 2px; font-size: 17px; font-weight: 700; color: var(--ink); } 127 .job-card h3 { margin: 0 0 2px; font-size: 17px; font-weight: 700; color: var(--ink); }
89−.job-card .employer { color: var(--green); font-weight: 600; } 128+.job-card .employer { color: var(--accent-deep); font-weight: 600; }
90 .job-card .meta { color: var(--ink-3); font-size: 13.5px; margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px 14px; } 129 .job-card .meta { color: var(--ink-3); font-size: 13.5px; margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px 14px; }
91 130
92 .badge { 131 .badge {
@@ -94,7 +133,7 @@ nav.main a.active { color: var(--accent); background: rgb(255 255 255 / 10%); }
94 font-size: 12.5px; font-weight: 600; background: var(--surface-2); color: var(--ink-2); 133 font-size: 12.5px; font-weight: 600; background: var(--surface-2); color: var(--ink-2);
95 } 134 }
96 .badge.salary { background: var(--green-soft); color: var(--green); } 135 .badge.salary { background: var(--green-soft); color: var(--green); }
97−.badge.mode { background: #eef1ff; color: #3c4a9e; } 136+.badge.mode { background: var(--accent-soft); color: var(--accent-deep); }
98 137
99 /* --- fiche ------------------------------------------------------------------------- */ 138 /* --- fiche ------------------------------------------------------------------------- */
100 .sheet { 139 .sheet {
@@ -104,12 +143,6 @@ nav.main a.active { color: var(--accent); background: rgb(255 255 255 / 10%); }
104 .sheet h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.3px; } 143 .sheet h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.3px; }
105 .sheet .facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 20px; } 144 .sheet .facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 20px; }
106 .sheet .description { white-space: pre-line; color: var(--ink-2); } 145 .sheet .description { white-space: pre-line; color: var(--ink-2); }
107−.btn {
108− display: inline-block; background: var(--brand); color: var(--accent);
109− font-weight: 700; padding: 11px 22px; border-radius: 10px; border: none;
110− cursor: pointer; font: inherit; font-weight: 700;
111−}
112−.btn:hover { text-decoration: none; opacity: 0.92; }
113 146
114 /* --- tuiles de statistiques (hero numbers) --------------------------------------- */ 147 /* --- tuiles de statistiques (hero numbers) --------------------------------------- */
115 .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 22px 0; } 148 .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 22px 0; }
@@ -117,15 +150,15 @@ nav.main a.active { color: var(--accent); background: rgb(255 255 255 / 10%); }
117 background: var(--surface); border: 1px solid var(--border); 150 background: var(--surface); border: 1px solid var(--border);
118 border-radius: var(--radius); padding: 18px 20px; 151 border-radius: var(--radius); padding: 18px 20px;
119 } 152 }
120−.tile .value { font-size: 26px; font-weight: 800; letter-spacing: -0.5px; } 153+.tile .value { font-family: var(--font-display); font-size: 26px; font-weight: 700; letter-spacing: -0.5px; }
121 .tile .label { color: var(--ink-3); font-size: 13px; margin-top: 2px; } 154 .tile .label { color: var(--ink-3); font-size: 13px; margin-top: 2px; }
122 155
123 /* --- barres horizontales (magnitude, teinte unique) --------------------------------- */ 156 /* --- barres horizontales (magnitude, teinte unique) --------------------------------- */
124 .bars { display: grid; gap: 8px; margin: 10px 0 26px; } 157 .bars { display: grid; gap: 8px; margin: 10px 0 26px; }
125 .bar-row { display: grid; grid-template-columns: 190px 1fr 52px; align-items: center; gap: 10px; } 158 .bar-row { display: grid; grid-template-columns: 190px 1fr 52px; align-items: center; gap: 10px; }
126 .bar-row .name { font-size: 13.5px; color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 159 .bar-row .name { font-size: 13.5px; color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
127−.bar-row .track { background: var(--surface-2); border-radius: 4px; height: 14px; } 160+.bar-row .track { background: rgb(20 24 20 / 6%); border-radius: 4px; height: 14px; }
128−.bar-row .fill { background: var(--green); border-radius: 4px 4px 4px 4px; height: 14px; min-width: 3px; } 161+.bar-row .fill { background: var(--accent); border-radius: 4px; height: 14px; min-width: 3px; }
129 .bar-row .n { font-size: 13px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; } 162 .bar-row .n { font-size: 13px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }
130 163
131 /* --- tableaux ----------------------------------------------------------------------- */ 164 /* --- tableaux ----------------------------------------------------------------------- */
@@ -149,24 +182,59 @@ table.data tr:last-child td { border-bottom: none; }
149 .pager { display: flex; gap: 8px; justify-content: center; margin: 26px 0; } 182 .pager { display: flex; gap: 8px; justify-content: center; margin: 26px 0; }
150 .pager button { 183 .pager button {
151 background: var(--surface); border: 1px solid var(--border); border-radius: 8px; 184 background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
152− padding: 8px 16px; cursor: pointer; font: inherit; 185+ padding: 8px 16px; cursor: pointer; font: inherit; min-height: var(--touch);
153 } 186 }
154 .pager button:disabled { opacity: 0.4; cursor: default; } 187 .pager button:disabled { opacity: 0.4; cursor: default; }
155 .empty { text-align: center; color: var(--ink-3); padding: 60px 0; } 188 .empty { text-align: center; color: var(--ink-3); padding: 60px 0; }
156 .section-title { font-size: 19px; font-weight: 700; margin: 30px 0 4px; } 189 .section-title { font-size: 19px; font-weight: 700; margin: 30px 0 4px; }
157 .muted { color: var(--ink-3); font-size: 13px; } 190 .muted { color: var(--ink-3); font-size: 13px; }
158 191
159−/* --- pied de page ----------------------------------------------------------------------- */ 192+/* --- page Contact (commune Groupe KA) -------------------------------------------------- */
160−footer.site { 193+.contact-page { padding: 44px 0 90px; }
161− background: var(--brand); color: #9aa5a0; margin-top: 60px; 194+.contact-page h1 { margin: 10px 0 8px; }
162− padding: 26px 0; font-size: 13.5px; 195+.contact-page .lede { color: var(--ink-2); max-width: 640px; margin: 0 0 26px; }
163−} 196+.contact-grid { display: grid; gap: 16px; grid-template-columns: 1fr; margin: 26px 0; }
164−footer.site a { color: #d7ddda; } 197+@media (min-width: 768px) { .contact-grid { grid-template-columns: repeat(3, 1fr); } }
165−footer.site .container { display: flex; flex-wrap: wrap; gap: 8px 24px; justify-content: space-between; } 198+.contact-card { padding: 18px 20px; }
199+.contact-card .klabel { display: block; margin-bottom: 6px; }
200+.contact-card a.mail {
201+ font-family: var(--font-mono); font-weight: 700; font-size: 13.5px;
202+ color: var(--accent-deep); word-break: break-all;
203+ text-decoration: underline; text-underline-offset: 3px;
204+}
205+.contact-note {
206+ border-left: 3px solid var(--accent); background: var(--surface);
207+ border-top: 1.5px solid var(--ink); border-right: 1.5px solid var(--ink);
208+ border-bottom: 1.5px solid var(--ink); border-radius: 0 var(--r-card) var(--r-card) 0;
209+ padding: 14px 18px; max-width: 720px; color: var(--ink-2); font-size: 14px;
210+}
211+.contact-note b { color: var(--ink); }
212+.contact-sites { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
213+.contact-sites a {
214+ display: inline-flex; align-items: center; min-height: 34px;
215+ padding: 4px 12px; border: 1.5px solid var(--ink); border-radius: var(--r-pill);
216+ background: var(--surface); font-family: var(--font-mono); font-size: 11px;
217+ font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
218+ color: var(--ink); transition: transform 0.12s, box-shadow 0.12s;
219+}
220+.contact-sites a:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); text-decoration: none; }
221+.contact-hub { margin-top: 26px; }
222+
223+/* --- rangée de navigation locale au-dessus du footer commun ----------------------------- */
224+.prefooter { border-top: 1.5px solid var(--ink); background: var(--surface-2); padding: 18px 0; margin-top: 60px; }
225+.prefooter .container {
226+ display: flex; flex-wrap: wrap; align-items: center; gap: 6px 20px;
227+}
228+.prefooter a {
229+ color: var(--ink-2); text-decoration: none; text-transform: uppercase;
230+ letter-spacing: 0.08em; font-family: var(--font-mono); font-weight: 700; font-size: 11.5px;
231+ min-height: var(--touch); display: inline-flex; align-items: center;
232+}
233+.prefooter a:hover { color: var(--accent-deep); text-decoration: underline; text-underline-offset: 4px; }
234+.prefooter .baseline { width: 100%; font-size: 13px; color: var(--ink-2); }
235+.ka-footer { margin-top: 0; }
166 236
167 @media (max-width: 640px) { 237 @media (max-width: 640px) {
168− nav.main { gap: 0; }
169− nav.main a { padding: 7px 8px; font-size: 13.5px; }
170 .bar-row { grid-template-columns: 120px 1fr 44px; } 238 .bar-row { grid-template-columns: 120px 1fr 44px; }
171 .sheet { padding: 20px; } 239 .sheet { padding: 20px; }
172 } 240 }
added jobka/auth.py +238 −0
@@ -0,0 +1,238 @@
1+# =============================================================================
2+# Job·Ka — Groupe KA
3+# Auteur : Simon-Pierre Boucher
4+# Contact : contact@spboucher.ai
5+# Fichier : jobka/auth.py
6+# Rôle : Connexion KA ID — SSO du hub Groupe KA (groupe-ka.com)
7+# Flux (hérité de Lou·Ka, client_id « job-ka ») :
8+# GET /api/auth/ka/login -> redirige vers {hub}/sso/authorize (state signé)
9+# GET /api/auth/ka/callback -> vérifie le jeton HS256 du hub, upsert
10+# l'utilisateur local, pose le cookie de session
11+# GET /api/auth/config -> {"ka": bool} (bouton affiché ou non)
12+# GET /api/me -> profil de la session courante (ou 401)
13+# POST /api/auth/logout -> efface le cookie
14+# Session : jeton signé HMAC-SHA256 (stdlib seulement) dans un cookie
15+# httpOnly propre à Job·Ka (« jobka_session »). La création de compte se
16+# fait sur le hub : https://www.groupe-ka.com/connexion.
17+# Config .env : KA_SSO_SECRET (partagé avec le hub), KA_HUB_URL (optionnel),
18+# JOBKA_BASE_URL (optionnel — défaut https://www.job-ka.com).
19+# Créé : 2026-08-17 Modifié : 2026-08-17
20+# =============================================================================
21+from __future__ import annotations
22+
23+import base64
24+import hashlib
25+import hmac
26+import json
27+import os
28+import secrets
29+import time
30+from urllib.parse import urlencode
31+
32+from fastapi import APIRouter, HTTPException, Request
33+from fastapi.responses import JSONResponse, RedirectResponse
34+
35+from . import db
36+
37+router = APIRouter(prefix="/api")
38+
39+COOKIE = "jobka_session"
40+SESSION_DAYS = 30
41+CLIENT_ID = "job-ka"
42+
43+KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
44+
45+
46+def _ka_secret() -> bytes | None:
47+ s = os.environ.get("KA_SSO_SECRET")
48+ return s.encode() if s else None
49+
50+
51+def _session_secret() -> bytes:
52+ """Clé de signature des sessions locales — dérivée du secret SSO partagé
53+ (aucune variable .env supplémentaire requise)."""
54+ s = _ka_secret()
55+ if not s:
56+ raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")
57+ return hashlib.sha256(b"jobka-session|" + s).digest()
58+
59+
60+def _base_url(request: Request) -> str:
61+ """URL publique de l'app. Le trafic prod passe par ngrok (schéma http vu
62+ du serveur) : on force https sur le domaine officiel. JOBKA_BASE_URL
63+ (optionnel) a préséance — utile en dev local."""
64+ env = os.environ.get("JOBKA_BASE_URL")
65+ if env:
66+ return env.rstrip("/")
67+ host = request.url.hostname or ""
68+ if host.endswith("job-ka.com"):
69+ return "https://www.job-ka.com"
70+ return str(request.base_url).rstrip("/")
71+
72+
73+# -- jeton signé local (payload JSON -> base64url.signature) -------------------
74+
75+def _sign(payload: dict) -> str:
76+ raw = base64.urlsafe_b64encode(
77+ json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=")
78+ sig = hmac.new(_session_secret(), raw.encode(), hashlib.sha256).hexdigest()
79+ return f"{raw}.{sig}"
80+
81+
82+def _verify(token: str) -> dict | None:
83+ try:
84+ raw, sig = token.rsplit(".", 1)
85+ expected = hmac.new(_session_secret(), raw.encode(),
86+ hashlib.sha256).hexdigest()
87+ if not hmac.compare_digest(sig, expected):
88+ return None
89+ payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))
90+ if payload.get("exp", 0) < time.time():
91+ return None
92+ return payload
93+ except Exception:
94+ return None
95+
96+
97+def current_user(request: Request) -> dict | None:
98+ """Payload de session ({uid, ka_id, email, name, picture}) ou None."""
99+ token = request.cookies.get(COOKIE)
100+ return _verify(token) if token else None
101+
102+
103+# -- vérification du JWT HS256 émis par le hub KA -------------------------------
104+
105+def _b64url(s: str) -> bytes:
106+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
107+
108+
109+def _verify_ka_token(token: str) -> dict | None:
110+ secret = _ka_secret()
111+ if not secret:
112+ return None
113+ try:
114+ h_b64, p_b64, s_b64 = token.split(".")
115+ expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(),
116+ hashlib.sha256).digest()
117+ if not hmac.compare_digest(_b64url(s_b64), expected):
118+ return None
119+ if json.loads(_b64url(h_b64)).get("alg") != "HS256":
120+ return None
121+ claims = json.loads(_b64url(p_b64))
122+ if claims.get("iss") != KA_HUB_URL:
123+ return None
124+ aud = claims.get("aud")
125+ if (aud if isinstance(aud, str) else "") != CLIENT_ID \
126+ and CLIENT_ID not in (aud if isinstance(aud, list) else []):
127+ return None
128+ if claims.get("exp", 0) < time.time():
129+ return None
130+ return claims
131+ except Exception:
132+ return None
133+
134+
135+# -- routes ---------------------------------------------------------------------
136+
137+@router.get("/auth/config")
138+def auth_config():
139+ return {"ka": bool(_ka_secret())}
140+
141+
142+@router.get("/auth/ka/login")
143+def ka_login(request: Request):
144+ """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com)."""
145+ if not _ka_secret():
146+ raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")
147+ state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600})
148+ params = {
149+ "client_id": CLIENT_ID,
150+ "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",
151+ "state": state,
152+ }
153+ return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}")
154+
155+
156+@router.get("/auth/ka/callback")
157+def ka_callback(request: Request, ka_token: str = "", state: str = ""):
158+ """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local,
159+ pose le cookie de session Job·Ka."""
160+ if not ka_token or _verify(state) is None:
161+ raise HTTPException(400, "state invalide ou expiré")
162+ claims = _verify_ka_token(ka_token)
163+ if claims is None:
164+ raise HTTPException(401, "jeton KA invalide ou expiré")
165+
166+ email = (claims.get("email") or "").lower()
167+ name = claims.get("name") or (email.split("@")[0] if email else "membre")
168+ picture = claims.get("picture") or ""
169+ ka_sub = f"ka:{claims.get('sub')}" # clé stable côté hub
170+ ka_id = (claims.get("ka_id") or "").strip() # créé À LA SOURCE par le hub
171+
172+ con = db.connect()
173+ now = time.time()
174+ con.execute(
175+ """INSERT INTO users (ka_sub, email, name, picture, created_at, last_login)
176+ VALUES (?,?,?,?,?,?)
177+ ON CONFLICT(ka_sub) DO UPDATE SET
178+ email=excluded.email, name=excluded.name,
179+ picture=excluded.picture, last_login=excluded.last_login""",
180+ (ka_sub, email, name, picture, now, now))
181+ row = con.execute("SELECT id FROM users WHERE ka_sub=?", (ka_sub,)).fetchone()
182+ uid = row["id"]
183+ if ka_id:
184+ import sqlite3
185+ try:
186+ con.execute("UPDATE users SET ka_id=? WHERE id=?", (ka_id, uid))
187+ except sqlite3.IntegrityError:
188+ pass # KA ID déjà porté par une autre ligne locale : on garde
189+ con.commit()
190+ con.close()
191+
192+ session = _sign({
193+ "uid": uid,
194+ "ka_id": ka_id,
195+ "email": email,
196+ "name": name,
197+ "picture": picture,
198+ "exp": time.time() + SESSION_DAYS * 86400,
199+ })
200+ resp = RedirectResponse("/?login=ok")
201+ resp.set_cookie(
202+ COOKIE, session,
203+ max_age=SESSION_DAYS * 86400,
204+ httponly=True,
205+ secure=_base_url(request).startswith("https"),
206+ samesite="lax",
207+ path="/",
208+ )
209+ return resp
210+
211+
212+@router.get("/me")
213+def me(request: Request):
214+ user = current_user(request)
215+ if user is None:
216+ raise HTTPException(401, "non connecté")
217+ con = db.connect()
218+ row = con.execute("SELECT * FROM users WHERE id=?", (user["uid"],)).fetchone()
219+ con.close()
220+ if row is None:
221+ raise HTTPException(401, "compte introuvable")
222+ return {
223+ "uid": row["id"],
224+ "ka_id": row["ka_id"],
225+ "email": row["email"],
226+ "name": row["name"],
227+ "picture": row["picture"],
228+ "created_at": row["created_at"],
229+ "last_login": row["last_login"],
230+ "provider": "ka-id",
231+ }
232+
233+
234+@router.post("/auth/logout")
235+def logout():
236+ resp = JSONResponse({"ok": True})
237+ resp.delete_cookie(COOKIE, path="/")
238+ return resp
modified jobka/db.py +12 −0
@@ -110,6 +110,18 @@ CREATE TABLE IF NOT EXISTS salary_log (
110 ); 110 );
111 CREATE INDEX IF NOT EXISTS idx_salary_log_uid ON salary_log(uid); 111 CREATE INDEX IF NOT EXISTS idx_salary_log_uid ON salary_log(uid);
112 112
113+CREATE TABLE IF NOT EXISTS users (
114+ id INTEGER PRIMARY KEY AUTOINCREMENT,
115+ ka_sub TEXT UNIQUE, -- « ka:{sub} » identifiant stable du hub KA
116+ ka_id TEXT, -- identifiant membre « ka-0123456789 » (hub)
117+ email TEXT,
118+ name TEXT,
119+ picture TEXT, -- URL de l'avatar (hub / Google)
120+ created_at REAL,
121+ last_login REAL
122+);
123+CREATE UNIQUE INDEX IF NOT EXISTS users_ka_id ON users(ka_id);
124+
113 CREATE TABLE IF NOT EXISTS geocode_cache ( 125 CREATE TABLE IF NOT EXISTS geocode_cache (
114 address TEXT PRIMARY KEY, -- adresse/ville normalisée (clé de cache) 126 address TEXT PRIMARY KEY, -- adresse/ville normalisée (clé de cache)
115 lat REAL, 127 lat REAL,
added jobka/kapdf.py +560 −0
@@ -0,0 +1,560 @@
1+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+# jobka/kapdf.py (copie de ka-ui/stats/kapdf.py) — moteur PDF commun Groupe KA (fpdf2).
3+# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4+# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques
5+# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.
6+# Usage :
7+# from kapdf import GroupeKAReport
8+# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
9+# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
10+# mode="complet").build()
11+# Dépendance : pip install fpdf2 (aucune autre)
12+from __future__ import annotations
13+
14+import math
15+from datetime import datetime
16+from zoneinfo import ZoneInfo
17+
18+from fpdf import FPDF
19+
20+INK = (20, 24, 20)
21+INK2 = (77, 85, 81)
22+INK3 = (139, 146, 140)
23+PAPER = (245, 243, 238)
24+SURFACE2 = (250, 249, 245)
25+GREEN = (28, 92, 65)
26+DANGER = (179, 66, 58)
27+WHITE = (255, 255, 255)
28+
29+EMAILS = [
30+ ("contact@groupe-ka.com", "Projets, partenariats & données"),
31+ ("info@groupe-ka.com", "Médias & questions générales"),
32+ ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),
33+]
34+DISCLAIMER = (
35+ "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "
36+ "rien et ne sommes partie à aucune transaction. Données lues à la source, "
37+ "rien d'inventé, tout est traçable."
38+)
39+
40+
41+def _hex(c: str) -> tuple[int, int, int]:
42+ c = c.lstrip("#")
43+ return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore
44+
45+
46+def _fr(n) -> str:
47+ if isinstance(n, float) and not n.is_integer():
48+ return f"{n:,.2f}".replace(",", " ").replace(".", ",")
49+ return f"{int(n):,}".replace(",", " ")
50+
51+
52+_SUBST = {
53+ "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
54+ "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
55+ "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
56+}
57+
58+
59+def _latin1(s: str) -> str:
60+ for k, v in _SUBST.items():
61+ s = s.replace(k, v)
62+ return s.encode("latin-1", "replace").decode("latin-1")
63+
64+
65+class _PDF(FPDF):
66+ """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
67+ Les polices core sont latin-1 : normalize_text sanitise en amont."""
68+
69+ def normalize_text(self, text):
70+ return super().normalize_text(_latin1(text))
71+
72+ def __init__(self, brand: str, accent: tuple, period_label: str):
73+ super().__init__(orientation="P", unit="mm", format="A4")
74+ self.brand = brand
75+ self.accent = accent
76+ self.period_label = period_label
77+ self.cover_mode = False
78+ self.set_margins(18, 20, 18)
79+ self.set_auto_page_break(True, margin=22)
80+
81+ def header(self):
82+ if self.cover_mode or self.page_no() == 1:
83+ return
84+ self.set_font("helvetica", "B", 8.5)
85+ self.set_text_color(*INK)
86+ self.set_xy(18, 9)
87+ self.cell(0, 5, f"Groupe KA · {self.brand}")
88+ self.set_font("helvetica", "", 8)
89+ self.set_text_color(*INK3)
90+ self.set_xy(18, 9)
91+ self.cell(0, 5, "Rapport statistique", align="R")
92+ self.set_draw_color(*INK)
93+ self.set_line_width(0.5)
94+ self.line(18, 15.5, 192, 15.5)
95+ self.set_y(20)
96+
97+ def footer(self):
98+ # page 1 = couverture (le flag cover_mode est déjà retombé quand
99+ # add_page() clôt la page 1 → tester aussi le numéro de page)
100+ if self.cover_mode or self.page_no() == 1:
101+ return
102+ self.set_y(-15)
103+ self.set_draw_color(*INK3)
104+ self.set_line_width(0.2)
105+ self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)
106+ self.set_font("helvetica", "", 7.5)
107+ self.set_text_color(*INK3)
108+ year = datetime.now(ZoneInfo("America/Toronto")).year
109+ self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")
110+ self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")
111+
112+
113+class GroupeKAReport:
114+ def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
115+ self.site = site
116+ self.d = dashboard
117+ self.mode = mode
118+ self.accent = _hex(site.get("accent", "#d9f26b"))
119+ period = dashboard.get("period", {}) or {}
120+ self.period_label = period.get("label") or "toute la période"
121+ self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
122+ self.toc: list[tuple[str, int]] = []
123+
124+ # ---------- primitives ----------
125+ def _card(self, x, y, w, h, fill=WHITE):
126+ p = self.pdf
127+ p.set_draw_color(*INK)
128+ p.set_line_width(0.45)
129+ p.set_fill_color(*fill)
130+ p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
131+
132+ def _kicker(self, text):
133+ p = self.pdf
134+ p.set_font("helvetica", "B", 8)
135+ p.set_text_color(*GREEN)
136+ p.set_draw_color(*GREEN)
137+ p.set_line_width(0.6)
138+ y = p.get_y() + 2
139+ p.line(p.l_margin, y, p.l_margin + 7, y)
140+ p.set_xy(p.l_margin + 9, y - 2.5)
141+ p.cell(0, 5, text.upper())
142+ p.ln(8)
143+
144+ def _section_title(self, title):
145+ if self.pdf.get_y() > 240:
146+ self.pdf.add_page()
147+ self._kicker("Groupe KA · " + self.site.get("wordmark", ""))
148+ self.pdf.set_font("helvetica", "B", 15)
149+ self.pdf.set_text_color(*INK)
150+ self.pdf.set_x(self.pdf.l_margin)
151+ self.pdf.cell(0, 8, title)
152+ self.toc.append((title, self.pdf.page_no()))
153+ self.pdf.ln(11)
154+
155+ # ---------- pages ----------
156+ def _cover(self):
157+ p = self.pdf
158+ p.cover_mode = True
159+ p.set_auto_page_break(False)
160+ p.add_page()
161+ p.set_fill_color(*PAPER)
162+ p.rect(0, 0, 210, 297, style="F")
163+ p.set_draw_color(*INK)
164+ p.set_line_width(1.0)
165+ p.rect(10, 10, 190, 277)
166+ # kicker
167+ p.set_font("helvetica", "B", 10)
168+ p.set_text_color(*GREEN)
169+ p.set_xy(24, 34)
170+ p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
171+ # wordmark : partie gauche + boîte encre/accent
172+ wm = self.site.get("wordmark", "")
173+ left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
174+ p.set_xy(24, 70)
175+ p.set_font("helvetica", "B", 40)
176+ p.set_text_color(*INK)
177+ p.cell(p.get_string_width(left) + 2, 20, left)
178+ if boxed:
179+ bw = p.get_string_width(boxed) + 12
180+ x = p.get_x() + 2
181+ p.set_fill_color(*INK)
182+ p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)
183+ p.set_text_color(*self.accent)
184+ p.set_xy(x + 6, 70)
185+ p.cell(bw - 12, 18, boxed)
186+ p.set_xy(24, 100)
187+ p.set_font("helvetica", "", 13)
188+ p.set_text_color(*INK2)
189+ p.multi_cell(150, 7, f"Rapport statistique — {wm}")
190+ now = datetime.now(ZoneInfo("America/Toronto"))
191+ per = self.d.get("period", {}) or {}
192+ p.set_xy(24, 125)
193+ p.set_font("helvetica", "", 10.5)
194+ rows = [
195+ ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
196+ ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
197+ ("Plateforme", "https://" + self.site.get("domain", "")),
198+ ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),
199+ ]
200+ y = 128
201+ for k, v in rows:
202+ p.set_xy(24, y)
203+ p.set_text_color(*INK3)
204+ p.cell(40, 6, k)
205+ p.set_text_color(*INK)
206+ p.set_font("helvetica", "B", 10.5)
207+ p.cell(0, 6, str(v))
208+ p.set_font("helvetica", "", 10.5)
209+ y += 8
210+ # bande encre au pied
211+ p.set_fill_color(*INK)
212+ p.rect(10, 262, 190, 25, style="F")
213+ p.set_xy(24, 270)
214+ p.set_font("helvetica", "B", 12)
215+ p.set_text_color(*WHITE)
216+ p.cell(60, 8, "par Groupe ")
217+ p.set_text_color(*self.accent)
218+ p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)
219+ p.cell(20, 8, "KA")
220+ p.set_font("helvetica", "B", 10)
221+ p.set_xy(24, 270)
222+ p.set_text_color(*self.accent)
223+ p.cell(162, 8, "groupe-ka.com", align="R")
224+ p.set_auto_page_break(True, margin=22)
225+ p.cover_mode = False
226+
227+ def _kpis(self):
228+ kpis = self.d.get("kpis") or []
229+ if not kpis:
230+ return
231+ self._section_title("Synthèse des indicateurs")
232+ p = self.pdf
233+ cols, gw, gh, gap = 3, 56, 26, 3
234+ x0, y = p.l_margin, p.get_y()
235+ for i, k in enumerate(kpis[:9]):
236+ x = x0 + (i % cols) * (gw + gap)
237+ if i and i % cols == 0:
238+ y += gh + gap
239+ if y > 250:
240+ p.add_page(); y = p.get_y()
241+ self._card(x, y, gw, gh)
242+ p.set_xy(x + 4, y + 4)
243+ p.set_font("helvetica", "B", 14)
244+ p.set_text_color(*INK)
245+ val = k.get("value")
246+ p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))
247+ p.set_xy(x + 4, y + 12)
248+ p.set_font("helvetica", "", 7.6)
249+ p.set_text_color(*INK2)
250+ p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])
251+ if k.get("delta_pct") is not None:
252+ up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"
253+ p.set_xy(x + 4, y + gh - 6.5)
254+ p.set_font("helvetica", "B", 8)
255+ p.set_text_color(*(GREEN if up else DANGER))
256+ arrow = "+" if k["delta_pct"] >= 0 else ""
257+ p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")
258+ p.set_y(y + gh + 8)
259+
260+ def _line_chart(self, s):
261+ p = self.pdf
262+ pts = s.get("points") or []
263+ if len(pts) < 2:
264+ return
265+ if p.get_y() > 200:
266+ p.add_page()
267+ p.set_font("helvetica", "B", 10)
268+ p.set_text_color(*INK)
269+ p.cell(0, 6, s.get("title", ""))
270+ p.ln(7)
271+ x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
272+ self._card(x0, y0, w, h, fill=WHITE)
273+ cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
274+ vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]
275+ vmax = max(vals) or 1
276+ vmin = min(0, min(vals))
277+ rng = (vmax - vmin) or 1
278+ # grille + graduations
279+ p.set_font("helvetica", "", 6.3)
280+ p.set_text_color(*INK3)
281+ p.set_draw_color(200, 200, 195)
282+ p.set_line_width(0.15)
283+ for g in range(5):
284+ gy = cy + ch - ch * g / 4
285+ p.line(cx, gy, cx + cw, gy)
286+ p.set_xy(x0 + 1, gy - 1.6)
287+ p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
288+
289+ def draw(series, color, width, dash=None):
290+ n = len(series)
291+ p.set_draw_color(*color)
292+ p.set_line_width(width)
293+ if dash:
294+ p.set_dash_pattern(dash=1.2, gap=1.2)
295+ last = None
296+ for i, pt in enumerate(series):
297+ px = cx + cw * (i / (n - 1))
298+ py = cy + ch - ch * ((pt["v"] - vmin) / rng)
299+ if last:
300+ p.line(last[0], last[1], px, py)
301+ last = (px, py)
302+ p.set_dash_pattern()
303+
304+ if s.get("compare"):
305+ draw(s["compare"], INK3, 0.35, dash=True)
306+ draw(pts, self.accent, 0.7)
307+ # libellés d'axe X (premier / milieu / dernier)
308+ p.set_text_color(*INK3)
309+ for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
310+ p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
311+ p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")
312+ p.set_y(y0 + h + 4)
313+ if s.get("compare"):
314+ p.set_font("helvetica", "", 6.8)
315+ p.set_text_color(*INK3)
316+ p.cell(0, 4, "— période courante (accent) · ---- période comparée")
317+ p.ln(6)
318+ else:
319+ p.ln(2)
320+
321+ def _bars(self, title, items, unit=""):
322+ p = self.pdf
323+ items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]
324+ if not items:
325+ return
326+ need = 10 + len(items) * 7
327+ if p.get_y() + need > 265:
328+ p.add_page()
329+ p.set_font("helvetica", "B", 10)
330+ p.set_text_color(*INK)
331+ p.cell(0, 6, title)
332+ p.ln(8)
333+ vmax = max(it["value"] for it in items) or 1
334+ for it in items:
335+ y = p.get_y()
336+ p.set_font("helvetica", "", 7.6)
337+ p.set_text_color(*INK)
338+ p.set_x(p.l_margin)
339+ p.cell(46, 5, str(it["label"])[:34])
340+ bw = 96 * (it["value"] / vmax)
341+ p.set_fill_color(*self.accent)
342+ p.set_draw_color(*INK)
343+ p.set_line_width(0.25)
344+ p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
345+ p.set_xy(p.l_margin + 148, y)
346+ p.set_font("helvetica", "B", 7.6)
347+ p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
348+ p.ln(6.4)
349+ p.ln(3)
350+
351+ def _donut(self, b):
352+ # anneau vectoriel simple (arcs) + légende
353+ p = self.pdf
354+ items = [it for it in (b.get("items") or []) if it.get("value")][:8]
355+ total = sum(it["value"] for it in items)
356+ if not items or not total:
357+ return
358+ if p.get_y() > 210:
359+ p.add_page()
360+ p.set_font("helvetica", "B", 10)
361+ p.set_text_color(*INK)
362+ p.cell(0, 6, b.get("title", ""))
363+ p.ln(8)
364+ cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
365+ shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
366+ start = -90.0
367+ for i, it in enumerate(items):
368+ frac = it["value"] / total
369+ f = shades[i % len(shades)]
370+ col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
371+ steps = max(2, int(72 * frac))
372+ p.set_fill_color(*col)
373+ p.set_draw_color(*col)
374+ for st in range(steps):
375+ a0 = math.radians(start + 360 * frac * st / steps)
376+ a1 = math.radians(start + 360 * frac * (st + 1) / steps)
377+ p.polygon(
378+ [(cx, cy),
379+ (cx + r * math.cos(a0), cy + r * math.sin(a0)),
380+ (cx + r * math.cos(a1), cy + r * math.sin(a1))],
381+ style="DF",
382+ )
383+ start += 360 * frac
384+ p.set_fill_color(*WHITE)
385+ p.set_draw_color(*INK)
386+ p.set_line_width(0.4)
387+ p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
388+ p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
389+ # légende
390+ ly = cy - 22
391+ for i, it in enumerate(items):
392+ f = shades[i % len(shades)]
393+ col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
394+ p.set_fill_color(*col)
395+ p.set_draw_color(*INK)
396+ p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
397+ p.set_xy(p.l_margin + 66, ly)
398+ p.set_font("helvetica", "", 7.6)
399+ p.set_text_color(*INK)
400+ pct = 100 * it["value"] / total
401+ p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))
402+ ly += 5.6
403+ p.set_y(max(cy + r, ly) + 6)
404+
405+ def _table(self, t):
406+ p = self.pdf
407+ cols = t.get("columns") or []
408+ rows = t.get("rows") or []
409+ if not cols or not rows:
410+ return
411+ self._section_title(t.get("title", "Tableau"))
412+ w = 174 / len(cols)
413+ def head():
414+ p.set_font("helvetica", "B", 7.6)
415+ p.set_fill_color(*INK)
416+ p.set_text_color(*WHITE)
417+ for c in cols:
418+ p.cell(w, 6, " " + str(c)[:30], fill=True)
419+ p.ln(6)
420+ head()
421+ p.set_text_color(*INK)
422+ for i, row in enumerate(rows[:200]):
423+ if p.get_y() > 262:
424+ p.add_page()
425+ head()
426+ p.set_text_color(*INK)
427+ p.set_font("helvetica", "", 7.4)
428+ p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))
429+ for cell in row:
430+ txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
431+ p.cell(w, 5.4, " " + txt[:34], fill=True)
432+ p.ln(5.4)
433+ if len(rows) > 200:
434+ p.set_font("helvetica", "", 7)
435+ p.set_text_color(*INK3)
436+ p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")
437+ p.ln(6)
438+
439+ def _records(self):
440+ recs = self.d.get("records") or []
441+ if not recs:
442+ return
443+ self._section_title("Records & faits marquants")
444+ p = self.pdf
445+ for r in recs[:10]:
446+ if p.get_y() > 258:
447+ p.add_page()
448+ y = p.get_y()
449+ self._card(p.l_margin, y, 174, 11, fill=SURFACE2)
450+ p.set_xy(p.l_margin + 4, y + 2)
451+ p.set_font("helvetica", "", 8.6)
452+ p.set_text_color(*INK2)
453+ p.cell(96, 7, str(r.get("label", ""))[:70])
454+ p.set_font("helvetica", "B", 9)
455+ p.set_text_color(*INK)
456+ p.cell(52, 7, str(r.get("value", ""))[:36], align="R")
457+ p.set_font("helvetica", "", 7.6)
458+ p.set_text_color(*INK3)
459+ p.cell(20, 7, str(r.get("date", "") or ""), align="R")
460+ p.set_y(y + 13.5)
461+ p.ln(4)
462+
463+ def _final_page(self):
464+ p = self.pdf
465+ p.add_page()
466+ self._kicker("Groupe KA · contact")
467+ p.set_font("helvetica", "B", 15)
468+ p.set_text_color(*INK)
469+ p.cell(0, 8, "Coordonnées du Groupe KA")
470+ p.ln(12)
471+ for email, role in EMAILS:
472+ p.set_font("helvetica", "B", 10.5)
473+ p.set_text_color(*INK)
474+ p.cell(0, 6, email)
475+ p.ln(5.5)
476+ p.set_font("helvetica", "", 8.6)
477+ p.set_text_color(*INK3)
478+ p.cell(0, 5, role)
479+ p.ln(8)
480+ p.ln(2)
481+ p.set_font("helvetica", "B", 10)
482+ p.set_text_color(*GREEN)
483+ p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")
484+ p.ln(10)
485+ p.set_draw_color(*self.accent)
486+ p.set_line_width(0.8)
487+ p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())
488+ p.ln(4)
489+ p.set_font("helvetica", "", 8.6)
490+ p.set_text_color(*INK2)
491+ p.multi_cell(160, 4.6, DISCLAIMER)
492+ p.ln(4)
493+ p.set_font("helvetica", "", 7.6)
494+ p.set_text_color(*INK3)
495+ p.multi_cell(
496+ 160, 4.2,
497+ "Mentions : rapport généré automatiquement à partir des données réelles de la "
498+ "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "
499+ "de confidentialité et protection des renseignements personnels (Loi 25) : "
500+ "groupe-ka.com/conditions · /confidentialite · /loi-25.",
501+ )
502+
503+ def _toc_page(self):
504+ # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en
505+ # page 2 en réservant la page lors du build (voir build()).
506+ pass
507+
508+ def build(self) -> bytes:
509+ p = self.pdf
510+ p.alias_nb_pages()
511+ self._cover()
512+ if self.mode == "synthese":
513+ p.add_page()
514+ self._kpis()
515+ self._records()
516+ self._final_page()
517+ else:
518+ p.add_page()
519+ toc_page_no = p.page_no()
520+ p.add_page()
521+ self._kpis()
522+ for s in self.d.get("series") or []:
523+ if s.get("kind") == "bar":
524+ self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))
525+ else:
526+ self._line_chart(s)
527+ for b in self.d.get("breakdowns") or []:
528+ if b.get("kind") == "donut":
529+ self._donut(b)
530+ else:
531+ self._bars(b.get("title", ""), b.get("items"))
532+ geo = self.d.get("geo")
533+ if geo:
534+ self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
535+ for t in self.d.get("tables") or []:
536+ self._table(t)
537+ self._records()
538+ self._final_page()
539+ # sommaire écrit sur la page réservée (page 2)
540+ last_page = p.page
541+ p.page = toc_page_no
542+ p.set_y(22)
543+ p.set_font("helvetica", "B", 15)
544+ p.set_text_color(*INK)
545+ p.cell(0, 8, "Sommaire")
546+ p.ln(12)
547+ p.set_font("helvetica", "", 9.5)
548+ for title, page_no in self.toc:
549+ p.set_text_color(*INK)
550+ p.cell(140, 6.5, title[:80])
551+ p.set_text_color(*INK3)
552+ p.cell(0, 6.5, str(page_no), align="R")
553+ p.ln(6.5)
554+ p.page = last_page
555+ return bytes(p.output())
556+
557+
558+def filename(platform_id: str, period: str) -> str:
559+ today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
560+ return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"
added jobka/statsdash.py +428 −0
@@ -0,0 +1,428 @@
1+# =============================================================================
2+# Job·Ka — Groupe KA
3+# Auteur : Simon-Pierre Boucher
4+# Contact : contact@spboucher.ai
5+# Fichier : jobka/statsdash.py
6+# Rôle : Tableau de bord statistique — contrat commun Groupe KA
7+# (voir frontend/src/ka/stats/SPEC.md). Construit le JSON du dashboard à
8+# partir de requêtes SQL agrégées (jobs, sync_log) avec un cache mémoire de
9+# 5 minutes par clé de période. AUCUNE stat inventée : une section sans
10+# donnée réelle est simplement absente du JSON (le front affiche « Pas
11+# encore mesuré »). Les salaires publiés sont bornés [SAL_MIN, SAL_MAX] $/an
12+# pour écarter les aberrations de lecture à la source.
13+# Créé : 2026-08-17 Modifié : 2026-08-17
14+# =============================================================================
15+from __future__ import annotations
16+
17+import threading
18+import time
19+from datetime import date, datetime, timedelta
20+from zoneinfo import ZoneInfo
21+
22+from . import db
23+
24+TZ = ZoneInfo("America/Toronto")
25+
26+CACHE_TTL = 300 # secondes
27+_cache: dict[str, tuple[float, dict]] = {}
28+_cache_lock = threading.Lock()
29+
30+# Bornes de plausibilité des salaires annualisés publiés ($ CA / an) : en
31+# dehors, c'est presque toujours une erreur de parsing à la source (taux
32+# horaire annualisé deux fois, montant par quart, etc.), pas un vrai salaire.
33+SAL_MIN, SAL_MAX = 25_000, 400_000
34+
35+PERIODS = {
36+ "auj": ("Aujourd'hui", 0),
37+ "7j": ("7 jours", 6),
38+ "30j": ("30 jours", 29),
39+ "3m": ("3 mois", 89),
40+ "6m": ("6 mois", 179),
41+ "12m": ("12 mois", 364),
42+}
43+
44+ATS_FR = {
45+ "workday": "Workday", "smartrecruiters": "SmartRecruiters",
46+ "workable": "Workable", "lever": "Lever", "ashby": "Ashby",
47+ "bamboohr": "BambooHR", "breezy": "Breezy", "recruitee": "Recruitee",
48+ "greenhouse": "Greenhouse", "custom": "Site employeur",
49+ "": "Autre / sur mesure",
50+}
51+MODE_FR = {"presentiel": "Présentiel", "hybride": "Hybride",
52+ "teletravail": "Télétravail", None: "Non précisé", "": "Non précisé"}
53+
54+# Fourchettes salariales annualisées (bornes inférieures, $ / an)
55+SAL_BUCKETS = [
56+ (SAL_MIN, 40_000, "25 k$ – 40 k$"),
57+ (40_000, 60_000, "40 k$ – 60 k$"),
58+ (60_000, 80_000, "60 k$ – 80 k$"),
59+ (80_000, 100_000, "80 k$ – 100 k$"),
60+ (100_000, 130_000, "100 k$ – 130 k$"),
61+ (130_000, SAL_MAX + 1, "130 k$ et plus"),
62+]
63+
64+# Milieu de fourchette annualisé (expression SQL réutilisée partout)
65+_SAL_MID = ("(COALESCE(salary_year_min, salary_year_max)"
66+ " + COALESCE(salary_year_max, salary_year_min)) / 2.0")
67+
68+
69+def _sal_where() -> str:
70+ """Clause : offre avec salaire annualisé publié ET plausible."""
71+ return (f" AND (salary_year_min IS NOT NULL OR salary_year_max IS NOT NULL)"
72+ f" AND {_SAL_MID} BETWEEN {SAL_MIN} AND {SAL_MAX}")
73+
74+
75+# ---------------------------------------------------------------- utilitaires
76+def _day_start_ts(d: date) -> float:
77+ return datetime(d.year, d.month, d.day, tzinfo=TZ).timestamp()
78+
79+
80+def _coverage(con) -> tuple[date | None, date | None]:
81+ """Première et dernière date OBSERVÉES par Job·Ka (first/last_seen)."""
82+ row = con.execute(
83+ "SELECT MIN(first_seen) a, MAX(last_seen) b FROM jobs"
84+ " WHERE dup_of IS NULL").fetchone()
85+ if row["a"] is None:
86+ return None, None
87+ return (datetime.fromtimestamp(row["a"], TZ).date(),
88+ datetime.fromtimestamp(row["b"], TZ).date())
89+
90+
91+def resolve_period(period: str, from_: str | None, to_: str | None,
92+ cov_min: date, today: date) -> tuple[date, date, str]:
93+ """Bornes [from, to] (dates locales incluses) + libellé humain."""
94+ if from_ and to_:
95+ try:
96+ a = date.fromisoformat(from_)
97+ b = date.fromisoformat(to_)
98+ if a > b:
99+ a, b = b, a
100+ return max(a, cov_min), min(b, today), f"{a} → {b}"
101+ except ValueError:
102+ pass
103+ if period == "tout":
104+ return cov_min, today, "Toute la période"
105+ if period == "annee":
106+ return max(date(today.year, 1, 1), cov_min), today, "Année en cours"
107+ label, back = PERIODS.get(period, PERIODS["30j"])
108+ return max(today - timedelta(days=back), cov_min), today, label
109+
110+
111+def _pct(cur: float, prev: float) -> float | None:
112+ if not prev:
113+ return None
114+ return round(100.0 * (cur - prev) / prev, 1)
115+
116+
117+def _days(a: date, b: date) -> list[date]:
118+ return [a + timedelta(days=i) for i in range((b - a).days + 1)]
119+
120+
121+def _fr_int(n: float) -> str:
122+ return f"{int(round(n)):,}".replace(",", " ")
123+
124+
125+def _fr_money(n: float) -> str:
126+ return _fr_int(n) + " $"
127+
128+
129+# ------------------------------------------------------------------- dashboard
130+def compute(period: str = "30j", from_: str | None = None,
131+ to_: str | None = None) -> dict:
132+ key = f"{period}|{from_ or ''}|{to_ or ''}"
133+ now = time.time()
134+ with _cache_lock:
135+ hit = _cache.get(key)
136+ if hit and hit[0] > now:
137+ return hit[1]
138+ data = _compute(period, from_, to_)
139+ with _cache_lock:
140+ _cache[key] = (now + CACHE_TTL, data)
141+ return data
142+
143+
144+def _compute(period: str, from_: str | None, to_: str | None) -> dict:
145+ con = db.connect()
146+ try:
147+ return _build(con, period, from_, to_)
148+ finally:
149+ con.close()
150+
151+
152+def _build(con, period: str, from_: str | None, to_: str | None) -> dict:
153+ today = datetime.now(TZ).date()
154+ cov_min, _cov_max = _coverage(con)
155+ if cov_min is None: # base vide
156+ return {"updated": datetime.now(TZ).isoformat(),
157+ "period": {"from": None, "to": None, "label": "—"},
158+ "kpis": [], "series": [], "breakdowns": [], "tables": [],
159+ "records": []}
160+
161+ d_from, d_to, label = resolve_period(period, from_, to_, cov_min, today)
162+ ts_from = _day_start_ts(d_from)
163+ ts_to = _day_start_ts(d_to + timedelta(days=1)) # borne exclusive
164+ iso_from, iso_to = d_from.isoformat(), d_to.isoformat()
165+ BASE = " FROM jobs WHERE active=1 AND dup_of IS NULL"
166+
167+ # ---- histogrammes journaliers (réutilisés partout) ----------------------
168+ starts = {r["d"]: r["n"] for r in con.execute(
169+ "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n"
170+ " FROM jobs WHERE dup_of IS NULL GROUP BY d")}
171+ ends = {r["d"]: r["n"] for r in con.execute(
172+ "SELECT date(last_seen,'unixepoch','localtime') d, COUNT(*) n"
173+ " FROM jobs WHERE dup_of IS NULL AND active=0 GROUP BY d")}
174+
175+ def actives_at(d: date) -> int:
176+ """Reconstruction : cum(first_seen<=d) − cum(retraits<=d−1)."""
177+ iso = d.isoformat()
178+ prev = (d - timedelta(days=1)).isoformat()
179+ s = sum(n for dd, n in starts.items() if dd <= iso)
180+ e = sum(n for dd, n in ends.items() if dd <= prev)
181+ return s - e
182+
183+ # ---- KPI -----------------------------------------------------------------
184+ actives_now = con.execute(f"SELECT COUNT(*) n{BASE}").fetchone()["n"]
185+ employers_now = con.execute(
186+ f"SELECT COUNT(DISTINCT employer) n{BASE} AND employer<>''"
187+ ).fetchone()["n"]
188+ new_in = sum(n for d, n in starts.items() if iso_from <= d <= iso_to)
189+ removed_in = sum(n for d, n in ends.items() if iso_from <= d <= iso_to)
190+
191+ # période précédente de même longueur — deltas SEULEMENT si couverte
192+ span = (d_to - d_from).days + 1
193+ p_from, p_to = d_from - timedelta(days=span), d_from - timedelta(days=1)
194+ prev_ok = p_from >= cov_min
195+ new_prev = removed_prev = None
196+ if prev_ok:
197+ new_prev = sum(n for d, n in starts.items()
198+ if p_from.isoformat() <= d <= p_to.isoformat())
199+ removed_prev = sum(n for d, n in ends.items()
200+ if p_from.isoformat() <= d <= p_to.isoformat())
201+ actives_prev = actives_at(p_to) if p_to >= cov_min else None
202+
203+ row = con.execute(
204+ f"SELECT AVG({_SAL_MID}) avg_s, COUNT(*) n_s{BASE}{_sal_where()}"
205+ ).fetchone()
206+ avg_sal, n_sal = row["avg_s"], row["n_s"]
207+ median_sal = None
208+ if n_sal:
209+ median_sal = con.execute(
210+ f"SELECT {_SAL_MID} m{BASE}{_sal_where()}"
211+ f" ORDER BY m LIMIT 1 OFFSET ?", (n_sal // 2,)).fetchone()["m"]
212+
213+ remote_n = con.execute(
214+ f"SELECT COUNT(*) n{BASE} AND work_mode='teletravail'").fetchone()["n"]
215+ pct_remote = round(100.0 * remote_n / actives_now, 1) if actives_now else None
216+
217+ kpis = [
218+ {"id": "actives", "label": "Offres actives", "value": actives_now,
219+ "delta_pct": _pct(actives_now, actives_prev) if actives_prev else None,
220+ "direction": ("up" if actives_now >= actives_prev else "down")
221+ if actives_prev else None},
222+ {"id": "nouvelles", "label": "Nouvelles offres (période)",
223+ "value": new_in,
224+ "delta_pct": _pct(new_in, new_prev) if prev_ok else None,
225+ "direction": ("up" if new_in >= (new_prev or 0) else "down")
226+ if prev_ok else None},
227+ {"id": "retirees", "label": "Offres retirées ou expirées (période)",
228+ "value": removed_in,
229+ "delta_pct": _pct(removed_in, removed_prev) if prev_ok else None,
230+ "direction": ("down" if removed_in >= (removed_prev or 0) else "up")
231+ if prev_ok else None},
232+ {"id": "employeurs", "label": "Employeurs avec offres actives",
233+ "value": employers_now},
234+ ]
235+ if avg_sal:
236+ kpis.append({"id": "salaire_moyen",
237+ "label": "Salaire annuel moyen publié",
238+ "value": round(avg_sal), "unit": "$"})
239+ if median_sal:
240+ kpis.append({"id": "salaire_median",
241+ "label": "Salaire annuel médian publié",
242+ "value": round(median_sal), "unit": "$"})
243+ if pct_remote is not None:
244+ kpis.append({"id": "teletravail", "label": "Offres en télétravail",
245+ "value": pct_remote, "unit": "%"})
246+ kpis = [{k: v for k, v in kpi.items() if v is not None} for kpi in kpis]
247+
248+ # ---- séries temporelles ----------------------------------------------------
249+ days = _days(d_from, d_to)
250+ series = []
251+ if len(days) >= 2:
252+ s_act = {"id": "actives_jour", "title": "Offres actives par jour",
253+ "unit": "offres", "kind": "line",
254+ "points": [{"t": d.isoformat(), "v": actives_at(d)} for d in days]}
255+ s_new = {"id": "nouvelles_jour", "title": "Nouvelles offres par jour",
256+ "unit": "offres", "kind": "line",
257+ "points": [{"t": d.isoformat(),
258+ "v": starts.get(d.isoformat(), 0)} for d in days]}
259+ if prev_ok:
260+ pdays = _days(p_from, p_to)
261+ s_act["compare"] = [{"t": d.isoformat(), "v": actives_at(d)}
262+ for d in pdays]
263+ s_new["compare"] = [{"t": d.isoformat(),
264+ "v": starts.get(d.isoformat(), 0)}
265+ for d in pdays]
266+ series = [s_act, s_new]
267+
268+ # dates de publication AFFICHÉES par les employeurs — données réelles qui
269+ # précèdent la mise en service de Job·Ka : la fenêtre demandée est prise
270+ # SANS la borner à la couverture d'observation (first_seen).
271+ rq_from, rq_to, _ = resolve_period(period, from_, to_, date(2000, 1, 1), today)
272+ rq_from = max(rq_from, today - timedelta(days=365)) # 12 mois max (lisibilité)
273+ posted_rows = con.execute(
274+ f"SELECT date_posted d, COUNT(*) n{BASE}"
275+ " AND date_posted IS NOT NULL AND date_posted>=? AND date_posted<=?"
276+ " GROUP BY date_posted ORDER BY date_posted",
277+ (rq_from.isoformat(), rq_to.isoformat())).fetchall()
278+ if len(posted_rows) >= 2:
279+ by_day = {r["d"]: r["n"] for r in posted_rows}
280+ series.append({"id": "publiees_jour",
281+ "title": "Offres publiées par jour "
282+ "(date affichée par l'employeur)",
283+ "unit": "offres", "kind": "line",
284+ "points": [{"t": d.isoformat(),
285+ "v": by_day.get(d.isoformat(), 0)}
286+ for d in _days(rq_from, rq_to)]})
287+
288+ # ---- répartitions ----------------------------------------------------------
289+ breakdowns = []
290+ ats_items = [{"label": ATS_FR.get(r["a"] or "", (r["a"] or "").title()),
291+ "value": r["n"]}
292+ for r in con.execute(
293+ f"SELECT ats a, COUNT(*) n{BASE}"
294+ " GROUP BY ats ORDER BY n DESC LIMIT 10")]
295+ if ats_items:
296+ breakdowns.append({"id": "ats",
297+ "title": "Offres actives par plateforme ATS",
298+ "kind": "donut", "items": ats_items})
299+
300+ sal_items = []
301+ for lo, hi, blabel in SAL_BUCKETS:
302+ n = con.execute(
303+ f"SELECT COUNT(*) n{BASE}{_sal_where()}"
304+ f" AND {_SAL_MID} >= ? AND {_SAL_MID} < ?", (lo, hi)).fetchone()["n"]
305+ if n:
306+ sal_items.append({"label": blabel, "value": n})
307+ if sal_items:
308+ breakdowns.append({"id": "salaires",
309+ "title": "Fourchettes salariales publiées "
310+ "($ / an, offres actives)",
311+ "kind": "bar", "items": sal_items})
312+
313+ mode_items = [{"label": MODE_FR.get(r["m"], r["m"] or "Non précisé"),
314+ "value": r["n"]}
315+ for r in con.execute(
316+ f"SELECT work_mode m, COUNT(*) n{BASE}"
317+ " GROUP BY work_mode ORDER BY n DESC")]
318+ if mode_items:
319+ breakdowns.append({"id": "modes",
320+ "title": "Télétravail, hybride, présentiel",
321+ "kind": "donut", "items": mode_items})
322+
323+ # ---- géographie (région administrative peu remplie -> villes) --------------
324+ geo_items = [{"label": r["c"], "value": r["n"]} for r in con.execute(
325+ f"SELECT city c, COUNT(*) n{BASE} AND city<>''"
326+ " GROUP BY city ORDER BY n DESC LIMIT 14")]
327+ geo = ({"title": "Top villes (offres actives)", "items": geo_items}
328+ if geo_items else None)
329+
330+ # ---- heatmap (nouvelles offres observées par jour) --------------------------
331+ heat_cells = [{"date": d.isoformat(), "value": starts.get(d.isoformat(), 0)}
332+ for d in days if starts.get(d.isoformat())]
333+ heatmap = ({"title": "Nouvelles offres par jour", "cells": heat_cells}
334+ if heat_cells else None)
335+
336+ # ---- tableaux ----------------------------------------------------------------
337+ tables = []
338+ top_emp = con.execute(
339+ f"""SELECT employer, COUNT(*) n,
340+ AVG(CASE WHEN {_SAL_MID} BETWEEN {SAL_MIN} AND {SAL_MAX}
341+ AND (salary_year_min IS NOT NULL
342+ OR salary_year_max IS NOT NULL)
343+ THEN {_SAL_MID} END) avg_s,
344+ SUM(CASE WHEN first_seen>=? AND first_seen<? THEN 1 ELSE 0 END) new_n
345+ {BASE} AND employer<>''
346+ GROUP BY employer ORDER BY n DESC LIMIT 50""",
347+ (ts_from, ts_to)).fetchall()
348+ if top_emp:
349+ tables.append({
350+ "id": "top_employeurs", "title": "Top employeurs",
351+ "columns": ["Employeur", "Offres actives",
352+ "Salaire annuel moyen publié", "Nouvelles (période)"],
353+ "rows": [[r["employer"], r["n"],
354+ _fr_money(r["avg_s"]) if r["avg_s"] else "—",
355+ r["new_n"]] for r in top_emp]})
356+
357+ top_cities = con.execute(
358+ f"""SELECT city, COUNT(*) n,
359+ AVG(CASE WHEN {_SAL_MID} BETWEEN {SAL_MIN} AND {SAL_MAX}
360+ AND (salary_year_min IS NOT NULL
361+ OR salary_year_max IS NOT NULL)
362+ THEN {_SAL_MID} END) avg_s,
363+ SUM(CASE WHEN first_seen>=? AND first_seen<? THEN 1 ELSE 0 END) new_n
364+ {BASE} AND city<>''
365+ GROUP BY city ORDER BY n DESC LIMIT 50""",
366+ (ts_from, ts_to)).fetchall()
367+ if top_cities:
368+ tables.append({
369+ "id": "top_villes", "title": "Top villes",
370+ "columns": ["Ville", "Offres actives",
371+ "Salaire annuel moyen publié", "Nouvelles (période)"],
372+ "rows": [[r["city"], r["n"],
373+ _fr_money(r["avg_s"]) if r["avg_s"] else "—",
374+ r["new_n"]] for r in top_cities]})
375+
376+ new_rows = con.execute(
377+ f"""SELECT title, employer, city, salary_label, date_posted
378+ {BASE} AND first_seen>=? AND first_seen<?
379+ ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC
380+ LIMIT 50""", (ts_from, ts_to)).fetchall()
381+ if new_rows:
382+ tables.append({
383+ "id": "nouvelles_offres", "title": "Nouvelles offres (période)",
384+ "columns": ["Titre", "Employeur", "Ville", "Salaire publié",
385+ "Publiée le"],
386+ "rows": [[r["title"][:70], r["employer"], r["city"] or "—",
387+ r["salary_label"] or "—", r["date_posted"] or "—"]
388+ for r in new_rows]})
389+
390+ # ---- records & faits marquants -------------------------------------------------
391+ records = []
392+ in_period = {d: n for d, n in starts.items() if iso_from <= d <= iso_to}
393+ if in_period:
394+ best = max(in_period, key=in_period.get)
395+ records.append({"label": "Jour record de nouvelles offres",
396+ "value": _fr_int(in_period[best]) + " offres",
397+ "date": best})
398+ top_sal = con.execute(
399+ f"""SELECT title, employer, salary_year_max s
400+ {BASE} AND salary_year_max BETWEEN {SAL_MIN} AND {SAL_MAX}
401+ ORDER BY salary_year_max DESC LIMIT 1""").fetchone()
402+ if top_sal:
403+ records.append({
404+ "label": "Salaire publié le plus élevé (borné, offres actives)",
405+ "value": f"{_fr_money(top_sal['s'])} / an — "
406+ f"{top_sal['title'][:40]} ({top_sal['employer']})"})
407+ if top_emp:
408+ emp = max(top_emp, key=lambda r: r["new_n"])
409+ if emp["new_n"]:
410+ records.append({"label": "Employeur le plus actif "
411+ "(nouvelles offres, période)",
412+ "value": f"{emp['employer']} — "
413+ f"{_fr_int(emp['new_n'])} offres"})
414+
415+ out = {
416+ "updated": datetime.now(TZ).isoformat(),
417+ "period": {"from": iso_from, "to": iso_to, "label": label},
418+ "kpis": kpis,
419+ "series": series,
420+ "breakdowns": breakdowns,
421+ "tables": tables,
422+ "records": records,
423+ }
424+ if geo:
425+ out["geo"] = geo
426+ if heatmap:
427+ out["heatmap"] = heatmap
428+ return out
modified jobka/web.py +42 −3
@@ -16,10 +16,10 @@ from pathlib import Path
16 from fastapi import BackgroundTasks, FastAPI, HTTPException, Query 16 from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
17 from fastapi.middleware.cors import CORSMiddleware 17 from fastapi.middleware.cors import CORSMiddleware
18 from fastapi.middleware.gzip import GZipMiddleware 18 from fastapi.middleware.gzip import GZipMiddleware
19−from fastapi.responses import FileResponse 19+from fastapi.responses import FileResponse, Response
20 from fastapi.staticfiles import StaticFiles 20 from fastapi.staticfiles import StaticFiles
21 21
22−from . import db, ingest 22+from . import auth, db, ingest
23 23
24 ROOT = Path(__file__).resolve().parent.parent 24 ROOT = Path(__file__).resolve().parent.parent
25 SOURCES_PATH = ROOT / "data" / "sources.json" 25 SOURCES_PATH = ROOT / "data" / "sources.json"
@@ -32,6 +32,9 @@ app.add_middleware(CORSMiddleware, allow_origins=["*"],
32 allow_methods=["*"], allow_headers=["*"]) 32 allow_methods=["*"], allow_headers=["*"])
33 app.add_middleware(GZipMiddleware, minimum_size=1000) 33 app.add_middleware(GZipMiddleware, minimum_size=1000)
34 34
35+# connexion KA ID (SSO du hub groupe-ka.com) — voir jobka/auth.py
36+app.include_router(auth.router)
37+
35 _sync_lock = threading.Lock() 38 _sync_lock = threading.Lock()
36 39
37 40
@@ -295,6 +298,41 @@ def stats():
295 "recent_syncs": log} 298 "recent_syncs": log}
296 299
297 300
301+@app.get("/api/stats/dashboard")
302+def stats_dashboard(period: str = "30j",
303+ from_: str | None = Query(None, alias="from"),
304+ to: str | None = None):
305+ """Tableau de bord analytique — contrat commun Groupe KA (SPEC.md).
306+ Cache mémoire 5 min par clé de période (voir jobka/statsdash.py)."""
307+ from . import statsdash
308+ if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"):
309+ period = "30j"
310+ return statsdash.compute(period, from_, to)
311+
312+
313+@app.get("/api/stats/report")
314+def stats_report(period: str = "30j",
315+ from_: str | None = Query(None, alias="from"),
316+ to: str | None = None,
317+ mode: str = "complet"):
318+ """Rapport PDF estampillé Groupe-KA (complet ou synthèse 2 pages)."""
319+ from . import kapdf, statsdash
320+ if period not in ("auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"):
321+ period = "30j"
322+ dash = statsdash.compute(period, from_, to)
323+ data = kapdf.GroupeKAReport(
324+ site={"wordmark": "Job·Ka", "accent": "#0c8599",
325+ "domain": "www.job-ka.com",
326+ "tagline": "Tous les emplois des employeurs québécois"},
327+ dashboard=dash,
328+ mode="synthese" if mode == "synthese" else "complet",
329+ ).build()
330+ fname = kapdf.filename("job-ka", period)
331+ return Response(
332+ content=data, media_type="application/pdf",
333+ headers={"Content-Disposition": f'attachment; filename="{fname}"'})
334+
335+
298 @app.post("/api/sync") 336 @app.post("/api/sync")
299 def trigger_sync(background: BackgroundTasks, source: str | None = None): 337 def trigger_sync(background: BackgroundTasks, source: str | None = None):
300 """Déclenche une synchronisation (équivalent d'un webhook entrant).""" 338 """Déclenche une synchronisation (équivalent d'un webhook entrant)."""
@@ -323,7 +361,8 @@ if FRONTEND_DIST.exists():
323 361
324 # routes rendues uniquement côté client — tout autre chemin inconnu renvoie 362 # routes rendues uniquement côté client — tout autre chemin inconnu renvoie
325 # index.html avec un statut 404 (pas de soft-404 pour les moteurs) 363 # index.html avec un statut 404 (pas de soft-404 pour les moteurs)
326− _CLIENT_ROUTES = {"carte", "sources", "stats", "confidentialite", "conditions"} 364+ _CLIENT_ROUTES = {"carte", "sources", "stats", "contact",
365+ "confidentialite", "conditions"}
327 _CLIENT_PREFIXES = ("emploi/",) 366 _CLIENT_PREFIXES = ("emploi/",)
328 367
329 @app.get("/{full_path:path}") 368 @app.get("/{full_path:path}")
modified requirements.txt +1 −0
@@ -10,3 +10,4 @@ fastapi>=0.110
10 uvicorn>=0.29 10 uvicorn>=0.29
11 requests>=2.31 11 requests>=2.31
12 beautifulsoup4>=4.12 12 beautifulsoup4>=4.12
13+fpdf2>=2.7 # rapports PDF Groupe-KA (jobka/kapdf.py)
13 14
Menu

Theme