SPB Git

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

Page /stats : tableau de bord analytique de la plateforme + rapport PDF Groupe-KA

- Journalisation légère des requêtes API : table api_requests (ts, méthode,
  endpoint normalisé, statut, durée) alimentée par le middleware de logging
  via un tampon mémoire non bloquant + tâche de fond (flush 5 s, purge 90 j).
- Backfill : scripts/backfill_api_requests.py réimporte l historique réel des
  logs JSON (811 requêtes depuis le 2026-08-16), idempotent.
- GET /api/stats/dashboard : contrat commun Groupe KA (SPEC.md) — KPI avec
  deltas, séries jour/heure (appels, latence, enregistrements collectés),
  top endpoints, anneau par service, heatmap, tableaux, records ; cache 5 min.
- GET /api/stats/report : PDF Groupe-KA (moteur kapdf/fpdf2, wordmark API·Ka,
  accent #3b5bdb), modes complet et synthèse ; correctif kit : pied de page
  qui fuyait sur la couverture.
- Page /stats (vanilla JS + SVG, langage visuel du kit kacharts) : chips de
  période + plage personnalisée, KPI, courbes avec infobulle et légende
  cliquable, barres, anneau, heatmap, tableaux triables (recherche +
  pagination), records, boutons PDF, états vides propres, mobile 360-1440.
- Lien « Stats » dans la nav de index.html et contact.html ; fpdf2 ajouté aux
  requirements ; tests /api/stats (32 tests verts).

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

15 changed files +3,229 −4

modified requirements.txt +1 −0
@@ -15,6 +15,7 @@ alembic>=1.13
15 apscheduler>=3.10 15 apscheduler>=3.10
16 httpx>=0.27 16 httpx>=0.27
17 python-dotenv>=1.0 17 python-dotenv>=1.0
18+fpdf2>=2.8
18 19
19 # Développement / tests 20 # Développement / tests
20 pytest>=8.0 21 pytest>=8.0
added scripts/backfill_api_requests.py +93 −0
@@ -0,0 +1,93 @@
1+# ============================================
2+# Projet : API-KA
3+# Fichier : scripts/backfill_api_requests.py
4+# Node : m3u96b
5+# Author : Simon-Pierre Boucher
6+# Contact : contact@spboucher.ai
7+# Date : 2026-08-17
8+# ============================================
9+"""Rattrapage : importe l'historique des requêtes HTTP déjà journalisées dans
10+``logs/apika.log*`` (lignes JSON ``http_request`` du middleware) vers la table
11+``api_requests`` qui alimente la page /stats.
12+
13+Idempotent : seules les lignes STRICTEMENT antérieures au plus ancien
14+enregistrement déjà présent dans ``api_requests`` sont insérées (à la première
15+exécution, tout l'historique des logs est repris ; aux suivantes, rien).
16+
17+Usage : venv/bin/python -m scripts.backfill_api_requests
18+"""
19+
20+from __future__ import annotations
21+
22+import datetime
23+import json
24+
25+from sqlalchemy import func, select
26+
27+from src.api.reqstats import normalize_endpoint
28+from src.config import get_settings
29+from src.database.db import init_db, session_scope
30+from src.database.models import ApiRequest
31+from src.utils.logger import get_logger
32+
33+
34+def main() -> None:
35+ logger = get_logger("apika.backfill_requests")
36+ init_db()
37+ settings = get_settings()
38+
39+ with session_scope() as session:
40+ cutoff = session.execute(select(func.min(ApiRequest.ts))).scalar()
41+ if cutoff is not None and cutoff.tzinfo is None:
42+ cutoff = cutoff.replace(tzinfo=datetime.UTC)
43+ if cutoff is None:
44+ cutoff = datetime.datetime.now(tz=datetime.UTC)
45+
46+ entries: list[dict] = []
47+ parsed = skipped = 0
48+ for log_file in sorted(settings.logs_dir.glob("apika.log*")):
49+ with open(log_file, encoding="utf-8") as fh:
50+ for line in fh:
51+ line = line.strip()
52+ if not line or '"http_request"' not in line:
53+ continue
54+ try:
55+ rec = json.loads(line)
56+ except json.JSONDecodeError:
57+ continue
58+ if rec.get("message") != "http_request":
59+ continue
60+ parsed += 1
61+ try:
62+ ts = datetime.datetime.fromisoformat(rec["timestamp"])
63+ except (KeyError, ValueError):
64+ skipped += 1
65+ continue
66+ if ts.tzinfo is None:
67+ ts = ts.replace(tzinfo=datetime.UTC)
68+ if ts >= cutoff:
69+ skipped += 1
70+ continue
71+ entries.append(
72+ {
73+ "ts": ts,
74+ "method": rec.get("method", "GET"),
75+ "endpoint": normalize_endpoint(rec.get("path", "(autre)")),
76+ "status": int(rec.get("status_code", 0)),
77+ "duration_ms": round(float(rec.get("duration_ms", 0.0)), 2),
78+ }
79+ )
80+
81+ if entries:
82+ with session_scope() as session:
83+ session.bulk_insert_mappings(ApiRequest.__mapper__, entries)
84+
85+ logger.info(
86+ "Backfill api_requests terminé",
87+ extra={"inserted": len(entries), "parsed": parsed, "skipped": skipped},
88+ )
89+ print(f"Backfill : {len(entries)} requêtes insérées ({parsed} lues, {skipped} ignorées)")
90+
91+
92+if __name__ == "__main__":
93+ main()
added src/api/kapdf.py +568 −0
@@ -0,0 +1,568 @@
1+# ============================================
2+# Projet : API-KA
3+# Fichier : src/api/kapdf.py
4+# Node : m3u96b
5+# Author : Simon-Pierre Boucher
6+# Contact : contact@spboucher.ai
7+# Date : 2026-08-17
8+# ============================================
9+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
10+# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2).
11+# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
12+# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques
13+# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.
14+# Usage :
15+# from kapdf import GroupeKAReport
16+# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
17+# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
18+# mode="complet").build()
19+# Dépendance : pip install fpdf2 (aucune autre)
20+from __future__ import annotations
21+
22+import math
23+from datetime import datetime
24+from zoneinfo import ZoneInfo
25+
26+from fpdf import FPDF
27+
28+INK = (20, 24, 20)
29+INK2 = (77, 85, 81)
30+INK3 = (139, 146, 140)
31+PAPER = (245, 243, 238)
32+SURFACE2 = (250, 249, 245)
33+GREEN = (28, 92, 65)
34+DANGER = (179, 66, 58)
35+WHITE = (255, 255, 255)
36+
37+EMAILS = [
38+ ("contact@groupe-ka.com", "Projets, partenariats & données"),
39+ ("info@groupe-ka.com", "Médias & questions générales"),
40+ ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),
41+]
42+DISCLAIMER = (
43+ "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "
44+ "rien et ne sommes partie à aucune transaction. Données lues à la source, "
45+ "rien d'inventé, tout est traçable."
46+)
47+
48+
49+def _hex(c: str) -> tuple[int, int, int]:
50+ c = c.lstrip("#")
51+ return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore
52+
53+
54+def _fr(n) -> str:
55+ if isinstance(n, float) and not n.is_integer():
56+ return f"{n:,.2f}".replace(",", " ").replace(".", ",")
57+ return f"{int(n):,}".replace(",", " ")
58+
59+
60+_SUBST = {
61+ "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
62+ "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
63+ "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
64+}
65+
66+
67+def _latin1(s: str) -> str:
68+ for k, v in _SUBST.items():
69+ s = s.replace(k, v)
70+ return s.encode("latin-1", "replace").decode("latin-1")
71+
72+
73+class _PDF(FPDF):
74+ """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
75+ Les polices core sont latin-1 : normalize_text sanitise en amont."""
76+
77+ def normalize_text(self, text):
78+ return super().normalize_text(_latin1(text))
79+
80+ def __init__(self, brand: str, accent: tuple, period_label: str):
81+ super().__init__(orientation="P", unit="mm", format="A4")
82+ self.brand = brand
83+ self.accent = accent
84+ self.period_label = period_label
85+ self.cover_mode = False
86+ self.set_margins(18, 20, 18)
87+ self.set_auto_page_break(True, margin=22)
88+
89+ def header(self):
90+ if self.cover_mode:
91+ return
92+ self.set_font("helvetica", "B", 8.5)
93+ self.set_text_color(*INK)
94+ self.set_xy(18, 9)
95+ self.cell(0, 5, f"Groupe KA · {self.brand}")
96+ self.set_font("helvetica", "", 8)
97+ self.set_text_color(*INK3)
98+ self.set_xy(18, 9)
99+ self.cell(0, 5, "Rapport statistique", align="R")
100+ self.set_draw_color(*INK)
101+ self.set_line_width(0.5)
102+ self.line(18, 15.5, 192, 15.5)
103+ self.set_y(20)
104+
105+ def footer(self):
106+ # La page 1 est toujours la couverture : fpdf dessine son pied de page
107+ # au add_page() suivant, quand cover_mode est deja retombe a False.
108+ if self.cover_mode or self.page_no() == 1:
109+ return
110+ self.set_y(-15)
111+ self.set_draw_color(*INK3)
112+ self.set_line_width(0.2)
113+ self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)
114+ self.set_font("helvetica", "", 7.5)
115+ self.set_text_color(*INK3)
116+ year = datetime.now(ZoneInfo("America/Toronto")).year
117+ self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")
118+ self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")
119+
120+
121+class GroupeKAReport:
122+ def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
123+ self.site = site
124+ self.d = dashboard
125+ self.mode = mode
126+ self.accent = _hex(site.get("accent", "#d9f26b"))
127+ period = dashboard.get("period", {}) or {}
128+ self.period_label = period.get("label") or "toute la période"
129+ self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
130+ self.toc: list[tuple[str, int]] = []
131+
132+ # ---------- primitives ----------
133+ def _card(self, x, y, w, h, fill=WHITE):
134+ p = self.pdf
135+ p.set_draw_color(*INK)
136+ p.set_line_width(0.45)
137+ p.set_fill_color(*fill)
138+ p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
139+
140+ def _kicker(self, text):
141+ p = self.pdf
142+ p.set_font("helvetica", "B", 8)
143+ p.set_text_color(*GREEN)
144+ p.set_draw_color(*GREEN)
145+ p.set_line_width(0.6)
146+ y = p.get_y() + 2
147+ p.line(p.l_margin, y, p.l_margin + 7, y)
148+ p.set_xy(p.l_margin + 9, y - 2.5)
149+ p.cell(0, 5, text.upper())
150+ p.ln(8)
151+
152+ def _section_title(self, title):
153+ if self.pdf.get_y() > 240:
154+ self.pdf.add_page()
155+ self._kicker("Groupe KA · " + self.site.get("wordmark", ""))
156+ self.pdf.set_font("helvetica", "B", 15)
157+ self.pdf.set_text_color(*INK)
158+ self.pdf.set_x(self.pdf.l_margin)
159+ self.pdf.cell(0, 8, title)
160+ self.toc.append((title, self.pdf.page_no()))
161+ self.pdf.ln(11)
162+
163+ # ---------- pages ----------
164+ def _cover(self):
165+ p = self.pdf
166+ p.cover_mode = True
167+ p.set_auto_page_break(False)
168+ p.add_page()
169+ p.set_fill_color(*PAPER)
170+ p.rect(0, 0, 210, 297, style="F")
171+ p.set_draw_color(*INK)
172+ p.set_line_width(1.0)
173+ p.rect(10, 10, 190, 277)
174+ # kicker
175+ p.set_font("helvetica", "B", 10)
176+ p.set_text_color(*GREEN)
177+ p.set_xy(24, 34)
178+ p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
179+ # wordmark : partie gauche + boîte encre/accent
180+ wm = self.site.get("wordmark", "")
181+ left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
182+ p.set_xy(24, 70)
183+ p.set_font("helvetica", "B", 40)
184+ p.set_text_color(*INK)
185+ p.cell(p.get_string_width(left) + 2, 20, left)
186+ if boxed:
187+ bw = p.get_string_width(boxed) + 12
188+ x = p.get_x() + 2
189+ p.set_fill_color(*INK)
190+ p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)
191+ p.set_text_color(*self.accent)
192+ p.set_xy(x + 6, 70)
193+ p.cell(bw - 12, 18, boxed)
194+ p.set_xy(24, 100)
195+ p.set_font("helvetica", "", 13)
196+ p.set_text_color(*INK2)
197+ p.multi_cell(150, 7, f"Rapport statistique — {wm}")
198+ now = datetime.now(ZoneInfo("America/Toronto"))
199+ per = self.d.get("period", {}) or {}
200+ p.set_xy(24, 125)
201+ p.set_font("helvetica", "", 10.5)
202+ rows = [
203+ ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
204+ ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
205+ ("Plateforme", "https://" + self.site.get("domain", "")),
206+ ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),
207+ ]
208+ y = 128
209+ for k, v in rows:
210+ p.set_xy(24, y)
211+ p.set_text_color(*INK3)
212+ p.cell(40, 6, k)
213+ p.set_text_color(*INK)
214+ p.set_font("helvetica", "B", 10.5)
215+ p.cell(0, 6, str(v))
216+ p.set_font("helvetica", "", 10.5)
217+ y += 8
218+ # bande encre au pied
219+ p.set_fill_color(*INK)
220+ p.rect(10, 262, 190, 25, style="F")
221+ p.set_xy(24, 270)
222+ p.set_font("helvetica", "B", 12)
223+ p.set_text_color(*WHITE)
224+ p.cell(60, 8, "par Groupe ")
225+ p.set_text_color(*self.accent)
226+ p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)
227+ p.cell(20, 8, "KA")
228+ p.set_font("helvetica", "B", 10)
229+ p.set_xy(24, 270)
230+ p.set_text_color(*self.accent)
231+ p.cell(162, 8, "groupe-ka.com", align="R")
232+ p.set_auto_page_break(True, margin=22)
233+ p.cover_mode = False
234+
235+ def _kpis(self):
236+ kpis = self.d.get("kpis") or []
237+ if not kpis:
238+ return
239+ self._section_title("Synthèse des indicateurs")
240+ p = self.pdf
241+ cols, gw, gh, gap = 3, 56, 26, 3
242+ x0, y = p.l_margin, p.get_y()
243+ for i, k in enumerate(kpis[:9]):
244+ x = x0 + (i % cols) * (gw + gap)
245+ if i and i % cols == 0:
246+ y += gh + gap
247+ if y > 250:
248+ p.add_page(); y = p.get_y()
249+ self._card(x, y, gw, gh)
250+ p.set_xy(x + 4, y + 4)
251+ p.set_font("helvetica", "B", 14)
252+ p.set_text_color(*INK)
253+ val = k.get("value")
254+ p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))
255+ p.set_xy(x + 4, y + 12)
256+ p.set_font("helvetica", "", 7.6)
257+ p.set_text_color(*INK2)
258+ p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])
259+ if k.get("delta_pct") is not None:
260+ up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"
261+ p.set_xy(x + 4, y + gh - 6.5)
262+ p.set_font("helvetica", "B", 8)
263+ p.set_text_color(*(GREEN if up else DANGER))
264+ arrow = "+" if k["delta_pct"] >= 0 else ""
265+ p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")
266+ p.set_y(y + gh + 8)
267+
268+ def _line_chart(self, s):
269+ p = self.pdf
270+ pts = s.get("points") or []
271+ if len(pts) < 2:
272+ return
273+ if p.get_y() > 200:
274+ p.add_page()
275+ p.set_font("helvetica", "B", 10)
276+ p.set_text_color(*INK)
277+ p.cell(0, 6, s.get("title", ""))
278+ p.ln(7)
279+ x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
280+ self._card(x0, y0, w, h, fill=WHITE)
281+ cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
282+ vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]
283+ vmax = max(vals) or 1
284+ vmin = min(0, min(vals))
285+ rng = (vmax - vmin) or 1
286+ # grille + graduations
287+ p.set_font("helvetica", "", 6.3)
288+ p.set_text_color(*INK3)
289+ p.set_draw_color(200, 200, 195)
290+ p.set_line_width(0.15)
291+ for g in range(5):
292+ gy = cy + ch - ch * g / 4
293+ p.line(cx, gy, cx + cw, gy)
294+ p.set_xy(x0 + 1, gy - 1.6)
295+ p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
296+
297+ def draw(series, color, width, dash=None):
298+ n = len(series)
299+ p.set_draw_color(*color)
300+ p.set_line_width(width)
301+ if dash:
302+ p.set_dash_pattern(dash=1.2, gap=1.2)
303+ last = None
304+ for i, pt in enumerate(series):
305+ px = cx + cw * (i / (n - 1))
306+ py = cy + ch - ch * ((pt["v"] - vmin) / rng)
307+ if last:
308+ p.line(last[0], last[1], px, py)
309+ last = (px, py)
310+ p.set_dash_pattern()
311+
312+ if s.get("compare"):
313+ draw(s["compare"], INK3, 0.35, dash=True)
314+ draw(pts, self.accent, 0.7)
315+ # libellés d'axe X (premier / milieu / dernier)
316+ p.set_text_color(*INK3)
317+ for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
318+ p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
319+ p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")
320+ p.set_y(y0 + h + 4)
321+ if s.get("compare"):
322+ p.set_font("helvetica", "", 6.8)
323+ p.set_text_color(*INK3)
324+ p.cell(0, 4, "— période courante (accent) · ---- période comparée")
325+ p.ln(6)
326+ else:
327+ p.ln(2)
328+
329+ def _bars(self, title, items, unit=""):
330+ p = self.pdf
331+ items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]
332+ if not items:
333+ return
334+ need = 10 + len(items) * 7
335+ if p.get_y() + need > 265:
336+ p.add_page()
337+ p.set_font("helvetica", "B", 10)
338+ p.set_text_color(*INK)
339+ p.cell(0, 6, title)
340+ p.ln(8)
341+ vmax = max(it["value"] for it in items) or 1
342+ for it in items:
343+ y = p.get_y()
344+ p.set_font("helvetica", "", 7.6)
345+ p.set_text_color(*INK)
346+ p.set_x(p.l_margin)
347+ p.cell(46, 5, str(it["label"])[:34])
348+ bw = 96 * (it["value"] / vmax)
349+ p.set_fill_color(*self.accent)
350+ p.set_draw_color(*INK)
351+ p.set_line_width(0.25)
352+ p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
353+ p.set_xy(p.l_margin + 148, y)
354+ p.set_font("helvetica", "B", 7.6)
355+ p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
356+ p.ln(6.4)
357+ p.ln(3)
358+
359+ def _donut(self, b):
360+ # anneau vectoriel simple (arcs) + légende
361+ p = self.pdf
362+ items = [it for it in (b.get("items") or []) if it.get("value")][:8]
363+ total = sum(it["value"] for it in items)
364+ if not items or not total:
365+ return
366+ if p.get_y() > 210:
367+ p.add_page()
368+ p.set_font("helvetica", "B", 10)
369+ p.set_text_color(*INK)
370+ p.cell(0, 6, b.get("title", ""))
371+ p.ln(8)
372+ cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
373+ shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
374+ start = -90.0
375+ for i, it in enumerate(items):
376+ frac = it["value"] / total
377+ f = shades[i % len(shades)]
378+ col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
379+ steps = max(2, int(72 * frac))
380+ p.set_fill_color(*col)
381+ p.set_draw_color(*col)
382+ for st in range(steps):
383+ a0 = math.radians(start + 360 * frac * st / steps)
384+ a1 = math.radians(start + 360 * frac * (st + 1) / steps)
385+ p.polygon(
386+ [(cx, cy),
387+ (cx + r * math.cos(a0), cy + r * math.sin(a0)),
388+ (cx + r * math.cos(a1), cy + r * math.sin(a1))],
389+ style="DF",
390+ )
391+ start += 360 * frac
392+ p.set_fill_color(*WHITE)
393+ p.set_draw_color(*INK)
394+ p.set_line_width(0.4)
395+ p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
396+ p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
397+ # légende
398+ ly = cy - 22
399+ for i, it in enumerate(items):
400+ f = shades[i % len(shades)]
401+ col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
402+ p.set_fill_color(*col)
403+ p.set_draw_color(*INK)
404+ p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
405+ p.set_xy(p.l_margin + 66, ly)
406+ p.set_font("helvetica", "", 7.6)
407+ p.set_text_color(*INK)
408+ pct = 100 * it["value"] / total
409+ p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))
410+ ly += 5.6
411+ p.set_y(max(cy + r, ly) + 6)
412+
413+ def _table(self, t):
414+ p = self.pdf
415+ cols = t.get("columns") or []
416+ rows = t.get("rows") or []
417+ if not cols or not rows:
418+ return
419+ self._section_title(t.get("title", "Tableau"))
420+ w = 174 / len(cols)
421+ def head():
422+ p.set_font("helvetica", "B", 7.6)
423+ p.set_fill_color(*INK)
424+ p.set_text_color(*WHITE)
425+ for c in cols:
426+ p.cell(w, 6, " " + str(c)[:30], fill=True)
427+ p.ln(6)
428+ head()
429+ p.set_text_color(*INK)
430+ for i, row in enumerate(rows[:200]):
431+ if p.get_y() > 262:
432+ p.add_page()
433+ head()
434+ p.set_text_color(*INK)
435+ p.set_font("helvetica", "", 7.4)
436+ p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))
437+ for cell in row:
438+ txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
439+ p.cell(w, 5.4, " " + txt[:34], fill=True)
440+ p.ln(5.4)
441+ if len(rows) > 200:
442+ p.set_font("helvetica", "", 7)
443+ p.set_text_color(*INK3)
444+ p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")
445+ p.ln(6)
446+
447+ def _records(self):
448+ recs = self.d.get("records") or []
449+ if not recs:
450+ return
451+ self._section_title("Records & faits marquants")
452+ p = self.pdf
453+ for r in recs[:10]:
454+ if p.get_y() > 258:
455+ p.add_page()
456+ y = p.get_y()
457+ self._card(p.l_margin, y, 174, 11, fill=SURFACE2)
458+ p.set_xy(p.l_margin + 4, y + 2)
459+ p.set_font("helvetica", "", 8.6)
460+ p.set_text_color(*INK2)
461+ p.cell(96, 7, str(r.get("label", ""))[:70])
462+ p.set_font("helvetica", "B", 9)
463+ p.set_text_color(*INK)
464+ p.cell(52, 7, str(r.get("value", ""))[:36], align="R")
465+ p.set_font("helvetica", "", 7.6)
466+ p.set_text_color(*INK3)
467+ p.cell(20, 7, str(r.get("date", "") or ""), align="R")
468+ p.set_y(y + 13.5)
469+ p.ln(4)
470+
471+ def _final_page(self):
472+ p = self.pdf
473+ p.add_page()
474+ self._kicker("Groupe KA · contact")
475+ p.set_font("helvetica", "B", 15)
476+ p.set_text_color(*INK)
477+ p.cell(0, 8, "Coordonnées du Groupe KA")
478+ p.ln(12)
479+ for email, role in EMAILS:
480+ p.set_font("helvetica", "B", 10.5)
481+ p.set_text_color(*INK)
482+ p.cell(0, 6, email)
483+ p.ln(5.5)
484+ p.set_font("helvetica", "", 8.6)
485+ p.set_text_color(*INK3)
486+ p.cell(0, 5, role)
487+ p.ln(8)
488+ p.ln(2)
489+ p.set_font("helvetica", "B", 10)
490+ p.set_text_color(*GREEN)
491+ p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")
492+ p.ln(10)
493+ p.set_draw_color(*self.accent)
494+ p.set_line_width(0.8)
495+ p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())
496+ p.ln(4)
497+ p.set_font("helvetica", "", 8.6)
498+ p.set_text_color(*INK2)
499+ p.multi_cell(160, 4.6, DISCLAIMER)
500+ p.ln(4)
501+ p.set_font("helvetica", "", 7.6)
502+ p.set_text_color(*INK3)
503+ p.multi_cell(
504+ 160, 4.2,
505+ "Mentions : rapport généré automatiquement à partir des données réelles de la "
506+ "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "
507+ "de confidentialité et protection des renseignements personnels (Loi 25) : "
508+ "groupe-ka.com/conditions · /confidentialite · /loi-25.",
509+ )
510+
511+ def _toc_page(self):
512+ # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en
513+ # page 2 en réservant la page lors du build (voir build()).
514+ pass
515+
516+ def build(self) -> bytes:
517+ p = self.pdf
518+ p.alias_nb_pages()
519+ self._cover()
520+ if self.mode == "synthese":
521+ p.add_page()
522+ self._kpis()
523+ self._records()
524+ self._final_page()
525+ else:
526+ p.add_page()
527+ toc_page_no = p.page_no()
528+ p.add_page()
529+ self._kpis()
530+ for s in self.d.get("series") or []:
531+ if s.get("kind") == "bar":
532+ self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))
533+ else:
534+ self._line_chart(s)
535+ for b in self.d.get("breakdowns") or []:
536+ if b.get("kind") == "donut":
537+ self._donut(b)
538+ else:
539+ self._bars(b.get("title", ""), b.get("items"))
540+ geo = self.d.get("geo")
541+ if geo:
542+ self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
543+ for t in self.d.get("tables") or []:
544+ self._table(t)
545+ self._records()
546+ self._final_page()
547+ # sommaire écrit sur la page réservée (page 2)
548+ last_page = p.page
549+ p.page = toc_page_no
550+ p.set_y(22)
551+ p.set_font("helvetica", "B", 15)
552+ p.set_text_color(*INK)
553+ p.cell(0, 8, "Sommaire")
554+ p.ln(12)
555+ p.set_font("helvetica", "", 9.5)
556+ for title, page_no in self.toc:
557+ p.set_text_color(*INK)
558+ p.cell(140, 6.5, title[:80])
559+ p.set_text_color(*INK3)
560+ p.cell(0, 6.5, str(page_no), align="R")
561+ p.ln(6.5)
562+ p.page = last_page
563+ return bytes(p.output())
564+
565+
566+def filename(platform_id: str, period: str) -> str:
567+ today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
568+ return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"
modified src/api/main.py +25 −4
@@ -11,9 +11,10 @@ exposée publiquement uniquement via le tunnel ngrok www.api-ka.com."""
11 11
12 from __future__ import annotations 12 from __future__ import annotations
13 13
14+import asyncio
14 import json 15 import json
15 from collections.abc import AsyncIterator 16 from collections.abc import AsyncIterator
16−from contextlib import asynccontextmanager 17+from contextlib import asynccontextmanager, suppress
17 from pathlib import Path 18 from pathlib import Path
18 from typing import Any 19 from typing import Any
19 20
@@ -21,9 +22,10 @@ from fastapi import FastAPI, Request
21 from fastapi.responses import FileResponse 22 from fastapi.responses import FileResponse
22 from fastapi.staticfiles import StaticFiles 23 from fastapi.staticfiles import StaticFiles
23 24
25+from src.api import reqstats
24 from src.api.middleware.logging import RequestLoggingMiddleware 26 from src.api.middleware.logging import RequestLoggingMiddleware
25 from src.api.middleware.ratelimit import RateLimitMiddleware 27 from src.api.middleware.ratelimit import RateLimitMiddleware
26−from src.api.routes import auth, envelope, health, runs, services 28+from src.api.routes import auth, envelope, health, runs, services, stats
27 from src.config import SERVICES, get_settings, verify_node 29 from src.config import SERVICES, get_settings, verify_node
28 from src.database.db import init_db 30 from src.database.db import init_db
29 from src.utils.logger import get_logger 31 from src.utils.logger import get_logger
@@ -33,14 +35,21 @@ VERSION = "1.0.0"
33 35
34 @asynccontextmanager 36 @asynccontextmanager
35 async def lifespan(app: FastAPI) -> AsyncIterator[None]: 37 async def lifespan(app: FastAPI) -> AsyncIterator[None]:
36− """Au démarrage : vérifie le node m3u96b et initialise le schéma DB.""" 38+ """Au démarrage : vérifie le node m3u96b, initialise le schéma DB et lance
39+ la tâche de fond qui persiste le journal léger des requêtes (/stats)."""
37 verify_node() 40 verify_node()
38 init_db() 41 init_db()
39 get_logger("apika.api").info( 42 get_logger("apika.api").info(
40 "API-KA démarrée", 43 "API-KA démarrée",
41 extra={"version": VERSION, "node": get_settings().node_name}, 44 extra={"version": VERSION, "node": get_settings().node_name},
42 ) 45 )
43− yield 46+ flusher = asyncio.create_task(reqstats.flusher_task())
47+ try:
48+ yield
49+ finally:
50+ flusher.cancel()
51+ with suppress(asyncio.CancelledError):
52+ await flusher
44 53
45 54
46 app = FastAPI( 55 app = FastAPI(
@@ -63,12 +72,14 @@ app.add_middleware(RequestLoggingMiddleware)
63 app.include_router(health.router) 72 app.include_router(health.router)
64 app.include_router(auth.router) 73 app.include_router(auth.router)
65 app.include_router(runs.router) 74 app.include_router(runs.router)
75+app.include_router(stats.router)
66 app.include_router(services.router) 76 app.include_router(services.router)
67 77
68 78
69 WEB_DIR = Path(__file__).resolve().parent / "web" 79 WEB_DIR = Path(__file__).resolve().parent / "web"
70 WEB_INDEX = WEB_DIR / "index.html" 80 WEB_INDEX = WEB_DIR / "index.html"
71 WEB_CONTACT = WEB_DIR / "contact.html" 81 WEB_CONTACT = WEB_DIR / "contact.html"
82+WEB_STATS = WEB_DIR / "stats.html"
72 KA_DIR = WEB_DIR / "ka" 83 KA_DIR = WEB_DIR / "ka"
73 84
74 # Package ka-ui vendorisé (tokens.css, ecosystem.json, ka-shell.js) — servi 85 # Package ka-ui vendorisé (tokens.css, ecosystem.json, ka-shell.js) — servi
@@ -119,6 +130,16 @@ def contact(request: Request) -> Any:
119 ) 130 )
120 131
121 132
133+@app.get("/stats", tags=["root"], include_in_schema=False)
134+def stats_page() -> FileResponse:
135+ """Page /stats — tableau de bord analytique de la plateforme (HTML statique).
136+
137+ Les données proviennent de GET /api/stats/dashboard ; l'export PDF de
138+ GET /api/stats/report (gabarit Groupe-KA).
139+ """
140+ return FileResponse(WEB_STATS, media_type="text/html")
141+
142+
122 # Assets de marque Groupe KA servis à la racine du domaine (voir src/api/web/) : 143 # Assets de marque Groupe KA servis à la racine du domaine (voir src/api/web/) :
123 # favicon SVG, icône iOS et image Open Graph 1200x630 referencée par les meta og:image. 144 # favicon SVG, icône iOS et image Open Graph 1200x630 referencée par les meta og:image.
124 @app.get("/favicon.svg", include_in_schema=False) 145 @app.get("/favicon.svg", include_in_schema=False)
modified src/api/middleware/logging.py +14 −0
@@ -10,12 +10,14 @@
10 10
11 from __future__ import annotations 11 from __future__ import annotations
12 12
13+import datetime
13 import time 14 import time
14 15
15 from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 16 from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
16 from starlette.requests import Request 17 from starlette.requests import Request
17 from starlette.responses import Response 18 from starlette.responses import Response
18 19
20+from src.api import reqstats
19 from src.utils.logger import get_logger 21 from src.utils.logger import get_logger
20 22
21 23
@@ -28,6 +30,18 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
28 start = time.perf_counter() 30 start = time.perf_counter()
29 response = await call_next(request) 31 response = await call_next(request)
30 duration_ms = round((time.perf_counter() - start) * 1000, 2) 32 duration_ms = round((time.perf_counter() - start) * 1000, 2)
33+ # Journalisation légère pour /stats : simple append en mémoire (O(1),
34+ # aucune I/O ici) — la persistance se fait en lot par la tâche de fond.
35+ try:
36+ reqstats.record(
37+ ts=datetime.datetime.now(tz=datetime.UTC),
38+ method=request.method,
39+ path=request.url.path,
40+ status=response.status_code,
41+ duration_ms=duration_ms,
42+ )
43+ except Exception: # pragma: no cover — jamais bloquant pour l'API
44+ pass
31 get_logger("apika.api").info( 45 get_logger("apika.api").info(
32 "http_request", 46 "http_request",
33 extra={ 47 extra={
added src/api/reqstats.py +164 −0
@@ -0,0 +1,164 @@
1+# ============================================
2+# Projet : API-KA
3+# Fichier : src/api/reqstats.py
4+# Node : m3u96b
5+# Author : Simon-Pierre Boucher
6+# Contact : contact@spboucher.ai
7+# Date : 2026-08-17
8+# ============================================
9+"""Journalisation légère des requêtes API vers la table ``api_requests``.
10+
11+Conçu pour ne JAMAIS ralentir le chemin de requête : le middleware appelle
12+``record()`` (un simple ``deque.append``, O(1), sans I/O) et une tâche de
13+fond (démarrée dans le lifespan de l'app) vide le tampon en lot toutes les
14+``FLUSH_INTERVAL`` secondes via un thread, hors boucle d'événements.
15+
16+Rétention : ``RETENTION_DAYS`` jours — une purge s'exécute au plus une fois
17+par jour lors d'un flush. Alimente exclusivement la page /stats.
18+"""
19+
20+from __future__ import annotations
21+
22+import asyncio
23+import datetime
24+import threading
25+from collections import deque
26+
27+from src.config import SERVICES
28+from src.utils.logger import get_logger
29+
30+FLUSH_INTERVAL = 5.0 # secondes entre deux vidages du tampon
31+MAX_BUFFER = 10_000 # garde-fou mémoire : au-delà, les plus anciens sont perdus
32+RETENTION_DAYS = 90
33+
34+_buffer: deque[dict] = deque(maxlen=MAX_BUFFER)
35+_lock = threading.Lock()
36+_last_purge: datetime.date | None = None
37+
38+# Chemins statiques connus, conservés tels quels dans la colonne ``endpoint``.
39+_KNOWN_PATHS = frozenset(
40+ {
41+ "/",
42+ "/health",
43+ "/contact",
44+ "/stats",
45+ "/docs",
46+ "/redoc",
47+ "/openapi.json",
48+ "/favicon.svg",
49+ "/apple-touch-icon.png",
50+ "/og.png",
51+ "/api/v1/runs",
52+ "/api/stats/dashboard",
53+ "/api/stats/report",
54+ }
55+)
56+_SERVICE_SUBROUTES = frozenset({"latest", "stats"})
57+
58+
59+def normalize_endpoint(path: str) -> str:
60+ """Replie un chemin de requête vers un endpoint à cardinalité bornée.
61+
62+ Les paramètres de route (dates) sont remplacés par des gabarits, les
63+ assets statiques /ka/* sont agrégés et les chemins inconnus (scans de
64+ bots, 404) sont regroupés sous « (autre) ».
65+ """
66+ path = path.rstrip("/") or "/"
67+ if path in _KNOWN_PATHS:
68+ return path
69+ if path.startswith("/ka/"):
70+ return "/ka/*"
71+ if path.startswith("/api/v1/"):
72+ parts = path.split("/") # ['', 'api', 'v1', service, ...]
73+ service = parts[3] if len(parts) > 3 else ""
74+ if service in SERVICES:
75+ if len(parts) == 4:
76+ return f"/api/v1/{service}"
77+ sub = parts[4]
78+ if sub == "date":
79+ return f"/api/v1/{service}/date/{{date}}"
80+ if sub in _SERVICE_SUBROUTES and len(parts) == 5:
81+ return f"/api/v1/{service}/{sub}"
82+ return "/api/v1/(autre)"
83+ return "(autre)"
84+
85+
86+def record(
87+ ts: datetime.datetime, method: str, path: str, status: int, duration_ms: float
88+) -> None:
89+ """Empile une requête dans le tampon mémoire (non bloquant, jamais d'I/O)."""
90+ entry = {
91+ "ts": ts,
92+ "method": method,
93+ "endpoint": normalize_endpoint(path),
94+ "status": int(status),
95+ "duration_ms": round(float(duration_ms), 2),
96+ }
97+ with _lock:
98+ _buffer.append(entry)
99+
100+
101+def _drain() -> list[dict]:
102+ """Vide le tampon et retourne son contenu."""
103+ with _lock:
104+ entries = list(_buffer)
105+ _buffer.clear()
106+ return entries
107+
108+
109+def flush() -> int:
110+ """Insère en lot le contenu du tampon dans ``api_requests`` (synchrone).
111+
112+ Appelée depuis un thread par la tâche de fond ; purge les lignes plus
113+ vieilles que ``RETENTION_DAYS`` jours au plus une fois par jour.
114+ """
115+ global _last_purge
116+ entries = _drain()
117+ if not entries:
118+ _maybe_purge()
119+ return 0
120+ try:
121+ from src.database.db import session_scope
122+ from src.database.models import ApiRequest
123+
124+ with session_scope() as session:
125+ session.bulk_insert_mappings(ApiRequest.__mapper__, entries)
126+ except Exception: # pragma: no cover — la stat ne doit jamais casser l'API
127+ get_logger("apika.reqstats").exception("Échec du flush api_requests")
128+ return 0
129+ _maybe_purge()
130+ return len(entries)
131+
132+
133+def _maybe_purge() -> None:
134+ """Purge les requêtes plus vieilles que RETENTION_DAYS (1 fois/jour max)."""
135+ global _last_purge
136+ today = datetime.datetime.now(tz=datetime.UTC).date()
137+ if _last_purge == today:
138+ return
139+ _last_purge = today
140+ try:
141+ from sqlalchemy import delete
142+
143+ from src.database.db import session_scope
144+ from src.database.models import ApiRequest
145+
146+ cutoff = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(
147+ days=RETENTION_DAYS
148+ )
149+ with session_scope() as session:
150+ session.execute(delete(ApiRequest).where(ApiRequest.ts < cutoff))
151+ except Exception: # pragma: no cover
152+ get_logger("apika.reqstats").exception("Échec de la purge api_requests")
153+
154+
155+async def flusher_task() -> None:
156+ """Tâche de fond : flush périodique du tampon, hors event loop (thread)."""
157+ try:
158+ while True:
159+ await asyncio.sleep(FLUSH_INTERVAL)
160+ await asyncio.to_thread(flush)
161+ except asyncio.CancelledError:
162+ # Dernier flush au shutdown pour ne rien perdre.
163+ await asyncio.to_thread(flush)
164+ raise
added src/api/routes/stats.py +580 −0
@@ -0,0 +1,580 @@
1+# ============================================
2+# Projet : API-KA
3+# Fichier : src/api/routes/stats.py
4+# Node : m3u96b
5+# Author : Simon-Pierre Boucher
6+# Contact : contact@spboucher.ai
7+# Date : 2026-08-17
8+# ============================================
9+"""Routes /api/stats : tableau de bord analytique de la plateforme + rapport PDF.
10+
11+Contrat commun Groupe KA (src/api/web/ka/stats/SPEC.md) :
12+- ``GET /api/stats/dashboard?period=…`` → JSON (KPI, séries, répartitions,
13+ heatmap, tableaux, records) calculé depuis les données réelles :
14+ table ``api_requests`` (journal du middleware) et ``collection_runs``.
15+- ``GET /api/stats/report?period=…&mode=complet|synthese`` → PDF Groupe-KA.
16+
17+Cache serveur : 5 minutes par période. Aucune stat inventée : une section
18+sans données est simplement absente (le front affiche « Pas encore mesuré »).
19+"""
20+
21+from __future__ import annotations
22+
23+import datetime
24+import threading
25+import time
26+from typing import Any
27+from zoneinfo import ZoneInfo
28+
29+from fastapi import APIRouter, Depends, HTTPException, Query, Response
30+from sqlalchemy import func, select
31+from sqlalchemy.orm import Session
32+
33+from src.api import kapdf
34+from src.api.routes import envelope
35+from src.database.db import get_db
36+from src.database.models import ApiRequest, CollectionRun
37+
38+router = APIRouter(prefix="/api/stats", tags=["stats"])
39+
40+TZ = ZoneInfo("America/Toronto")
41+CACHE_TTL = 300.0 # ≥ 5 min (SPEC)
42+PLATFORM_ID = "api-ka"
43+
44+SITE = {
45+ "wordmark": "API·Ka",
46+ "accent": "#3b5bdb",
47+ "domain": "www.api-ka.com",
48+ "tagline": "Plateforme centrale de l'écosystème Groupe KA",
49+}
50+
51+SERVICE_NAMES = {
52+ "louka": "Lou·Ka",
53+ "immoka": "Immo·Ka",
54+ "foodka": "Food·Ka",
55+ "autoka": "Auto·Ka",
56+ "fabrika": "Fabri·Ka",
57+ "restoka": "Resto·Ka",
58+ "sortika": "Sorti·Ka",
59+ "creaka": "Créa·Ka",
60+}
61+
62+PERIOD_LABELS = {
63+ "auj": "aujourd'hui",
64+ "7j": "7 jours",
65+ "30j": "30 jours",
66+ "3m": "3 mois",
67+ "6m": "6 mois",
68+ "12m": "12 mois",
69+ "annee": "année en cours",
70+ "tout": "toute la période",
71+}
72+PERIOD_DAYS = {"7j": 7, "30j": 30, "3m": 91, "6m": 182, "12m": 365}
73+
74+_cache: dict[tuple, tuple[float, dict]] = {}
75+_cache_lock = threading.Lock()
76+
77+
78+# ---------------------------------------------------------------- périodes
79+def _resolve_period(
80+ period: str,
81+ date_from: datetime.date | None,
82+ date_to: datetime.date | None,
83+ db: Session,
84+) -> tuple[datetime.date, datetime.date, str]:
85+ """Résout (from, to, label) en dates locales America/Toronto."""
86+ today = datetime.datetime.now(tz=TZ).date()
87+ if date_from and date_to:
88+ if date_from > date_to:
89+ raise HTTPException(status_code=422, detail="from doit précéder to")
90+ return date_from, date_to, f"du {date_from} au {date_to}"
91+ if period == "auj":
92+ return today, today, PERIOD_LABELS["auj"]
93+ if period == "annee":
94+ return datetime.date(today.year, 1, 1), today, PERIOD_LABELS["annee"]
95+ if period == "tout":
96+ firsts = [
97+ db.execute(select(func.min(ApiRequest.ts))).scalar(),
98+ db.execute(select(func.min(CollectionRun.started_at))).scalar(),
99+ ]
100+ dates = []
101+ for f in firsts:
102+ if f is not None:
103+ if f.tzinfo is None:
104+ f = f.replace(tzinfo=datetime.UTC)
105+ dates.append(f.astimezone(TZ).date())
106+ return (min(dates) if dates else today), today, PERIOD_LABELS["tout"]
107+ if period in PERIOD_DAYS:
108+ return (
109+ today - datetime.timedelta(days=PERIOD_DAYS[period] - 1),
110+ today,
111+ PERIOD_LABELS[period],
112+ )
113+ raise HTTPException(
114+ status_code=422,
115+ detail=f"Période invalide : {period}. Valides : {', '.join(PERIOD_LABELS)}",
116+ )
117+
118+
119+def _utc_bounds(
120+ d_from: datetime.date, d_to: datetime.date
121+) -> tuple[datetime.datetime, datetime.datetime]:
122+ """Bornes UTC [00:00 from, 24:00 to] exprimées depuis les dates locales."""
123+ start = datetime.datetime.combine(d_from, datetime.time.min, tzinfo=TZ)
124+ end = datetime.datetime.combine(
125+ d_to + datetime.timedelta(days=1), datetime.time.min, tzinfo=TZ
126+ )
127+ return start.astimezone(datetime.UTC), end.astimezone(datetime.UTC)
128+
129+
130+# ---------------------------------------------------------------- agrégats
131+def _fetch_requests(
132+ db: Session, d_from: datetime.date, d_to: datetime.date
133+) -> list[tuple[datetime.datetime, str, int, float]]:
134+ """Requêtes API de la fenêtre : (ts local, endpoint, status, duration_ms)."""
135+ lo, hi = _utc_bounds(d_from, d_to)
136+ rows = db.execute(
137+ select(
138+ ApiRequest.ts, ApiRequest.endpoint, ApiRequest.status, ApiRequest.duration_ms
139+ ).where(ApiRequest.ts >= lo, ApiRequest.ts < hi)
140+ ).all()
141+ out = []
142+ for ts, endpoint, status, duration in rows:
143+ if ts.tzinfo is None:
144+ ts = ts.replace(tzinfo=datetime.UTC)
145+ out.append((ts.astimezone(TZ), endpoint, status, duration or 0.0))
146+ return out
147+
148+
149+def _fetch_runs(
150+ db: Session, d_from: datetime.date, d_to: datetime.date
151+) -> list[CollectionRun]:
152+ """Runs de collecte de la fenêtre (par date logique ``date_key``)."""
153+ return (
154+ db.execute(
155+ select(CollectionRun)
156+ .where(CollectionRun.date_key >= d_from, CollectionRun.date_key <= d_to)
157+ .order_by(CollectionRun.started_at.desc())
158+ )
159+ .scalars()
160+ .all()
161+ )
162+
163+
164+def _p95(values: list[float]) -> float:
165+ if not values:
166+ return 0.0
167+ vs = sorted(values)
168+ idx = max(0, int(round(0.95 * (len(vs) - 1))))
169+ return vs[idx]
170+
171+
172+def _delta(cur: float, prev: float | None) -> float | None:
173+ if prev is None or prev == 0:
174+ return None
175+ return round((cur - prev) / prev * 100, 1)
176+
177+
178+def _daterange(d_from: datetime.date, d_to: datetime.date) -> list[datetime.date]:
179+ n = (d_to - d_from).days + 1
180+ return [d_from + datetime.timedelta(days=i) for i in range(n)]
181+
182+
183+def _fr_int(n: float) -> str:
184+ return f"{int(n):,}".replace(",", " ")
185+
186+
187+# ---------------------------------------------------------------- dashboard
188+def _build_dashboard(db: Session, period: str, d_from, d_to, label) -> dict[str, Any]:
189+ """Calcule le JSON complet du contrat SPEC depuis les données réelles."""
190+ hourly = period == "auj" or d_from == d_to
191+ span = (d_to - d_from).days + 1
192+ prev_to = d_from - datetime.timedelta(days=1)
193+ prev_from = prev_to - datetime.timedelta(days=span - 1)
194+
195+ reqs = _fetch_requests(db, d_from, d_to)
196+ prev_reqs = _fetch_requests(db, prev_from, prev_to)
197+ runs = _fetch_runs(db, d_from, d_to)
198+ prev_runs = _fetch_runs(db, prev_from, prev_to)
199+
200+ # -------- agrégats requêtes API (période courante)
201+ total_calls = len(reqs)
202+ durations = [r[3] for r in reqs]
203+ avg_ms = round(sum(durations) / total_calls, 2) if total_calls else 0.0
204+ p95_ms = round(_p95(durations), 2)
205+ errors = sum(1 for r in reqs if r[2] >= 400)
206+ err_rate = round(errors / total_calls * 100, 2) if total_calls else 0.0
207+ active_endpoints = len({r[1] for r in reqs})
208+
209+ # -------- agrégats requêtes API (période précédente, pour les deltas)
210+ prev_calls = len(prev_reqs)
211+ prev_durs = [r[3] for r in prev_reqs]
212+ prev_avg = (sum(prev_durs) / prev_calls) if prev_calls else None
213+ prev_err = (
214+ sum(1 for r in prev_reqs if r[2] >= 400) / prev_calls * 100
215+ if prev_calls
216+ else None
217+ )
218+
219+ # -------- agrégats runs de collecte
220+ ok_runs = [r for r in runs if r.status in ("success", "retried")]
221+ failed_runs = [r for r in runs if r.status == "failed"]
222+ records_total = sum(r.records_count for r in ok_runs)
223+ prev_ok = [r for r in prev_runs if r.status in ("success", "retried")]
224+ prev_records = sum(r.records_count for r in prev_ok)
225+
226+ # -------- KPI (uniquement des mesures réelles)
227+ kpis: list[dict[str, Any]] = []
228+
229+ def kpi(id_, label_, value, unit="", delta=None, invert=False):
230+ d: dict[str, Any] = {"id": id_, "label": label_, "value": value, "unit": unit}
231+ if delta is not None:
232+ d["delta_pct"] = delta
233+ good = (delta <= 0) if invert else (delta >= 0)
234+ d["direction"] = "up" if good else "down"
235+ if invert:
236+ d["invert"] = True
237+ kpis.append(d)
238+
239+ kpi("calls", "Appels API", total_calls, "", _delta(total_calls, prev_calls))
240+ kpi(
241+ "latency",
242+ "Latence moyenne",
243+ avg_ms,
244+ "ms",
245+ _delta(avg_ms, round(prev_avg, 2) if prev_avg else None),
246+ invert=True,
247+ )
248+ kpi("p95", "Latence p95", p95_ms, "ms")
249+ kpi(
250+ "errors",
251+ "Taux d'erreur (HTTP ≥ 400)",
252+ err_rate,
253+ "%",
254+ _delta(err_rate, round(prev_err, 2) if prev_err else None),
255+ invert=True,
256+ )
257+ kpi("endpoints", "Endpoints actifs", active_endpoints)
258+ kpi(
259+ "runs_ok",
260+ "Collectes réussies",
261+ len(ok_runs),
262+ "",
263+ _delta(len(ok_runs), len(prev_ok) or None),
264+ )
265+ kpi("runs_failed", "Collectes échouées", len(failed_runs), "", invert=True)
266+ kpi(
267+ "records",
268+ "Enregistrements collectés",
269+ records_total,
270+ "",
271+ _delta(records_total, prev_records or None),
272+ )
273+
274+ # -------- séries temporelles
275+ series: list[dict[str, Any]] = []
276+ days = _daterange(d_from, d_to)
277+
278+ if hourly:
279+ buckets = [f"{h:02d}h" for h in range(24)]
280+ calls_by = {b: 0 for b in buckets}
281+ lat_by: dict[str, list[float]] = {b: [] for b in buckets}
282+ for ts, _, _, dur in reqs:
283+ b = f"{ts.hour:02d}h"
284+ calls_by[b] += 1
285+ lat_by[b].append(dur)
286+ calls_pts = [{"t": b, "v": calls_by[b]} for b in buckets]
287+ lat_pts = [
288+ {
289+ "t": b,
290+ "v": round(sum(lat_by[b]) / len(lat_by[b]), 1) if lat_by[b] else 0,
291+ }
292+ for b in buckets
293+ ]
294+ calls_title = "Appels API par heure"
295+ lat_title = "Latence moyenne par heure (ms)"
296+ else:
297+ calls_by = {d: 0 for d in days}
298+ lat_by = {d: [] for d in days}
299+ for ts, _, _, dur in reqs:
300+ d = ts.date()
301+ if d in calls_by:
302+ calls_by[d] += 1
303+ lat_by[d].append(dur)
304+ calls_pts = [{"t": d.isoformat(), "v": calls_by[d]} for d in days]
305+ lat_pts = [
306+ {
307+ "t": d.isoformat(),
308+ "v": round(sum(lat_by[d]) / len(lat_by[d]), 1) if lat_by[d] else 0,
309+ }
310+ for d in days
311+ ]
312+ calls_title = "Appels API par jour"
313+ lat_title = "Latence moyenne par jour (ms)"
314+
315+ calls_serie: dict[str, Any] = {
316+ "id": "calls",
317+ "title": calls_title,
318+ "unit": "appels",
319+ "kind": "line",
320+ "points": calls_pts,
321+ }
322+ if prev_reqs and not hourly:
323+ prev_days = _daterange(prev_from, prev_to)
324+ prev_by = {d: 0 for d in prev_days}
325+ for ts, _, _, _ in prev_reqs:
326+ d = ts.date()
327+ if d in prev_by:
328+ prev_by[d] += 1
329+ calls_serie["compare"] = [
330+ {"t": d.isoformat(), "v": prev_by[d]} for d in prev_days
331+ ]
332+ if reqs:
333+ series.append(calls_serie)
334+ series.append(
335+ {
336+ "id": "latency",
337+ "title": lat_title,
338+ "unit": "ms",
339+ "kind": "line",
340+ "points": lat_pts,
341+ }
342+ )
343+
344+ if runs and not hourly:
345+ rec_by = {d: 0 for d in days}
346+ for r in ok_runs:
347+ if r.date_key in rec_by:
348+ rec_by[r.date_key] += r.records_count
349+ series.append(
350+ {
351+ "id": "records",
352+ "title": "Enregistrements collectés par jour",
353+ "unit": "enregistrements",
354+ "kind": "line",
355+ "points": [{"t": d.isoformat(), "v": rec_by[d]} for d in days],
356+ }
357+ )
358+
359+ # -------- répartitions
360+ breakdowns: list[dict[str, Any]] = []
361+ ep_stats: dict[str, dict[str, Any]] = {}
362+ for _, endpoint, status, dur in reqs:
363+ s = ep_stats.setdefault(endpoint, {"calls": 0, "durs": [], "errors": 0})
364+ s["calls"] += 1
365+ s["durs"].append(dur)
366+ if status >= 400:
367+ s["errors"] += 1
368+ top_eps = sorted(ep_stats.items(), key=lambda kv: kv[1]["calls"], reverse=True)
369+ if top_eps:
370+ breakdowns.append(
371+ {
372+ "id": "top_endpoints",
373+ "title": "Top endpoints (appels)",
374+ "kind": "bars",
375+ "items": [
376+ {"label": ep, "value": s["calls"]} for ep, s in top_eps[:12]
377+ ],
378+ }
379+ )
380+ svc_records: dict[str, int] = {}
381+ for r in ok_runs:
382+ svc_records[r.service] = svc_records.get(r.service, 0) + r.records_count
383+ if svc_records:
384+ breakdowns.append(
385+ {
386+ "id": "services",
387+ "title": "Enregistrements collectés par service",
388+ "kind": "donut",
389+ "items": sorted(
390+ (
391+ {"label": SERVICE_NAMES.get(s, s), "value": v}
392+ for s, v in svc_records.items()
393+ ),
394+ key=lambda it: it["value"],
395+ reverse=True,
396+ ),
397+ }
398+ )
399+
400+ # -------- heatmap : appels API par jour
401+ heatmap = None
402+ if reqs and not hourly:
403+ cells = [
404+ {"date": d.isoformat(), "value": calls_by[d]}
405+ for d in days
406+ if calls_by[d] > 0
407+ ]
408+ if cells:
409+ heatmap = {"title": "Appels API par jour", "cells": cells}
410+
411+ # -------- tableaux
412+ tables: list[dict[str, Any]] = []
413+ if top_eps:
414+ tables.append(
415+ {
416+ "id": "endpoints",
417+ "title": "Endpoints — appels, latence et erreurs",
418+ "columns": [
419+ "Endpoint",
420+ "Appels",
421+ "Latence moy. (ms)",
422+ "p95 (ms)",
423+ "Erreurs",
424+ "Taux d'erreur",
425+ ],
426+ "rows": [
427+ [
428+ ep,
429+ s["calls"],
430+ round(sum(s["durs"]) / len(s["durs"]), 1),
431+ round(_p95(s["durs"]), 1),
432+ s["errors"],
433+ f"{s['errors'] / s['calls'] * 100:.1f} %".replace(".", ","),
434+ ]
435+ for ep, s in top_eps
436+ ],
437+ }
438+ )
439+ if runs:
440+ tables.append(
441+ {
442+ "id": "runs",
443+ "title": "Derniers runs de collecte",
444+ "columns": ["Service", "Date", "Statut", "Enregistrements", "Durée (s)"],
445+ "rows": [
446+ [
447+ SERVICE_NAMES.get(r.service, r.service),
448+ r.date_key.isoformat(),
449+ {"success": "succès", "failed": "échec", "retried": "relancé"}.get(
450+ r.status, r.status
451+ ),
452+ r.records_count,
453+ round(r.duration_seconds, 1),
454+ ]
455+ for r in runs[:80]
456+ ],
457+ }
458+ )
459+
460+ # -------- records & faits marquants
461+ records: list[dict[str, Any]] = []
462+ if reqs and not hourly:
463+ best_day = max(calls_by.items(), key=lambda kv: kv[1])
464+ if best_day[1] > 0:
465+ records.append(
466+ {
467+ "label": "Jour record d'appels API",
468+ "value": f"{_fr_int(best_day[1])} appels",
469+ "date": best_day[0].isoformat(),
470+ }
471+ )
472+ if top_eps:
473+ records.append(
474+ {
475+ "label": "Endpoint le plus sollicité",
476+ "value": f"{top_eps[0][0]} — {_fr_int(top_eps[0][1]['calls'])} appels",
477+ }
478+ )
479+ if ok_runs:
480+ biggest = max(ok_runs, key=lambda r: r.records_count)
481+ records.append(
482+ {
483+ "label": "Run de collecte le plus volumineux",
484+ "value": (
485+ f"{_fr_int(biggest.records_count)} enregistrements "
486+ f"({SERVICE_NAMES.get(biggest.service, biggest.service)})"
487+ ),
488+ "date": biggest.date_key.isoformat(),
489+ }
490+ )
491+ fastest = min(ok_runs, key=lambda r: r.duration_seconds)
492+ records.append(
493+ {
494+ "label": "Collecte la plus rapide",
495+ "value": (
496+ f"{fastest.duration_seconds:.1f} s "
497+ f"({SERVICE_NAMES.get(fastest.service, fastest.service)})"
498+ ).replace(".", ","),
499+ "date": fastest.date_key.isoformat(),
500+ }
501+ )
502+
503+ # -------- couverture de mesure (depuis quand les appels sont journalisés)
504+ first_req = db.execute(select(func.min(ApiRequest.ts))).scalar()
505+ if first_req is not None and first_req.tzinfo is None:
506+ first_req = first_req.replace(tzinfo=datetime.UTC)
507+
508+ dash: dict[str, Any] = {
509+ "updated": datetime.datetime.now(tz=TZ).isoformat(timespec="seconds"),
510+ "period": {"from": d_from.isoformat(), "to": d_to.isoformat(), "label": label},
511+ "kpis": kpis,
512+ "series": series,
513+ "breakdowns": breakdowns,
514+ "tables": tables,
515+ "records": records,
516+ "coverage": {
517+ "api_requests_since": (
518+ first_req.astimezone(TZ).isoformat(timespec="seconds")
519+ if first_req
520+ else None
521+ )
522+ },
523+ }
524+ if heatmap:
525+ dash["heatmap"] = heatmap
526+ return dash
527+
528+
529+def _dashboard_cached(
530+ db: Session,
531+ period: str,
532+ date_from: datetime.date | None,
533+ date_to: datetime.date | None,
534+) -> dict[str, Any]:
535+ d_from, d_to, label = _resolve_period(period, date_from, date_to, db)
536+ key = (period, d_from.isoformat(), d_to.isoformat())
537+ now = time.monotonic()
538+ with _cache_lock:
539+ hit = _cache.get(key)
540+ if hit and now - hit[0] < CACHE_TTL:
541+ return hit[1]
542+ dash = _build_dashboard(db, period, d_from, d_to, label)
543+ with _cache_lock:
544+ if len(_cache) > 64:
545+ _cache.clear()
546+ _cache[key] = (now, dash)
547+ return dash
548+
549+
550+# ---------------------------------------------------------------- routes
551+@router.get("/dashboard")
552+def stats_dashboard(
553+ period: str = Query("30j", description="auj, 7j, 30j, 3m, 6m, 12m, annee, tout"),
554+ date_from: datetime.date | None = Query(None, alias="from"),
555+ date_to: datetime.date | None = Query(None, alias="to"),
556+ db: Session = Depends(get_db),
557+) -> dict[str, Any]:
558+ """Tableau de bord analytique de la plateforme (contrat commun Groupe KA)."""
559+ return envelope(_dashboard_cached(db, period, date_from, date_to))
560+
561+
562+@router.get("/report")
563+def stats_report(
564+ period: str = Query("30j", description="auj, 7j, 30j, 3m, 6m, 12m, annee, tout"),
565+ date_from: datetime.date | None = Query(None, alias="from"),
566+ date_to: datetime.date | None = Query(None, alias="to"),
567+ mode: str = Query("complet", description="complet ou synthese"),
568+ db: Session = Depends(get_db),
569+) -> Response:
570+ """Rapport statistique PDF estampillé Groupe-KA (complet ou synthèse)."""
571+ if mode not in ("complet", "synthese"):
572+ raise HTTPException(status_code=422, detail="mode : complet ou synthese")
573+ dash = _dashboard_cached(db, period, date_from, date_to)
574+ pdf_bytes = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build()
575+ fname = kapdf.filename(PLATFORM_ID, period)
576+ return Response(
577+ content=pdf_bytes,
578+ media_type="application/pdf",
579+ headers={"Content-Disposition": f'attachment; filename="{fname}"'},
580+ )
modified src/api/web/contact.html +1 −0
@@ -78,6 +78,7 @@ section{padding:64px 0}
78 <li><a href="/#services">Services</a></li> 78 <li><a href="/#services">Services</a></li>
79 <li><a href="/#endpoints">Endpoints</a></li> 79 <li><a href="/#endpoints">Endpoints</a></li>
80 <li><a href="/#playground">Playground</a></li> 80 <li><a href="/#playground">Playground</a></li>
81+ <li><a href="/stats">Stats</a></li>
81 <li><a href="/docs">Swagger</a></li> 82 <li><a href="/docs">Swagger</a></li>
82 <li><a href="/contact">Contact</a></li> 83 <li><a href="/contact">Contact</a></li>
83 </ul> 84 </ul>
modified src/api/web/index.html +1 −0
@@ -175,6 +175,7 @@ pre{margin:0;background:var(--ink);color:var(--paper);padding:16px 18px;overflow
175 <li><a href="#endpoints">Endpoints</a></li> 175 <li><a href="#endpoints">Endpoints</a></li>
176 <li><a href="#playground">Playground</a></li> 176 <li><a href="#playground">Playground</a></li>
177 <li><a href="#conventions">Conventions</a></li> 177 <li><a href="#conventions">Conventions</a></li>
178+ <li><a href="/stats">Stats</a></li>
178 <li><a href="/docs">Swagger</a></li> 179 <li><a href="/docs">Swagger</a></li>
179 <li><a href="/contact">Contact</a></li> 180 <li><a href="/contact">Contact</a></li>
180 </ul> 181 </ul>
added src/api/web/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 src/api/web/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 src/api/web/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:
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+ # La page 1 est toujours la couverture : fpdf dessine son pied de page
99+ # au add_page() suivant, quand cover_mode est deja retombe a False.
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 src/api/web/stats.html +572 −0
@@ -0,0 +1,572 @@
1+<!DOCTYPE html>
2+<!--
3+============================================
4+Projet : API-KA
5+Fichier : src/api/web/stats.html
6+Node : m3u96b
7+Author : Simon-Pierre Boucher
8+Contact : contact@spboucher.ai
9+Date : 2026-08-17
10+============================================
11+Page /stats — tableau de bord analytique de la plateforme (module Stats commun
12+Groupe KA, SPEC.md). Vanilla JS + SVG : reproduit le langage visuel du kit
13+ka-ui/stats/kacharts.tsx (KPI + delta, chips de période, courbe avec infobulle
14+et légende cliquable, barres, anneau, heatmap calendrier, tableaux triables,
15+records, bouton PDF). Données : GET /api/stats/dashboard — rien d'inventé.
16+-->
17+<html lang="fr">
18+<head>
19+<meta charset="utf-8">
20+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
21+<title>Statistiques — API-Ka — Un service Groupe KA</title>
22+<meta name="description" content="Tableau de bord analytique d'API-Ka : appels API par endpoint, latences moyennes et p95, taux d'erreur, runs de collecte quotidiens des 8 services KA et export PDF Groupe-KA.">
23+<meta name="theme-color" content="#f5f3ee">
24+<link rel="icon" type="image/svg+xml" href="/favicon.svg">
25+<link rel="apple-touch-icon" href="/apple-touch-icon.png">
26+<meta property="og:title" content="Statistiques — API-Ka — Un service Groupe KA">
27+<meta property="og:description" content="Tableau de bord analytique d'API-Ka : appels API, latences, taux d'erreur, collectes quotidiennes des 8 services KA et export PDF Groupe-KA.">
28+<meta property="og:url" content="https://www.api-ka.com/stats">
29+<meta property="og:type" content="website">
30+<meta property="og:image" content="https://www.api-ka.com/og.png">
31+<meta property="og:image:width" content="1200">
32+<meta property="og:image:height" content="630">
33+<meta name="twitter:card" content="summary_large_image">
34+<meta name="twitter:image" content="https://www.api-ka.com/og.png">
35+<link rel="preconnect" href="https://fonts.googleapis.com">
36+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
37+<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
38+<link rel="stylesheet" href="/ka/tokens.css">
39+<style>
40+/* ---- Accent API-Ka : INDIGO (identique à l'accueil) ---- */
41+:root{
42+ --accent:#3b5bdb; --accent-soft:#e4eafb; --accent-deep:#2b44a8; --on-accent:#ffffff;
43+}
44+
45+/* ---- topbar (identique à l'accueil) ---- */
46+.topbar{position:sticky;top:0;z-index:60;background:rgba(245,243,238,.94);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-bottom:1.5px solid var(--ink)}
47+.topbar-in{display:flex;align-items:center;gap:14px;min-height:68px;flex-wrap:wrap;padding-top:8px;padding-bottom:8px}
48+.brand{font-family:var(--font-display);font-weight:700;font-size:24px;letter-spacing:-.03em;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;min-height:var(--touch);white-space:nowrap}
49+.brand .ka{display:inline-block;background:var(--ink);color:var(--accent);border-radius:6px;padding:0 7px 2px;margin-left:5px;transform:rotate(-2deg);transition:transform .15s}
50+.brand:hover .ka{transform:rotate(0)}
51+.topnav{display:flex;gap:2px;list-style:none;margin:0 0 0 auto;padding:0;overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}
52+.topnav a{display:inline-flex;align-items:center;min-height:var(--touch);text-decoration:none;font-family:var(--font-mono);font-size:11.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-2);padding:0 12px;border-radius:var(--r-ctl);white-space:nowrap;transition:background .13s,color .13s}
53+.topnav a:hover,.topnav a[aria-current]{background:var(--accent-soft);color:var(--accent-deep)}
54+#ka-auth{display:flex;align-items:center;gap:8px}
55+#ka-auth .btn{font-size:12.5px;padding:8px 14px}
56+.ka-user{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2)}
57+.ka-user b{font-family:var(--font-display);font-size:13px;text-transform:none;letter-spacing:-.01em;color:var(--ink)}
58+.ka-avatar{width:28px;height:28px;border-radius:50%;border:1.5px solid var(--ink);object-fit:cover}
59+@media(max-width:1023px){.topnav{order:5;flex-basis:100%;margin-left:0}}
60+
61+/* ---- entête de page ---- */
62+.hero-s{padding:52px 0 40px;border-bottom:1.5px solid var(--ink);background:linear-gradient(rgba(59,91,219,.05),transparent 60%),var(--paper)}
63+.hero-s h1{max-width:640px;margin-top:16px}
64+.lede{margin-top:14px;max-width:62ch;color:var(--ink-2);font-size:16px}
65+.hero-tools{display:flex;flex-wrap:wrap;gap:12px;margin-top:24px;align-items:center}
66+.fresh{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:0}
67+
68+/* ---- page ---- */
69+section{padding:44px 0}
70+.stats-head{display:flex;flex-wrap:wrap;gap:14px;align-items:center;justify-content:space-between;margin-bottom:22px}
71+.periods{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
72+.periods .chip{cursor:pointer;min-height:44px;display:inline-flex;align-items:center;border:1.5px solid var(--ink);background:var(--surface);transition:background .12s,color .12s}
73+.periods .chip[aria-pressed="true"]{background:var(--accent);color:var(--on-accent)}
74+.custom-range{display:inline-flex;gap:6px;align-items:center}
75+.custom-range input{min-height:44px;width:148px}
76+
77+.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:14px;margin-bottom:26px}
78+.kpi{padding:14px 16px;min-width:0}
79+.kpi .v{margin:0;font-family:var(--font-display);font-weight:700;font-size:clamp(22px,2.4vw,30px);letter-spacing:-.02em;color:var(--ink);font-variant-numeric:tabular-nums}
80+.kpi .v small{font-size:.55em;color:var(--ink-2);font-weight:600}
81+.kpi .klabel{margin:6px 0 0;display:block}
82+.kpi .delta{margin:8px 0 0;font-family:var(--font-mono);font-size:11px;font-weight:700}
83+.kpi .delta span{color:var(--ink-3);font-weight:500}
84+.d-good{color:var(--green)}.d-bad{color:var(--danger)}
85+
86+.charts-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
87+.charts-grid .full{grid-column:1/-1}
88+@media(max-width:1023px){.charts-grid{grid-template-columns:1fr}}
89+figure.chart{margin:0;padding:16px}
90+figure.chart figcaption{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:baseline}
91+figure.chart figcaption b{font-family:var(--font-display);font-size:15px}
92+.legend{display:flex;gap:10px;flex-wrap:wrap}
93+.legend button{display:inline-flex;align-items:center;gap:6px;border:0;background:none;cursor:pointer;font-family:var(--font-mono);font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;min-height:44px;color:var(--ink-2)}
94+.legend button[aria-pressed="false"]{opacity:.4}
95+.legend .sw{width:18px;height:0;border-top:3px solid var(--accent)}
96+.legend .sw.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}
97+.chart svg{width:100%;height:auto;margin-top:10px;touch-action:pan-y;display:block}
98+.tip{margin-top:8px;min-height:30px}
99+.tip .chip{font-size:11.5px}
100+.tip .n1{color:var(--ink-3)}
101+
102+.bars-rows{margin-top:12px;display:grid;gap:9px}
103+.bar-row .bl{display:flex;justify-content:space-between;font-size:12.5px;gap:10px}
104+.bar-row .bl span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
105+.bar-row .bl b{font-family:var(--font-mono);font-size:11.5px;font-variant-numeric:tabular-nums}
106+.bar-row .track{margin-top:3px;height:12px;background:rgba(20,24,20,.06);border-radius:0 3px 3px 0}
107+.bar-row .fill{height:100%;background:var(--accent);border:1px solid var(--ink);border-radius:0 3px 3px 0;box-sizing:border-box;min-width:2px}
108+
109+.donut-wrap{display:flex;flex-wrap:wrap;gap:18px;align-items:center;margin-top:12px}
110+.donut-wrap svg{width:180px;max-width:100%;margin:0}
111+.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:6px;min-width:200px;flex:1}
112+.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px}
113+.donut-legend .sq{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);flex:none}
114+.donut-legend span.lbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
115+.donut-legend b{font-family:var(--font-mono);font-size:11px}
116+
117+.tbl-card{padding:16px;margin-top:16px}
118+.tbl-top{display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center}
119+.tbl-top b{font-family:var(--font-display);font-size:15px}
120+.tbl-top .input{max-width:240px}
121+table.dt{width:100%;border-collapse:collapse;font-size:13px;margin-top:10px}
122+table.dt th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);color:var(--paper);font-family:var(--font-mono);font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap;user-select:none}
123+table.dt td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}
124+table.dt tbody tr:nth-child(even){background:var(--surface-2)}
125+.tbl-foot{display:flex;justify-content:space-between;align-items:center;margin-top:10px;flex-wrap:wrap;gap:8px}
126+.tbl-foot .pg{display:flex;gap:6px;align-items:center}
127+
128+.records-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}
129+.record{padding:12px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;background:var(--surface-2)}
130+.record .rl{font-size:13px;color:var(--ink-2)}
131+.record .rv{text-align:right}
132+.record .rv b{font-family:var(--font-display);font-size:15px}
133+.record .rv .klabel{display:block}
134+
135+.empty{padding:20px;background:var(--surface-2);border-style:dashed}
136+.empty b{font-family:var(--font-display);font-size:14px}
137+.empty p{margin:6px 0 0}
138+
139+.pdf-note{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em}
140+#pdf-busy:not(:empty){font-family:var(--font-mono);font-size:11px;color:var(--accent-deep);font-weight:700}
141+.sec-gap{margin-top:30px}
142+@media(max-width:767px){section{padding:32px 0}.hero-s{padding:40px 0 32px}}
143+@media(prefers-reduced-motion:reduce){*{transition:none!important}}
144+</style>
145+</head>
146+<body>
147+
148+<div class="topbar"><div class="container topbar-in">
149+ <a class="brand" href="/" aria-label="API-Ka — accueil">API<span class="ka">·Ka</span></a>
150+ <a class="gk-badge" href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"
151+ aria-label="Un service Groupe KA — visiter le portail de l'écosystème">Un service <b>Groupe<span class="ka">KA</span></b></a>
152+ <ul class="topnav">
153+ <li><a href="/#services">Services</a></li>
154+ <li><a href="/#endpoints">Endpoints</a></li>
155+ <li><a href="/#playground">Playground</a></li>
156+ <li><a href="/stats" aria-current="page">Stats</a></li>
157+ <li><a href="/docs">Swagger</a></li>
158+ <li><a href="/contact">Contact</a></li>
159+ </ul>
160+ <div id="ka-auth"></div>
161+</div></div>
162+
163+<header class="hero-s"><div class="container">
164+ <span class="kicker">Statistiques · Plateforme</span>
165+ <h1>Les <span class="hl">stats</span> de la plateforme</h1>
166+ <p class="lede">Appels API par endpoint, latences moyennes et p95, taux d'erreur et
167+ runs de collecte quotidiens des huit services KA — mesurés en continu par la
168+ plateforme, rien d'inventé.</p>
169+ <div class="hero-tools">
170+ <button type="button" class="btn btn-primary" id="pdf-complet">⬇ Télécharger le rapport PDF</button>
171+ <button type="button" class="btn btn-ghost" id="pdf-synthese">Synthèse (2 p.)</button>
172+ <span id="pdf-busy" role="status"></span>
173+ </div>
174+ <p class="fresh" style="margin-top:18px">
175+ <span class="klabel" id="updated">Chargement…</span>
176+ <button type="button" class="btn btn-ghost" id="refresh">↻ Rafraîchir</button>
177+ <span class="pdf-note" id="coverage"></span>
178+ </p>
179+</div></header>
180+
181+<section><div class="container">
182+ <div class="stats-head">
183+ <div class="periods" id="periods" role="group" aria-label="Période"></div>
184+ <span class="custom-range">
185+ <input class="input" type="date" id="d-from" aria-label="Du">
186+ <span class="klabel">au</span>
187+ <input class="input" type="date" id="d-to" aria-label="Au">
188+ </span>
189+ </div>
190+
191+ <div class="kpi-grid" id="kpis"></div>
192+
193+ <div class="charts-grid" id="charts"></div>
194+
195+ <div id="heatmap" class="sec-gap"></div>
196+
197+ <div id="tables"></div>
198+
199+ <div class="sec-gap">
200+ <span class="kicker">Records &amp; faits marquants</span>
201+ <div class="records-grid" id="records" style="margin-top:14px"></div>
202+ </div>
203+</div></section>
204+
205+<footer class="ka-footer"><div class="container" id="ka-footer-root"></div></footer>
206+
207+<script src="/ka/ka-shell.js" defer></script>
208+<script>
209+"use strict";
210+/* Reproduction vanilla JS + SVG du kit ka-ui/stats/kacharts.tsx. */
211+const NF = new Intl.NumberFormat("fr-CA");
212+const fmt = n => Number.isInteger(n) ? NF.format(n) : new Intl.NumberFormat("fr-CA",{maximumFractionDigits:2}).format(n);
213+const esc = s => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/"/g,"&quot;");
214+
215+const PERIODS = [
216+ {id:"auj", label:"Aujourd'hui"},
217+ {id:"7j", label:"7 jours"},
218+ {id:"30j", label:"30 jours"},
219+ {id:"3m", label:"3 mois"},
220+ {id:"6m", label:"6 mois"},
221+ {id:"12m", label:"12 mois"},
222+ {id:"annee", label:"Année en cours"},
223+ {id:"tout", label:"Tout"},
224+];
225+const state = {period:"30j", from:"", to:""};
226+
227+/* ---------- sélecteur de période ---------- */
228+const periodsEl = document.getElementById("periods");
229+PERIODS.forEach(p => {
230+ const b = document.createElement("button");
231+ b.type = "button"; b.className = "chip"; b.textContent = p.label;
232+ b.setAttribute("aria-pressed", String(p.id === state.period));
233+ b.addEventListener("click", () => {
234+ state.period = p.id; state.from = ""; state.to = "";
235+ document.getElementById("d-from").value = "";
236+ document.getElementById("d-to").value = "";
237+ syncChips(); load();
238+ });
239+ b.dataset.p = p.id;
240+ periodsEl.appendChild(b);
241+});
242+function syncChips(){
243+ periodsEl.querySelectorAll(".chip").forEach(c =>
244+ c.setAttribute("aria-pressed", String(!state.from && c.dataset.p === state.period)));
245+}
246+["d-from","d-to"].forEach(id => document.getElementById(id).addEventListener("change", () => {
247+ const f = document.getElementById("d-from").value, t = document.getElementById("d-to").value;
248+ if (f && t) { state.from = f; state.to = t; syncChips(); load(); }
249+}));
250+document.getElementById("refresh").addEventListener("click", load);
251+
252+/* ---------- bouton PDF ---------- */
253+function pdfUrl(mode){
254+ const p = new URLSearchParams({period: state.period, mode});
255+ if (state.from && state.to) { p.set("from", state.from); p.set("to", state.to); }
256+ return "/api/stats/report?" + p;
257+}
258+function dlPdf(mode){
259+ const busy = document.getElementById("pdf-busy");
260+ busy.textContent = "Génération du PDF…";
261+ const a = document.createElement("a");
262+ a.href = pdfUrl(mode); a.download = "";
263+ document.body.appendChild(a); a.click(); a.remove();
264+ setTimeout(() => { busy.textContent = ""; }, 2600);
265+}
266+document.getElementById("pdf-complet").addEventListener("click", () => dlPdf("complet"));
267+document.getElementById("pdf-synthese").addEventListener("click", () => dlPdf("synthese"));
268+
269+/* ---------- blocs vides (jamais de faux chiffres) ---------- */
270+function emptyBlock(title){
271+ const d = document.createElement("div");
272+ d.className = "card empty";
273+ d.innerHTML = `<b>${esc(title)}</b><p class="klabel">Pas encore mesuré — aucune donnée disponible pour cette période.</p>`;
274+ return d;
275+}
276+
277+/* ---------- KPI ---------- */
278+function renderKpis(kpis){
279+ const grid = document.getElementById("kpis");
280+ grid.innerHTML = "";
281+ if (!kpis || !kpis.length) { grid.appendChild(emptyBlock("Indicateurs")); return; }
282+ kpis.forEach(k => {
283+ const card = document.createElement("article");
284+ card.className = "card kpi";
285+ let delta = "";
286+ if (k.delta_pct !== undefined && k.delta_pct !== null) {
287+ const arrowUp = k.delta_pct >= 0;
288+ const good = (k.direction ?? (arrowUp ? "up" : "down")) === "up";
289+ delta = `<p class="delta ${good ? "d-good" : "d-bad"}">${arrowUp ? "▲" : "▼"} ${arrowUp ? "+" : ""}${fmt(k.delta_pct)} % <span>vs période préc.</span></p>`;
290+ }
291+ card.innerHTML = `
292+ <p class="v">${typeof k.value === "number" ? fmt(k.value) : esc(k.value)}${k.unit ? `<small> ${esc(k.unit)}</small>` : ""}</p>
293+ <span class="klabel">${esc(k.label)}</span>${delta}`;
294+ grid.appendChild(card);
295+ });
296+}
297+
298+/* ---------- courbe (infobulle + légende cliquable + N-1 pointillé) ---------- */
299+function lineChart(serie){
300+ const pts = serie.points || [];
301+ if (pts.length < 2) return emptyBlock(serie.title);
302+ const W = 720, H = 240, PL = 54, PR = 10, PT = 14, PB = 26;
303+ const fig = document.createElement("figure");
304+ fig.className = "card chart";
305+ const hasCmp = Array.isArray(serie.compare) && serie.compare.length > 1;
306+ fig.innerHTML = `
307+ <figcaption><b>${esc(serie.title)}</b>
308+ <span class="legend">
309+ <button type="button" data-s="cur" aria-pressed="true"><span class="sw"></span>Période courante</button>
310+ ${hasCmp ? '<button type="button" data-s="cmp" aria-pressed="true"><span class="sw cmp"></span>Période comparée</button>' : ""}
311+ </span>
312+ </figcaption>
313+ <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(serie.title)}"></svg>
314+ <p class="tip" aria-live="polite"></p>`;
315+ const svg = fig.querySelector("svg");
316+ const tip = fig.querySelector(".tip");
317+ const hide = {cur:false, cmp:false};
318+ let hover = null;
319+
320+ function draw(){
321+ const all = [...(hide.cur ? [] : pts), ...((!hide.cmp && hasCmp) ? serie.compare : [])];
322+ const vmax = Math.max(...all.map(p => p.v), 1);
323+ const vmin = Math.min(0, ...all.map(p => p.v));
324+ const X = (i, n) => PL + (W - PL - PR) * i / (n - 1);
325+ const Y = v => PT + (H - PT - PB) * (1 - (v - vmin) / ((vmax - vmin) || 1));
326+ const path = s => s.map((p, i) => `${i ? "L" : "M"}${X(i, s.length)},${Y(p.v)}`).join("");
327+ let g = "";
328+ for (let k = 0; k < 5; k++) {
329+ const y = PT + (H - PT - PB) * k / 4;
330+ const v = vmax - (vmax - vmin) * k / 4;
331+ g += `<line x1="${PL}" x2="${W-PR}" y1="${y}" y2="${y}" stroke="var(--line)" stroke-width="1"/>
332+ <text x="${PL-6}" y="${y+3}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${NF.format(Math.round(v))}</text>`;
333+ }
334+ [0, Math.floor(pts.length/2), pts.length-1].forEach((i, k) => {
335+ const anchor = k === 0 ? "start" : k === 2 ? "end" : "middle";
336+ g += `<text x="${X(i, pts.length)}" y="${H-8}" text-anchor="${anchor}" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${esc(pts[i].t)}</text>`;
337+ });
338+ if (hasCmp && !hide.cmp) g += `<path d="${path(serie.compare)}" fill="none" stroke="var(--ink-3)" stroke-width="1.4" stroke-dasharray="4 4"/>`;
339+ if (!hide.cur) g += `<path d="${path(pts)}" fill="none" stroke="var(--accent)" stroke-width="2.4"/>`;
340+ if (hover !== null) {
341+ const x = X(hover, pts.length);
342+ g += `<line x1="${x}" x2="${x}" y1="${PT}" y2="${H-PB}" stroke="var(--ink)" stroke-width="1" stroke-dasharray="2 3"/>`;
343+ if (!hide.cur) g += `<circle cx="${x}" cy="${Y(pts[hover].v)}" r="4" fill="var(--accent)" stroke="var(--ink)" stroke-width="1.5"/>`;
344+ }
345+ svg.innerHTML = g;
346+ if (hover !== null) {
347+ const cmpTxt = (hasCmp && !hide.cmp && serie.compare[hover])
348+ ? ` <span class="n1">· N-1 : ${fmt(serie.compare[hover].v)}</span>` : "";
349+ tip.innerHTML = `<span class="chip">${esc(pts[hover].t)} — <b>&nbsp;${fmt(pts[hover].v)}${serie.unit ? " " + esc(serie.unit) : ""}</b>${cmpTxt}</span>`;
350+ } else tip.innerHTML = "";
351+ }
352+ function setHover(clientX){
353+ const r = svg.getBoundingClientRect();
354+ const fx = (clientX - r.left) / r.width * W;
355+ hover = Math.max(0, Math.min(pts.length - 1, Math.round((fx - PL) / (W - PL - PR) * (pts.length - 1))));
356+ draw();
357+ }
358+ svg.addEventListener("mousemove", e => setHover(e.clientX));
359+ svg.addEventListener("touchstart", e => setHover(e.touches[0].clientX), {passive:true});
360+ svg.addEventListener("touchmove", e => setHover(e.touches[0].clientX), {passive:true});
361+ svg.addEventListener("mouseleave", () => { hover = null; draw(); });
362+ fig.querySelectorAll(".legend button").forEach(b => b.addEventListener("click", () => {
363+ hide[b.dataset.s] = !hide[b.dataset.s];
364+ b.setAttribute("aria-pressed", String(!hide[b.dataset.s]));
365+ draw();
366+ }));
367+ draw();
368+ return fig;
369+}
370+
371+/* ---------- barres horizontales ---------- */
372+function barChart(title, items, unit){
373+ const rows = (items || []).slice(0, 14);
374+ if (!rows.length) return emptyBlock(title);
375+ const max = Math.max(...rows.map(r => r.value), 1);
376+ const fig = document.createElement("figure");
377+ fig.className = "card chart";
378+ fig.innerHTML = `<figcaption><b>${esc(title)}</b></figcaption><div class="bars-rows"></div>`;
379+ const zone = fig.querySelector(".bars-rows");
380+ rows.forEach(r => {
381+ const d = document.createElement("div");
382+ d.className = "bar-row";
383+ d.title = `${r.label} — ${fmt(r.value)}${unit ? " " + unit : ""}`;
384+ d.innerHTML = `
385+ <div class="bl"><span>${esc(r.label)}</span><b>${fmt(r.value)}${unit ? " " + esc(unit) : ""}</b></div>
386+ <div class="track"><div class="fill" style="width:${Math.max(r.value / max * 100, 1)}%"></div></div>`;
387+ zone.appendChild(d);
388+ });
389+ return fig;
390+}
391+
392+/* ---------- anneau ---------- */
393+function donut(title, items){
394+ const rows = (items || []).filter(i => i.value > 0).slice(0, 8);
395+ const total = rows.reduce((s, r) => s + r.value, 0);
396+ if (!total) return emptyBlock(title);
397+ const R = 74, C = 2 * Math.PI * R;
398+ const shades = [1, .78, .58, .42, .3, .22, .15, .1];
399+ let acc = 0, segs = "";
400+ rows.forEach((r, i) => {
401+ const frac = r.value / total, off = acc; acc += frac;
402+ segs += `<circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--accent)" stroke-opacity="${shades[i % 8]}"
403+ stroke-width="30" stroke-dasharray="${frac * C} ${C}" stroke-dashoffset="${-off * C}" transform="rotate(-90 100 100)">
404+ <title>${esc(r.label)} — ${fmt(r.value)} (${(100 * r.value / total).toFixed(1)} %)</title></circle>`;
405+ });
406+ const fig = document.createElement("figure");
407+ fig.className = "card chart";
408+ fig.innerHTML = `
409+ <figcaption><b>${esc(title)}</b></figcaption>
410+ <div class="donut-wrap">
411+ <svg viewBox="0 0 200 200" role="img" aria-label="${esc(title)}">${segs}
412+ <circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--ink)" stroke-width="1" opacity=".5"/></svg>
413+ <ul class="donut-legend">
414+ ${rows.map((r, i) => `<li><span class="sq" style="opacity:${shades[i % 8]}"></span>
415+ <span class="lbl">${esc(r.label)}</span><b>${(100 * r.value / total).toFixed(1).replace(".", ",")} %</b></li>`).join("")}
416+ </ul>
417+ </div>`;
418+ return fig;
419+}
420+
421+/* ---------- calendrier de chaleur ---------- */
422+function heatmap(title, cells){
423+ if (!cells || !cells.length) return null;
424+ const byDate = new Map(cells.map(c => [c.date, c.value]));
425+ const dates = cells.map(c => c.date).sort();
426+ const end = new Date(dates[dates.length - 1] + "T12:00:00");
427+ const max = Math.max(...cells.map(c => c.value), 1);
428+ const weeks = 26;
429+ const cur = new Date(end);
430+ cur.setDate(cur.getDate() - (weeks * 7 - 1));
431+ let rects = "";
432+ for (let w = 0; w < weeks; w++) for (let d = 0; d < 7; d++) {
433+ const iso = cur.toISOString().slice(0, 10);
434+ const v = byDate.get(iso) ?? 0;
435+ rects += `<rect x="${w * 14}" y="${d * 14}" width="12" height="12" rx="2.5"
436+ fill="${v ? "var(--accent)" : "rgba(20,24,20,0.07)"}" fill-opacity="${v ? (0.25 + 0.75 * v / max).toFixed(3) : 1}"
437+ stroke="rgba(20,24,20,0.15)" stroke-width="0.5"><title>${iso} — ${fmt(v)}</title></rect>`;
438+ cur.setDate(cur.getDate() + 1);
439+ }
440+ const fig = document.createElement("figure");
441+ fig.className = "card chart";
442+ fig.innerHTML = `
443+ <figcaption><b>${esc(title)}</b> <span class="klabel">26 dernières semaines</span></figcaption>
444+ <div class="tbl-wrap" style="margin-top:12px">
445+ <svg viewBox="0 0 ${weeks * 14} ${7 * 14}" style="min-width:480px" role="img" aria-label="${esc(title)}">${rects}</svg>
446+ </div>`;
447+ return fig;
448+}
449+
450+/* ---------- tableau : tri, recherche, pagination 25/pg ---------- */
451+function dataTable(spec, pageSize = 25){
452+ if (!spec.rows || !spec.rows.length) return emptyBlock(spec.title);
453+ const sec = document.createElement("section");
454+ sec.className = "card tbl-card";
455+ sec.innerHTML = `
456+ <div class="tbl-top"><b>${esc(spec.title)}</b>
457+ <input class="input" placeholder="Rechercher…" aria-label="Rechercher dans ${esc(spec.title)}"></div>
458+ <div class="tbl-wrap"><table class="dt"><thead><tr>
459+ ${spec.columns.map((c, i) => `<th data-i="${i}" aria-sort="none">${esc(c)} <span class="si">↕</span></th>`).join("")}
460+ </tr></thead><tbody></tbody></table></div>
461+ <div class="tbl-foot"><span class="klabel"></span>
462+ <span class="pg">
463+ <button type="button" class="btn btn-ghost" data-d="-1" aria-label="Page précédente">←</button>
464+ <span class="chip"></span>
465+ <button type="button" class="btn btn-ghost" data-d="1" aria-label="Page suivante">→</button>
466+ </span></div>`;
467+ const st = {q:"", sort:null, page:0};
468+ const num = x => typeof x === "number" ? x : parseFloat(String(x).replace(/[^\d.,-]/g, "").replace(",", "."));
469+ function rowsFiltered(){
470+ let r = spec.rows;
471+ if (st.q) r = r.filter(row => row.some(c => String(c).toLowerCase().includes(st.q.toLowerCase())));
472+ if (st.sort) r = [...r].sort((a, b) => {
473+ const nx = num(a[st.sort.col]), ny = num(b[st.sort.col]);
474+ if (!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx - ny) * st.sort.dir;
475+ return String(a[st.sort.col]).localeCompare(String(b[st.sort.col]), "fr") * st.sort.dir;
476+ });
477+ return r;
478+ }
479+ function render(){
480+ const rows = rowsFiltered();
481+ const pages = Math.max(1, Math.ceil(rows.length / pageSize));
482+ st.page = Math.min(st.page, pages - 1);
483+ sec.querySelector("tbody").innerHTML = rows.slice(st.page * pageSize, (st.page + 1) * pageSize)
484+ .map(row => `<tr>${row.map(c => `<td>${typeof c === "number" ? fmt(c) : esc(c)}</td>`).join("")}</tr>`).join("");
485+ sec.querySelector(".tbl-foot .klabel").textContent = NF.format(rows.length) + " lignes";
486+ sec.querySelector(".pg .chip").textContent = `${st.page + 1} / ${pages}`;
487+ sec.querySelector('[data-d="-1"]').disabled = st.page === 0;
488+ sec.querySelector('[data-d="1"]').disabled = st.page >= pages - 1;
489+ sec.querySelectorAll("th").forEach(th => {
490+ const i = +th.dataset.i;
491+ const on = st.sort && st.sort.col === i;
492+ th.setAttribute("aria-sort", on ? (st.sort.dir === 1 ? "ascending" : "descending") : "none");
493+ th.querySelector(".si").textContent = on ? (st.sort.dir === 1 ? "▲" : "▼") : "↕";
494+ });
495+ }
496+ sec.querySelector(".tbl-top .input").addEventListener("input", e => { st.q = e.target.value; st.page = 0; render(); });
497+ sec.querySelectorAll("th").forEach(th => th.addEventListener("click", () => {
498+ const i = +th.dataset.i;
499+ st.sort = { col:i, dir: st.sort && st.sort.col === i && st.sort.dir === 1 ? -1 : 1 };
500+ render();
501+ }));
502+ sec.querySelectorAll(".pg button").forEach(b => b.addEventListener("click", () => { st.page += +b.dataset.d; render(); }));
503+ render();
504+ return sec;
505+}
506+
507+/* ---------- records ---------- */
508+function renderRecords(records){
509+ const grid = document.getElementById("records");
510+ grid.innerHTML = "";
511+ if (!records || !records.length) { grid.appendChild(emptyBlock("Records")); return; }
512+ records.forEach(r => {
513+ const a = document.createElement("article");
514+ a.className = "card record";
515+ a.innerHTML = `<span class="rl">${esc(r.label)}</span>
516+ <span class="rv"><b>${esc(r.value)}</b>${r.date ? `<span class="klabel">${esc(r.date)}</span>` : ""}</span>`;
517+ grid.appendChild(a);
518+ });
519+}
520+
521+/* ---------- chargement & rendu ---------- */
522+async function load(){
523+ const upd = document.getElementById("updated");
524+ upd.textContent = "Chargement…";
525+ const p = new URLSearchParams({period: state.period});
526+ if (state.from && state.to) { p.set("from", state.from); p.set("to", state.to); }
527+ let dash;
528+ try {
529+ const r = await fetch("/api/stats/dashboard?" + p, {headers:{Accept:"application/json"}});
530+ dash = (await r.json()).data;
531+ } catch {
532+ upd.textContent = "Impossible de charger les statistiques.";
533+ return;
534+ }
535+ upd.textContent = "Mis à jour le " + new Date(dash.updated).toLocaleString("fr-CA", {dateStyle:"medium", timeStyle:"short"})
536+ + (dash.period ? ` · ${dash.period.label} (${dash.period.from} → ${dash.period.to})` : "");
537+ const cov = document.getElementById("coverage");
538+ cov.textContent = dash.coverage && dash.coverage.api_requests_since
539+ ? "Appels API journalisés depuis le " + new Date(dash.coverage.api_requests_since).toLocaleDateString("fr-CA")
540+ : "";
541+
542+ renderKpis(dash.kpis);
543+
544+ const charts = document.getElementById("charts");
545+ charts.innerHTML = "";
546+ const series = dash.series || [];
547+ if (!series.length) charts.appendChild(Object.assign(emptyBlock("Séries temporelles"), {className:"card empty full"}));
548+ series.forEach((s, i) => {
549+ const el = lineChart(s);
550+ if (i === 0) el.classList.add("full");
551+ charts.appendChild(el);
552+ });
553+ (dash.breakdowns || []).forEach(b => {
554+ charts.appendChild(b.kind === "donut" ? donut(b.title, b.items) : barChart(b.title, b.items));
555+ });
556+ if (dash.geo && dash.geo.items) charts.appendChild(barChart(dash.geo.title || "Répartition géographique", dash.geo.items));
557+
558+ const hm = document.getElementById("heatmap");
559+ hm.innerHTML = "";
560+ const hmEl = dash.heatmap ? heatmap(dash.heatmap.title, dash.heatmap.cells) : null;
561+ if (hmEl) hm.appendChild(hmEl);
562+
563+ const tbls = document.getElementById("tables");
564+ tbls.innerHTML = "";
565+ (dash.tables || []).forEach(t => tbls.appendChild(dataTable(t)));
566+
567+ renderRecords(dash.records);
568+}
569+load();
570+</script>
571+</body>
572+</html>
modified src/database/models.py +25 −0
@@ -156,3 +156,28 @@ class CollectionRun(Base):
156 finished_at: Mapped[datetime.datetime] = mapped_column( 156 finished_at: Mapped[datetime.datetime] = mapped_column(
157 DateTime(timezone=True), nullable=False 157 DateTime(timezone=True), nullable=False
158 ) 158 )
159+
160+
161+class ApiRequest(Base):
162+ """Journal léger des requêtes HTTP servies par l'API (alimenté par le
163+ middleware de logging, purge automatique > 90 jours).
164+
165+ Sert exclusivement la page /stats : appels par endpoint/jour, latences
166+ moyennes et p95, taux d'erreur. Le chemin est normalisé (paramètres de
167+ route repliés) pour garder une cardinalité bornée.
168+ """
169+
170+ __tablename__ = "api_requests"
171+ __table_args__ = (
172+ Index("ix_api_requests_ts", "ts"),
173+ Index("ix_api_requests_endpoint_ts", "endpoint", "ts"),
174+ )
175+
176+ id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True)
177+ ts: Mapped[datetime.datetime] = mapped_column(
178+ DateTime(timezone=True), nullable=False
179+ )
180+ method: Mapped[str] = mapped_column(Text, nullable=False, default="GET")
181+ endpoint: Mapped[str] = mapped_column(Text, nullable=False)
182+ status: Mapped[int] = mapped_column(Integer, nullable=False)
183+ duration_ms: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
added tests/test_stats.py +113 −0
@@ -0,0 +1,113 @@
1+# ============================================
2+# Projet : API-KA
3+# Fichier : tests/test_stats.py
4+# Node : m3u96b
5+# Author : Simon-Pierre Boucher
6+# Contact : contact@spboucher.ai
7+# Date : 2026-08-17
8+# ============================================
9+"""Tests des routes /api/stats : dashboard (contrat SPEC), rapport PDF, page /stats."""
10+
11+from __future__ import annotations
12+
13+import datetime
14+
15+import pytest
16+from fastapi.testclient import TestClient
17+
18+from src.api import reqstats
19+from src.api.main import app
20+from src.api.routes import stats as stats_module
21+from src.database.db import session_scope
22+from src.database.models import ApiRequest, CollectionRun
23+
24+
25+@pytest.fixture(autouse=True)
26+def _clear_stats_cache():
27+ """Le cache 5 min du dashboard ne doit pas fuiter entre les tests."""
28+ stats_module._cache.clear()
29+ yield
30+ stats_module._cache.clear()
31+
32+
33+def _seed_requests_and_run() -> None:
34+ now = datetime.datetime.now(tz=datetime.UTC)
35+ today = datetime.datetime.now(tz=stats_module.TZ).date()
36+ with session_scope() as session:
37+ if session.query(ApiRequest).count() == 0:
38+ for i in range(5):
39+ session.add(
40+ ApiRequest(
41+ ts=now - datetime.timedelta(hours=i + 1),
42+ method="GET",
43+ endpoint="/api/v1/louka",
44+ status=200 if i else 404,
45+ duration_ms=10.0 + i,
46+ )
47+ )
48+ session.add(
49+ CollectionRun(
50+ service="louka",
51+ date_key=today,
52+ status="success",
53+ records_count=42,
54+ duration_seconds=2.0,
55+ error_message=None,
56+ node="m3u96b",
57+ started_at=now,
58+ finished_at=now,
59+ )
60+ )
61+
62+
63+def test_dashboard_contract() -> None:
64+ _seed_requests_and_run()
65+ with TestClient(app) as client:
66+ response = client.get("/api/stats/dashboard?period=7j")
67+ assert response.status_code == 200
68+ body = response.json()
69+ assert body["success"] is True
70+ dash = body["data"]
71+ assert set(dash) >= {"updated", "period", "kpis", "series", "tables", "records"}
72+ kpis = {k["id"]: k for k in dash["kpis"]}
73+ assert kpis["calls"]["value"] >= 5
74+ assert kpis["records"]["value"] >= 42
75+ assert any(s["id"] == "calls" for s in dash["series"])
76+
77+
78+def test_dashboard_invalid_period() -> None:
79+ with TestClient(app) as client:
80+ assert client.get("/api/stats/dashboard?period=xx").status_code == 422
81+
82+
83+def test_report_pdf() -> None:
84+ _seed_requests_and_run()
85+ with TestClient(app) as client:
86+ response = client.get("/api/stats/report?period=7j&mode=synthese")
87+ assert response.status_code == 200
88+ assert response.headers["content-type"] == "application/pdf"
89+ assert "groupe-ka_api-ka_stats_7j_" in response.headers["content-disposition"]
90+ assert response.content.startswith(b"%PDF")
91+
92+
93+def test_stats_page_and_middleware_recording() -> None:
94+ with TestClient(app) as client:
95+ response = client.get("/stats")
96+ assert response.status_code == 200
97+ assert "tableau de bord" in response.text.lower() or "stats" in response.text.lower()
98+ # Le middleware a empilé la requête ; flush() la persiste en base.
99+ reqstats.flush()
100+ with session_scope() as session:
101+ assert (
102+ session.query(ApiRequest).filter(ApiRequest.endpoint == "/stats").count()
103+ >= 1
104+ )
105+
106+
107+def test_normalize_endpoint() -> None:
108+ assert reqstats.normalize_endpoint("/api/v1/louka/date/2026-08-16") == (
109+ "/api/v1/louka/date/{date}"
110+ )
111+ assert reqstats.normalize_endpoint("/api/v1/louka/latest") == "/api/v1/louka/latest"
112+ assert reqstats.normalize_endpoint("/ka/tokens.css") == "/ka/*"
113+ assert reqstats.normalize_endpoint("/wp-admin/setup.php") == "(autre)"
114
Menu

Theme