SPB Git

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

feat(stats): tableau de bord analytique /stats + rapport PDF Groupe-KA

- creaka/stats.py : GET /api/stats/dashboard (contrat ka-stats SPEC.md) —
  KPI avec deltas calculables, séries quotidiennes (ajouts, taille cumulative),
  répartitions (anneau plateformes, barres tiers/niches), heatmap d ajouts,
  tableaux (top créateurs, niches), records générés des données ; cache 5 min.
- creaka/kapdf.py + GET /api/stats/report : PDF estampillé Groupe-KA (fpdf2),
  modes complet/synthèse, graphiques vectoriels, filename kapdf normé ;
  correctif : plus de pied de page sur la couverture.
- frontend : vue /stats refaite en vanilla JS + SVG (kit kacharts reproduit) —
  chips de période + plage personnalisée, courbes avec infobulle et légende
  cliquable, anneau, barres, calendrier de chaleur, tableaux triables/
  cherchables/paginés (25/pg), records, fraîcheur + rafraîchir, boutons PDF ;
  mobile 360/768/1440 sans débordement horizontal.
- frontend/src/ka/stats : kit commun (SPEC.md, kacharts.tsx, kapdf.py) versionné.

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

9 changed files +2,658 −56

added creaka/kapdf.py +560 −0
@@ -0,0 +1,560 @@
1+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2).
3+# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4+# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques
5+# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.
6+# Usage :
7+# from kapdf import GroupeKAReport
8+# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
9+# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
10+# mode="complet").build()
11+# Dépendance : pip install fpdf2 (aucune autre)
12+from __future__ import annotations
13+
14+import math
15+from datetime import datetime
16+from zoneinfo import ZoneInfo
17+
18+from fpdf import FPDF
19+
20+INK = (20, 24, 20)
21+INK2 = (77, 85, 81)
22+INK3 = (139, 146, 140)
23+PAPER = (245, 243, 238)
24+SURFACE2 = (250, 249, 245)
25+GREEN = (28, 92, 65)
26+DANGER = (179, 66, 58)
27+WHITE = (255, 255, 255)
28+
29+EMAILS = [
30+ ("contact@groupe-ka.com", "Projets, partenariats & données"),
31+ ("info@groupe-ka.com", "Médias & questions générales"),
32+ ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),
33+]
34+DISCLAIMER = (
35+ "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "
36+ "rien et ne sommes partie à aucune transaction. Données lues à la source, "
37+ "rien d'inventé, tout est traçable."
38+)
39+
40+
41+def _hex(c: str) -> tuple[int, int, int]:
42+ c = c.lstrip("#")
43+ return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore
44+
45+
46+def _fr(n) -> str:
47+ if isinstance(n, float) and not n.is_integer():
48+ return f"{n:,.2f}".replace(",", " ").replace(".", ",")
49+ return f"{int(n):,}".replace(",", " ")
50+
51+
52+_SUBST = {
53+ "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
54+ "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
55+ "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
56+}
57+
58+
59+def _latin1(s: str) -> str:
60+ for k, v in _SUBST.items():
61+ s = s.replace(k, v)
62+ return s.encode("latin-1", "replace").decode("latin-1")
63+
64+
65+class _PDF(FPDF):
66+ """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
67+ Les polices core sont latin-1 : normalize_text sanitise en amont."""
68+
69+ def normalize_text(self, text):
70+ return super().normalize_text(_latin1(text))
71+
72+ def __init__(self, brand: str, accent: tuple, period_label: str):
73+ super().__init__(orientation="P", unit="mm", format="A4")
74+ self.brand = brand
75+ self.accent = accent
76+ self.period_label = period_label
77+ self.cover_mode = False
78+ self.set_margins(18, 20, 18)
79+ self.set_auto_page_break(True, margin=22)
80+
81+ def header(self):
82+ if self.cover_mode or self.page_no() == 1: # jamais sur la couverture
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+ # le pied de la couverture se rend APRÈS la remise à zéro de cover_mode
99+ # (add_page suivant) : on exclut donc aussi explicitement la page 1
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 creaka/stats.py +330 −0
@@ -0,0 +1,330 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: creaka/stats.py
4+# Desc: Tableau de bord analytique /api/stats/dashboard (contrat ka-stats
5+# SPEC.md) + rapport PDF Groupe-KA (/api/stats/report via kapdf.py).
6+# AUCUNE stat inventée : tout est calculé depuis creators/accounts/
7+# sync_log (first_seen converti en date locale, cache 5 min / période).
8+# ==============================================================================
9+from __future__ import annotations
10+
11+import json
12+import sqlite3
13+import time
14+from datetime import date, datetime, timedelta, timezone
15+from pathlib import Path
16+from zoneinfo import ZoneInfo
17+
18+TZ = ZoneInfo("America/Toronto")
19+ROOT = Path(__file__).resolve().parent.parent
20+ECO_PATH = ROOT / "frontend" / "src" / "ka" / "ecosystem.json"
21+
22+CACHE_TTL = 300 # ≥ 5 min par période (SPEC §2)
23+_CACHE: dict[tuple, tuple[float, dict]] = {}
24+
25+PERIOD_DAYS = {"auj": 1, "7j": 7, "30j": 30, "3m": 91, "6m": 182, "12m": 365}
26+PERIOD_LABELS = {
27+ "auj": "aujourd'hui", "7j": "7 jours", "30j": "30 jours", "3m": "3 mois",
28+ "6m": "6 mois", "12m": "12 mois", "annee": "année en cours",
29+ "tout": "toute la période",
30+}
31+
32+PLAT_LBL = {
33+ "instagram": "Instagram", "tiktok": "TikTok", "youtube": "YouTube",
34+ "twitch": "Twitch", "kick": "Kick", "x": "X (Twitter)",
35+ "facebook": "Facebook", "snapchat": "Snapchat", "substack": "Substack",
36+ "patreon": "Patreon", "onlyfans": "OnlyFans", "linkedin": "LinkedIn",
37+ "threads": "Threads", "podcast": "Balado", "site-web": "Site web",
38+ "autre": "Autre",
39+}
40+NICHE_LBL = {
41+ "humour": "Humour", "mode": "Mode", "beaute": "Beauté",
42+ "lifestyle": "Lifestyle", "famille-parentalite": "Famille & parentalité",
43+ "cuisine": "Cuisine", "gaming": "Gaming", "tech": "Tech",
44+ "sport-fitness": "Sport & fitness", "plein-air": "Plein air",
45+ "voyage": "Voyage", "musique": "Musique", "arts": "Arts", "danse": "Danse",
46+ "education": "Éducation", "finance-affaires": "Finance & affaires",
47+ "sante-mieux-etre": "Santé & mieux-être", "bouffe-resto": "Bouffe & resto",
48+ "actualite-opinion": "Actualité & opinion", "autre": "Autre",
49+}
50+# bornes réelles de normalize.audience_tier (§6.3)
51+TIER_LBL = [("nano", "Nano (< 10 k)"), ("micro", "Micro (10 k – 100 k)"),
52+ ("macro", "Macro (100 k – 1 M)"), ("mega", "Méga (1 M et +)")]
53+
54+
55+def site_info() -> dict:
56+ """Identité Créa-Ka pour le PDF, lue dans ka/ecosystem.json (source commune)."""
57+ try:
58+ eco = json.loads(ECO_PATH.read_text(encoding="utf-8"))
59+ s = next(x for x in eco["sites"] if x["id"] == "crea-ka")
60+ return {"wordmark": s["wordmark"], "accent": s["accent"],
61+ "domain": s["domain"], "tagline": s.get("tagline", "")}
62+ except Exception:
63+ return {"wordmark": "Créa·Ka", "accent": "#7048e8",
64+ "domain": "www.crea-ka.com",
65+ "tagline": "Les créateurs d'ici, tous leurs liens"}
66+
67+
68+# --- utilitaires -----------------------------------------------------------------
69+
70+def _local_date(iso: str) -> date | None:
71+ """ISO-8601 UTC (…Z) → date locale (America/Toronto)."""
72+ if not iso:
73+ return None
74+ try:
75+ dt = datetime.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S")
76+ return dt.replace(tzinfo=timezone.utc).astimezone(TZ).date()
77+ except ValueError:
78+ try:
79+ return date.fromisoformat(iso[:10])
80+ except ValueError:
81+ return None
82+
83+
84+def _parse_date(s: str) -> date | None:
85+ try:
86+ return date.fromisoformat(s.strip()[:10])
87+ except (ValueError, AttributeError):
88+ return None
89+
90+
91+def _pct(cur: float, prev: float) -> float | None:
92+ """Variation vs période précédente ; None si la base est nulle (pas de faux %)."""
93+ if not prev:
94+ return None
95+ return round(100.0 * (cur - prev) / prev, 1)
96+
97+
98+def _delta(cur: float, prev: float) -> dict:
99+ p = _pct(cur, prev)
100+ if p is None:
101+ return {"delta_pct": None}
102+ return {"delta_pct": p, "direction": "up" if p >= 0 else "down"}
103+
104+
105+def _fr_int(n: int) -> str:
106+ return f"{int(n):,}".replace(",", " ")
107+
108+
109+# --- construction du tableau de bord ----------------------------------------------
110+
111+def _resolve_period(period: str, d_from: str, d_to: str,
112+ min_day: date | None) -> tuple[date, date, str]:
113+ today = datetime.now(TZ).date()
114+ f, t = _parse_date(d_from), _parse_date(d_to)
115+ if f and t:
116+ if t < f:
117+ f, t = t, f
118+ return f, t, f"du {f.isoformat()} au {t.isoformat()}"
119+ if period == "annee":
120+ return date(today.year, 1, 1), today, f"année {today.year}"
121+ if period == "tout":
122+ # depuis la première fiche (plancher 30 j pour des courbes lisibles)
123+ start = min(min_day or today, today - timedelta(days=29))
124+ return start, today, PERIOD_LABELS["tout"]
125+ days = PERIOD_DAYS.get(period, 30)
126+ label = PERIOD_LABELS.get(period, PERIOD_LABELS["30j"])
127+ return today - timedelta(days=days - 1), today, label
128+
129+
130+def _build(con: sqlite3.Connection, period: str, d_from: str, d_to: str) -> dict:
131+ # fiches actives (mineurs & opt-out exclus, comme partout dans l'API)
132+ creators = con.execute(
133+ "SELECT display_name, first_seen, niches, audience_tier, region, "
134+ "total_reach, primary_platform FROM creators "
135+ "WHERE status='active' AND is_minor=0").fetchall()
136+ days_seen = [d for d in (_local_date(r["first_seen"]) for r in creators) if d]
137+ min_day = min(days_seen) if days_seen else None
138+ p_from, p_to, p_label = _resolve_period(period, d_from, d_to, min_day)
139+ span = (p_to - p_from).days + 1
140+ prev_to = p_from - timedelta(days=1)
141+ prev_from = prev_to - timedelta(days=span - 1)
142+
143+ # ajouts par jour (toute l'historique) — sert séries, heatmap, records
144+ adds_by_day: dict[date, int] = {}
145+ for d in days_seen:
146+ adds_by_day[d] = adds_by_day.get(d, 0) + 1
147+
148+ def added_between(a: date, b: date) -> int:
149+ return sum(v for d, v in adds_by_day.items() if a <= d <= b)
150+
151+ def total_until(d: date) -> int:
152+ return sum(v for dd, v in adds_by_day.items() if dd <= d)
153+
154+ # comptes reliés par plateforme (comptes « à vérifier » exclus, §12.1)
155+ plat_rows = con.execute(
156+ "SELECT a.platform, COUNT(*) c FROM accounts a "
157+ "JOIN creators c2 ON c2.id=a.creator_id "
158+ "WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0 "
159+ "GROUP BY a.platform ORDER BY c DESC").fetchall()
160+ n_accounts = sum(r["c"] for r in plat_rows)
161+
162+ # niches (multivaluées) — global + ajouts sur la période
163+ niche_total: dict[str, int] = {}
164+ niche_period: dict[str, int] = {}
165+ tier_total: dict[str, int] = {}
166+ region_total: dict[str, int] = {}
167+ for r in creators:
168+ d = _local_date(r["first_seen"])
169+ for n in (r["niches"] or "").split(","):
170+ if not n:
171+ continue
172+ niche_total[n] = niche_total.get(n, 0) + 1
173+ if d and p_from <= d <= p_to:
174+ niche_period[n] = niche_period.get(n, 0) + 1
175+ tier_total[r["audience_tier"]] = tier_total.get(r["audience_tier"], 0) + 1
176+ if r["region"]:
177+ region_total[r["region"]] = region_total.get(r["region"], 0) + 1
178+
179+ # ---- KPI (deltas seulement quand ils sont réellement calculables) ----
180+ added_cur = added_between(p_from, p_to)
181+ added_prev = added_between(prev_from, prev_to)
182+ total_end = total_until(p_to)
183+ total_start = total_until(prev_to)
184+ kpis = [
185+ {"id": "creators", "label": "Créateurs au répertoire", "value": total_end,
186+ "unit": "", **_delta(total_end, total_start)},
187+ {"id": "accounts", "label": "Comptes publics reliés", "value": n_accounts,
188+ "unit": "", "delta_pct": None},
189+ {"id": "platforms", "label": "Plateformes couvertes",
190+ "value": len(plat_rows), "unit": "", "delta_pct": None},
191+ {"id": "added", "label": "Créateurs ajoutés sur la période",
192+ "value": added_cur, "unit": "", **_delta(added_cur, added_prev)},
193+ {"id": "niches", "label": "Niches couvertes", "value": len(niche_total),
194+ "unit": "", "delta_pct": None},
195+ ]
196+ reach = sum(r["total_reach"] for r in creators if r["total_reach"])
197+ if reach:
198+ kpis.append({"id": "reach", "label": "Portée cumulée connue",
199+ "value": reach, "unit": "abonnés", "delta_pct": None})
200+
201+ # ---- séries quotidiennes ----
202+ day_axis = [p_from + timedelta(days=i) for i in range(span)]
203+ pts_added = [{"t": d.isoformat(), "v": adds_by_day.get(d, 0)} for d in day_axis]
204+ cmp_added = [{"t": (prev_from + timedelta(days=i)).isoformat(),
205+ "v": adds_by_day.get(prev_from + timedelta(days=i), 0)}
206+ for i in range(span)]
207+ running, pts_cumul = 0, []
208+ before = total_until(p_from - timedelta(days=1))
209+ running = before
210+ for d in day_axis:
211+ running += adds_by_day.get(d, 0)
212+ pts_cumul.append({"t": d.isoformat(), "v": running})
213+ series = [
214+ {"id": "added", "title": "Créateurs ajoutés par jour", "unit": "créateurs",
215+ "kind": "line", "points": pts_added,
216+ **({"compare": cmp_added} if any(c["v"] for c in cmp_added) else {})},
217+ {"id": "cumul", "title": "Taille cumulative du répertoire",
218+ "unit": "créateurs", "kind": "line", "points": pts_cumul},
219+ ]
220+
221+ # ---- répartitions ----
222+ breakdowns = [
223+ {"id": "platforms", "title": "Comptes reliés par plateforme",
224+ "kind": "donut",
225+ "items": [{"label": PLAT_LBL.get(r["platform"], r["platform"]),
226+ "value": r["c"]} for r in plat_rows]},
227+ {"id": "tiers", "title": "Créateurs par taille d'audience", "kind": "bar",
228+ "items": [{"label": lbl, "value": tier_total.get(t, 0)}
229+ for t, lbl in TIER_LBL if tier_total.get(t)]},
230+ {"id": "niches", "title": "Top niches", "kind": "bar",
231+ "items": [{"label": NICHE_LBL.get(n, n), "value": v}
232+ for n, v in sorted(niche_total.items(), key=lambda x: -x[1])[:12]]},
233+ ]
234+
235+ geo = None
236+ if region_total:
237+ geo = {"title": "Par région déclarée (quand le créateur la rend publique)",
238+ "items": [{"label": k, "value": v} for k, v in
239+ sorted(region_total.items(), key=lambda x: -x[1])]}
240+
241+ # ---- heatmap : ajouts par jour, toute l'historique ----
242+ heatmap = {"title": "Ajouts au répertoire",
243+ "cells": [{"date": d.isoformat(), "value": v}
244+ for d, v in sorted(adds_by_day.items())]}
245+
246+ # ---- tableaux ----
247+ top = sorted((r for r in creators if r["total_reach"]),
248+ key=lambda r: -r["total_reach"])[:100]
249+ tables = []
250+ if top:
251+ tables.append({
252+ "id": "top_creators", "title": "Top créateurs par audience connue",
253+ "columns": ["Créateur", "Taille", "Plateforme principale",
254+ "Abonnés cumulés", "Niches"],
255+ "rows": [[r["display_name"],
256+ dict(TIER_LBL).get(r["audience_tier"], r["audience_tier"]),
257+ PLAT_LBL.get(r["primary_platform"], r["primary_platform"]),
258+ r["total_reach"],
259+ ", ".join(NICHE_LBL.get(n, n)
260+ for n in (r["niches"] or "").split(",")[:2] if n)]
261+ for r in top]})
262+ n_active = len(creators)
263+ tables.append({
264+ "id": "niches", "title": "Répartition par niche",
265+ "columns": ["Niche", "Créateurs", "Ajoutés sur la période", "Part"],
266+ "rows": [[NICHE_LBL.get(n, n), v, niche_period.get(n, 0),
267+ f"{100 * v / max(1, n_active):.1f} %".replace(".", ",")]
268+ for n, v in sorted(niche_total.items(), key=lambda x: -x[1])]})
269+
270+ # ---- records & faits marquants (générés depuis les données) ----
271+ records = []
272+ in_period = {d: v for d, v in adds_by_day.items() if p_from <= d <= p_to}
273+ if in_period:
274+ best = max(in_period.items(), key=lambda x: x[1])
275+ records.append({"label": "Jour record d'ajouts (période)",
276+ "value": f"{_fr_int(best[1])} créateurs",
277+ "date": best[0].isoformat()})
278+ if niche_period:
279+ bn = max(niche_period.items(), key=lambda x: x[1])
280+ records.append({"label": "Niche la plus dynamique (ajouts sur la période)",
281+ "value": f"{NICHE_LBL.get(bn[0], bn[0])} — {_fr_int(bn[1])}"})
282+ if plat_rows:
283+ records.append({"label": "Plateforme la plus reliée",
284+ "value": f"{PLAT_LBL.get(plat_rows[0]['platform'], plat_rows[0]['platform'])}"
285+ f" — {_fr_int(plat_rows[0]['c'])} comptes"})
286+ if top:
287+ records.append({"label": "Plus grande portée connue",
288+ "value": f"{top[0]['display_name']} — "
289+ f"{_fr_int(top[0]['total_reach'])} abonnés"})
290+ last_sync = con.execute(
291+ "SELECT ts FROM sync_log ORDER BY id DESC LIMIT 1").fetchone()
292+ if last_sync and last_sync["ts"]:
293+ try:
294+ dt = datetime.strptime(last_sync["ts"][:19], "%Y-%m-%dT%H:%M:%S") \
295+ .replace(tzinfo=timezone.utc).astimezone(TZ)
296+ records.append({"label": "Dernière synchronisation des connecteurs",
297+ "value": dt.strftime("%H:%M (heure de l'Est)"),
298+ "date": dt.date().isoformat()})
299+ except ValueError:
300+ pass
301+
302+ out = {
303+ "updated": datetime.now(TZ).isoformat(timespec="seconds"),
304+ "period": {"from": p_from.isoformat(), "to": p_to.isoformat(),
305+ "label": p_label},
306+ "kpis": kpis,
307+ "series": series,
308+ "breakdowns": breakdowns,
309+ "heatmap": heatmap,
310+ "tables": tables,
311+ "records": records,
312+ }
313+ if geo:
314+ out["geo"] = geo
315+ return out
316+
317+
318+def dashboard(con: sqlite3.Connection, period: str = "30j",
319+ date_from: str = "", date_to: str = "") -> dict:
320+ """Point d'entrée avec cache mémoire (TTL 5 min par combinaison de période)."""
321+ key = (period, date_from, date_to)
322+ now = time.time()
323+ hit = _CACHE.get(key)
324+ if hit and now - hit[0] < CACHE_TTL:
325+ return hit[1]
326+ data = _build(con, period, date_from, date_to)
327+ if len(_CACHE) > 64: # borne dure (plages personnalisées illimitées)
328+ _CACHE.clear()
329+ _CACHE[key] = (now, data)
330+ return data
modified creaka/web.py +37 −2
@@ -12,10 +12,11 @@ from pathlib import Path
12 from fastapi import FastAPI, HTTPException, Query 12 from fastapi import FastAPI, HTTPException, Query
13 from fastapi.middleware.cors import CORSMiddleware 13 from fastapi.middleware.cors import CORSMiddleware
14 from fastapi.middleware.gzip import GZipMiddleware 14 from fastapi.middleware.gzip import GZipMiddleware
15−from fastapi.responses import FileResponse 15+from fastapi.responses import FileResponse, Response
16 from pydantic import BaseModel, Field 16 from pydantic import BaseModel, Field
17 17
18−from . import auth, db, ethics 18+from . import auth, db, ethics, kapdf
19+from . import stats as stats_mod
19 from .normalize import NICHES, PLATFORMS, REGIONS 20 from .normalize import NICHES, PLATFORMS, REGIONS
20 21
21 ROOT = Path(__file__).resolve().parent.parent 22 ROOT = Path(__file__).resolve().parent.parent
@@ -70,6 +71,40 @@ def get_stats():
70 return db.stats(_con) 71 return db.stats(_con)
71 72
72 73
74+_PERIODS_OK = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"}
75+
76+
77+@app.get("/api/stats/dashboard")
78+def stats_dashboard(period: str = "30j",
79+ date_from: str = Query("", alias="from"),
80+ date_to: str = Query("", alias="to")):
81+ """Tableau de bord analytique (contrat ka-stats SPEC.md) — cache 5 min."""
82+ if period not in _PERIODS_OK and not (date_from and date_to):
83+ raise HTTPException(400, "période inconnue "
84+ "(auj|7j|30j|3m|6m|12m|annee|tout ou from/to)")
85+ return stats_mod.dashboard(_con, period=period,
86+ date_from=date_from, date_to=date_to)
87+
88+
89+@app.get("/api/stats/report")
90+def stats_report(period: str = "30j", mode: str = "complet",
91+ date_from: str = Query("", alias="from"),
92+ date_to: str = Query("", alias="to")):
93+ """Rapport PDF estampillé Groupe-KA (complet ou synthèse 2 pages)."""
94+ if mode not in ("complet", "synthese"):
95+ raise HTTPException(400, "mode invalide (complet|synthese)")
96+ if period not in _PERIODS_OK and not (date_from and date_to):
97+ raise HTTPException(400, "période inconnue")
98+ dash = stats_mod.dashboard(_con, period=period,
99+ date_from=date_from, date_to=date_to)
100+ pdf = kapdf.GroupeKAReport(site=stats_mod.site_info(),
101+ dashboard=dash, mode=mode).build()
102+ fname = kapdf.filename("crea-ka", period)
103+ return Response(content=pdf, media_type="application/pdf",
104+ headers={"Content-Disposition":
105+ f'attachment; filename="{fname}"'})
106+
107+
73 @app.get("/api/taxonomies") 108 @app.get("/api/taxonomies")
74 def taxonomies(): 109 def taxonomies():
75 return {"niches": sorted(NICHES), "plateformes": sorted(PLATFORMS), 110 return {"niches": sorted(NICHES), "plateformes": sorted(PLATFORMS),
modified frontend/dist/index.html +329 −27
@@ -754,7 +754,7 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
754 .cpage-foot a{color:var(--green);font-weight:600} 754 .cpage-foot a{color:var(--green);font-weight:600}
755 755
756 /* ===== page stats ===== */ 756 /* ===== page stats ===== */
757−.stats-page{padding:40px 0 70px} 757+.stats-page{padding-top:40px;padding-bottom:70px}
758 .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0} 758 .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}
759 .tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card); 759 .tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);
760 padding:18px 20px;box-shadow:var(--shadow-off-soft)} 760 padding:18px 20px;box-shadow:var(--shadow-off-soft)}
@@ -782,6 +782,73 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
782 .hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700; 782 .hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;
783 color:var(--ink);min-width:44px;text-align:right} 783 color:var(--ink);min-width:44px;text-align:right}
784 784
785+/* ===== page stats : tableau de bord ka-stats (SPEC) ===== */
786+.stats-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;justify-content:space-between}
787+.pdf-actions{display:flex;gap:8px;flex-wrap:wrap}
788+.fresh-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px;
789+ font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}
790+.pchips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:22px 0 2px}
791+.pchip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);
792+ padding:8px 14px;min-height:44px;font-family:var(--font-display);font-size:12.5px;
793+ font-weight:700;color:var(--ink-2);box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:.15s}
794+.pchip[aria-pressed="true"]{background:var(--accent);color:var(--on-accent)}
795+.pchip:active{transform:translate(2px,2px);box-shadow:none}
796+.pdates{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}
797+.pdates input{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:8px 10px;
798+ font:inherit;font-size:13px;background:var(--surface);min-height:44px;max-width:160px}
799+@media(max-width:760px){.pchips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;
800+ padding:4px 0 8px;scrollbar-width:none}
801+ .pchips::-webkit-scrollbar{display:none}
802+ .pchip{flex:none}.pdates{flex:none}.pdates input{font-size:16px}}
803+@media(max-width:640px){.pchips{margin-left:-16px;margin-right:-16px;
804+ padding-left:16px;padding-right:16px}}
805+.tile-d{font-family:var(--font-mono);font-size:10.5px;font-weight:700;margin-top:8px}
806+.tile-d.up{color:var(--green)}.tile-d.down{color:var(--danger)}
807+.tile.hero-tile .tile-d.up{color:var(--accent-soft)}
808+.viz-grid{display:grid;gap:22px;grid-template-columns:1fr;margin-bottom:22px}
809+@media(min-width:960px){.viz-grid.two{grid-template-columns:1fr 1fr}}
810+.viz-grid .viz-card{margin-bottom:0}
811+.viz-grid>*,.dt,.lc{min-width:0}
812+.dt .viz-card,.lc .viz-card{margin-bottom:0}
813+.lc svg{width:100%;height:auto;display:block;touch-action:pan-y}
814+.lc-legend{display:flex;gap:14px;flex-wrap:wrap;margin:2px 0 0}
815+.leg-btn{display:inline-flex;align-items:center;gap:6px;border:0;background:none;
816+ font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;
817+ letter-spacing:.06em;color:var(--ink-2);min-height:44px}
818+.leg-btn[aria-pressed="false"]{opacity:.35}
819+.leg-swatch{width:18px;height:0;border-top:3px solid var(--accent)}
820+.leg-swatch.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}
821+.lc-tip{display:inline-flex;margin-top:6px;visibility:hidden}
822+.donut-wrap{display:flex;flex-wrap:wrap;gap:22px;align-items:center;margin-top:14px}
823+.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:7px;flex:1;min-width:200px}
824+.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}
825+.donut-legend .dsw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);
826+ background:var(--accent);flex:none}
827+.donut-legend .dlbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
828+.donut-legend b{font-family:var(--font-mono);font-size:11px}
829+.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}
830+.dtable table{width:100%;border-collapse:collapse;font-size:13px}
831+.dtable th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);
832+ color:var(--paper);font-family:var(--font-mono);font-size:10px;text-transform:uppercase;
833+ letter-spacing:.06em;white-space:nowrap;user-select:none}
834+.dtable td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}
835+.dtable tbody tr:nth-child(even) td{background:var(--surface-2)}
836+.dtable .dt-q{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:9px 13px;
837+ font:inherit;font-size:13px;background:var(--surface);max-width:230px;min-height:44px}
838+@media(max-width:760px){.dtable .dt-q{font-size:16px;max-width:100%;flex:1}}
839+.dtable-foot{display:flex;justify-content:space-between;align-items:center;margin-top:12px;
840+ flex-wrap:wrap;gap:8px}
841+.records-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}
842+.record{background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-card);
843+ padding:13px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;
844+ box-shadow:var(--shadow-off-soft)}
845+.record .r-lbl{font-size:12.5px;color:var(--ink-2)}
846+.record .r-val{text-align:right;font-family:var(--font-display);font-size:14.5px;font-weight:700}
847+.hm svg{min-width:520px;width:100%;height:auto}
848+.empty-block{border:1.5px dashed var(--ink);border-radius:var(--r-card);
849+ background:var(--surface-2);padding:20px}
850+.stats-sect{font-size:17px;text-transform:uppercase;margin:34px 0 14px}
851+
785 /* ===== retrait ===== */ 852 /* ===== retrait ===== */
786 .optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px} 853 .optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}
787 .optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase} 854 .optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}
@@ -1143,38 +1210,273 @@ async function renderCreator(id){
1143 bindNav();window.scrollTo(0,0); 1210 bindNav();window.scrollTo(0,0);
1144 } 1211 }
1145 1212
1146−/* ---------- page stats ---------- */ 1213+/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */
1214+const ST={period:"30j",from:"",to:"",data:null,hide:{},tbl:{}};
1215+const PERIODS_UI=[["auj","Aujourd'hui"],["7j","7 jours"],["30j","30 jours"],["3m","3 mois"],
1216+ ["6m","6 mois"],["12m","12 mois"],["annee","Année en cours"],["tout","Tout"]];
1217+const fmtPct=v=>(v>=0?"+":"")+v.toLocaleString("fr-CA",{maximumFractionDigits:1})+" %";
1218+const emptyBlock=t=>`<div class="empty-block"><b style="font-family:var(--font-display);font-size:14px">${esc(t)}</b>
1219+ <p class="klabel" style="margin:6px 0 0">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;
1220+
1221+function kpiHtml(k,i){
1222+ const v=k.value,hero=i===0;
1223+ const num=typeof v==="number"?(v>=1e6?fmt.format(v):fmtFull.format(v)):esc(v);
1224+ let delta="";
1225+ if(k.delta_pct!==undefined&&k.delta_pct!==null){
1226+ const up=(k.direction||(k.delta_pct>=0?"up":"down"))==="up";
1227+ delta=`<div class="tile-d ${up?"up":"down"}">${up?"▲":"▼"} ${fmtPct(k.delta_pct)} <span style="opacity:.6">vs période préc.</span></div>`;}
1228+ return `<div class="tile${hero?" hero-tile":""}"${typeof v==="number"?` title="${fmtFull.format(v)}"`:""}>
1229+ <div class="tile-v">${num}${k.unit?`<span style="font-size:.42em;opacity:.6"> ${esc(k.unit)}</span>`:""}</div>
1230+ <div class="tile-k">${esc(k.label)}</div>${delta}</div>`;
1231+}
1232+
1233+/* --- courbe SVG : infobulle (pointeur), légende cliquable, comparaison N-1 --- */
1234+function lineGeom(s,hide){
1235+ const W=720,H=240,PL=54,PR=12,PT=14,PB=26,pts=s.points||[];
1236+ const all=[...(hide.cur?[]:pts),...(!hide.cmp&&s.compare?s.compare:[])];
1237+ const vmax=Math.max(...all.map(p=>p.v),1),vmin=Math.min(0,...all.map(p=>p.v));
1238+ const X=(i,n)=>PL+(W-PL-PR)*i/((n||pts.length)-1);
1239+ const Y=v=>PT+(H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));
1240+ return{W,H,PL,PR,PT,PB,vmax,vmin,X,Y};
1241+}
1242+function lineFigure(s,hide){
1243+ const pts=s.points||[];
1244+ if(pts.length<2)return emptyBlock(s.title);
1245+ const g=lineGeom(s,hide),{W,H,PL,PR,PT,PB,vmax,vmin,X,Y}=g;
1246+ const path=arr=>arr.map((p,i)=>`${i?"L":"M"}${X(i,arr.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join("");
1247+ let grid="";
1248+ for(let k=0;k<5;k++){const y=PT+(H-PT-PB)*k/4,v=vmax-(vmax-vmin)*k/4;
1249+ grid+=`<line x1="${PL}" x2="${W-PR}" y1="${y.toFixed(1)}" y2="${y.toFixed(1)}" stroke="var(--line)"/>
1250+ <text x="${PL-6}" y="${(y+3).toFixed(1)}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${fmtFull.format(Math.round(v))}</text>`;}
1251+ const xl=[0,Math.floor(pts.length/2),pts.length-1].map(i=>
1252+ `<text x="${X(i).toFixed(1)}" y="${H-8}" text-anchor="middle" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${esc(pts[i].t)}</text>`).join("");
1253+ return `<div class="viz-card">
1254+ <div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center">
1255+ <h2>${esc(s.title)}</h2>
1256+ <span class="lc-legend">
1257+ <button type="button" class="leg-btn" data-leg="cur" aria-pressed="${!hide.cur}"><span class="leg-swatch"></span>Période courante</button>
1258+ ${s.compare?`<button type="button" class="leg-btn" data-leg="cmp" aria-pressed="${!hide.cmp}"><span class="leg-swatch cmp"></span>Période comparée</button>`:""}
1259+ </span></div>
1260+ <svg viewBox="0 0 ${g.W} ${g.H}" role="img" aria-label="${esc(s.title)}" style="margin-top:10px">
1261+ ${grid}${xl}
1262+ ${!hide.cmp&&s.compare&&s.compare.length>1?`<path d="${path(s.compare)}" fill="none" stroke="var(--ink-3)" stroke-width="1.4" stroke-dasharray="4 4"/>`:""}
1263+ ${!hide.cur?`<path d="${path(pts)}" fill="none" stroke="var(--accent)" stroke-width="2.4"/>`:""}
1264+ <g class="lc-cursor" style="display:none">
1265+ <line y1="${PT}" y2="${H-PB}" stroke="var(--ink)" stroke-dasharray="2 3"/>
1266+ <circle r="4" fill="var(--accent)" stroke="var(--ink)" stroke-width="1.5"/></g>
1267+ </svg><span class="chip lc-tip">&nbsp;</span>`+
1268+ `</div>`;
1269+}
1270+function mountLine(el,s){
1271+ const hide=ST.hide[s.id]||(ST.hide[s.id]={cur:false,cmp:false});
1272+ const render=()=>{
1273+ el.innerHTML=lineFigure(s,hide);
1274+ el.querySelectorAll("[data-leg]").forEach(b=>b.addEventListener("click",()=>{
1275+ hide[b.dataset.leg]=!hide[b.dataset.leg];render();}));
1276+ const svg=el.querySelector("svg");if(!svg)return;
1277+ const pts=s.points,g=lineGeom(s,hide);
1278+ const cursor=el.querySelector(".lc-cursor"),tip=el.querySelector(".lc-tip");
1279+ const cl=cursor.querySelector("line"),cc=cursor.querySelector("circle");
1280+ svg.addEventListener("pointermove",e=>{
1281+ const r=svg.getBoundingClientRect();
1282+ const fx=(e.clientX-r.left)/r.width*g.W;
1283+ let i=Math.round((fx-g.PL)/(g.W-g.PL-g.PR)*(pts.length-1));
1284+ i=Math.max(0,Math.min(pts.length-1,i));
1285+ const x=g.X(i).toFixed(1);
1286+ cl.setAttribute("x1",x);cl.setAttribute("x2",x);
1287+ cc.setAttribute("cx",x);cc.setAttribute("cy",g.Y(pts[i].v).toFixed(1));
1288+ cursor.style.display="";
1289+ const cmp=s.compare&&!hide.cmp&&s.compare[i]?` · N-1 : ${fmtFull.format(s.compare[i].v)}`:"";
1290+ tip.innerHTML=`${esc(pts[i].t)} — <b>&nbsp;${fmtFull.format(pts[i].v)}${s.unit?" "+esc(s.unit):""}</b><span style="color:var(--ink-3)">${cmp}</span>`;
1291+ tip.style.visibility="visible";});
1292+ svg.addEventListener("pointerleave",()=>{cursor.style.display="none";tip.style.visibility="hidden"});
1293+ };
1294+ render();
1295+}
1296+
1297+/* --- anneau (top 7 + « Autres ») --- */
1298+function donutHtml(b){
1299+ let rows=(b.items||[]).filter(i=>i.value>0);
1300+ if(!rows.length)return emptyBlock(b.title);
1301+ if(rows.length>8){const rest=rows.slice(7).reduce((s,r)=>s+r.value,0);
1302+ rows=rows.slice(0,7);rows.push({label:"Autres",value:rest});}
1303+ const total=rows.reduce((s,r)=>s+r.value,0);
1304+ const R=74,C=2*Math.PI*R,shades=[1,.78,.58,.42,.3,.22,.15,.1];let acc=0;
1305+ const segs=rows.map((r,i)=>{const frac=r.value/total,off=acc;acc+=frac;
1306+ return `<circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--accent)" stroke-opacity="${shades[i%8]}"
1307+ stroke-width="30" stroke-dasharray="${(frac*C).toFixed(2)} ${C.toFixed(2)}" stroke-dashoffset="${(-off*C).toFixed(2)}"
1308+ transform="rotate(-90 100 100)"><title>${esc(r.label)} — ${fmtFull.format(r.value)}</title></circle>`;}).join("");
1309+ const leg=rows.map((r,i)=>`<li><span class="dsw" style="opacity:${shades[i%8]}"></span>
1310+ <span class="dlbl" title="${esc(r.label)} — ${fmtFull.format(r.value)}">${esc(r.label)}</span>
1311+ <b>${(100*r.value/total).toFixed(1).replace(".",",")} %</b></li>`).join("");
1312+ return `<div class="viz-card"><h2>${esc(b.title)}</h2><div class="donut-wrap">
1313+ <svg viewBox="0 0 200 200" role="img" aria-label="${esc(b.title)}" style="width:180px;max-width:100%">${segs}
1314+ <circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--ink)" opacity=".5"/></svg>
1315+ <ul class="donut-legend">${leg}</ul></div></div>`;
1316+}
1317+
1318+/* --- barres horizontales (répartitions, géo) --- */
1319+function barsHtml(title,items,sub){
1320+ const rows=(items||[]).slice(0,14);
1321+ if(!rows.length)return emptyBlock(title);
1322+ const max=Math.max(...rows.map(r=>r.value),1);
1323+ return `<div class="viz-card"><h2>${esc(title)}</h2>${sub?`<div class="viz-sub">${esc(sub)}</div>`:`<div style="height:14px"></div>`}
1324+ ${rows.map(r=>`<div class="hrow" title="${esc(r.label)} — ${fmtFull.format(r.value)}">
1325+ <span class="hrow-label">${esc(r.label)}</span>
1326+ <span class="hbar"><i style="width:${Math.max(2,Math.round(r.value/max*100))}%"></i></span>
1327+ <span class="n">${fmtFull.format(r.value)}</span></div>`).join("")}</div>`;
1328+}
1329+
1330+/* --- calendrier de chaleur (26 dernières semaines) --- */
1331+function heatmapHtml(h){
1332+ const cells=h&&h.cells||[];
1333+ if(!cells.length)return emptyBlock(h?h.title:"Activité");
1334+ const byDate=new Map(cells.map(c=>[c.date,c.value]));
1335+ const dates=cells.map(c=>c.date).sort();
1336+ const end=new Date(dates[dates.length-1]+"T12:00:00");
1337+ const max=Math.max(...cells.map(c=>c.value),1);
1338+ const weeks=26,cols=[];const cur=new Date(end);
1339+ cur.setDate(cur.getDate()-(weeks*7-1));
1340+ for(let w=0;w<weeks;w++){const col=[];
1341+ for(let d=0;d<7;d++){const iso=cur.toISOString().slice(0,10);
1342+ col.push({date:iso,v:byDate.get(iso)||0});cur.setDate(cur.getDate()+1);}
1343+ cols.push(col);}
1344+ const rects=cols.map((col,w)=>col.map((c,d)=>
1345+ `<rect x="${w*14}" y="${d*14}" width="12" height="12" rx="2.5"
1346+ fill="${c.v?"var(--accent)":"rgba(20,24,20,0.07)"}" fill-opacity="${c.v?(0.25+0.75*c.v/max).toFixed(2):1}"
1347+ stroke="rgba(20,24,20,0.15)" stroke-width="0.5"><title>${c.date} — ${fmtFull.format(c.v)}</title></rect>`).join("")).join("");
1348+ return `<div class="viz-card hm"><h2>${esc(h.title)}</h2><div class="viz-sub">26 dernières semaines</div>
1349+ <div class="tbl-wrap"><svg viewBox="0 0 ${weeks*14} ${7*14}" role="img" aria-label="${esc(h.title)}">${rects}</svg></div></div>`;
1350+}
1351+
1352+/* --- tableau : tri par colonne, recherche, pagination 25/pg --- */
1353+function mountTable(el,spec){
1354+ const st=ST.tbl[spec.id]||(ST.tbl[spec.id]={q:"",sort:null,page:0});
1355+ el.innerHTML=`<div class="viz-card dtable">
1356+ <div style="display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center">
1357+ <h2>${esc(spec.title)}</h2>
1358+ <input class="dt-q" placeholder="Rechercher…" value="${esc(st.q)}" aria-label="Rechercher dans ${esc(spec.title)}">
1359+ </div><div class="dt-body"></div></div>`;
1360+ const body=el.querySelector(".dt-body"),qi=el.querySelector(".dt-q");
1361+ const num=x=>typeof x==="number"?x:parseFloat(String(x).replace(/[^\d.,-]/g,"").replace(",","."));
1362+ const render=()=>{
1363+ let rows=spec.rows||[];
1364+ if(st.q){const q=st.q.toLowerCase();
1365+ rows=rows.filter(r=>r.some(c=>String(c).toLowerCase().includes(q)));}
1366+ if(st.sort){const{col,dir}=st.sort;
1367+ rows=[...rows].sort((a,b)=>{const nx=num(a[col]),ny=num(b[col]);
1368+ if(!Number.isNaN(nx)&&!Number.isNaN(ny))return(nx-ny)*dir;
1369+ return String(a[col]).localeCompare(String(b[col]),"fr")*dir;});}
1370+ const pages=Math.max(1,Math.ceil(rows.length/25));
1371+ st.page=Math.min(st.page,pages-1);const cur=st.page;
1372+ body.innerHTML=`<div class="tbl-wrap" style="margin-top:12px"><table><thead><tr>
1373+ ${spec.columns.map((c,i)=>`<th data-col="${i}" aria-sort="${st.sort&&st.sort.col===i?(st.sort.dir===1?"ascending":"descending"):"none"}">${esc(c)} ${st.sort&&st.sort.col===i?(st.sort.dir===1?"▲":"▼"):"↕"}</th>`).join("")}
1374+ </tr></thead><tbody>
1375+ ${rows.slice(cur*25,(cur+1)*25).map(r=>`<tr>${r.map(c=>`<td>${typeof c==="number"?fmtFull.format(c):esc(c)}</td>`).join("")}</tr>`).join("")||`<tr><td colspan="${spec.columns.length}" style="color:var(--ink-3)">Aucun résultat.</td></tr>`}
1376+ </tbody></table></div>
1377+ <div class="dtable-foot"><span class="klabel">${fmtFull.format(rows.length)} ligne${rows.length>1?"s":""}</span>
1378+ <span style="display:flex;gap:6px;align-items:center">
1379+ <button type="button" class="btn btn-ghost dt-prev"${cur===0?" disabled":""}>←</button>
1380+ <span class="chip">${cur+1} / ${pages}</span>
1381+ <button type="button" class="btn btn-ghost dt-next"${cur>=pages-1?" disabled":""}>→</button></span></div>`;
1382+ body.querySelectorAll("th").forEach(th=>th.addEventListener("click",()=>{
1383+ const col=+th.dataset.col;
1384+ st.sort={col,dir:st.sort&&st.sort.col===col&&st.sort.dir===1?-1:1};render();}));
1385+ const prev=body.querySelector(".dt-prev"),next=body.querySelector(".dt-next");
1386+ prev.addEventListener("click",()=>{st.page=Math.max(0,cur-1);render()});
1387+ next.addEventListener("click",()=>{st.page=Math.min(pages-1,cur+1);render()});
1388+ };
1389+ qi.addEventListener("input",deb(()=>{st.q=qi.value;st.page=0;render()},250));
1390+ render();
1391+}
1392+
1393+/* --- barre de période + boutons PDF --- */
1394+function periodBar(){
1395+ const customOn=!!(ST.from&&ST.to);
1396+ return `<div class="pchips" role="group" aria-label="Période d'analyse">
1397+ ${PERIODS_UI.map(([id,l])=>`<button type="button" class="pchip" data-period="${id}" aria-pressed="${!customOn&&ST.period===id}">${l}</button>`).join("")}
1398+ <span class="pdates"><input type="date" id="pfrom" value="${esc(ST.from)}" aria-label="Du">
1399+ <span class="klabel">au</span><input type="date" id="pto" value="${esc(ST.to)}" aria-label="Au"></span></div>`;
1400+}
1401+function bindPeriod(){
1402+ document.querySelectorAll(".pchip").forEach(b=>b.addEventListener("click",()=>{
1403+ ST.period=b.dataset.period;ST.from="";ST.to="";loadStatsData();}));
1404+ const pf=$("#pfrom"),pt=$("#pto");
1405+ const go=()=>{if(pf.value&&pt.value){ST.from=pf.value;ST.to=pt.value;loadStatsData();}};
1406+ pf.addEventListener("change",go);pt.addEventListener("change",go);
1407+}
1408+function bindPdf(){
1409+ const url=m=>{const p=new URLSearchParams({period:ST.period,mode:m});
1410+ if(ST.from&&ST.to){p.set("from",ST.from);p.set("to",ST.to);}
1411+ return "/api/stats/report?"+p;};
1412+ [["pdf-full","complet"],["pdf-syn","synthese"]].forEach(([id,m])=>{
1413+ const b=document.getElementById(id);if(!b)return;
1414+ b.addEventListener("click",async()=>{
1415+ const t=b.textContent;b.disabled=true;b.textContent="Génération…";
1416+ try{const r=await fetch(url(m));if(!r.ok)throw new Error(r.status);
1417+ const blob=await r.blob();
1418+ const fn=((r.headers.get("Content-Disposition")||"").match(/filename="?([^";]+)/)||[])[1]||"rapport-crea-ka.pdf";
1419+ const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=fn;
1420+ document.body.appendChild(a);a.click();a.remove();
1421+ setTimeout(()=>URL.revokeObjectURL(a.href),4000);}
1422+ catch(e){alert("Échec de la génération du PDF — réessayez.");}
1423+ b.disabled=false;b.textContent=t;});});
1424+}
1425+
1147 async function renderStats(){ 1426 async function renderStats(){
1148 document.title="Statistiques — Créa-Ka"; 1427 document.title="Statistiques — Créa-Ka";
1149 app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("stats");bindNav(); 1428 app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("stats");bindNav();
1150− const s=await api("/api/stats").catch(()=>null); 1429+ await loadStatsData(true);
1151− if(!s){app.innerHTML=header()+`<div class="container empty">Statistiques indisponibles.</div>`+footer()+tabbar("stats");bindNav();return} 1430+ window.scrollTo(0,0);
1152− const bars=(obj,lbls,useIcon)=>{const entries=Object.entries(obj).slice(0,12); 1431+}
1153− const max=Math.max(...entries.map(e=>e[1]),1); 1432+async function loadStatsData(first){
1154− return entries.map(([k,v])=>`<div class="hrow"> 1433+ const main=$("#stats-main");if(main)main.style.opacity=".45";
1155− ${useIcon?`<span class="plat" style="background:${(PLAT[k]||PLAT.autre).color}">${icon(k)}</span>`:""} 1434+ let d;
1156− <span class="hrow-label">${esc(lbls?(lbls[k]||k):k)}</span> 1435+ try{const p=new URLSearchParams({period:ST.period});
1157− <span class="hbar"><i style="width:${Math.max(2,Math.round(v/max*100))}%"></i></span> 1436+ if(ST.from&&ST.to){p.set("from",ST.from);p.set("to",ST.to);}
1158− <span class="n">${fmtFull.format(v)}</span></div>`).join("")}; 1437+ d=await api("/api/stats/dashboard?"+p);}
1438+ catch(e){app.innerHTML=header()+`<div class="container empty">Statistiques indisponibles pour le moment.</div>`+footer()+tabbar("stats");bindNav();return}
1439+ ST.data=d;renderStatsPage();
1440+}
1441+function renderStatsPage(){
1442+ const d=ST.data;
1443+ const upd=new Date(d.updated).toLocaleString("fr-CA",{dateStyle:"medium",timeStyle:"short"});
1444+ const donut=(d.breakdowns||[]).find(b=>b.kind==="donut");
1445+ const barsB=(d.breakdowns||[]).filter(b=>b.kind!=="donut");
1159 app.innerHTML=header()+` 1446 app.innerHTML=header()+`
1160− <div class="container stats-page"> 1447+ <div class="container stats-page" id="stats-main">
1161− <span class="kicker">Portrait de l'annuaire</span> 1448+ <div class="stats-head">
1162− <h1 style="font-size:clamp(28px,4.6vw,46px);margin:12px 0 4px;text-transform:uppercase">Les créateurs québécois <span class="hl">en chiffres.</span></h1> 1449+ <div><span class="kicker">Tableau de bord — ${esc(d.period.label)}</span>
1163− <div class="tiles"> 1450+ <h1 style="font-size:clamp(28px,4.6vw,46px);margin:12px 0 0;text-transform:uppercase">Les créateurs québécois <span class="hl">en chiffres.</span></h1>
1164− <div class="tile hero-tile"><div class="tile-v">${fmtFull.format(s.creators)}</div><div class="tile-k">Créateurs</div></div> 1451+ <div class="fresh-row"><span>Mis à jour le ${esc(upd)}</span>
1165− <div class="tile"><div class="tile-v">${fmtFull.format(s.accounts)}</div><div class="tile-k">Comptes reliés</div></div> 1452+ <button type="button" class="btn btn-ghost" id="st-refresh">↻ Rafraîchir</button></div></div>
1166− <div class="tile"><div class="tile-v">${Object.keys(s.by_platform||{}).length}</div><div class="tile-k">Plateformes</div></div> 1453+ <span class="pdf-actions">
1167− <div class="tile"><div class="tile-v">${(s.accounts/Math.max(1,s.creators)).toFixed(1)}</div><div class="tile-k">Comptes / créateur</div></div> 1454+ <button type="button" class="btn btn-primary" id="pdf-full">⬇ Télécharger le rapport PDF</button>
1455+ <button type="button" class="btn btn-ghost" id="pdf-syn">Synthèse (2 p.)</button></span>
1456+ </div>
1457+ <div class="tiles">${(d.kpis||[]).map(kpiHtml).join("")||emptyBlock("Indicateurs")}</div>
1458+ ${periodBar()}
1459+ <p class="klabel" style="margin:6px 0 0">Période analysée : ${esc(d.period.from)} → ${esc(d.period.to)}</p>
1460+ <h2 class="stats-sect">Évolution</h2>
1461+ <div class="viz-grid">${(d.series||[]).map(s=>`<div class="lc" data-serie="${esc(s.id)}"></div>`).join("")||emptyBlock("Évolution")}</div>
1462+ <h2 class="stats-sect">Répartitions</h2>
1463+ <div class="viz-grid two">
1464+ ${donut?donutHtml(donut):""}
1465+ ${barsB.map(b=>barsHtml(b.title,b.items)).join("")}
1466+ ${d.geo?barsHtml(d.geo.title,d.geo.items):""}
1168 </div> 1467 </div>
1169− <div class="viz-card"><h2>Par plateforme</h2><div class="viz-sub">comptes publics reliés</div> 1468+ ${d.heatmap?`<h2 class="stats-sect">Activité</h2>${heatmapHtml(d.heatmap)}`:""}
1170− ${bars(s.by_platform,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])),true)}</div> 1469+ ${(d.tables||[]).length?`<h2 class="stats-sect">Détails</h2><div style="display:grid;gap:22px">${(d.tables||[]).map(t=>`<div class="dt" data-table="${esc(t.id)}"></div>`).join("")}</div>`:""}
1171− <div class="viz-card"><h2>Par niche</h2><div class="viz-sub">créateurs actifs</div> 1470+ ${(d.records||[]).length?`<h2 class="stats-sect">Records & faits marquants</h2>
1172− ${bars(s.by_niche,NICHE_LBL)}</div> 1471+ <div class="records-grid">${d.records.map(r=>`<div class="record"><span class="r-lbl">${esc(r.label)}</span>
1173− <div class="viz-card"><h2>Par taille d'audience</h2><div class="viz-sub">basée sur la plateforme principale</div> 1472+ <span style="text-align:right"><span class="r-val">${esc(r.value)}</span>${r.date?`<span class="klabel" style="display:block">${esc(r.date)}</span>`:""}</span></div>`).join("")}</div>`:""}
1174− ${bars(s.by_tier,TIER_LBL)}</div> 1473+ <p style="font-size:12px;color:var(--ink-3);margin-top:30px">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.
1175− ${Object.keys(s.by_region||{}).length?`<div class="viz-card"><h2>Par région déclarée</h2><div class="viz-sub">quand le créateur la rend publique</div>${bars(s.by_region)}</div>`:""} 1474+ Rapport PDF estampillé Groupe-KA disponible en haut de page.</p>
1176 </div>`+footer()+tabbar("stats"); 1475 </div>`+footer()+tabbar("stats");
1177− bindNav();window.scrollTo(0,0); 1476+ bindNav();bindPeriod();bindPdf();
1477+ $("#st-refresh").addEventListener("click",()=>loadStatsData());
1478+ (ST.data.series||[]).forEach(s=>{const el=document.querySelector(`.lc[data-serie="${s.id}"]`);if(el)mountLine(el,s);});
1479+ (ST.data.tables||[]).forEach(t=>{const el=document.querySelector(`.dt[data-table="${t.id}"]`);if(el)mountTable(el,t);});
1178 } 1480 }
1179 1481
1180 /* ---------- page compte (KA ID) ---------- */ 1482 /* ---------- page compte (KA ID) ---------- */
modified frontend/src/index.template.html +329 −27
@@ -296,7 +296,7 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
296 .cpage-foot a{color:var(--green);font-weight:600} 296 .cpage-foot a{color:var(--green);font-weight:600}
297 297
298 /* ===== page stats ===== */ 298 /* ===== page stats ===== */
299−.stats-page{padding:40px 0 70px} 299+.stats-page{padding-top:40px;padding-bottom:70px}
300 .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0} 300 .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}
301 .tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card); 301 .tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);
302 padding:18px 20px;box-shadow:var(--shadow-off-soft)} 302 padding:18px 20px;box-shadow:var(--shadow-off-soft)}
@@ -324,6 +324,73 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
324 .hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700; 324 .hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;
325 color:var(--ink);min-width:44px;text-align:right} 325 color:var(--ink);min-width:44px;text-align:right}
326 326
327+/* ===== page stats : tableau de bord ka-stats (SPEC) ===== */
328+.stats-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;justify-content:space-between}
329+.pdf-actions{display:flex;gap:8px;flex-wrap:wrap}
330+.fresh-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px;
331+ font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}
332+.pchips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:22px 0 2px}
333+.pchip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);
334+ padding:8px 14px;min-height:44px;font-family:var(--font-display);font-size:12.5px;
335+ font-weight:700;color:var(--ink-2);box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:.15s}
336+.pchip[aria-pressed="true"]{background:var(--accent);color:var(--on-accent)}
337+.pchip:active{transform:translate(2px,2px);box-shadow:none}
338+.pdates{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}
339+.pdates input{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:8px 10px;
340+ font:inherit;font-size:13px;background:var(--surface);min-height:44px;max-width:160px}
341+@media(max-width:760px){.pchips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;
342+ padding:4px 0 8px;scrollbar-width:none}
343+ .pchips::-webkit-scrollbar{display:none}
344+ .pchip{flex:none}.pdates{flex:none}.pdates input{font-size:16px}}
345+@media(max-width:640px){.pchips{margin-left:-16px;margin-right:-16px;
346+ padding-left:16px;padding-right:16px}}
347+.tile-d{font-family:var(--font-mono);font-size:10.5px;font-weight:700;margin-top:8px}
348+.tile-d.up{color:var(--green)}.tile-d.down{color:var(--danger)}
349+.tile.hero-tile .tile-d.up{color:var(--accent-soft)}
350+.viz-grid{display:grid;gap:22px;grid-template-columns:1fr;margin-bottom:22px}
351+@media(min-width:960px){.viz-grid.two{grid-template-columns:1fr 1fr}}
352+.viz-grid .viz-card{margin-bottom:0}
353+.viz-grid>*,.dt,.lc{min-width:0}
354+.dt .viz-card,.lc .viz-card{margin-bottom:0}
355+.lc svg{width:100%;height:auto;display:block;touch-action:pan-y}
356+.lc-legend{display:flex;gap:14px;flex-wrap:wrap;margin:2px 0 0}
357+.leg-btn{display:inline-flex;align-items:center;gap:6px;border:0;background:none;
358+ font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;
359+ letter-spacing:.06em;color:var(--ink-2);min-height:44px}
360+.leg-btn[aria-pressed="false"]{opacity:.35}
361+.leg-swatch{width:18px;height:0;border-top:3px solid var(--accent)}
362+.leg-swatch.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}
363+.lc-tip{display:inline-flex;margin-top:6px;visibility:hidden}
364+.donut-wrap{display:flex;flex-wrap:wrap;gap:22px;align-items:center;margin-top:14px}
365+.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:7px;flex:1;min-width:200px}
366+.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}
367+.donut-legend .dsw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);
368+ background:var(--accent);flex:none}
369+.donut-legend .dlbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
370+.donut-legend b{font-family:var(--font-mono);font-size:11px}
371+.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}
372+.dtable table{width:100%;border-collapse:collapse;font-size:13px}
373+.dtable th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);
374+ color:var(--paper);font-family:var(--font-mono);font-size:10px;text-transform:uppercase;
375+ letter-spacing:.06em;white-space:nowrap;user-select:none}
376+.dtable td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}
377+.dtable tbody tr:nth-child(even) td{background:var(--surface-2)}
378+.dtable .dt-q{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:9px 13px;
379+ font:inherit;font-size:13px;background:var(--surface);max-width:230px;min-height:44px}
380+@media(max-width:760px){.dtable .dt-q{font-size:16px;max-width:100%;flex:1}}
381+.dtable-foot{display:flex;justify-content:space-between;align-items:center;margin-top:12px;
382+ flex-wrap:wrap;gap:8px}
383+.records-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}
384+.record{background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-card);
385+ padding:13px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;
386+ box-shadow:var(--shadow-off-soft)}
387+.record .r-lbl{font-size:12.5px;color:var(--ink-2)}
388+.record .r-val{text-align:right;font-family:var(--font-display);font-size:14.5px;font-weight:700}
389+.hm svg{min-width:520px;width:100%;height:auto}
390+.empty-block{border:1.5px dashed var(--ink);border-radius:var(--r-card);
391+ background:var(--surface-2);padding:20px}
392+.stats-sect{font-size:17px;text-transform:uppercase;margin:34px 0 14px}
393+
327 /* ===== retrait ===== */ 394 /* ===== retrait ===== */
328 .optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px} 395 .optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}
329 .optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase} 396 .optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}
@@ -685,38 +752,273 @@ async function renderCreator(id){
685 bindNav();window.scrollTo(0,0); 752 bindNav();window.scrollTo(0,0);
686 } 753 }
687 754
688−/* ---------- page stats ---------- */ 755+/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */
756+const ST={period:"30j",from:"",to:"",data:null,hide:{},tbl:{}};
757+const PERIODS_UI=[["auj","Aujourd'hui"],["7j","7 jours"],["30j","30 jours"],["3m","3 mois"],
758+ ["6m","6 mois"],["12m","12 mois"],["annee","Année en cours"],["tout","Tout"]];
759+const fmtPct=v=>(v>=0?"+":"")+v.toLocaleString("fr-CA",{maximumFractionDigits:1})+" %";
760+const emptyBlock=t=>`<div class="empty-block"><b style="font-family:var(--font-display);font-size:14px">${esc(t)}</b>
761+ <p class="klabel" style="margin:6px 0 0">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;
762+
763+function kpiHtml(k,i){
764+ const v=k.value,hero=i===0;
765+ const num=typeof v==="number"?(v>=1e6?fmt.format(v):fmtFull.format(v)):esc(v);
766+ let delta="";
767+ if(k.delta_pct!==undefined&&k.delta_pct!==null){
768+ const up=(k.direction||(k.delta_pct>=0?"up":"down"))==="up";
769+ delta=`<div class="tile-d ${up?"up":"down"}">${up?"▲":"▼"} ${fmtPct(k.delta_pct)} <span style="opacity:.6">vs période préc.</span></div>`;}
770+ return `<div class="tile${hero?" hero-tile":""}"${typeof v==="number"?` title="${fmtFull.format(v)}"`:""}>
771+ <div class="tile-v">${num}${k.unit?`<span style="font-size:.42em;opacity:.6"> ${esc(k.unit)}</span>`:""}</div>
772+ <div class="tile-k">${esc(k.label)}</div>${delta}</div>`;
773+}
774+
775+/* --- courbe SVG : infobulle (pointeur), légende cliquable, comparaison N-1 --- */
776+function lineGeom(s,hide){
777+ const W=720,H=240,PL=54,PR=12,PT=14,PB=26,pts=s.points||[];
778+ const all=[...(hide.cur?[]:pts),...(!hide.cmp&&s.compare?s.compare:[])];
779+ const vmax=Math.max(...all.map(p=>p.v),1),vmin=Math.min(0,...all.map(p=>p.v));
780+ const X=(i,n)=>PL+(W-PL-PR)*i/((n||pts.length)-1);
781+ const Y=v=>PT+(H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));
782+ return{W,H,PL,PR,PT,PB,vmax,vmin,X,Y};
783+}
784+function lineFigure(s,hide){
785+ const pts=s.points||[];
786+ if(pts.length<2)return emptyBlock(s.title);
787+ const g=lineGeom(s,hide),{W,H,PL,PR,PT,PB,vmax,vmin,X,Y}=g;
788+ const path=arr=>arr.map((p,i)=>`${i?"L":"M"}${X(i,arr.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join("");
789+ let grid="";
790+ for(let k=0;k<5;k++){const y=PT+(H-PT-PB)*k/4,v=vmax-(vmax-vmin)*k/4;
791+ grid+=`<line x1="${PL}" x2="${W-PR}" y1="${y.toFixed(1)}" y2="${y.toFixed(1)}" stroke="var(--line)"/>
792+ <text x="${PL-6}" y="${(y+3).toFixed(1)}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${fmtFull.format(Math.round(v))}</text>`;}
793+ const xl=[0,Math.floor(pts.length/2),pts.length-1].map(i=>
794+ `<text x="${X(i).toFixed(1)}" y="${H-8}" text-anchor="middle" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${esc(pts[i].t)}</text>`).join("");
795+ return `<div class="viz-card">
796+ <div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center">
797+ <h2>${esc(s.title)}</h2>
798+ <span class="lc-legend">
799+ <button type="button" class="leg-btn" data-leg="cur" aria-pressed="${!hide.cur}"><span class="leg-swatch"></span>Période courante</button>
800+ ${s.compare?`<button type="button" class="leg-btn" data-leg="cmp" aria-pressed="${!hide.cmp}"><span class="leg-swatch cmp"></span>Période comparée</button>`:""}
801+ </span></div>
802+ <svg viewBox="0 0 ${g.W} ${g.H}" role="img" aria-label="${esc(s.title)}" style="margin-top:10px">
803+ ${grid}${xl}
804+ ${!hide.cmp&&s.compare&&s.compare.length>1?`<path d="${path(s.compare)}" fill="none" stroke="var(--ink-3)" stroke-width="1.4" stroke-dasharray="4 4"/>`:""}
805+ ${!hide.cur?`<path d="${path(pts)}" fill="none" stroke="var(--accent)" stroke-width="2.4"/>`:""}
806+ <g class="lc-cursor" style="display:none">
807+ <line y1="${PT}" y2="${H-PB}" stroke="var(--ink)" stroke-dasharray="2 3"/>
808+ <circle r="4" fill="var(--accent)" stroke="var(--ink)" stroke-width="1.5"/></g>
809+ </svg><span class="chip lc-tip">&nbsp;</span>`+
810+ `</div>`;
811+}
812+function mountLine(el,s){
813+ const hide=ST.hide[s.id]||(ST.hide[s.id]={cur:false,cmp:false});
814+ const render=()=>{
815+ el.innerHTML=lineFigure(s,hide);
816+ el.querySelectorAll("[data-leg]").forEach(b=>b.addEventListener("click",()=>{
817+ hide[b.dataset.leg]=!hide[b.dataset.leg];render();}));
818+ const svg=el.querySelector("svg");if(!svg)return;
819+ const pts=s.points,g=lineGeom(s,hide);
820+ const cursor=el.querySelector(".lc-cursor"),tip=el.querySelector(".lc-tip");
821+ const cl=cursor.querySelector("line"),cc=cursor.querySelector("circle");
822+ svg.addEventListener("pointermove",e=>{
823+ const r=svg.getBoundingClientRect();
824+ const fx=(e.clientX-r.left)/r.width*g.W;
825+ let i=Math.round((fx-g.PL)/(g.W-g.PL-g.PR)*(pts.length-1));
826+ i=Math.max(0,Math.min(pts.length-1,i));
827+ const x=g.X(i).toFixed(1);
828+ cl.setAttribute("x1",x);cl.setAttribute("x2",x);
829+ cc.setAttribute("cx",x);cc.setAttribute("cy",g.Y(pts[i].v).toFixed(1));
830+ cursor.style.display="";
831+ const cmp=s.compare&&!hide.cmp&&s.compare[i]?` · N-1 : ${fmtFull.format(s.compare[i].v)}`:"";
832+ tip.innerHTML=`${esc(pts[i].t)} — <b>&nbsp;${fmtFull.format(pts[i].v)}${s.unit?" "+esc(s.unit):""}</b><span style="color:var(--ink-3)">${cmp}</span>`;
833+ tip.style.visibility="visible";});
834+ svg.addEventListener("pointerleave",()=>{cursor.style.display="none";tip.style.visibility="hidden"});
835+ };
836+ render();
837+}
838+
839+/* --- anneau (top 7 + « Autres ») --- */
840+function donutHtml(b){
841+ let rows=(b.items||[]).filter(i=>i.value>0);
842+ if(!rows.length)return emptyBlock(b.title);
843+ if(rows.length>8){const rest=rows.slice(7).reduce((s,r)=>s+r.value,0);
844+ rows=rows.slice(0,7);rows.push({label:"Autres",value:rest});}
845+ const total=rows.reduce((s,r)=>s+r.value,0);
846+ const R=74,C=2*Math.PI*R,shades=[1,.78,.58,.42,.3,.22,.15,.1];let acc=0;
847+ const segs=rows.map((r,i)=>{const frac=r.value/total,off=acc;acc+=frac;
848+ return `<circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--accent)" stroke-opacity="${shades[i%8]}"
849+ stroke-width="30" stroke-dasharray="${(frac*C).toFixed(2)} ${C.toFixed(2)}" stroke-dashoffset="${(-off*C).toFixed(2)}"
850+ transform="rotate(-90 100 100)"><title>${esc(r.label)} — ${fmtFull.format(r.value)}</title></circle>`;}).join("");
851+ const leg=rows.map((r,i)=>`<li><span class="dsw" style="opacity:${shades[i%8]}"></span>
852+ <span class="dlbl" title="${esc(r.label)} — ${fmtFull.format(r.value)}">${esc(r.label)}</span>
853+ <b>${(100*r.value/total).toFixed(1).replace(".",",")} %</b></li>`).join("");
854+ return `<div class="viz-card"><h2>${esc(b.title)}</h2><div class="donut-wrap">
855+ <svg viewBox="0 0 200 200" role="img" aria-label="${esc(b.title)}" style="width:180px;max-width:100%">${segs}
856+ <circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--ink)" opacity=".5"/></svg>
857+ <ul class="donut-legend">${leg}</ul></div></div>`;
858+}
859+
860+/* --- barres horizontales (répartitions, géo) --- */
861+function barsHtml(title,items,sub){
862+ const rows=(items||[]).slice(0,14);
863+ if(!rows.length)return emptyBlock(title);
864+ const max=Math.max(...rows.map(r=>r.value),1);
865+ return `<div class="viz-card"><h2>${esc(title)}</h2>${sub?`<div class="viz-sub">${esc(sub)}</div>`:`<div style="height:14px"></div>`}
866+ ${rows.map(r=>`<div class="hrow" title="${esc(r.label)} — ${fmtFull.format(r.value)}">
867+ <span class="hrow-label">${esc(r.label)}</span>
868+ <span class="hbar"><i style="width:${Math.max(2,Math.round(r.value/max*100))}%"></i></span>
869+ <span class="n">${fmtFull.format(r.value)}</span></div>`).join("")}</div>`;
870+}
871+
872+/* --- calendrier de chaleur (26 dernières semaines) --- */
873+function heatmapHtml(h){
874+ const cells=h&&h.cells||[];
875+ if(!cells.length)return emptyBlock(h?h.title:"Activité");
876+ const byDate=new Map(cells.map(c=>[c.date,c.value]));
877+ const dates=cells.map(c=>c.date).sort();
878+ const end=new Date(dates[dates.length-1]+"T12:00:00");
879+ const max=Math.max(...cells.map(c=>c.value),1);
880+ const weeks=26,cols=[];const cur=new Date(end);
881+ cur.setDate(cur.getDate()-(weeks*7-1));
882+ for(let w=0;w<weeks;w++){const col=[];
883+ for(let d=0;d<7;d++){const iso=cur.toISOString().slice(0,10);
884+ col.push({date:iso,v:byDate.get(iso)||0});cur.setDate(cur.getDate()+1);}
885+ cols.push(col);}
886+ const rects=cols.map((col,w)=>col.map((c,d)=>
887+ `<rect x="${w*14}" y="${d*14}" width="12" height="12" rx="2.5"
888+ fill="${c.v?"var(--accent)":"rgba(20,24,20,0.07)"}" fill-opacity="${c.v?(0.25+0.75*c.v/max).toFixed(2):1}"
889+ stroke="rgba(20,24,20,0.15)" stroke-width="0.5"><title>${c.date} — ${fmtFull.format(c.v)}</title></rect>`).join("")).join("");
890+ return `<div class="viz-card hm"><h2>${esc(h.title)}</h2><div class="viz-sub">26 dernières semaines</div>
891+ <div class="tbl-wrap"><svg viewBox="0 0 ${weeks*14} ${7*14}" role="img" aria-label="${esc(h.title)}">${rects}</svg></div></div>`;
892+}
893+
894+/* --- tableau : tri par colonne, recherche, pagination 25/pg --- */
895+function mountTable(el,spec){
896+ const st=ST.tbl[spec.id]||(ST.tbl[spec.id]={q:"",sort:null,page:0});
897+ el.innerHTML=`<div class="viz-card dtable">
898+ <div style="display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center">
899+ <h2>${esc(spec.title)}</h2>
900+ <input class="dt-q" placeholder="Rechercher…" value="${esc(st.q)}" aria-label="Rechercher dans ${esc(spec.title)}">
901+ </div><div class="dt-body"></div></div>`;
902+ const body=el.querySelector(".dt-body"),qi=el.querySelector(".dt-q");
903+ const num=x=>typeof x==="number"?x:parseFloat(String(x).replace(/[^\d.,-]/g,"").replace(",","."));
904+ const render=()=>{
905+ let rows=spec.rows||[];
906+ if(st.q){const q=st.q.toLowerCase();
907+ rows=rows.filter(r=>r.some(c=>String(c).toLowerCase().includes(q)));}
908+ if(st.sort){const{col,dir}=st.sort;
909+ rows=[...rows].sort((a,b)=>{const nx=num(a[col]),ny=num(b[col]);
910+ if(!Number.isNaN(nx)&&!Number.isNaN(ny))return(nx-ny)*dir;
911+ return String(a[col]).localeCompare(String(b[col]),"fr")*dir;});}
912+ const pages=Math.max(1,Math.ceil(rows.length/25));
913+ st.page=Math.min(st.page,pages-1);const cur=st.page;
914+ body.innerHTML=`<div class="tbl-wrap" style="margin-top:12px"><table><thead><tr>
915+ ${spec.columns.map((c,i)=>`<th data-col="${i}" aria-sort="${st.sort&&st.sort.col===i?(st.sort.dir===1?"ascending":"descending"):"none"}">${esc(c)} ${st.sort&&st.sort.col===i?(st.sort.dir===1?"▲":"▼"):"↕"}</th>`).join("")}
916+ </tr></thead><tbody>
917+ ${rows.slice(cur*25,(cur+1)*25).map(r=>`<tr>${r.map(c=>`<td>${typeof c==="number"?fmtFull.format(c):esc(c)}</td>`).join("")}</tr>`).join("")||`<tr><td colspan="${spec.columns.length}" style="color:var(--ink-3)">Aucun résultat.</td></tr>`}
918+ </tbody></table></div>
919+ <div class="dtable-foot"><span class="klabel">${fmtFull.format(rows.length)} ligne${rows.length>1?"s":""}</span>
920+ <span style="display:flex;gap:6px;align-items:center">
921+ <button type="button" class="btn btn-ghost dt-prev"${cur===0?" disabled":""}>←</button>
922+ <span class="chip">${cur+1} / ${pages}</span>
923+ <button type="button" class="btn btn-ghost dt-next"${cur>=pages-1?" disabled":""}>→</button></span></div>`;
924+ body.querySelectorAll("th").forEach(th=>th.addEventListener("click",()=>{
925+ const col=+th.dataset.col;
926+ st.sort={col,dir:st.sort&&st.sort.col===col&&st.sort.dir===1?-1:1};render();}));
927+ const prev=body.querySelector(".dt-prev"),next=body.querySelector(".dt-next");
928+ prev.addEventListener("click",()=>{st.page=Math.max(0,cur-1);render()});
929+ next.addEventListener("click",()=>{st.page=Math.min(pages-1,cur+1);render()});
930+ };
931+ qi.addEventListener("input",deb(()=>{st.q=qi.value;st.page=0;render()},250));
932+ render();
933+}
934+
935+/* --- barre de période + boutons PDF --- */
936+function periodBar(){
937+ const customOn=!!(ST.from&&ST.to);
938+ return `<div class="pchips" role="group" aria-label="Période d'analyse">
939+ ${PERIODS_UI.map(([id,l])=>`<button type="button" class="pchip" data-period="${id}" aria-pressed="${!customOn&&ST.period===id}">${l}</button>`).join("")}
940+ <span class="pdates"><input type="date" id="pfrom" value="${esc(ST.from)}" aria-label="Du">
941+ <span class="klabel">au</span><input type="date" id="pto" value="${esc(ST.to)}" aria-label="Au"></span></div>`;
942+}
943+function bindPeriod(){
944+ document.querySelectorAll(".pchip").forEach(b=>b.addEventListener("click",()=>{
945+ ST.period=b.dataset.period;ST.from="";ST.to="";loadStatsData();}));
946+ const pf=$("#pfrom"),pt=$("#pto");
947+ const go=()=>{if(pf.value&&pt.value){ST.from=pf.value;ST.to=pt.value;loadStatsData();}};
948+ pf.addEventListener("change",go);pt.addEventListener("change",go);
949+}
950+function bindPdf(){
951+ const url=m=>{const p=new URLSearchParams({period:ST.period,mode:m});
952+ if(ST.from&&ST.to){p.set("from",ST.from);p.set("to",ST.to);}
953+ return "/api/stats/report?"+p;};
954+ [["pdf-full","complet"],["pdf-syn","synthese"]].forEach(([id,m])=>{
955+ const b=document.getElementById(id);if(!b)return;
956+ b.addEventListener("click",async()=>{
957+ const t=b.textContent;b.disabled=true;b.textContent="Génération…";
958+ try{const r=await fetch(url(m));if(!r.ok)throw new Error(r.status);
959+ const blob=await r.blob();
960+ const fn=((r.headers.get("Content-Disposition")||"").match(/filename="?([^";]+)/)||[])[1]||"rapport-crea-ka.pdf";
961+ const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=fn;
962+ document.body.appendChild(a);a.click();a.remove();
963+ setTimeout(()=>URL.revokeObjectURL(a.href),4000);}
964+ catch(e){alert("Échec de la génération du PDF — réessayez.");}
965+ b.disabled=false;b.textContent=t;});});
966+}
967+
689 async function renderStats(){ 968 async function renderStats(){
690 document.title="Statistiques — Créa-Ka"; 969 document.title="Statistiques — Créa-Ka";
691 app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("stats");bindNav(); 970 app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("stats");bindNav();
692− const s=await api("/api/stats").catch(()=>null); 971+ await loadStatsData(true);
693− if(!s){app.innerHTML=header()+`<div class="container empty">Statistiques indisponibles.</div>`+footer()+tabbar("stats");bindNav();return} 972+ window.scrollTo(0,0);
694− const bars=(obj,lbls,useIcon)=>{const entries=Object.entries(obj).slice(0,12); 973+}
695− const max=Math.max(...entries.map(e=>e[1]),1); 974+async function loadStatsData(first){
696− return entries.map(([k,v])=>`<div class="hrow"> 975+ const main=$("#stats-main");if(main)main.style.opacity=".45";
697− ${useIcon?`<span class="plat" style="background:${(PLAT[k]||PLAT.autre).color}">${icon(k)}</span>`:""} 976+ let d;
698− <span class="hrow-label">${esc(lbls?(lbls[k]||k):k)}</span> 977+ try{const p=new URLSearchParams({period:ST.period});
699− <span class="hbar"><i style="width:${Math.max(2,Math.round(v/max*100))}%"></i></span> 978+ if(ST.from&&ST.to){p.set("from",ST.from);p.set("to",ST.to);}
700− <span class="n">${fmtFull.format(v)}</span></div>`).join("")}; 979+ d=await api("/api/stats/dashboard?"+p);}
980+ catch(e){app.innerHTML=header()+`<div class="container empty">Statistiques indisponibles pour le moment.</div>`+footer()+tabbar("stats");bindNav();return}
981+ ST.data=d;renderStatsPage();
982+}
983+function renderStatsPage(){
984+ const d=ST.data;
985+ const upd=new Date(d.updated).toLocaleString("fr-CA",{dateStyle:"medium",timeStyle:"short"});
986+ const donut=(d.breakdowns||[]).find(b=>b.kind==="donut");
987+ const barsB=(d.breakdowns||[]).filter(b=>b.kind!=="donut");
701 app.innerHTML=header()+` 988 app.innerHTML=header()+`
702− <div class="container stats-page"> 989+ <div class="container stats-page" id="stats-main">
703− <span class="kicker">Portrait de l'annuaire</span> 990+ <div class="stats-head">
704− <h1 style="font-size:clamp(28px,4.6vw,46px);margin:12px 0 4px;text-transform:uppercase">Les créateurs québécois <span class="hl">en chiffres.</span></h1> 991+ <div><span class="kicker">Tableau de bord — ${esc(d.period.label)}</span>
705− <div class="tiles"> 992+ <h1 style="font-size:clamp(28px,4.6vw,46px);margin:12px 0 0;text-transform:uppercase">Les créateurs québécois <span class="hl">en chiffres.</span></h1>
706− <div class="tile hero-tile"><div class="tile-v">${fmtFull.format(s.creators)}</div><div class="tile-k">Créateurs</div></div> 993+ <div class="fresh-row"><span>Mis à jour le ${esc(upd)}</span>
707− <div class="tile"><div class="tile-v">${fmtFull.format(s.accounts)}</div><div class="tile-k">Comptes reliés</div></div> 994+ <button type="button" class="btn btn-ghost" id="st-refresh">↻ Rafraîchir</button></div></div>
708− <div class="tile"><div class="tile-v">${Object.keys(s.by_platform||{}).length}</div><div class="tile-k">Plateformes</div></div> 995+ <span class="pdf-actions">
709− <div class="tile"><div class="tile-v">${(s.accounts/Math.max(1,s.creators)).toFixed(1)}</div><div class="tile-k">Comptes / créateur</div></div> 996+ <button type="button" class="btn btn-primary" id="pdf-full">⬇ Télécharger le rapport PDF</button>
997+ <button type="button" class="btn btn-ghost" id="pdf-syn">Synthèse (2 p.)</button></span>
998+ </div>
999+ <div class="tiles">${(d.kpis||[]).map(kpiHtml).join("")||emptyBlock("Indicateurs")}</div>
1000+ ${periodBar()}
1001+ <p class="klabel" style="margin:6px 0 0">Période analysée : ${esc(d.period.from)} → ${esc(d.period.to)}</p>
1002+ <h2 class="stats-sect">Évolution</h2>
1003+ <div class="viz-grid">${(d.series||[]).map(s=>`<div class="lc" data-serie="${esc(s.id)}"></div>`).join("")||emptyBlock("Évolution")}</div>
1004+ <h2 class="stats-sect">Répartitions</h2>
1005+ <div class="viz-grid two">
1006+ ${donut?donutHtml(donut):""}
1007+ ${barsB.map(b=>barsHtml(b.title,b.items)).join("")}
1008+ ${d.geo?barsHtml(d.geo.title,d.geo.items):""}
710 </div> 1009 </div>
711− <div class="viz-card"><h2>Par plateforme</h2><div class="viz-sub">comptes publics reliés</div> 1010+ ${d.heatmap?`<h2 class="stats-sect">Activité</h2>${heatmapHtml(d.heatmap)}`:""}
712− ${bars(s.by_platform,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])),true)}</div> 1011+ ${(d.tables||[]).length?`<h2 class="stats-sect">Détails</h2><div style="display:grid;gap:22px">${(d.tables||[]).map(t=>`<div class="dt" data-table="${esc(t.id)}"></div>`).join("")}</div>`:""}
713− <div class="viz-card"><h2>Par niche</h2><div class="viz-sub">créateurs actifs</div> 1012+ ${(d.records||[]).length?`<h2 class="stats-sect">Records & faits marquants</h2>
714− ${bars(s.by_niche,NICHE_LBL)}</div> 1013+ <div class="records-grid">${d.records.map(r=>`<div class="record"><span class="r-lbl">${esc(r.label)}</span>
715− <div class="viz-card"><h2>Par taille d'audience</h2><div class="viz-sub">basée sur la plateforme principale</div> 1014+ <span style="text-align:right"><span class="r-val">${esc(r.value)}</span>${r.date?`<span class="klabel" style="display:block">${esc(r.date)}</span>`:""}</span></div>`).join("")}</div>`:""}
716− ${bars(s.by_tier,TIER_LBL)}</div> 1015+ <p style="font-size:12px;color:var(--ink-3);margin-top:30px">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.
717− ${Object.keys(s.by_region||{}).length?`<div class="viz-card"><h2>Par région déclarée</h2><div class="viz-sub">quand le créateur la rend publique</div>${bars(s.by_region)}</div>`:""} 1016+ Rapport PDF estampillé Groupe-KA disponible en haut de page.</p>
718 </div>`+footer()+tabbar("stats"); 1017 </div>`+footer()+tabbar("stats");
719− bindNav();window.scrollTo(0,0); 1018+ bindNav();bindPeriod();bindPdf();
1019+ $("#st-refresh").addEventListener("click",()=>loadStatsData());
1020+ (ST.data.series||[]).forEach(s=>{const el=document.querySelector(`.lc[data-serie="${s.id}"]`);if(el)mountLine(el,s);});
1021+ (ST.data.tables||[]).forEach(t=>{const el=document.querySelector(`.dt[data-table="${t.id}"]`);if(el)mountTable(el,t);});
720 } 1022 }
721 1023
722 /* ---------- page compte (KA ID) ---------- */ 1024 /* ---------- page compte (KA ID) ---------- */
added frontend/src/ka/stats/SPEC.md +123 −0
@@ -0,0 +1,123 @@
1+# ka-stats — module Stats commun Groupe KA (spec v1)
2+
3+Contrat partagé par les 12 plateformes pour leurs pages **/stats** (tableau de
4+bord analytique) et l'**export PDF** estampillé Groupe-KA. Le visuel suit le
5+design system ka-ui (tokens.css) avec l'accent de la marque.
6+
7+## 1. Page /stats — structure obligatoire (dans cet ordre)
8+
9+1. **Bandeau KPI** : 4–6 grandes cartes (`KpiCard`) — valeur, libellé,
10+ variation vs période précédente (▲/▼ + %, vert `--green` / rouge `--danger`).
11+2. **Sélecteur de période global** (`PeriodSelector`) : `aujourd'hui · 7 j ·
12+ 30 j · 3 m · 6 m · 12 m · année en cours · tout` + plage personnalisée
13+ (2 champs date). Toute la page se recalcule (state → refetch dashboard).
14+3. **Graphiques** : courbes d'évolution (`LineChart`, survol = infobulle,
15+ légende cliquable pour masquer une série, comparaison N vs N-1 en
16+ pointillé), barres (`BarChart`), anneaux (`Donut`), calendrier de chaleur
17+ (`CalendarHeatmap`) quand pertinent.
18+4. **Répartition géographique** (par ville/région) quand pertinent — barres
19+ horizontales triées (pas besoin de vraie carte).
20+5. **Tableaux détaillés** (`DataTable`) : tri par colonne, recherche interne,
21+ pagination (25/pg), débordement horizontal propre sur mobile (.tbl-wrap).
22+6. **Records & faits marquants** : générés depuis les données (jour record,
23+ plus forte croissance, meilleure entrée…) — cartes compactes.
24+7. **Fraîcheur** : « Mis à jour le {date heure} » + bouton Rafraîchir.
25+8. **Bouton PDF** bien visible en haut : « Télécharger le rapport PDF » avec
26+ deux choix (Rapport complet / Synthèse 2 pages). Indicateur de progression
27+ si > 2 s.
28+
29+Responsive : KPI empilés < 768 px, graphiques pleine largeur redimensionnés
30+(SVG viewBox), tableaux en défilement horizontal contenu, tactile ≥ 44 px.
31+AUCUNE donnée inventée : une stat indisponible = bloc « Pas encore mesuré »
32+(carte grise propre), jamais un faux chiffre.
33+
34+## 2. API — contrat commun
35+
36+`GET /api/stats/dashboard?period=7j|30j|3m|6m|12m|annee|tout|auj&from=YYYY-MM-DD&to=YYYY-MM-DD`
37+
38+```jsonc
39+{
40+ "updated": "2026-08-17T21:04:00-04:00",
41+ "period": { "from": "2026-07-18", "to": "2026-08-17", "label": "30 jours" },
42+ "kpis": [ { "id": "total", "label": "Annonces actives", "value": 33744,
43+ "unit": "", "delta_pct": 4.2, "direction": "up" } ],
44+ "series": [ { "id": "vol", "title": "Annonces actives par jour", "unit": "annonces",
45+ "kind": "line", "points": [{ "t": "2026-07-18", "v": 31200 }],
46+ "compare": [{ "t": "2025-07-18", "v": 24100 }] } ],
47+ "breakdowns": [ { "id": "types", "title": "Par type", "kind": "donut",
48+ "items": [{ "label": "4½", "value": 9120 }] } ],
49+ "geo": { "title": "Par région", "items": [{ "label": "Montréal", "value": 15680 }] },
50+ "heatmap": { "title": "Activité", "cells": [{ "date": "2026-08-01", "value": 210 }] },
51+ "tables": [ { "id": "top", "title": "Top villes", "columns": ["Ville", "Annonces", "Δ 30 j"],
52+ "rows": [["Montréal", 15680, "+3,1 %"]] } ],
53+ "records": [ { "label": "Jour record d'ajouts", "value": "412 annonces", "date": "2026-08-09" } ]
54+}
55+```
56+
57+Champs absents = section masquée. Cache serveur recommandé (≥ 5 min par
58+période). Les valeurs proviennent des données réelles (DB de la plateforme,
59+journaux de sync des connecteurs, /api/v1/runs d'API-KA…).
60+
61+`GET /api/stats/report?period=…&from=&to=&mode=complet|synthese`
62+→ `application/pdf`, en-tête `Content-Disposition: attachment; filename=
63+groupe-ka_<plateforme>_stats_<periode>_<YYYY-MM-DD>.pdf`.
64+
65+## 3. PDF — gabarit Groupe-KA (implémentations : `kapdf.py` fpdf2 pour les
66+apps Python ; les apps Next portent le même gabarit en pdfkit)
67+
68+- **Couverture** : cadre encre, kicker « GROUPE KA · RAPPORT STATISTIQUE »,
69+ wordmark de la plateforme (boîte encre + accent), sous-titre, période
70+ couverte, date/heure de génération, bande encre au pied avec
71+ « par Groupe KA — groupe-ka.com ».
72+- **Sommaire** avec numéros de pages (mode complet).
73+- **KPI** : grille de cartes (bordure encre, valeur en gros, delta coloré).
74+- **Graphiques VECTORIELS** (dessinés en primitives, jamais de capture) :
75+ courbes, barres, anneaux — accent de la plateforme, axes/graduations encre.
76+- **Tableaux** paginés proprement (lignes zébrées `--surface-2`, jamais
77+ coupés en deux à cheval sur une ligne).
78+- **Records** puis **page de fin** : coordonnées Groupe KA (3 courriels +
79+ rôles d'ecosystem.json, groupe-ka.com), avertissement d'agrégateur,
80+ mentions légales courtes.
81+- **Chaque page** : en-tête discret (« Groupe KA · {Plateforme} », filet
82+ encre) + pied (« © Groupe-KA — {année} — groupe-ka.com · {période} · p. X/Y »).
83+- A4 portrait, marges 18 mm, typo : Helvetica (fallback sûr) ou fonts TTF du
84+ DS si présentes. Mode « synthese » = couverture + 1 page KPI/records.
85+
86+## 4. Spécifique par plateforme (sections métier attendues)
87+
88+- **groupe-ka** : tableau de bord maître — consolidation des 12 (volume total,
89+ croissance), classement des plateformes, bloc résumé par plateforme + lien
90+ vers sa page /stats ; « Rapport écosystème complet » = PDF consolidé.
91+- **lou-ka** : annonces actives/nouvelles/retirées, loyers moyens/médians par
92+ ville & taille, évolution, répartition par type, top villes.
93+- **immo-ka** : annonces actives/nouvelles/vendues-retirées, prix moyen/médian
94+ par ville/région/type, délai de présence, top villes, tension du marché.
95+- **vrai-prix** : couverture du rôle (unités, valeur totale), estimations
96+ servies si journalisées, répartitions par municipalité/type, indices marché.
97+- **auto-ka** : volume par marque/modèle/année/carburant/boîte, prix moyens et
98+ km moyens par segment, top marques/modèles.
99+- **fabri-ka** : produits par catégorie/région/boutique, fourchettes de prix,
100+ nouveautés par période, top catégories.
101+- **food-ka** : produits suivis, relevés de prix, soldes détectés (baisses/
102+ hausses, amplitude), top produits en solde, prix moyens par catégorie.
103+- **resto-ka** : restos par cuisine/ville/gamme, menus & plats, nouveautés/
104+ fermetures détectées, top établissements.
105+- **sorti-ka** : événements à venir/passés par catégorie/ville, gratuits vs
106+ payants, heatmap calendrier, top lieux.
107+- **crea-ka** : créateurs par plateforme/niche/tier, comptes reliés, top
108+ créateurs, croissance du répertoire.
109+- **trouve-ka** : pages indexées, domaines, rythme de crawl (indexées/h),
110+ erreurs, file frontier, tendances si les requêtes sont journalisées.
111+- **api-ka** : appels par endpoint/jour/heure, latences moyennes + p95, taux
112+ d'erreur, top endpoints, uptime (données des middlewares de logging + runs).
113+- **Transverse (tous)** : volume total agrégé + croissance, connecteurs actifs
114+ et éléments ajoutés/mis à jour par période (journaux de sync), complétude/
115+ fraîcheur moyenne des fiches quand mesurable. Trafic web : seulement si des
116+ journaux d'accès existent — sinon état vide propre.
117+
118+## 5. Ajouter une métrique / un graphique / une plateforme
119+
120+1 métrique = 1 entrée `kpis[]` ou `series[]` côté API (requête SQL agrégée +
121+cache) — le front la rend automatiquement. 1 plateforme = implémenter les 2
122+endpoints du contrat + une page /stats montée sur les composants du kit +
123+`kapdf.py` (ou gabarit pdfkit) branché sur le même JSON de dashboard.
added frontend/src/ka/stats/kacharts.tsx +389 −0
@@ -0,0 +1,389 @@
1+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+// ka-ui/stats/kacharts.tsx — kit de graphiques SVG du module Stats commun
3+// Groupe KA (zéro dépendance, React 18+). Style : design system ka-ui
4+// (bordures encre, accent de la plateforme via var(--accent)).
5+// Composants : KpiCard, PeriodSelector, LineChart (infobulle + légende
6+// cliquable + comparaison N-1), BarChart, Donut, CalendarHeatmap, DataTable
7+// (tri/recherche/pagination), RecordCard, PdfButton, EmptyBlock, Fraicheur.
8+import { useMemo, useState } from "react";
9+
10+/* ---------- types (contrat SPEC.md) ---------- */
11+export type Kpi = {
12+ id: string; label: string; value: number | string; unit?: string;
13+ delta_pct?: number | null; direction?: "up" | "down";
14+};
15+export type Point = { t: string; v: number };
16+export type Serie = {
17+ id: string; title: string; unit?: string; kind?: "line" | "bar";
18+ points: Point[]; compare?: Point[];
19+};
20+export type BreakItem = { label: string; value: number };
21+export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] };
22+export type RecordFact = { label: string; value: string; date?: string };
23+
24+export const PERIODS: { id: string; label: string }[] = [
25+ { id: "auj", label: "Aujourd'hui" },
26+ { id: "7j", label: "7 jours" },
27+ { id: "30j", label: "30 jours" },
28+ { id: "3m", label: "3 mois" },
29+ { id: "6m", label: "6 mois" },
30+ { id: "12m", label: "12 mois" },
31+ { id: "annee", label: "Année en cours" },
32+ { id: "tout", label: "Tout" },
33+];
34+
35+export const fmtInt = (n: number) => n.toLocaleString("fr-CA");
36+export const fmtNum = (n: number) =>
37+ Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("fr-CA", { maximumFractionDigits: 2 });
38+
39+/* ---------- KPI ---------- */
40+export function KpiCard({ k }: { k: Kpi }) {
41+ const up = (k.direction ?? ((k.delta_pct ?? 0) >= 0 ? "up" : "down")) === "up";
42+ return (
43+ <article className="card" style={{ padding: "14px 16px", minWidth: 0 }}>
44+ <p style={{ margin: 0, fontFamily: "var(--font-display)", fontWeight: 700, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em" }}>
45+ {typeof k.value === "number" ? fmtNum(k.value) : k.value}
46+ {k.unit ? <span style={{ fontSize: "0.6em", color: "var(--ink-2)" }}> {k.unit}</span> : null}
47+ </p>
48+ <p className="klabel" style={{ margin: "6px 0 0" }}>{k.label}</p>
49+ {k.delta_pct !== undefined && k.delta_pct !== null && (
50+ <p style={{ margin: "8px 0 0", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, color: up ? "var(--green)" : "var(--danger)" }}>
51+ {up ? "▲" : "▼"} {k.delta_pct >= 0 ? "+" : ""}{fmtNum(k.delta_pct)} % <span style={{ color: "var(--ink-3)", fontWeight: 500 }}>vs période préc.</span>
52+ </p>
53+ )}
54+ </article>
55+ );
56+}
57+
58+/* ---------- Sélecteur de période ---------- */
59+export function PeriodSelector({
60+ value, onChange, custom, onCustom,
61+}: {
62+ value: string; onChange: (p: string) => void;
63+ custom?: { from: string; to: string }; onCustom?: (from: string, to: string) => void;
64+}) {
65+ return (
66+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
67+ {PERIODS.map((p) => (
68+ <button key={p.id} type="button" onClick={() => onChange(p.id)}
69+ className="chip" aria-pressed={value === p.id}
70+ style={{ cursor: "pointer", minHeight: 44, background: value === p.id ? "var(--accent)" : "var(--surface)", color: value === p.id ? "var(--on-accent)" : "var(--ink)" }}>
71+ {p.label}
72+ </button>
73+ ))}
74+ {onCustom && (
75+ <span style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>
76+ <input className="input" type="date" style={{ width: 150 }} value={custom?.from ?? ""} aria-label="Du"
77+ onChange={(e) => onCustom(e.target.value, custom?.to ?? "")} />
78+ <span className="klabel">au</span>
79+ <input className="input" type="date" style={{ width: 150 }} value={custom?.to ?? ""} aria-label="Au"
80+ onChange={(e) => onCustom(custom?.from ?? "", e.target.value)} />
81+ </span>
82+ )}
83+ </div>
84+ );
85+}
86+
87+/* ---------- Courbe ---------- */
88+export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) {
89+ const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false });
90+ const [hover, setHover] = useState<number | null>(null);
91+ const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
92+ const pts = serie.points ?? [];
93+ if (pts.length < 2) return <EmptyBlock title={serie.title} />;
94+ const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])];
95+ const vmax = Math.max(...all.map((p) => p.v), 1);
96+ const vmin = Math.min(0, ...all.map((p) => p.v));
97+ const X = (i: number, n: number) => PL + ((W - PL - PR) * i) / (n - 1);
98+ const Y = (v: number) => PT + (H - PT - PB) * (1 - (v - vmin) / (vmax - vmin || 1));
99+ const path = (s: Point[]) => s.map((p, i) => `${i ? "L" : "M"}${X(i, s.length)},${Y(p.v)}`).join("");
100+ const hi = hover !== null ? Math.min(pts.length - 1, Math.max(0, hover)) : null;
101+ return (
102+ <figure className="card" style={{ margin: 0, padding: 16 }}>
103+ <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
104+ <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{serie.title}</b>
105+ <span style={{ display: "flex", gap: 10 }}>
106+ <LegendChip label="Période courante" color="var(--accent)" off={hide.cur} onClick={() => setHide((h) => ({ ...h, cur: !h.cur }))} />
107+ {serie.compare && <LegendChip label="Période comparée" color="var(--ink-3)" dashed off={hide.cmp} onClick={() => setHide((h) => ({ ...h, cmp: !h.cmp }))} />}
108+ </span>
109+ </figcaption>
110+ <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10, touchAction: "pan-y" }} role="img" aria-label={serie.title}
111+ onMouseMove={(e) => {
112+ const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
113+ const fx = ((e.clientX - r.left) / r.width) * W;
114+ setHover(Math.round(((fx - PL) / (W - PL - PR)) * (pts.length - 1)));
115+ }}
116+ onMouseLeave={() => setHover(null)}>
117+ {[0, 1, 2, 3, 4].map((g) => {
118+ const y = PT + ((H - PT - PB) * g) / 4;
119+ const v = vmax - ((vmax - vmin) * g) / 4;
120+ return (
121+ <g key={g}>
122+ <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
123+ <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>
124+ </g>
125+ );
126+ })}
127+ {[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => (
128+ <text key={i} x={X(i, pts.length)} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
129+ ))}
130+ {!hide.cmp && serie.compare && serie.compare.length > 1 && (
131+ <path d={path(serie.compare)} fill="none" stroke="var(--ink-3)" strokeWidth={1.4} strokeDasharray="4 4" />
132+ )}
133+ {!hide.cur && <path d={path(pts)} fill="none" stroke="var(--accent)" strokeWidth={2.4} />}
134+ {hi !== null && (
135+ <g>
136+ <line x1={X(hi, pts.length)} x2={X(hi, pts.length)} y1={PT} y2={H - PB} stroke="var(--ink)" strokeWidth={1} strokeDasharray="2 3" />
137+ <circle cx={X(hi, pts.length)} cy={Y(pts[hi].v)} r={4} fill="var(--accent)" stroke="var(--ink)" strokeWidth={1.5} />
138+ </g>
139+ )}
140+ </svg>
141+ {hi !== null && (
142+ <p className="chip" style={{ marginTop: 8 }}>
143+ {pts[hi].t} — <b>{fmtNum(pts[hi].v)}{serie.unit ? ` ${serie.unit}` : ""}</b>
144+ {serie.compare?.[hi] && !hide.cmp ? <span style={{ color: "var(--ink-3)" }}> · N-1 : {fmtNum(serie.compare[hi].v)}</span> : null}
145+ </p>
146+ )}
147+ </figure>
148+ );
149+}
150+
151+function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off: boolean; dashed?: boolean; onClick: () => void }) {
152+ return (
153+ <button type="button" onClick={onClick} aria-pressed={!off}
154+ style={{ display: "inline-flex", alignItems: "center", gap: 6, border: 0, background: "none", cursor: "pointer", opacity: off ? 0.4 : 1, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", minHeight: 44 }}>
155+ <span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} />
156+ {label}
157+ </button>
158+ );
159+}
160+
161+/* ---------- Barres horizontales (répartitions, géo) ---------- */
162+export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) {
163+ const rows = (items ?? []).slice(0, 14);
164+ if (!rows.length) return <EmptyBlock title={title} />;
165+ const max = Math.max(...rows.map((r) => r.value), 1);
166+ return (
167+ <figure className="card" style={{ margin: 0, padding: 16 }}>
168+ <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption>
169+ <div style={{ marginTop: 12, display: "grid", gap: 9 }}>
170+ {rows.map((r) => (
171+ <div key={r.label} title={`${r.label} — ${fmtNum(r.value)}${unit ? ` ${unit}` : ""}`}>
172+ <div style={{ display: "flex", justifyContent: "space-between", fontSize: 12.5 }}>
173+ <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
174+ <b style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}>{fmtNum(r.value)}{unit ? ` ${unit}` : ""}</b>
175+ </div>
176+ <div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}>
177+ <div style={{ height: "100%", width: `${Math.max((r.value / max) * 100, 1)}%`, background: "var(--accent)", border: "1px solid var(--ink)", borderRadius: "0 3px 3px 0", boxSizing: "border-box" }} />
178+ </div>
179+ </div>
180+ ))}
181+ </div>
182+ </figure>
183+ );
184+}
185+
186+/* ---------- Anneau ---------- */
187+export function Donut({ title, items }: { title: string; items: BreakItem[] }) {
188+ const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8);
189+ const total = rows.reduce((s, r) => s + r.value, 0);
190+ if (!total) return <EmptyBlock title={title} />;
191+ const R = 74, C = 2 * Math.PI * R;
192+ let acc = 0;
193+ const shades = [1, 0.78, 0.58, 0.42, 0.3, 0.22, 0.15, 0.1];
194+ return (
195+ <figure className="card" style={{ margin: 0, padding: 16 }}>
196+ <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption>
197+ <div style={{ display: "flex", flexWrap: "wrap", gap: 18, alignItems: "center", marginTop: 12 }}>
198+ <svg viewBox="0 0 200 200" style={{ width: 180, maxWidth: "100%" }} role="img" aria-label={title}>
199+ {rows.map((r, i) => {
200+ const frac = r.value / total;
201+ const off = acc; acc += frac;
202+ return (
203+ <circle key={r.label} cx={100} cy={100} r={R} fill="none"
204+ stroke="var(--accent)" strokeOpacity={shades[i % shades.length]}
205+ strokeWidth={30} strokeDasharray={`${frac * C} ${C}`} strokeDashoffset={-off * C}
206+ transform="rotate(-90 100 100)">
207+ <title>{`${r.label} — ${fmtNum(r.value)} (${((100 * r.value) / total).toFixed(1)} %)`}</title>
208+ </circle>
209+ );
210+ })}
211+ <circle cx={100} cy={100} r={R} fill="none" stroke="var(--ink)" strokeWidth={1} opacity={0.5} />
212+ </svg>
213+ <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 6, minWidth: 200, flex: 1 }}>
214+ {rows.map((r, i) => (
215+ <li key={r.label} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5 }}>
216+ <span style={{ width: 11, height: 11, borderRadius: 3, border: "1px solid var(--ink)", background: "var(--accent)", opacity: shades[i % shades.length] }} />
217+ <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
218+ <b style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}>{((100 * r.value) / total).toFixed(1)} %</b>
219+ </li>
220+ ))}
221+ </ul>
222+ </div>
223+ </figure>
224+ );
225+}
226+
227+/* ---------- Calendrier de chaleur ---------- */
228+export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) {
229+ if (!cells?.length) return <EmptyBlock title={title} />;
230+ const byDate = new Map(cells.map((c) => [c.date, c.value]));
231+ const dates = cells.map((c) => c.date).sort();
232+ const end = new Date(dates[dates.length - 1] + "T12:00:00");
233+ const max = Math.max(...cells.map((c) => c.value), 1);
234+ const weeks = 26, cols: { date: string; v: number }[][] = [];
235+ const cur = new Date(end);
236+ cur.setDate(cur.getDate() - (weeks * 7 - 1));
237+ for (let w = 0; w < weeks; w++) {
238+ const col: { date: string; v: number }[] = [];
239+ for (let d = 0; d < 7; d++) {
240+ const iso = cur.toISOString().slice(0, 10);
241+ col.push({ date: iso, v: byDate.get(iso) ?? 0 });
242+ cur.setDate(cur.getDate() + 1);
243+ }
244+ cols.push(col);
245+ }
246+ return (
247+ <figure className="card" style={{ margin: 0, padding: 16 }}>
248+ <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b> <span className="klabel">26 dernières semaines</span></figcaption>
249+ <div className="tbl-wrap" style={{ marginTop: 12 }}>
250+ <svg viewBox={`0 0 ${weeks * 14} ${7 * 14}`} style={{ minWidth: 480, width: "100%", height: "auto" }} role="img" aria-label={title}>
251+ {cols.map((col, w) => col.map((c, d) => (
252+ <rect key={c.date} x={w * 14} y={d * 14} width={12} height={12} rx={2.5}
253+ fill={c.v ? "var(--accent)" : "rgba(20,24,20,0.07)"} fillOpacity={c.v ? 0.25 + 0.75 * (c.v / max) : 1}
254+ stroke="rgba(20,24,20,0.15)" strokeWidth={0.5}>
255+ <title>{`${c.date} — ${fmtNum(c.v)}`}</title>
256+ </rect>
257+ )))}
258+ </svg>
259+ </div>
260+ </figure>
261+ );
262+}
263+
264+/* ---------- Tableau : tri, recherche, pagination ---------- */
265+export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) {
266+ const [q, setQ] = useState("");
267+ const [sort, setSort] = useState<{ col: number; dir: 1 | -1 } | null>(null);
268+ const [page, setPage] = useState(0);
269+ const rows = useMemo(() => {
270+ let r = spec.rows ?? [];
271+ if (q) r = r.filter((row) => row.some((c) => String(c).toLowerCase().includes(q.toLowerCase())));
272+ if (sort) r = [...r].sort((a, b) => {
273+ const x = a[sort.col], y = b[sort.col];
274+ const nx = typeof x === "number" ? x : parseFloat(String(x).replace(/[^\d.,-]/g, "").replace(",", "."));
275+ const ny = typeof y === "number" ? y : parseFloat(String(y).replace(/[^\d.,-]/g, "").replace(",", "."));
276+ if (!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx - ny) * sort.dir;
277+ return String(x).localeCompare(String(y), "fr") * sort.dir;
278+ });
279+ return r;
280+ }, [spec.rows, q, sort]);
281+ const pages = Math.max(1, Math.ceil(rows.length / pageSize));
282+ const cur = Math.min(page, pages - 1);
283+ return (
284+ <section className="card" style={{ padding: 16 }}>
285+ <div style={{ display: "flex", flexWrap: "wrap", gap: 10, justifyContent: "space-between", alignItems: "center" }}>
286+ <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{spec.title}</b>
287+ <input className="input" style={{ maxWidth: 240 }} placeholder="Rechercher…" value={q}
288+ onChange={(e) => { setQ(e.target.value); setPage(0); }} aria-label={`Rechercher dans ${spec.title}`} />
289+ </div>
290+ <div className="tbl-wrap" style={{ marginTop: 10 }}>
291+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
292+ <thead>
293+ <tr>
294+ {spec.columns.map((c, i) => (
295+ <th key={c} onClick={() => setSort((s) => ({ col: i, dir: s?.col === i && s.dir === 1 ? -1 : 1 }))}
296+ style={{ cursor: "pointer", textAlign: "left", padding: "8px 10px", background: "var(--ink)", color: "var(--paper)", fontFamily: "var(--font-mono)", fontSize: 10.5, textTransform: "uppercase", letterSpacing: "0.06em", whiteSpace: "nowrap", userSelect: "none" }}
297+ aria-sort={sort?.col === i ? (sort.dir === 1 ? "ascending" : "descending") : "none"}>
298+ {c} {sort?.col === i ? (sort.dir === 1 ? "▲" : "▼") : "↕"}
299+ </th>
300+ ))}
301+ </tr>
302+ </thead>
303+ <tbody>
304+ {rows.slice(cur * pageSize, (cur + 1) * pageSize).map((row, ri) => (
305+ <tr key={ri} style={{ background: ri % 2 ? "var(--surface-2)" : "var(--surface)" }}>
306+ {row.map((c, ci) => (
307+ <td key={ci} style={{ padding: "7px 10px", borderBottom: "1px solid var(--line)", whiteSpace: "nowrap" }}>
308+ {typeof c === "number" ? fmtNum(c) : c}
309+ </td>
310+ ))}
311+ </tr>
312+ ))}
313+ </tbody>
314+ </table>
315+ </div>
316+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, flexWrap: "wrap", gap: 8 }}>
317+ <span className="klabel">{fmtInt(rows.length)} lignes</span>
318+ <span style={{ display: "flex", gap: 6 }}>
319+ <button type="button" className="btn btn-ghost" disabled={cur === 0} onClick={() => setPage(cur - 1)}>←</button>
320+ <span className="chip">{cur + 1} / {pages}</span>
321+ <button type="button" className="btn btn-ghost" disabled={cur >= pages - 1} onClick={() => setPage(cur + 1)}>→</button>
322+ </span>
323+ </div>
324+ </section>
325+ );
326+}
327+
328+/* ---------- Records / faits marquants ---------- */
329+export function RecordCard({ r }: { r: RecordFact }) {
330+ return (
331+ <article className="card" style={{ padding: "12px 16px", display: "flex", justifyContent: "space-between", gap: 12, alignItems: "baseline", background: "var(--surface-2)" }}>
332+ <span style={{ fontSize: 13, color: "var(--ink-2)" }}>{r.label}</span>
333+ <span style={{ textAlign: "right" }}>
334+ <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{r.value}</b>
335+ {r.date && <span className="klabel" style={{ display: "block" }}>{r.date}</span>}
336+ </span>
337+ </article>
338+ );
339+}
340+
341+/* ---------- Bouton PDF ---------- */
342+export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) {
343+ const [busy, setBusy] = useState(false);
344+ const url = (mode: string) => {
345+ const p = new URLSearchParams({ period, mode });
346+ if (from) p.set("from", from);
347+ if (to) p.set("to", to);
348+ return `${endpoint}?${p}`;
349+ };
350+ const dl = (mode: string) => {
351+ setBusy(true);
352+ const a = document.createElement("a");
353+ a.href = url(mode);
354+ a.download = "";
355+ document.body.appendChild(a);
356+ a.click();
357+ a.remove();
358+ setTimeout(() => setBusy(false), 2500);
359+ };
360+ return (
361+ <span style={{ display: "inline-flex", gap: 8, flexWrap: "wrap" }}>
362+ <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={busy}>
363+ {busy ? "Génération…" : "⬇ Télécharger le rapport PDF"}
364+ </button>
365+ <button type="button" className="btn btn-ghost" onClick={() => dl("synthese")} disabled={busy}>
366+ Synthèse (2 p.)
367+ </button>
368+ </span>
369+ );
370+}
371+
372+/* ---------- États ---------- */
373+export function EmptyBlock({ title }: { title: string }) {
374+ return (
375+ <div className="card" style={{ padding: 20, background: "var(--surface-2)", borderStyle: "dashed" }}>
376+ <b style={{ fontFamily: "var(--font-display)", fontSize: 14 }}>{title}</b>
377+ <p className="klabel" style={{ margin: "6px 0 0" }}>Pas encore mesuré — aucune donnée disponible pour cette période.</p>
378+ </div>
379+ );
380+}
381+
382+export function Fraicheur({ updated, onRefresh }: { updated: string; onRefresh: () => void }) {
383+ return (
384+ <p style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap", margin: 0 }}>
385+ <span className="klabel">Mis à jour le {new Date(updated).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" })}</span>
386+ <button type="button" className="btn btn-ghost" onClick={onRefresh}>↻ Rafraîchir</button>
387+ </p>
388+ );
389+}
added frontend/src/ka/stats/kapdf.py +560 −0
@@ -0,0 +1,560 @@
1+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2).
3+# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4+# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques
5+# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.
6+# Usage :
7+# from kapdf import GroupeKAReport
8+# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
9+# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
10+# mode="complet").build()
11+# Dépendance : pip install fpdf2 (aucune autre)
12+from __future__ import annotations
13+
14+import math
15+from datetime import datetime
16+from zoneinfo import ZoneInfo
17+
18+from fpdf import FPDF
19+
20+INK = (20, 24, 20)
21+INK2 = (77, 85, 81)
22+INK3 = (139, 146, 140)
23+PAPER = (245, 243, 238)
24+SURFACE2 = (250, 249, 245)
25+GREEN = (28, 92, 65)
26+DANGER = (179, 66, 58)
27+WHITE = (255, 255, 255)
28+
29+EMAILS = [
30+ ("contact@groupe-ka.com", "Projets, partenariats & données"),
31+ ("info@groupe-ka.com", "Médias & questions générales"),
32+ ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),
33+]
34+DISCLAIMER = (
35+ "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "
36+ "rien et ne sommes partie à aucune transaction. Données lues à la source, "
37+ "rien d'inventé, tout est traçable."
38+)
39+
40+
41+def _hex(c: str) -> tuple[int, int, int]:
42+ c = c.lstrip("#")
43+ return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore
44+
45+
46+def _fr(n) -> str:
47+ if isinstance(n, float) and not n.is_integer():
48+ return f"{n:,.2f}".replace(",", " ").replace(".", ",")
49+ return f"{int(n):,}".replace(",", " ")
50+
51+
52+_SUBST = {
53+ "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
54+ "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
55+ "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
56+}
57+
58+
59+def _latin1(s: str) -> str:
60+ for k, v in _SUBST.items():
61+ s = s.replace(k, v)
62+ return s.encode("latin-1", "replace").decode("latin-1")
63+
64+
65+class _PDF(FPDF):
66+ """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
67+ Les polices core sont latin-1 : normalize_text sanitise en amont."""
68+
69+ def normalize_text(self, text):
70+ return super().normalize_text(_latin1(text))
71+
72+ def __init__(self, brand: str, accent: tuple, period_label: str):
73+ super().__init__(orientation="P", unit="mm", format="A4")
74+ self.brand = brand
75+ self.accent = accent
76+ self.period_label = period_label
77+ self.cover_mode = False
78+ self.set_margins(18, 20, 18)
79+ self.set_auto_page_break(True, margin=22)
80+
81+ def header(self):
82+ if self.cover_mode or self.page_no() == 1: # jamais sur la couverture
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+ # le pied de la couverture se rend APRÈS la remise à zéro de cover_mode
99+ # (add_page suivant) : on exclut donc aussi explicitement la page 1
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"
modified requirements.txt +1 −0
@@ -8,3 +8,4 @@ uvicorn>=0.29
8 requests>=2.31 8 requests>=2.31
9 pydantic>=2.5 9 pydantic>=2.5
10 pytest>=8.0 10 pytest>=8.0
11+fpdf2>=2.7
11 12
Menu

Theme