SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
12 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%

Page /stats refaite : tableau de bord analytique Groupe KA + rapport PDF

- autoka/statsdash.py : moteur du dashboard (contrat ka/stats/SPEC.md) —
  KPI avec deltas vs période précédente, séries quotidiennes reconstruites
  du cycle de vie réel des annonces (inventaire, nouveautés, prix moyen),
  répartitions (carburant, boîte, marques, années), géo par région,
  heatmap, tableaux (marques/modèles/concessionnaires), records ;
  cache serveur 5 min par période, aucun delta inventé avant le début
  réel des données.
- API : GET /api/stats/dashboard?period=… et GET /api/stats/report
  (PDF Groupe-KA complet ou synthèse, filename normalisé groupe-ka_…).
- autoka/kapdf.py : moteur PDF commun fpdf2 (couverture wordmark Auto·Ka
  accent #ff5a2a, sommaire, KPI, graphiques vectoriels, tableaux zébrés,
  records, page contact) + substitutions latin-1 (≤ ≥).
- frontend : Stats.tsx réécrite sur le kit ka/stats/kacharts.tsx —
  PdfButton + fraîcheur, sélecteur de période (refetch), KPI, courbes
  interactives, anneau, barres, heatmap calendrier, DataTables triables,
  records ; mobile 360/768/1440 vérifié.
- requirements.txt : fpdf2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 0edc674

8 changed files +2,204 −148

added autoka/kapdf.py +559 −0
@@ -0,0 +1,559 @@
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 +
60 +def _latin1(s: str) -> str:
61 + for k, v in _SUBST.items():
62 + s = s.replace(k, v)
63 + return s.encode("latin-1", "replace").decode("latin-1")
64 +
65 +
66 +class _PDF(FPDF):
67 + """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
68 + Les polices core sont latin-1 : normalize_text sanitise en amont."""
69 +
70 + def normalize_text(self, text):
71 + return super().normalize_text(_latin1(text))
72 +
73 + def __init__(self, brand: str, accent: tuple, period_label: str):
74 + super().__init__(orientation="P", unit="mm", format="A4")
75 + self.brand = brand
76 + self.accent = accent
77 + self.period_label = period_label
78 + self.cover_mode = False
79 + self.set_margins(18, 20, 18)
80 + self.set_auto_page_break(True, margin=22)
81 +
82 + def header(self):
83 + if self.cover_mode:
84 + return
85 + self.set_font("helvetica", "B", 8.5)
86 + self.set_text_color(*INK)
87 + self.set_xy(18, 9)
88 + self.cell(0, 5, f"Groupe KA · {self.brand}")
89 + self.set_font("helvetica", "", 8)
90 + self.set_text_color(*INK3)
91 + self.set_xy(18, 9)
92 + self.cell(0, 5, "Rapport statistique", align="R")
93 + self.set_draw_color(*INK)
94 + self.set_line_width(0.5)
95 + self.line(18, 15.5, 192, 15.5)
96 + self.set_y(20)
97 +
98 + def footer(self):
99 + if self.cover_mode:
100 + return
101 + self.set_y(-15)
102 + self.set_draw_color(*INK3)
103 + self.set_line_width(0.2)
104 + self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)
105 + self.set_font("helvetica", "", 7.5)
106 + self.set_text_color(*INK3)
107 + year = datetime.now(ZoneInfo("America/Toronto")).year
108 + self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")
109 + self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")
110 +
111 +
112 +class GroupeKAReport:
113 + def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
114 + self.site = site
115 + self.d = dashboard
116 + self.mode = mode
117 + self.accent = _hex(site.get("accent", "#d9f26b"))
118 + period = dashboard.get("period", {}) or {}
119 + self.period_label = period.get("label") or "toute la période"
120 + self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
121 + self.toc: list[tuple[str, int]] = []
122 +
123 + # ---------- primitives ----------
124 + def _card(self, x, y, w, h, fill=WHITE):
125 + p = self.pdf
126 + p.set_draw_color(*INK)
127 + p.set_line_width(0.45)
128 + p.set_fill_color(*fill)
129 + p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
130 +
131 + def _kicker(self, text):
132 + p = self.pdf
133 + p.set_font("helvetica", "B", 8)
134 + p.set_text_color(*GREEN)
135 + p.set_draw_color(*GREEN)
136 + p.set_line_width(0.6)
137 + y = p.get_y() + 2
138 + p.line(p.l_margin, y, p.l_margin + 7, y)
139 + p.set_xy(p.l_margin + 9, y - 2.5)
140 + p.cell(0, 5, text.upper())
141 + p.ln(8)
142 +
143 + def _section_title(self, title):
144 + if self.pdf.get_y() > 240:
145 + self.pdf.add_page()
146 + self._kicker("Groupe KA · " + self.site.get("wordmark", ""))
147 + self.pdf.set_font("helvetica", "B", 15)
148 + self.pdf.set_text_color(*INK)
149 + self.pdf.set_x(self.pdf.l_margin)
150 + self.pdf.cell(0, 8, title)
151 + self.toc.append((title, self.pdf.page_no()))
152 + self.pdf.ln(11)
153 +
154 + # ---------- pages ----------
155 + def _cover(self):
156 + p = self.pdf
157 + p.cover_mode = True
158 + p.set_auto_page_break(False)
159 + p.add_page()
160 + p.set_fill_color(*PAPER)
161 + p.rect(0, 0, 210, 297, style="F")
162 + p.set_draw_color(*INK)
163 + p.set_line_width(1.0)
164 + p.rect(10, 10, 190, 277)
165 + # kicker
166 + p.set_font("helvetica", "B", 10)
167 + p.set_text_color(*GREEN)
168 + p.set_xy(24, 34)
169 + p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
170 + # wordmark : partie gauche + boîte encre/accent
171 + wm = self.site.get("wordmark", "")
172 + left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
173 + p.set_xy(24, 70)
174 + p.set_font("helvetica", "B", 40)
175 + p.set_text_color(*INK)
176 + p.cell(p.get_string_width(left) + 2, 20, left)
177 + if boxed:
178 + bw = p.get_string_width(boxed) + 12
179 + x = p.get_x() + 2
180 + p.set_fill_color(*INK)
181 + p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)
182 + p.set_text_color(*self.accent)
183 + p.set_xy(x + 6, 70)
184 + p.cell(bw - 12, 18, boxed)
185 + p.set_xy(24, 100)
186 + p.set_font("helvetica", "", 13)
187 + p.set_text_color(*INK2)
188 + p.multi_cell(150, 7, f"Rapport statistique — {wm}")
189 + now = datetime.now(ZoneInfo("America/Toronto"))
190 + per = self.d.get("period", {}) or {}
191 + p.set_xy(24, 125)
192 + p.set_font("helvetica", "", 10.5)
193 + rows = [
194 + ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
195 + ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
196 + ("Plateforme", "https://" + self.site.get("domain", "")),
197 + ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),
198 + ]
199 + y = 128
200 + for k, v in rows:
201 + p.set_xy(24, y)
202 + p.set_text_color(*INK3)
203 + p.cell(40, 6, k)
204 + p.set_text_color(*INK)
205 + p.set_font("helvetica", "B", 10.5)
206 + p.cell(0, 6, str(v))
207 + p.set_font("helvetica", "", 10.5)
208 + y += 8
209 + # bande encre au pied
210 + p.set_fill_color(*INK)
211 + p.rect(10, 262, 190, 25, style="F")
212 + p.set_xy(24, 270)
213 + p.set_font("helvetica", "B", 12)
214 + p.set_text_color(*WHITE)
215 + p.cell(60, 8, "par Groupe ")
216 + p.set_text_color(*self.accent)
217 + p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)
218 + p.cell(20, 8, "KA")
219 + p.set_font("helvetica", "B", 10)
220 + p.set_xy(24, 270)
221 + p.set_text_color(*self.accent)
222 + p.cell(162, 8, "groupe-ka.com", align="R")
223 + p.set_auto_page_break(True, margin=22)
224 + p.cover_mode = False
225 +
226 + def _kpis(self):
227 + kpis = self.d.get("kpis") or []
228 + if not kpis:
229 + return
230 + self._section_title("Synthèse des indicateurs")
231 + p = self.pdf
232 + cols, gw, gh, gap = 3, 56, 26, 3
233 + x0, y = p.l_margin, p.get_y()
234 + for i, k in enumerate(kpis[:9]):
235 + x = x0 + (i % cols) * (gw + gap)
236 + if i and i % cols == 0:
237 + y += gh + gap
238 + if y > 250:
239 + p.add_page(); y = p.get_y()
240 + self._card(x, y, gw, gh)
241 + p.set_xy(x + 4, y + 4)
242 + p.set_font("helvetica", "B", 14)
243 + p.set_text_color(*INK)
244 + val = k.get("value")
245 + p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))
246 + p.set_xy(x + 4, y + 12)
247 + p.set_font("helvetica", "", 7.6)
248 + p.set_text_color(*INK2)
249 + p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])
250 + if k.get("delta_pct") is not None:
251 + up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"
252 + p.set_xy(x + 4, y + gh - 6.5)
253 + p.set_font("helvetica", "B", 8)
254 + p.set_text_color(*(GREEN if up else DANGER))
255 + arrow = "+" if k["delta_pct"] >= 0 else ""
256 + p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")
257 + p.set_y(y + gh + 8)
258 +
259 + def _line_chart(self, s):
260 + p = self.pdf
261 + pts = s.get("points") or []
262 + if len(pts) < 2:
263 + return
264 + if p.get_y() > 200:
265 + p.add_page()
266 + p.set_font("helvetica", "B", 10)
267 + p.set_text_color(*INK)
268 + p.cell(0, 6, s.get("title", ""))
269 + p.ln(7)
270 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
271 + self._card(x0, y0, w, h, fill=WHITE)
272 + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
273 + vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]
274 + vmax = max(vals) or 1
275 + vmin = min(0, min(vals))
276 + rng = (vmax - vmin) or 1
277 + # grille + graduations
278 + p.set_font("helvetica", "", 6.3)
279 + p.set_text_color(*INK3)
280 + p.set_draw_color(200, 200, 195)
281 + p.set_line_width(0.15)
282 + for g in range(5):
283 + gy = cy + ch - ch * g / 4
284 + p.line(cx, gy, cx + cw, gy)
285 + p.set_xy(x0 + 1, gy - 1.6)
286 + p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
287 +
288 + def draw(series, color, width, dash=None):
289 + n = len(series)
290 + p.set_draw_color(*color)
291 + p.set_line_width(width)
292 + if dash:
293 + p.set_dash_pattern(dash=1.2, gap=1.2)
294 + last = None
295 + for i, pt in enumerate(series):
296 + px = cx + cw * (i / (n - 1))
297 + py = cy + ch - ch * ((pt["v"] - vmin) / rng)
298 + if last:
299 + p.line(last[0], last[1], px, py)
300 + last = (px, py)
301 + p.set_dash_pattern()
302 +
303 + if s.get("compare"):
304 + draw(s["compare"], INK3, 0.35, dash=True)
305 + draw(pts, self.accent, 0.7)
306 + # libellés d'axe X (premier / milieu / dernier)
307 + p.set_text_color(*INK3)
308 + for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
309 + p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
310 + p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")
311 + p.set_y(y0 + h + 4)
312 + if s.get("compare"):
313 + p.set_font("helvetica", "", 6.8)
314 + p.set_text_color(*INK3)
315 + p.cell(0, 4, "— période courante (accent) · ---- période comparée")
316 + p.ln(6)
317 + else:
318 + p.ln(2)
319 +
320 + def _bars(self, title, items, unit=""):
321 + p = self.pdf
322 + items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]
323 + if not items:
324 + return
325 + need = 10 + len(items) * 7
326 + if p.get_y() + need > 265:
327 + p.add_page()
328 + p.set_font("helvetica", "B", 10)
329 + p.set_text_color(*INK)
330 + p.cell(0, 6, title)
331 + p.ln(8)
332 + vmax = max(it["value"] for it in items) or 1
333 + for it in items:
334 + y = p.get_y()
335 + p.set_font("helvetica", "", 7.6)
336 + p.set_text_color(*INK)
337 + p.set_x(p.l_margin)
338 + p.cell(46, 5, str(it["label"])[:34])
339 + bw = 96 * (it["value"] / vmax)
340 + p.set_fill_color(*self.accent)
341 + p.set_draw_color(*INK)
342 + p.set_line_width(0.25)
343 + p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
344 + p.set_xy(p.l_margin + 148, y)
345 + p.set_font("helvetica", "B", 7.6)
346 + p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
347 + p.ln(6.4)
348 + p.ln(3)
349 +
350 + def _donut(self, b):
351 + # anneau vectoriel simple (arcs) + légende
352 + p = self.pdf
353 + items = [it for it in (b.get("items") or []) if it.get("value")][:8]
354 + total = sum(it["value"] for it in items)
355 + if not items or not total:
356 + return
357 + if p.get_y() > 210:
358 + p.add_page()
359 + p.set_font("helvetica", "B", 10)
360 + p.set_text_color(*INK)
361 + p.cell(0, 6, b.get("title", ""))
362 + p.ln(8)
363 + cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
364 + shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
365 + start = -90.0
366 + for i, it in enumerate(items):
367 + frac = it["value"] / total
368 + f = shades[i % len(shades)]
369 + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
370 + steps = max(2, int(72 * frac))
371 + p.set_fill_color(*col)
372 + p.set_draw_color(*col)
373 + for st in range(steps):
374 + a0 = math.radians(start + 360 * frac * st / steps)
375 + a1 = math.radians(start + 360 * frac * (st + 1) / steps)
376 + p.polygon(
377 + [(cx, cy),
378 + (cx + r * math.cos(a0), cy + r * math.sin(a0)),
379 + (cx + r * math.cos(a1), cy + r * math.sin(a1))],
380 + style="DF",
381 + )
382 + start += 360 * frac
383 + p.set_fill_color(*WHITE)
384 + p.set_draw_color(*INK)
385 + p.set_line_width(0.4)
386 + p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
387 + p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
388 + # légende
389 + ly = cy - 22
390 + for i, it in enumerate(items):
391 + f = shades[i % len(shades)]
392 + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
393 + p.set_fill_color(*col)
394 + p.set_draw_color(*INK)
395 + p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
396 + p.set_xy(p.l_margin + 66, ly)
397 + p.set_font("helvetica", "", 7.6)
398 + p.set_text_color(*INK)
399 + pct = 100 * it["value"] / total
400 + p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))
401 + ly += 5.6
402 + p.set_y(max(cy + r, ly) + 6)
403 +
404 + def _table(self, t):
405 + p = self.pdf
406 + cols = t.get("columns") or []
407 + rows = t.get("rows") or []
408 + if not cols or not rows:
409 + return
410 + self._section_title(t.get("title", "Tableau"))
411 + w = 174 / len(cols)
412 + def head():
413 + p.set_font("helvetica", "B", 7.6)
414 + p.set_fill_color(*INK)
415 + p.set_text_color(*WHITE)
416 + for c in cols:
417 + p.cell(w, 6, " " + str(c)[:30], fill=True)
418 + p.ln(6)
419 + head()
420 + p.set_text_color(*INK)
421 + for i, row in enumerate(rows[:200]):
422 + if p.get_y() > 262:
423 + p.add_page()
424 + head()
425 + p.set_text_color(*INK)
426 + p.set_font("helvetica", "", 7.4)
427 + p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))
428 + for cell in row:
429 + txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
430 + p.cell(w, 5.4, " " + txt[:34], fill=True)
431 + p.ln(5.4)
432 + if len(rows) > 200:
433 + p.set_font("helvetica", "", 7)
434 + p.set_text_color(*INK3)
435 + p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")
436 + p.ln(6)
437 +
438 + def _records(self):
439 + recs = self.d.get("records") or []
440 + if not recs:
441 + return
442 + self._section_title("Records & faits marquants")
443 + p = self.pdf
444 + for r in recs[:10]:
445 + if p.get_y() > 258:
446 + p.add_page()
447 + y = p.get_y()
448 + self._card(p.l_margin, y, 174, 11, fill=SURFACE2)
449 + p.set_xy(p.l_margin + 4, y + 2)
450 + p.set_font("helvetica", "", 8.6)
451 + p.set_text_color(*INK2)
452 + p.cell(96, 7, str(r.get("label", ""))[:70])
453 + p.set_font("helvetica", "B", 9)
454 + p.set_text_color(*INK)
455 + p.cell(52, 7, str(r.get("value", ""))[:36], align="R")
456 + p.set_font("helvetica", "", 7.6)
457 + p.set_text_color(*INK3)
458 + p.cell(20, 7, str(r.get("date", "") or ""), align="R")
459 + p.set_y(y + 13.5)
460 + p.ln(4)
461 +
462 + def _final_page(self):
463 + p = self.pdf
464 + p.add_page()
465 + self._kicker("Groupe KA · contact")
466 + p.set_font("helvetica", "B", 15)
467 + p.set_text_color(*INK)
468 + p.cell(0, 8, "Coordonnées du Groupe KA")
469 + p.ln(12)
470 + for email, role in EMAILS:
471 + p.set_font("helvetica", "B", 10.5)
472 + p.set_text_color(*INK)
473 + p.cell(0, 6, email)
474 + p.ln(5.5)
475 + p.set_font("helvetica", "", 8.6)
476 + p.set_text_color(*INK3)
477 + p.cell(0, 5, role)
478 + p.ln(8)
479 + p.ln(2)
480 + p.set_font("helvetica", "B", 10)
481 + p.set_text_color(*GREEN)
482 + p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")
483 + p.ln(10)
484 + p.set_draw_color(*self.accent)
485 + p.set_line_width(0.8)
486 + p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())
487 + p.ln(4)
488 + p.set_font("helvetica", "", 8.6)
489 + p.set_text_color(*INK2)
490 + p.multi_cell(160, 4.6, DISCLAIMER)
491 + p.ln(4)
492 + p.set_font("helvetica", "", 7.6)
493 + p.set_text_color(*INK3)
494 + p.multi_cell(
495 + 160, 4.2,
496 + "Mentions : rapport généré automatiquement à partir des données réelles de la "
497 + "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "
498 + "de confidentialité et protection des renseignements personnels (Loi 25) : "
499 + "groupe-ka.com/conditions · /confidentialite · /loi-25.",
500 + )
501 +
502 + def _toc_page(self):
503 + # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en
504 + # page 2 en réservant la page lors du build (voir build()).
505 + pass
506 +
507 + def build(self) -> bytes:
508 + p = self.pdf
509 + p.alias_nb_pages()
510 + self._cover()
511 + if self.mode == "synthese":
512 + p.add_page()
513 + self._kpis()
514 + self._records()
515 + self._final_page()
516 + else:
517 + p.add_page()
518 + toc_page_no = p.page_no()
519 + p.add_page()
520 + self._kpis()
521 + for s in self.d.get("series") or []:
522 + if s.get("kind") == "bar":
523 + self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))
524 + else:
525 + self._line_chart(s)
526 + for b in self.d.get("breakdowns") or []:
527 + if b.get("kind") == "donut":
528 + self._donut(b)
529 + else:
530 + self._bars(b.get("title", ""), b.get("items"))
531 + geo = self.d.get("geo")
532 + if geo:
533 + self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
534 + for t in self.d.get("tables") or []:
535 + self._table(t)
536 + self._records()
537 + self._final_page()
538 + # sommaire écrit sur la page réservée (page 2)
539 + last_page = p.page
540 + p.page = toc_page_no
541 + p.set_y(22)
542 + p.set_font("helvetica", "B", 15)
543 + p.set_text_color(*INK)
544 + p.cell(0, 8, "Sommaire")
545 + p.ln(12)
546 + p.set_font("helvetica", "", 9.5)
547 + for title, page_no in self.toc:
548 + p.set_text_color(*INK)
549 + p.cell(140, 6.5, title[:80])
550 + p.set_text_color(*INK3)
551 + p.cell(0, 6.5, str(page_no), align="R")
552 + p.ln(6.5)
553 + p.page = last_page
554 + return bytes(p.output())
555 +
556 +
557 +def filename(platform_id: str, period: str) -> str:
558 + today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
559 + return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"
added autoka/statsdash.py +399 −0
@@ -0,0 +1,399 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# statsdash.py : tableau de bord analytique — construit le JSON du contrat
5 +# commun Groupe KA (/api/stats/dashboard, voir ka/stats/SPEC.md)
6 +# à partir des données réelles (vehicles, price_log). Sert aussi
7 +# de source unique au rapport PDF (autoka/kapdf.py).
8 +#
9 +# Principes :
10 +# - AUCUNE statistique inventée : tout vient de la base SQLite.
11 +# - L'« inventaire à la date t » est reconstruit depuis le cycle de vie réel
12 +# des annonces : first_seen (arrivée) et updated_at des annonces
13 +# désactivées (retrait). Les prix historiques par jour utilisent le
14 +# dernier prix connu de chaque véhicule (approximation documentée).
15 +# - Les séries sont bornées au début réel des données (12 août 2026) :
16 +# avant, rien n'était mesuré — on ne trace pas de faux zéros.
17 +# - Cache serveur de 5 minutes par période.
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +import threading
22 +import time
23 +from collections import Counter
24 +from datetime import date, datetime, timedelta
25 +from zoneinfo import ZoneInfo
26 +
27 +from . import db
28 +
29 +TZ = ZoneInfo("America/Toronto")
30 +CACHE_TTL = 300 # 5 minutes
31 +
32 +_cache: dict[tuple, tuple[float, dict]] = {}
33 +_cache_lock = threading.Lock()
34 +
35 +PERIOD_LABELS = {
36 + "auj": "Aujourd'hui",
37 + "7j": "7 jours",
38 + "30j": "30 jours",
39 + "3m": "3 mois",
40 + "6m": "6 mois",
41 + "12m": "12 mois",
42 + "annee": "Année en cours",
43 + "tout": "Toute la période",
44 +}
45 +PERIOD_DAYS = {"auj": 1, "7j": 7, "30j": 30, "3m": 90, "6m": 180, "12m": 365}
46 +
47 +
48 +# --- utilitaires ---------------------------------------------------------------
49 +
50 +def _day_start(d: date) -> float:
51 + return datetime(d.year, d.month, d.day, tzinfo=TZ).timestamp()
52 +
53 +
54 +def _to_date(ts: float) -> date:
55 + return datetime.fromtimestamp(ts, TZ).date()
56 +
57 +
58 +def _fr_money(p) -> str:
59 + if p is None:
60 + return "—"
61 + return f"{int(round(p)):,}".replace(",", " ") + " $"
62 +
63 +
64 +def _fr_km(k) -> str:
65 + if k is None:
66 + return "—"
67 + return f"{int(round(k)):,}".replace(",", " ") + " km"
68 +
69 +
70 +def _delta(cur, prev) -> tuple[float | None, str | None]:
71 + """Variation en % vs période précédente ; None si non mesurable."""
72 + if cur is None or prev is None or not prev:
73 + return None, None
74 + pct = round(100.0 * (cur - prev) / prev, 1)
75 + return pct, ("up" if pct >= 0 else "down")
76 +
77 +
78 +def _resolve_period(period: str, from_: str | None, to: str | None):
79 + """(start_ts, end_ts, prev_start_ts, prev_end_ts, label, from_iso, to_iso)."""
80 + now = datetime.now(TZ)
81 + today = now.date()
82 + if from_ and to:
83 + d0 = date.fromisoformat(from_)
84 + d1 = date.fromisoformat(to)
85 + if d1 < d0:
86 + d0, d1 = d1, d0
87 + start = _day_start(d0)
88 + end = min(now.timestamp(), _day_start(d1 + timedelta(days=1)))
89 + label = f"du {d0.isoformat()} au {d1.isoformat()}"
90 + elif period == "tout":
91 + con = db.connect()
92 + row = con.execute("SELECT MIN(first_seen) m FROM vehicles").fetchone()
93 + con.close()
94 + start = row["m"] or now.timestamp()
95 + end = now.timestamp()
96 + label = PERIOD_LABELS["tout"]
97 + elif period == "annee":
98 + start = _day_start(date(today.year, 1, 1))
99 + end = now.timestamp()
100 + label = f"Année {today.year}"
101 + else:
102 + days = PERIOD_DAYS.get(period, 30)
103 + start = _day_start(today - timedelta(days=days - 1))
104 + end = now.timestamp()
105 + label = PERIOD_LABELS.get(period, PERIOD_LABELS["30j"])
106 + span = max(end - start, 1.0)
107 + return start, end, start - span, start, label, \
108 + _to_date(start).isoformat(), _to_date(end - 1).isoformat()
109 +
110 +
111 +# --- reconstruction de l'inventaire par jour ------------------------------------
112 +
113 +def _lifecycle_rows(con) -> list[tuple[float, float | None, float | None]]:
114 + """(arrivée, retrait|None, prix courant|None) pour chaque annonce auto."""
115 + return [
116 + (r["first_seen"], r["updated_at"] if not r["active"] else None, r["price"])
117 + for r in con.execute(
118 + "SELECT first_seen, active, updated_at, price FROM vehicles"
119 + " WHERE kind='auto' AND first_seen IS NOT NULL")
120 + ]
121 +
122 +
123 +def _daily_series(rows, start_ts: float, end_ts: float):
124 + """Par jour : inventaire actif, prix moyen de l'inventaire, nouveautés.
125 +
126 + Balayage d'événements (arrivées/retraits triés) — l'inventaire au soir du
127 + jour J = annonces arrivées avant la fin de J et pas encore retirées.
128 + """
129 + if not rows:
130 + return []
131 + data_start = min(r[0] for r in rows)
132 + d = max(_to_date(start_ts), _to_date(data_start))
133 + d_end = _to_date(end_ts - 1)
134 + if d > d_end:
135 + return []
136 + adds = sorted(rows, key=lambda r: r[0])
137 + rems = sorted((r for r in rows if r[1] is not None), key=lambda r: r[1])
138 + new_by_day = Counter(_to_date(r[0]) for r in rows)
139 + ai = ri = count = n_price = 0
140 + sum_price = 0.0
141 + out = []
142 + while d <= d_end:
143 + cutoff = _day_start(d + timedelta(days=1))
144 + while ai < len(adds) and adds[ai][0] < cutoff:
145 + count += 1
146 + if adds[ai][2] is not None:
147 + sum_price += adds[ai][2]
148 + n_price += 1
149 + ai += 1
150 + while ri < len(rems) and rems[ri][1] < cutoff:
151 + count -= 1
152 + if rems[ri][2] is not None:
153 + sum_price -= rems[ri][2]
154 + n_price -= 1
155 + ri += 1
156 + out.append({
157 + "t": d.isoformat(),
158 + "inv": count,
159 + "avg_price": round(sum_price / n_price) if n_price else None,
160 + "new": new_by_day.get(d, 0),
161 + })
162 + d += timedelta(days=1)
163 + return out
164 +
165 +
166 +def _snapshot(con, t: float):
167 + """Indicateurs de l'inventaire actif reconstitué à l'instant t."""
168 + return dict(con.execute(
169 + """SELECT COUNT(*) n, AVG(price) avg_price, AVG(mileage_km) avg_km,
170 + AVG(year) avg_year, COUNT(DISTINCT dealer_name) dealers
171 + FROM vehicles WHERE kind='auto' AND first_seen<=?
172 + AND (active=1 OR updated_at>?)""", (t, t)).fetchone())
173 +
174 +
175 +# --- construction du dashboard ---------------------------------------------------
176 +
177 +def _build(period: str, from_: str | None, to: str | None) -> dict:
178 + start, end, pstart, pend, label, f_iso, t_iso = _resolve_period(period, from_, to)
179 + con = db.connect()
180 + now_ts = time.time()
181 +
182 + # --- KPI : maintenant vs début de période / période précédente -------------
183 + cur = _snapshot(con, min(end, now_ts))
184 + # Pas de delta si la période commence avant le début réel des données :
185 + # l'inventaire de référence n'existait pas encore (rien d'inventé).
186 + data_start = con.execute(
187 + "SELECT MIN(first_seen) m FROM vehicles WHERE kind='auto'").fetchone()["m"]
188 + if data_start is not None and start > data_start:
189 + ref = _snapshot(con, start)
190 + else:
191 + ref = {"n": None, "avg_price": None, "avg_km": None,
192 + "avg_year": None, "dealers": None}
193 +
194 + def _count(sql, args):
195 + return con.execute(sql, args).fetchone()["n"]
196 +
197 + new_cur = _count("SELECT COUNT(*) n FROM vehicles WHERE kind='auto'"
198 + " AND first_seen>=? AND first_seen<?", (start, end))
199 + new_prev = _count("SELECT COUNT(*) n FROM vehicles WHERE kind='auto'"
200 + " AND first_seen>=? AND first_seen<?", (pstart, pend))
201 + gone_cur = _count("SELECT COUNT(*) n FROM vehicles WHERE kind='auto'"
202 + " AND active=0 AND updated_at>=? AND updated_at<?", (start, end))
203 + gone_prev = _count("SELECT COUNT(*) n FROM vehicles WHERE kind='auto'"
204 + " AND active=0 AND updated_at>=? AND updated_at<?", (pstart, pend))
205 +
206 + def _kpi(id_, label_, value, unit="", prev=None):
207 + pct, direction = _delta(value, prev)
208 + return {"id": id_, "label": label_, "value": value, "unit": unit,
209 + "delta_pct": pct, "direction": direction}
210 +
211 + kpis = [
212 + _kpi("actifs", "Véhicules actifs", cur["n"], "", ref["n"]),
213 + _kpi("nouveaux", "Nouveaux véhicules (période)", new_cur, "", new_prev),
214 + _kpi("retires", "Vendus / retirés (période)", gone_cur, "", gone_prev),
215 + _kpi("prix", "Prix moyen (inventaire actif)",
216 + round(cur["avg_price"]) if cur["avg_price"] else None, "$",
217 + round(ref["avg_price"]) if ref["avg_price"] else None),
218 + _kpi("km", "Km moyen (inventaire actif)",
219 + round(cur["avg_km"]) if cur["avg_km"] else None, "km",
220 + round(ref["avg_km"]) if ref["avg_km"] else None),
221 + # année moyenne : valeur pré-formatée (« 2021,1 » — pas de séparateur
222 + # de milliers) ; un delta en % n'aurait aucun sens sur un millésime.
223 + _kpi("annee", "Année-modèle moyenne",
224 + f"{cur['avg_year']:.1f}".replace(".", ",")
225 + if cur["avg_year"] else None, ""),
226 + _kpi("dealers", "Concessionnaires actifs", cur["dealers"], "", ref["dealers"]),
227 + ]
228 + kpis = [k for k in kpis if k["value"] is not None]
229 +
230 + # --- séries quotidiennes ---------------------------------------------------
231 + rows = _lifecycle_rows(con)
232 + daily = _daily_series(rows, start, end)
233 + prev_daily = _daily_series(rows, pstart, pend)
234 + full_prev = len(prev_daily) == len(daily) and len(daily) > 1
235 +
236 + series = []
237 + if daily:
238 + series.append({
239 + "id": "inv", "title": "Inventaire actif par jour", "unit": "véhicules",
240 + "kind": "line", "points": [{"t": p["t"], "v": p["inv"]} for p in daily],
241 + **({"compare": [{"t": p["t"], "v": p["inv"]} for p in prev_daily]}
242 + if full_prev else {}),
243 + })
244 + series.append({
245 + "id": "new", "title": "Nouveaux véhicules par jour", "unit": "véhicules",
246 + "kind": "line", "points": [{"t": p["t"], "v": p["new"]} for p in daily],
247 + **({"compare": [{"t": p["t"], "v": p["new"]} for p in prev_daily]}
248 + if full_prev else {}),
249 + })
250 + price_pts = [{"t": p["t"], "v": p["avg_price"]} for p in daily
251 + if p["avg_price"] is not None]
252 + if price_pts:
253 + series.append({
254 + "id": "avg_price", "title": "Prix moyen de l'inventaire par jour",
255 + "unit": "$", "kind": "line", "points": price_pts,
256 + })
257 +
258 + # --- répartitions (inventaire actif) ----------------------------------------
259 + def _items(sql, args=()):
260 + return [{"label": r[0] or "Non précisé", "value": r[1]}
261 + for r in con.execute(sql, args).fetchall()]
262 +
263 + by_fuel = _items(
264 + "SELECT CASE WHEN fuel IN ('', 'N.D.') THEN 'Non précisé' ELSE fuel END f,"
265 + " COUNT(*) FROM vehicles WHERE active=1 AND kind='auto'"
266 + " GROUP BY f ORDER BY COUNT(*) DESC")
267 + by_trans = _items(
268 + "SELECT CASE WHEN transmission IN ('', 'NA', 'N.D.') THEN 'Non précisé'"
269 + " ELSE transmission END t, COUNT(*) FROM vehicles"
270 + " WHERE active=1 AND kind='auto' GROUP BY t ORDER BY COUNT(*) DESC LIMIT 8")
271 + by_make = _items(
272 + "SELECT make, COUNT(*) FROM vehicles WHERE active=1 AND kind='auto'"
273 + " AND make<>'' GROUP BY make ORDER BY COUNT(*) DESC LIMIT 12")
274 + year_rows = con.execute(
275 + "SELECT year, COUNT(*) n FROM vehicles WHERE active=1 AND kind='auto'"
276 + " AND year IS NOT NULL GROUP BY year ORDER BY year").fetchall()
277 + # 11 années récentes + un groupe « antérieures » = 12 barres max, aucune
278 + # année récente escamotée par la limite d'affichage des graphiques.
279 + by_year, older = [], 0
280 + cutoff = max((r["year"] for r in year_rows), default=0) - 10
281 + for r in year_rows:
282 + if r["year"] < cutoff:
283 + older += r["n"]
284 + else:
285 + by_year.append({"label": str(r["year"]), "value": r["n"]})
286 + if older:
287 + by_year.insert(0, {"label": f"≤ {cutoff - 1}", "value": older})
288 +
289 + breakdowns = [
290 + {"id": "fuel", "title": "Par carburant", "kind": "donut", "items": by_fuel},
291 + {"id": "trans", "title": "Par boîte de vitesses", "kind": "bar", "items": by_trans},
292 + {"id": "make", "title": "Top 12 des marques", "kind": "bar", "items": by_make},
293 + {"id": "year", "title": "Par année-modèle", "kind": "bar", "items": by_year},
294 + ]
295 + breakdowns = [b for b in breakdowns if b["items"]]
296 +
297 + geo = {"title": "Par région", "items": _items(
298 + "SELECT region, COUNT(*) FROM vehicles WHERE active=1 AND kind='auto'"
299 + " AND region<>'' GROUP BY region ORDER BY COUNT(*) DESC")}
300 +
301 + heatmap = {"title": "Nouveaux véhicules par jour",
302 + "cells": [{"date": p["t"], "value": p["new"]} for p in daily]}
303 +
304 + # --- tableaux détaillés (inventaire actif) ------------------------------------
305 + def _table_rows(sql):
306 + return [list(r) for r in con.execute(sql).fetchall()]
307 +
308 + tables = [
309 + {"id": "makes", "title": "Top marques — volume, prix et km moyens",
310 + "columns": ["Marque", "Véhicules", "Prix moyen", "Km moyen"],
311 + "rows": [[m, n, _fr_money(p), _fr_km(k)] for m, n, p, k in _table_rows(
312 + "SELECT make, COUNT(*), AVG(price), AVG(mileage_km) FROM vehicles"
313 + " WHERE active=1 AND kind='auto' AND make<>''"
314 + " GROUP BY make ORDER BY COUNT(*) DESC LIMIT 25")]},
315 + {"id": "models", "title": "Top modèles — volume, prix et km moyens",
316 + "columns": ["Modèle", "Véhicules", "Prix moyen", "Km moyen"],
317 + "rows": [[m, n, _fr_money(p), _fr_km(k)] for m, n, p, k in _table_rows(
318 + "SELECT make || ' ' || model, COUNT(*), AVG(price), AVG(mileage_km)"
319 + " FROM vehicles WHERE active=1 AND kind='auto' AND make<>''"
320 + " AND model<>'' GROUP BY make, model ORDER BY COUNT(*) DESC LIMIT 25")]},
321 + {"id": "dealers", "title": "Top concessionnaires — inventaire et prix moyen",
322 + "columns": ["Concessionnaire", "Région", "Véhicules", "Prix moyen"],
323 + "rows": [[d, rg or "—", n, _fr_money(p)] for d, rg, n, p in _table_rows(
324 + "SELECT dealer_name, MAX(region), COUNT(*), AVG(price) FROM vehicles"
325 + " WHERE active=1 AND kind='auto' AND dealer_name<>''"
326 + " GROUP BY dealer_name ORDER BY COUNT(*) DESC LIMIT 25")]},
327 + ]
328 +
329 + # --- records & faits marquants -------------------------------------------------
330 + records = []
331 + if daily:
332 + best = max(daily, key=lambda p: p["new"])
333 + if best["new"]:
334 + records.append({"label": "Jour record d'arrivées",
335 + "value": f"{best['new']:,}".replace(",", " ") + " véhicules",
336 + "date": best["t"]})
337 + fastest = con.execute(
338 + """SELECT title, year, updated_at - first_seen dur, updated_at
339 + FROM vehicles WHERE kind='auto' AND active=0
340 + AND updated_at>=? AND updated_at<? AND updated_at>first_seen
341 + ORDER BY dur ASC LIMIT 1""", (start, end)).fetchone()
342 + if fastest:
343 + days_ = fastest["dur"] / 86400
344 + dur_txt = (f"{fastest['dur'] / 3600:.0f} h" if days_ < 1
345 + else f"{days_:.1f} jours".replace(".", ","))
346 + records.append({"label": f"Vente la plus rapide — {fastest['title']}",
347 + "value": dur_txt,
348 + "date": _to_date(fastest["updated_at"]).isoformat()})
349 + drop = con.execute(
350 + """WITH pl AS (
351 + SELECT uid, ts, price,
352 + LAG(price) OVER (PARTITION BY uid ORDER BY ts) prev_price
353 + FROM price_log WHERE price IS NOT NULL)
354 + SELECT v.title, pl.prev_price - pl.price baisse, pl.ts
355 + FROM pl JOIN vehicles v ON v.uid=pl.uid AND v.kind='auto'
356 + WHERE pl.ts>=? AND pl.ts<? AND pl.prev_price > pl.price
357 + ORDER BY baisse DESC LIMIT 1""", (start, end)).fetchone()
358 + if drop:
359 + records.append({"label": f"Plus forte baisse de prix — {drop['title']}",
360 + "value": "−" + _fr_money(drop["baisse"]),
361 + "date": _to_date(drop["ts"]).isoformat()})
362 + busiest = con.execute(
363 + """SELECT dealer_name, COUNT(*) n FROM vehicles
364 + WHERE kind='auto' AND dealer_name<>'' AND first_seen>=? AND first_seen<?
365 + GROUP BY dealer_name ORDER BY n DESC LIMIT 1""", (start, end)).fetchone()
366 + if busiest and busiest["n"]:
367 + records.append({"label": f"Concessionnaire le plus actif — {busiest['dealer_name']}",
368 + "value": f"{busiest['n']:,}".replace(",", " ") + " nouveautés",
369 + "date": None})
370 +
371 + con.close()
372 + return {
373 + "updated": datetime.now(TZ).isoformat(timespec="seconds"),
374 + "period": {"from": f_iso, "to": t_iso, "label": label},
375 + "kpis": kpis,
376 + "series": series,
377 + "breakdowns": breakdowns,
378 + "geo": geo,
379 + "heatmap": heatmap,
380 + "tables": tables,
381 + "records": records,
382 + }
383 +
384 +
385 +def dashboard(period: str = "30j", from_: str | None = None,
386 + to: str | None = None) -> dict:
387 + """Dashboard du contrat SPEC — mis en cache 5 minutes par période."""
388 + if period not in PERIOD_LABELS and not (from_ and to):
389 + period = "30j"
390 + key = (period, from_ or "", to or "")
391 + now = time.time()
392 + with _cache_lock:
393 + hit = _cache.get(key)
394 + if hit and now - hit[0] < CACHE_TTL:
395 + return hit[1]
396 + data = _build(period, from_, to)
397 + with _cache_lock:
398 + _cache[key] = (now, data)
399 + return data
modified autoka/web.py +46 −0
@@ -260,6 +260,52 @@ def stats_detailed():
260 260 return marketstats.compute()
261 261
262 262
263 +# --- Module Stats commun Groupe KA (contrat ka/stats/SPEC.md) ----------------
264 +
265 +KA_SITE = {
266 + "wordmark": "Auto·Ka",
267 + "accent": "#ff5a2a",
268 + "domain": "www.auto-ka.com",
269 + "tagline": "Voitures usagées · tout le Québec · toujours à jour",
270 +}
271 +
272 +
273 +@app.get("/api/stats/dashboard")
274 +def stats_dashboard(
275 + period: str = "30j",
276 + from_: str | None = Query(None, alias="from"),
277 + to: str | None = None,
278 +):
279 + """Tableau de bord analytique — contrat commun Groupe KA (cache 5 min)."""
280 + from . import statsdash
281 + try:
282 + return statsdash.dashboard(period, from_, to)
283 + except ValueError:
284 + raise HTTPException(400, "Dates invalides (format attendu : YYYY-MM-DD)")
285 +
286 +
287 +@app.get("/api/stats/report")
288 +def stats_report(
289 + period: str = "30j",
290 + from_: str | None = Query(None, alias="from"),
291 + to: str | None = None,
292 + mode: str = "complet",
293 +):
294 + """Rapport PDF estampillé Groupe-KA (complet ou synthèse 2 pages)."""
295 + from fastapi.responses import Response
296 + from . import kapdf, statsdash
297 + try:
298 + dash = statsdash.dashboard(period, from_, to)
299 + except ValueError:
300 + raise HTTPException(400, "Dates invalides (format attendu : YYYY-MM-DD)")
301 + mode = "synthese" if mode == "synthese" else "complet"
302 + pdf = kapdf.GroupeKAReport(site=KA_SITE, dashboard=dash, mode=mode).build()
303 + return Response(
304 + content=pdf, media_type="application/pdf",
305 + headers={"Content-Disposition":
306 + f'attachment; filename="{kapdf.filename("auto-ka", period)}"'})
307 +
308 +
263 309 @app.get("/api/stats/rapport.pdf")
264 310 def rapport_pdf():
265 311 """Rapport PDF « Le marché de l'occasion » — vue d'ensemble."""
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 +391 −0
@@ -0,0 +1,391 @@
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}
129 + textAnchor={i === 0 ? "start" : i === pts.length - 1 ? "end" : "middle"}
130 + fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
131 + ))}
132 + {!hide.cmp && serie.compare && serie.compare.length > 1 && (
133 + <path d={path(serie.compare)} fill="none" stroke="var(--ink-3)" strokeWidth={1.4} strokeDasharray="4 4" />
134 + )}
135 + {!hide.cur && <path d={path(pts)} fill="none" stroke="var(--accent)" strokeWidth={2.4} />}
136 + {hi !== null && (
137 + <g>
138 + <line x1={X(hi, pts.length)} x2={X(hi, pts.length)} y1={PT} y2={H - PB} stroke="var(--ink)" strokeWidth={1} strokeDasharray="2 3" />
139 + <circle cx={X(hi, pts.length)} cy={Y(pts[hi].v)} r={4} fill="var(--accent)" stroke="var(--ink)" strokeWidth={1.5} />
140 + </g>
141 + )}
142 + </svg>
143 + {hi !== null && (
144 + <p className="chip" style={{ marginTop: 8 }}>
145 + {pts[hi].t} — <b>{fmtNum(pts[hi].v)}{serie.unit ? ` ${serie.unit}` : ""}</b>
146 + {serie.compare?.[hi] && !hide.cmp ? <span style={{ color: "var(--ink-3)" }}> · N-1 : {fmtNum(serie.compare[hi].v)}</span> : null}
147 + </p>
148 + )}
149 + </figure>
150 + );
151 +}
152 +
153 +function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off: boolean; dashed?: boolean; onClick: () => void }) {
154 + return (
155 + <button type="button" onClick={onClick} aria-pressed={!off}
156 + 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 }}>
157 + <span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} />
158 + {label}
159 + </button>
160 + );
161 +}
162 +
163 +/* ---------- Barres horizontales (répartitions, géo) ---------- */
164 +export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) {
165 + const rows = (items ?? []).slice(0, 14);
166 + if (!rows.length) return <EmptyBlock title={title} />;
167 + const max = Math.max(...rows.map((r) => r.value), 1);
168 + return (
169 + <figure className="card" style={{ margin: 0, padding: 16 }}>
170 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption>
171 + <div style={{ marginTop: 12, display: "grid", gap: 9 }}>
172 + {rows.map((r) => (
173 + <div key={r.label} title={`${r.label} — ${fmtNum(r.value)}${unit ? ` ${unit}` : ""}`}>
174 + <div style={{ display: "flex", justifyContent: "space-between", fontSize: 12.5 }}>
175 + <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
176 + <b style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}>{fmtNum(r.value)}{unit ? ` ${unit}` : ""}</b>
177 + </div>
178 + <div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}>
179 + <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" }} />
180 + </div>
181 + </div>
182 + ))}
183 + </div>
184 + </figure>
185 + );
186 +}
187 +
188 +/* ---------- Anneau ---------- */
189 +export function Donut({ title, items }: { title: string; items: BreakItem[] }) {
190 + const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8);
191 + const total = rows.reduce((s, r) => s + r.value, 0);
192 + if (!total) return <EmptyBlock title={title} />;
193 + const R = 74, C = 2 * Math.PI * R;
194 + let acc = 0;
195 + const shades = [1, 0.78, 0.58, 0.42, 0.3, 0.22, 0.15, 0.1];
196 + return (
197 + <figure className="card" style={{ margin: 0, padding: 16 }}>
198 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption>
199 + <div style={{ display: "flex", flexWrap: "wrap", gap: 18, alignItems: "center", marginTop: 12 }}>
200 + <svg viewBox="0 0 200 200" style={{ width: 180, maxWidth: "100%" }} role="img" aria-label={title}>
201 + {rows.map((r, i) => {
202 + const frac = r.value / total;
203 + const off = acc; acc += frac;
204 + return (
205 + <circle key={r.label} cx={100} cy={100} r={R} fill="none"
206 + stroke="var(--accent)" strokeOpacity={shades[i % shades.length]}
207 + strokeWidth={30} strokeDasharray={`${frac * C} ${C}`} strokeDashoffset={-off * C}
208 + transform="rotate(-90 100 100)">
209 + <title>{`${r.label} — ${fmtNum(r.value)} (${((100 * r.value) / total).toFixed(1)} %)`}</title>
210 + </circle>
211 + );
212 + })}
213 + <circle cx={100} cy={100} r={R} fill="none" stroke="var(--ink)" strokeWidth={1} opacity={0.5} />
214 + </svg>
215 + <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 6, minWidth: 200, flex: 1 }}>
216 + {rows.map((r, i) => (
217 + <li key={r.label} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5 }}>
218 + <span style={{ width: 11, height: 11, borderRadius: 3, border: "1px solid var(--ink)", background: "var(--accent)", opacity: shades[i % shades.length] }} />
219 + <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
220 + <b style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}>{((100 * r.value) / total).toFixed(1)} %</b>
221 + </li>
222 + ))}
223 + </ul>
224 + </div>
225 + </figure>
226 + );
227 +}
228 +
229 +/* ---------- Calendrier de chaleur ---------- */
230 +export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) {
231 + if (!cells?.length) return <EmptyBlock title={title} />;
232 + const byDate = new Map(cells.map((c) => [c.date, c.value]));
233 + const dates = cells.map((c) => c.date).sort();
234 + const end = new Date(dates[dates.length - 1] + "T12:00:00");
235 + const max = Math.max(...cells.map((c) => c.value), 1);
236 + const weeks = 26, cols: { date: string; v: number }[][] = [];
237 + const cur = new Date(end);
238 + cur.setDate(cur.getDate() - (weeks * 7 - 1));
239 + for (let w = 0; w < weeks; w++) {
240 + const col: { date: string; v: number }[] = [];
241 + for (let d = 0; d < 7; d++) {
242 + const iso = cur.toISOString().slice(0, 10);
243 + col.push({ date: iso, v: byDate.get(iso) ?? 0 });
244 + cur.setDate(cur.getDate() + 1);
245 + }
246 + cols.push(col);
247 + }
248 + return (
249 + <figure className="card" style={{ margin: 0, padding: 16 }}>
250 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b> <span className="klabel">26 dernières semaines</span></figcaption>
251 + <div className="tbl-wrap" style={{ marginTop: 12 }}>
252 + <svg viewBox={`0 0 ${weeks * 14} ${7 * 14}`} style={{ minWidth: 480, width: "100%", height: "auto" }} role="img" aria-label={title}>
253 + {cols.map((col, w) => col.map((c, d) => (
254 + <rect key={c.date} x={w * 14} y={d * 14} width={12} height={12} rx={2.5}
255 + fill={c.v ? "var(--accent)" : "rgba(20,24,20,0.07)"} fillOpacity={c.v ? 0.25 + 0.75 * (c.v / max) : 1}
256 + stroke="rgba(20,24,20,0.15)" strokeWidth={0.5}>
257 + <title>{`${c.date} — ${fmtNum(c.v)}`}</title>
258 + </rect>
259 + )))}
260 + </svg>
261 + </div>
262 + </figure>
263 + );
264 +}
265 +
266 +/* ---------- Tableau : tri, recherche, pagination ---------- */
267 +export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) {
268 + const [q, setQ] = useState("");
269 + const [sort, setSort] = useState<{ col: number; dir: 1 | -1 } | null>(null);
270 + const [page, setPage] = useState(0);
271 + const rows = useMemo(() => {
272 + let r = spec.rows ?? [];
273 + if (q) r = r.filter((row) => row.some((c) => String(c).toLowerCase().includes(q.toLowerCase())));
274 + if (sort) r = [...r].sort((a, b) => {
275 + const x = a[sort.col], y = b[sort.col];
276 + const nx = typeof x === "number" ? x : parseFloat(String(x).replace(/[^\d.,-]/g, "").replace(",", "."));
277 + const ny = typeof y === "number" ? y : parseFloat(String(y).replace(/[^\d.,-]/g, "").replace(",", "."));
278 + if (!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx - ny) * sort.dir;
279 + return String(x).localeCompare(String(y), "fr") * sort.dir;
280 + });
281 + return r;
282 + }, [spec.rows, q, sort]);
283 + const pages = Math.max(1, Math.ceil(rows.length / pageSize));
284 + const cur = Math.min(page, pages - 1);
285 + return (
286 + <section className="card" style={{ padding: 16 }}>
287 + <div style={{ display: "flex", flexWrap: "wrap", gap: 10, justifyContent: "space-between", alignItems: "center" }}>
288 + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{spec.title}</b>
289 + <input className="input" style={{ maxWidth: 240 }} placeholder="Rechercher…" value={q}
290 + onChange={(e) => { setQ(e.target.value); setPage(0); }} aria-label={`Rechercher dans ${spec.title}`} />
291 + </div>
292 + <div className="tbl-wrap" style={{ marginTop: 10 }}>
293 + <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
294 + <thead>
295 + <tr>
296 + {spec.columns.map((c, i) => (
297 + <th key={c} onClick={() => setSort((s) => ({ col: i, dir: s?.col === i && s.dir === 1 ? -1 : 1 }))}
298 + 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" }}
299 + aria-sort={sort?.col === i ? (sort.dir === 1 ? "ascending" : "descending") : "none"}>
300 + {c} {sort?.col === i ? (sort.dir === 1 ? "▲" : "▼") : "↕"}
301 + </th>
302 + ))}
303 + </tr>
304 + </thead>
305 + <tbody>
306 + {rows.slice(cur * pageSize, (cur + 1) * pageSize).map((row, ri) => (
307 + <tr key={ri} style={{ background: ri % 2 ? "var(--surface-2)" : "var(--surface)" }}>
308 + {row.map((c, ci) => (
309 + <td key={ci} style={{ padding: "7px 10px", borderBottom: "1px solid var(--line)", whiteSpace: "nowrap" }}>
310 + {typeof c === "number" ? fmtNum(c) : c}
311 + </td>
312 + ))}
313 + </tr>
314 + ))}
315 + </tbody>
316 + </table>
317 + </div>
318 + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, flexWrap: "wrap", gap: 8 }}>
319 + <span className="klabel">{fmtInt(rows.length)} lignes</span>
320 + <span style={{ display: "flex", gap: 6 }}>
321 + <button type="button" className="btn btn-ghost" disabled={cur === 0} onClick={() => setPage(cur - 1)}>←</button>
322 + <span className="chip">{cur + 1} / {pages}</span>
323 + <button type="button" className="btn btn-ghost" disabled={cur >= pages - 1} onClick={() => setPage(cur + 1)}>→</button>
324 + </span>
325 + </div>
326 + </section>
327 + );
328 +}
329 +
330 +/* ---------- Records / faits marquants ---------- */
331 +export function RecordCard({ r }: { r: RecordFact }) {
332 + return (
333 + <article className="card" style={{ padding: "12px 16px", display: "flex", justifyContent: "space-between", gap: 12, alignItems: "baseline", background: "var(--surface-2)" }}>
334 + <span style={{ fontSize: 13, color: "var(--ink-2)" }}>{r.label}</span>
335 + <span style={{ textAlign: "right" }}>
336 + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{r.value}</b>
337 + {r.date && <span className="klabel" style={{ display: "block" }}>{r.date}</span>}
338 + </span>
339 + </article>
340 + );
341 +}
342 +
343 +/* ---------- Bouton PDF ---------- */
344 +export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) {
345 + const [busy, setBusy] = useState(false);
346 + const url = (mode: string) => {
347 + const p = new URLSearchParams({ period, mode });
348 + if (from) p.set("from", from);
349 + if (to) p.set("to", to);
350 + return `${endpoint}?${p}`;
351 + };
352 + const dl = (mode: string) => {
353 + setBusy(true);
354 + const a = document.createElement("a");
355 + a.href = url(mode);
356 + a.download = "";
357 + document.body.appendChild(a);
358 + a.click();
359 + a.remove();
360 + setTimeout(() => setBusy(false), 2500);
361 + };
362 + return (
363 + <span style={{ display: "inline-flex", gap: 8, flexWrap: "wrap" }}>
364 + <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={busy}>
365 + {busy ? "Génération…" : "⬇ Télécharger le rapport PDF"}
366 + </button>
367 + <button type="button" className="btn btn-ghost" onClick={() => dl("synthese")} disabled={busy}>
368 + Synthèse (2 p.)
369 + </button>
370 + </span>
371 + );
372 +}
373 +
374 +/* ---------- États ---------- */
375 +export function EmptyBlock({ title }: { title: string }) {
376 + return (
377 + <div className="card" style={{ padding: 20, background: "var(--surface-2)", borderStyle: "dashed" }}>
378 + <b style={{ fontFamily: "var(--font-display)", fontSize: 14 }}>{title}</b>
379 + <p className="klabel" style={{ margin: "6px 0 0" }}>Pas encore mesuré — aucune donnée disponible pour cette période.</p>
380 + </div>
381 + );
382 +}
383 +
384 +export function Fraicheur({ updated, onRefresh }: { updated: string; onRefresh: () => void }) {
385 + return (
386 + <p style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap", margin: 0 }}>
387 + <span className="klabel">Mis à jour le {new Date(updated).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" })}</span>
388 + <button type="button" className="btn btn-ghost" onClick={onRefresh}>↻ Rafraîchir</button>
389 + </p>
390 + );
391 +}
added frontend/src/ka/stats/kapdf.py +559 −0
@@ -0,0 +1,559 @@
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 +
60 +def _latin1(s: str) -> str:
61 + for k, v in _SUBST.items():
62 + s = s.replace(k, v)
63 + return s.encode("latin-1", "replace").decode("latin-1")
64 +
65 +
66 +class _PDF(FPDF):
67 + """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
68 + Les polices core sont latin-1 : normalize_text sanitise en amont."""
69 +
70 + def normalize_text(self, text):
71 + return super().normalize_text(_latin1(text))
72 +
73 + def __init__(self, brand: str, accent: tuple, period_label: str):
74 + super().__init__(orientation="P", unit="mm", format="A4")
75 + self.brand = brand
76 + self.accent = accent
77 + self.period_label = period_label
78 + self.cover_mode = False
79 + self.set_margins(18, 20, 18)
80 + self.set_auto_page_break(True, margin=22)
81 +
82 + def header(self):
83 + if self.cover_mode:
84 + return
85 + self.set_font("helvetica", "B", 8.5)
86 + self.set_text_color(*INK)
87 + self.set_xy(18, 9)
88 + self.cell(0, 5, f"Groupe KA · {self.brand}")
89 + self.set_font("helvetica", "", 8)
90 + self.set_text_color(*INK3)
91 + self.set_xy(18, 9)
92 + self.cell(0, 5, "Rapport statistique", align="R")
93 + self.set_draw_color(*INK)
94 + self.set_line_width(0.5)
95 + self.line(18, 15.5, 192, 15.5)
96 + self.set_y(20)
97 +
98 + def footer(self):
99 + if self.cover_mode:
100 + return
101 + self.set_y(-15)
102 + self.set_draw_color(*INK3)
103 + self.set_line_width(0.2)
104 + self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)
105 + self.set_font("helvetica", "", 7.5)
106 + self.set_text_color(*INK3)
107 + year = datetime.now(ZoneInfo("America/Toronto")).year
108 + self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")
109 + self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")
110 +
111 +
112 +class GroupeKAReport:
113 + def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
114 + self.site = site
115 + self.d = dashboard
116 + self.mode = mode
117 + self.accent = _hex(site.get("accent", "#d9f26b"))
118 + period = dashboard.get("period", {}) or {}
119 + self.period_label = period.get("label") or "toute la période"
120 + self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
121 + self.toc: list[tuple[str, int]] = []
122 +
123 + # ---------- primitives ----------
124 + def _card(self, x, y, w, h, fill=WHITE):
125 + p = self.pdf
126 + p.set_draw_color(*INK)
127 + p.set_line_width(0.45)
128 + p.set_fill_color(*fill)
129 + p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
130 +
131 + def _kicker(self, text):
132 + p = self.pdf
133 + p.set_font("helvetica", "B", 8)
134 + p.set_text_color(*GREEN)
135 + p.set_draw_color(*GREEN)
136 + p.set_line_width(0.6)
137 + y = p.get_y() + 2
138 + p.line(p.l_margin, y, p.l_margin + 7, y)
139 + p.set_xy(p.l_margin + 9, y - 2.5)
140 + p.cell(0, 5, text.upper())
141 + p.ln(8)
142 +
143 + def _section_title(self, title):
144 + if self.pdf.get_y() > 240:
145 + self.pdf.add_page()
146 + self._kicker("Groupe KA · " + self.site.get("wordmark", ""))
147 + self.pdf.set_font("helvetica", "B", 15)
148 + self.pdf.set_text_color(*INK)
149 + self.pdf.set_x(self.pdf.l_margin)
150 + self.pdf.cell(0, 8, title)
151 + self.toc.append((title, self.pdf.page_no()))
152 + self.pdf.ln(11)
153 +
154 + # ---------- pages ----------
155 + def _cover(self):
156 + p = self.pdf
157 + p.cover_mode = True
158 + p.set_auto_page_break(False)
159 + p.add_page()
160 + p.set_fill_color(*PAPER)
161 + p.rect(0, 0, 210, 297, style="F")
162 + p.set_draw_color(*INK)
163 + p.set_line_width(1.0)
164 + p.rect(10, 10, 190, 277)
165 + # kicker
166 + p.set_font("helvetica", "B", 10)
167 + p.set_text_color(*GREEN)
168 + p.set_xy(24, 34)
169 + p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
170 + # wordmark : partie gauche + boîte encre/accent
171 + wm = self.site.get("wordmark", "")
172 + left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
173 + p.set_xy(24, 70)
174 + p.set_font("helvetica", "B", 40)
175 + p.set_text_color(*INK)
176 + p.cell(p.get_string_width(left) + 2, 20, left)
177 + if boxed:
178 + bw = p.get_string_width(boxed) + 12
179 + x = p.get_x() + 2
180 + p.set_fill_color(*INK)
181 + p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)
182 + p.set_text_color(*self.accent)
183 + p.set_xy(x + 6, 70)
184 + p.cell(bw - 12, 18, boxed)
185 + p.set_xy(24, 100)
186 + p.set_font("helvetica", "", 13)
187 + p.set_text_color(*INK2)
188 + p.multi_cell(150, 7, f"Rapport statistique — {wm}")
189 + now = datetime.now(ZoneInfo("America/Toronto"))
190 + per = self.d.get("period", {}) or {}
191 + p.set_xy(24, 125)
192 + p.set_font("helvetica", "", 10.5)
193 + rows = [
194 + ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
195 + ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
196 + ("Plateforme", "https://" + self.site.get("domain", "")),
197 + ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),
198 + ]
199 + y = 128
200 + for k, v in rows:
201 + p.set_xy(24, y)
202 + p.set_text_color(*INK3)
203 + p.cell(40, 6, k)
204 + p.set_text_color(*INK)
205 + p.set_font("helvetica", "B", 10.5)
206 + p.cell(0, 6, str(v))
207 + p.set_font("helvetica", "", 10.5)
208 + y += 8
209 + # bande encre au pied
210 + p.set_fill_color(*INK)
211 + p.rect(10, 262, 190, 25, style="F")
212 + p.set_xy(24, 270)
213 + p.set_font("helvetica", "B", 12)
214 + p.set_text_color(*WHITE)
215 + p.cell(60, 8, "par Groupe ")
216 + p.set_text_color(*self.accent)
217 + p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)
218 + p.cell(20, 8, "KA")
219 + p.set_font("helvetica", "B", 10)
220 + p.set_xy(24, 270)
221 + p.set_text_color(*self.accent)
222 + p.cell(162, 8, "groupe-ka.com", align="R")
223 + p.set_auto_page_break(True, margin=22)
224 + p.cover_mode = False
225 +
226 + def _kpis(self):
227 + kpis = self.d.get("kpis") or []
228 + if not kpis:
229 + return
230 + self._section_title("Synthèse des indicateurs")
231 + p = self.pdf
232 + cols, gw, gh, gap = 3, 56, 26, 3
233 + x0, y = p.l_margin, p.get_y()
234 + for i, k in enumerate(kpis[:9]):
235 + x = x0 + (i % cols) * (gw + gap)
236 + if i and i % cols == 0:
237 + y += gh + gap
238 + if y > 250:
239 + p.add_page(); y = p.get_y()
240 + self._card(x, y, gw, gh)
241 + p.set_xy(x + 4, y + 4)
242 + p.set_font("helvetica", "B", 14)
243 + p.set_text_color(*INK)
244 + val = k.get("value")
245 + p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))
246 + p.set_xy(x + 4, y + 12)
247 + p.set_font("helvetica", "", 7.6)
248 + p.set_text_color(*INK2)
249 + p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])
250 + if k.get("delta_pct") is not None:
251 + up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"
252 + p.set_xy(x + 4, y + gh - 6.5)
253 + p.set_font("helvetica", "B", 8)
254 + p.set_text_color(*(GREEN if up else DANGER))
255 + arrow = "+" if k["delta_pct"] >= 0 else ""
256 + p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")
257 + p.set_y(y + gh + 8)
258 +
259 + def _line_chart(self, s):
260 + p = self.pdf
261 + pts = s.get("points") or []
262 + if len(pts) < 2:
263 + return
264 + if p.get_y() > 200:
265 + p.add_page()
266 + p.set_font("helvetica", "B", 10)
267 + p.set_text_color(*INK)
268 + p.cell(0, 6, s.get("title", ""))
269 + p.ln(7)
270 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
271 + self._card(x0, y0, w, h, fill=WHITE)
272 + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
273 + vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]
274 + vmax = max(vals) or 1
275 + vmin = min(0, min(vals))
276 + rng = (vmax - vmin) or 1
277 + # grille + graduations
278 + p.set_font("helvetica", "", 6.3)
279 + p.set_text_color(*INK3)
280 + p.set_draw_color(200, 200, 195)
281 + p.set_line_width(0.15)
282 + for g in range(5):
283 + gy = cy + ch - ch * g / 4
284 + p.line(cx, gy, cx + cw, gy)
285 + p.set_xy(x0 + 1, gy - 1.6)
286 + p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
287 +
288 + def draw(series, color, width, dash=None):
289 + n = len(series)
290 + p.set_draw_color(*color)
291 + p.set_line_width(width)
292 + if dash:
293 + p.set_dash_pattern(dash=1.2, gap=1.2)
294 + last = None
295 + for i, pt in enumerate(series):
296 + px = cx + cw * (i / (n - 1))
297 + py = cy + ch - ch * ((pt["v"] - vmin) / rng)
298 + if last:
299 + p.line(last[0], last[1], px, py)
300 + last = (px, py)
301 + p.set_dash_pattern()
302 +
303 + if s.get("compare"):
304 + draw(s["compare"], INK3, 0.35, dash=True)
305 + draw(pts, self.accent, 0.7)
306 + # libellés d'axe X (premier / milieu / dernier)
307 + p.set_text_color(*INK3)
308 + for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
309 + p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
310 + p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")
311 + p.set_y(y0 + h + 4)
312 + if s.get("compare"):
313 + p.set_font("helvetica", "", 6.8)
314 + p.set_text_color(*INK3)
315 + p.cell(0, 4, "— période courante (accent) · ---- période comparée")
316 + p.ln(6)
317 + else:
318 + p.ln(2)
319 +
320 + def _bars(self, title, items, unit=""):
321 + p = self.pdf
322 + items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]
323 + if not items:
324 + return
325 + need = 10 + len(items) * 7
326 + if p.get_y() + need > 265:
327 + p.add_page()
328 + p.set_font("helvetica", "B", 10)
329 + p.set_text_color(*INK)
330 + p.cell(0, 6, title)
331 + p.ln(8)
332 + vmax = max(it["value"] for it in items) or 1
333 + for it in items:
334 + y = p.get_y()
335 + p.set_font("helvetica", "", 7.6)
336 + p.set_text_color(*INK)
337 + p.set_x(p.l_margin)
338 + p.cell(46, 5, str(it["label"])[:34])
339 + bw = 96 * (it["value"] / vmax)
340 + p.set_fill_color(*self.accent)
341 + p.set_draw_color(*INK)
342 + p.set_line_width(0.25)
343 + p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
344 + p.set_xy(p.l_margin + 148, y)
345 + p.set_font("helvetica", "B", 7.6)
346 + p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
347 + p.ln(6.4)
348 + p.ln(3)
349 +
350 + def _donut(self, b):
351 + # anneau vectoriel simple (arcs) + légende
352 + p = self.pdf
353 + items = [it for it in (b.get("items") or []) if it.get("value")][:8]
354 + total = sum(it["value"] for it in items)
355 + if not items or not total:
356 + return
357 + if p.get_y() > 210:
358 + p.add_page()
359 + p.set_font("helvetica", "B", 10)
360 + p.set_text_color(*INK)
361 + p.cell(0, 6, b.get("title", ""))
362 + p.ln(8)
363 + cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
364 + shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
365 + start = -90.0
366 + for i, it in enumerate(items):
367 + frac = it["value"] / total
368 + f = shades[i % len(shades)]
369 + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
370 + steps = max(2, int(72 * frac))
371 + p.set_fill_color(*col)
372 + p.set_draw_color(*col)
373 + for st in range(steps):
374 + a0 = math.radians(start + 360 * frac * st / steps)
375 + a1 = math.radians(start + 360 * frac * (st + 1) / steps)
376 + p.polygon(
377 + [(cx, cy),
378 + (cx + r * math.cos(a0), cy + r * math.sin(a0)),
379 + (cx + r * math.cos(a1), cy + r * math.sin(a1))],
380 + style="DF",
381 + )
382 + start += 360 * frac
383 + p.set_fill_color(*WHITE)
384 + p.set_draw_color(*INK)
385 + p.set_line_width(0.4)
386 + p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
387 + p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
388 + # légende
389 + ly = cy - 22
390 + for i, it in enumerate(items):
391 + f = shades[i % len(shades)]
392 + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
393 + p.set_fill_color(*col)
394 + p.set_draw_color(*INK)
395 + p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
396 + p.set_xy(p.l_margin + 66, ly)
397 + p.set_font("helvetica", "", 7.6)
398 + p.set_text_color(*INK)
399 + pct = 100 * it["value"] / total
400 + p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))
401 + ly += 5.6
402 + p.set_y(max(cy + r, ly) + 6)
403 +
404 + def _table(self, t):
405 + p = self.pdf
406 + cols = t.get("columns") or []
407 + rows = t.get("rows") or []
408 + if not cols or not rows:
409 + return
410 + self._section_title(t.get("title", "Tableau"))
411 + w = 174 / len(cols)
412 + def head():
413 + p.set_font("helvetica", "B", 7.6)
414 + p.set_fill_color(*INK)
415 + p.set_text_color(*WHITE)
416 + for c in cols:
417 + p.cell(w, 6, " " + str(c)[:30], fill=True)
418 + p.ln(6)
419 + head()
420 + p.set_text_color(*INK)
421 + for i, row in enumerate(rows[:200]):
422 + if p.get_y() > 262:
423 + p.add_page()
424 + head()
425 + p.set_text_color(*INK)
426 + p.set_font("helvetica", "", 7.4)
427 + p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))
428 + for cell in row:
429 + txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
430 + p.cell(w, 5.4, " " + txt[:34], fill=True)
431 + p.ln(5.4)
432 + if len(rows) > 200:
433 + p.set_font("helvetica", "", 7)
434 + p.set_text_color(*INK3)
435 + p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")
436 + p.ln(6)
437 +
438 + def _records(self):
439 + recs = self.d.get("records") or []
440 + if not recs:
441 + return
442 + self._section_title("Records & faits marquants")
443 + p = self.pdf
444 + for r in recs[:10]:
445 + if p.get_y() > 258:
446 + p.add_page()
447 + y = p.get_y()
448 + self._card(p.l_margin, y, 174, 11, fill=SURFACE2)
449 + p.set_xy(p.l_margin + 4, y + 2)
450 + p.set_font("helvetica", "", 8.6)
451 + p.set_text_color(*INK2)
452 + p.cell(96, 7, str(r.get("label", ""))[:70])
453 + p.set_font("helvetica", "B", 9)
454 + p.set_text_color(*INK)
455 + p.cell(52, 7, str(r.get("value", ""))[:36], align="R")
456 + p.set_font("helvetica", "", 7.6)
457 + p.set_text_color(*INK3)
458 + p.cell(20, 7, str(r.get("date", "") or ""), align="R")
459 + p.set_y(y + 13.5)
460 + p.ln(4)
461 +
462 + def _final_page(self):
463 + p = self.pdf
464 + p.add_page()
465 + self._kicker("Groupe KA · contact")
466 + p.set_font("helvetica", "B", 15)
467 + p.set_text_color(*INK)
468 + p.cell(0, 8, "Coordonnées du Groupe KA")
469 + p.ln(12)
470 + for email, role in EMAILS:
471 + p.set_font("helvetica", "B", 10.5)
472 + p.set_text_color(*INK)
473 + p.cell(0, 6, email)
474 + p.ln(5.5)
475 + p.set_font("helvetica", "", 8.6)
476 + p.set_text_color(*INK3)
477 + p.cell(0, 5, role)
478 + p.ln(8)
479 + p.ln(2)
480 + p.set_font("helvetica", "B", 10)
481 + p.set_text_color(*GREEN)
482 + p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")
483 + p.ln(10)
484 + p.set_draw_color(*self.accent)
485 + p.set_line_width(0.8)
486 + p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())
487 + p.ln(4)
488 + p.set_font("helvetica", "", 8.6)
489 + p.set_text_color(*INK2)
490 + p.multi_cell(160, 4.6, DISCLAIMER)
491 + p.ln(4)
492 + p.set_font("helvetica", "", 7.6)
493 + p.set_text_color(*INK3)
494 + p.multi_cell(
495 + 160, 4.2,
496 + "Mentions : rapport généré automatiquement à partir des données réelles de la "
497 + "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "
498 + "de confidentialité et protection des renseignements personnels (Loi 25) : "
499 + "groupe-ka.com/conditions · /confidentialite · /loi-25.",
500 + )
501 +
502 + def _toc_page(self):
503 + # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en
504 + # page 2 en réservant la page lors du build (voir build()).
505 + pass
506 +
507 + def build(self) -> bytes:
508 + p = self.pdf
509 + p.alias_nb_pages()
510 + self._cover()
511 + if self.mode == "synthese":
512 + p.add_page()
513 + self._kpis()
514 + self._records()
515 + self._final_page()
516 + else:
517 + p.add_page()
518 + toc_page_no = p.page_no()
519 + p.add_page()
520 + self._kpis()
521 + for s in self.d.get("series") or []:
522 + if s.get("kind") == "bar":
523 + self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))
524 + else:
525 + self._line_chart(s)
526 + for b in self.d.get("breakdowns") or []:
527 + if b.get("kind") == "donut":
528 + self._donut(b)
529 + else:
530 + self._bars(b.get("title", ""), b.get("items"))
531 + geo = self.d.get("geo")
532 + if geo:
533 + self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
534 + for t in self.d.get("tables") or []:
535 + self._table(t)
536 + self._records()
537 + self._final_page()
538 + # sommaire écrit sur la page réservée (page 2)
539 + last_page = p.page
540 + p.page = toc_page_no
541 + p.set_y(22)
542 + p.set_font("helvetica", "B", 15)
543 + p.set_text_color(*INK)
544 + p.cell(0, 8, "Sommaire")
545 + p.ln(12)
546 + p.set_font("helvetica", "", 9.5)
547 + for title, page_no in self.toc:
548 + p.set_text_color(*INK)
549 + p.cell(140, 6.5, title[:80])
550 + p.set_text_color(*INK3)
551 + p.cell(0, 6.5, str(page_no), align="R")
552 + p.ln(6.5)
553 + p.page = last_page
554 + return bytes(p.output())
555 +
556 +
557 +def filename(platform_id: str, period: str) -> str:
558 + today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
559 + return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"
modified frontend/src/pages/Stats.tsx +126 −148
@@ -1,54 +1,64 @@
1 1 // -----------------------------------------------------------------------------
2 2 // Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// Stats.tsx : tableau de bord du marché — héros, distributions, classements,
5 −// parts de marché, aubaines + rapport PDF téléchargeable.
4 +// Stats.tsx : tableau de bord analytique du marché — module Stats commun
5 +// Groupe KA (contrat ka/stats/SPEC.md) : KPI + deltas, sélecteur
6 +// de période, courbes/anneaux/barres/heatmap, répartition
7 +// géographique, tableaux détaillés, records et export PDF.
6 8 // -----------------------------------------------------------------------------
7 −import { useEffect, useState } from "react";
8 −import { Link } from "react-router-dom";
9 −import { fmtPrice } from "../api";
10 −import { Datum, Donut, HBars, VBars } from "../components/Charts";
11 −
12 −interface Detailed {
13 − total: number;
14 − sources: number;
15 − regions: number;
16 − avg_price: number | null;
17 − median_price: number | null;
18 − avg_km: number | null;
19 − median_km: number | null;
20 − avg_year: number | null;
21 − new_7d: number;
22 − electrified_pct: number;
23 − price_hist: Datum[];
24 − km_hist: Datum[];
25 − year_hist: Datum[];
26 − by_make: { label: string; n: number; avg_price: number | null }[];
27 − by_region: { label: string; n: number; avg_price: number | null }[];
28 − by_body: Datum[];
29 − by_fuel: Datum[];
30 − top_models: { label: string; n: number; avg_price: number | null; avg_km: number | null }[];
31 − top_dealers: { label: string; n: number; avg_price: number | null }[];
32 − price_drops: {
33 − uid: string; title: string; year: number | null; price: number;
34 − prev_price: number; dealer_name: string; city: string;
35 − }[];
9 +import { useCallback, useEffect, useState } from "react";
10 +import type { CSSProperties } from "react";
11 +import {
12 + BarChart, CalendarHeatmap, DataTable, Donut, EmptyBlock, Fraicheur,
13 + KpiCard, LineChart, PdfButton, PeriodSelector, RecordCard,
14 +} from "../ka/stats/kacharts";
15 +import type { Kpi, RecordFact, Serie, TableSpec } from "../ka/stats/kacharts";
16 +
17 +interface Breakdown {
18 + id: string; title: string; kind: "donut" | "bar";
19 + items: { label: string; value: number }[];
20 +}
21 +interface Dashboard {
22 + updated: string;
23 + period: { from: string; to: string; label: string };
24 + kpis: Kpi[];
25 + series: Serie[];
26 + breakdowns: Breakdown[];
27 + geo?: { title: string; items: { label: string; value: number }[] };
28 + heatmap?: { title: string; cells: { date: string; value: number }[] };
29 + tables: TableSpec[];
30 + records: RecordFact[];
36 31 }
37 32
38 −const fmtK = (n: number | null) =>
39 − n == null ? "—" : `${Math.round(n / 1000)}k km`;
33 +const grid = (min: number): CSSProperties => ({
34 + display: "grid", gap: 16,
35 + gridTemplateColumns: `repeat(auto-fit, minmax(min(${min}px, 100%), 1fr))`,
36 +});
40 37
41 38 export default function StatsPage() {
42 − const [s, setS] = useState<Detailed | null>(null);
39 + const [period, setPeriod] = useState("30j");
40 + const [custom, setCustom] = useState<{ from: string; to: string }>({ from: "", to: "" });
41 + const [dash, setDash] = useState<Dashboard | null>(null);
42 + const [loading, setLoading] = useState(true);
43 + const [error, setError] = useState(false);
44 +
45 + const useCustom = Boolean(custom.from && custom.to);
43 46
44 − useEffect(() => {
45 − fetch("/api/stats/detailed")
46 − .then((r) => r.json())
47 − .then(setS)
48 − .catch(() => {});
49 − }, []);
47 + const load = useCallback(() => {
48 + setLoading(true);
49 + setError(false);
50 + const q = new URLSearchParams({ period });
51 + if (useCustom) { q.set("from", custom.from); q.set("to", custom.to); }
52 + fetch(`/api/stats/dashboard?${q}`)
53 + .then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
54 + .then((d: Dashboard) => setDash(d))
55 + .catch(() => setError(true))
56 + .finally(() => setLoading(false));
57 + }, [period, custom.from, custom.to, useCustom]);
50 58
51 − if (!s)
59 + useEffect(load, [load]);
60 +
61 + if (!dash && loading)
52 62 return (
53 63 <div className="container page">
54 64 <div className="skeleton" style={{ height: 120, marginBottom: 20 }} />
@@ -56,130 +66,98 @@ export default function StatsPage() {
56 66 </div>
57 67 );
58 68
59 − const withPrice = (rows: { label: string; n: number; avg_price: number | null }[]): Datum[] =>
60 − rows.map((r) => ({
61 − label: r.label, n: r.n,
62 − extra: r.avg_price ? `moy. ${fmtPrice(r.avg_price)}` : undefined,
63 − }));
69 + if (!dash)
70 + return (
71 + <div className="container page">
72 + <EmptyBlock title="Statistiques du marché" />
73 + </div>
74 + );
75 +
76 + const donuts = dash.breakdowns.filter((b) => b.kind === "donut");
77 + const bars = dash.breakdowns.filter((b) => b.kind !== "donut");
64 78
65 79 return (
66 − <div className="container page">
80 + <div className="container page" style={{ display: "grid", gap: 22 }}>
81 + {/* --- 1. entête : titre + PDF + fraîcheur ------------------------------ */}
67 82 <div className="stats-head">
68 83 <div>
69 − <span className="kicker">Marché</span>
70 − <h1>Le marché de l'occasion, en direct</h1>
71 − <p className="lead" style={{ marginBottom: 0 }}>
72 − Calculé en temps réel sur l'inventaire actif de {s.sources} concessionnaires
73 − dans {s.regions} régions du Québec.
84 + <span className="kicker">Statistiques · {dash.period.label}</span>
85 + <h1>Le marché de l'occasion, en chiffres</h1>
86 + <p className="lead" style={{ marginBottom: 10 }}>
87 + Tableau de bord calculé sur les données réelles d'Auto-Ka —
88 + inventaire des concessionnaires du Québec, du {dash.period.from} au{" "}
89 + {dash.period.to}.
74 90 </p>
91 + <Fraicheur updated={dash.updated} onRefresh={load} />
75 92 </div>
76 − <a className="btn pdf-btn" href="/api/stats/rapport.pdf">
77 − ⬇ Télécharger le rapport PDF
78 − </a>
79 − </div>
80 −
81 − <div className="stat-grid" style={{ marginTop: 26 }}>
82 − <div className="stat-tile hero-tile">
83 − <b>{s.total.toLocaleString("fr-CA")}</b>
84 − <span>véhicules en vente</span>
85 − </div>
86 − <div className="stat-tile">
87 − <b>{fmtPrice(s.avg_price)}</b>
88 − <span>prix moyen</span>
89 − </div>
90 − <div className="stat-tile">
91 − <b>{fmtPrice(s.median_price)}</b>
92 − <span>prix médian</span>
93 − </div>
94 − <div className="stat-tile">
95 − <b>{fmtK(s.avg_km)}</b>
96 − <span>km moyen</span>
97 − </div>
98 − <div className="stat-tile">
99 − <b>{s.new_7d.toLocaleString("fr-CA")}</b>
100 − <span>arrivages · 7 jours</span>
101 − </div>
102 − <div className="stat-tile">
103 − <b>{s.electrified_pct} %</b>
104 − <span>électrifiés (VÉ + hybrides)</span>
93 + <div style={{ marginLeft: "auto" }}>
94 + <PdfButton period={useCustom ? "perso" : period}
95 + from={useCustom ? custom.from : undefined}
96 + to={useCustom ? custom.to : undefined} />
105 97 </div>
106 98 </div>
107 99
108 − <div className="panel">
109 − <h3>💰 Distribution des prix</h3>
110 − <VBars data={s.price_hist} color="#d94f1e" />
100 + {/* --- 2. sélecteur de période global ----------------------------------- */}
101 + <div className="card" style={{ padding: "12px 16px" }}>
102 + <PeriodSelector
103 + value={useCustom ? "" : period}
104 + onChange={(p) => { setCustom({ from: "", to: "" }); setPeriod(p); }}
105 + custom={custom}
106 + onCustom={(from, to) => setCustom({ from, to })}
107 + />
111 108 </div>
112 109
113 − <div className="vd-cols">
114 − <div className="panel">
115 − <h3>🛣 Kilométrage (milliers de km)</h3>
116 − <VBars data={s.km_hist} color="#1f6fb5" height={170} />
117 − </div>
118 − <div className="panel">
119 − <h3>📅 Années-modèles</h3>
120 − <VBars data={s.year_hist} color="#b58500" height={170} />
121 − </div>
122 − </div>
110 + {error && (
111 + <p className="chip" style={{ background: "var(--danger-soft)" }}>
112 + Impossible de charger les statistiques — réessayez.
113 + </p>
114 + )}
123 115
124 − <div className="vd-cols">
125 − <div className="panel">
126 − <h3>🏷 Top marques</h3>
127 − <HBars data={withPrice(s.by_make)} color="#d94f1e" />
128 − </div>
129 − <div className="panel">
130 − <h3>📍 Par région</h3>
131 − <HBars data={withPrice(s.by_region)} color="#1f6fb5" />
132 − </div>
133 − </div>
116 + <div style={{ display: "grid", gap: 22, opacity: loading ? 0.55 : 1, transition: "opacity 0.2s" }}>
117 + {/* --- 3. bandeau KPI -------------------------------------------------- */}
118 + {dash.kpis.length ? (
119 + <div style={grid(210)}>
120 + {dash.kpis.map((k) => <KpiCard key={k.id} k={k} />)}
121 + </div>
122 + ) : (
123 + <EmptyBlock title="Indicateurs clés" />
124 + )}
134 125
135 − <div className="vd-cols">
136 − <div className="panel">
137 − <h3>🚙 Carrosseries</h3>
138 − <Donut data={s.by_body} />
139 − </div>
140 − <div className="panel">
141 − <h3>⛽ Carburants</h3>
142 − <Donut data={s.by_fuel} />
126 + {/* --- 4. courbes d'évolution ------------------------------------------ */}
127 + {dash.series.length ? (
128 + dash.series.map((s) => <LineChart key={s.id} serie={s} />)
129 + ) : (
130 + <EmptyBlock title="Évolution quotidienne" />
131 + )}
132 +
133 + {/* --- 5. répartitions : anneau + barres ------------------------------- */}
134 + <div style={grid(340)}>
135 + {donuts.map((b) => <Donut key={b.id} title={b.title} items={b.items} />)}
136 + {bars.map((b) => <BarChart key={b.id} title={b.title} items={b.items} />)}
143 137 </div>
144 − </div>
145 138
146 − <div className="panel">
147 − <h3>🚗 Modèles les plus offerts</h3>
148 − <HBars
149 − data={s.top_models.map((m) => ({
150 − label: m.label, n: m.n,
151 − extra: `moy. ${fmtPrice(m.avg_price)} · ${fmtK(m.avg_km)}`,
152 − }))}
153 − color="#1c7a4d"
154 − />
155 − </div>
139 + {/* --- 6. répartition géographique -------------------------------------- */}
140 + {dash.geo?.items?.length
141 + ? <BarChart title={dash.geo.title} items={dash.geo.items} unit="véhicules" />
142 + : <EmptyBlock title="Par région" />}
156 143
157 − {s.price_drops.length > 0 && (
158 − <section style={{ margin: "30px 0" }}>
159 − <h2 style={{ marginBottom: 14 }}>📉 Baisses de prix récentes</h2>
160 − <div className="src-grid">
161 − {s.price_drops.map((d) => (
162 − <Link key={d.uid} to={`/vehicule/${encodeURIComponent(d.uid)}`} className="src-card">
163 − <h3>{d.title}</h3>
164 − <div className="meta">{d.dealer_name} · {d.city}</div>
165 − <div className="n">
166 − {fmtPrice(d.price)}{" "}
167 − <span style={{ fontSize: 13, color: "var(--ink-3)", textDecoration: "line-through" }}>
168 − {fmtPrice(d.prev_price)}
169 − </span>
170 − </div>
171 − <div className="meta" style={{ color: "var(--good)", fontWeight: 700 }}>
172 − −{fmtPrice(d.prev_price - d.price)}
173 − </div>
174 − </Link>
175 − ))}
176 − </div>
177 − </section>
178 − )}
144 + {/* --- 7. calendrier de chaleur ------------------------------------------ */}
145 + {dash.heatmap?.cells?.length
146 + ? <CalendarHeatmap title={dash.heatmap.title} cells={dash.heatmap.cells} />
147 + : <EmptyBlock title="Activité quotidienne" />}
148 +
149 + {/* --- 8. tableaux détaillés --------------------------------------------- */}
150 + {dash.tables.map((t) => <DataTable key={t.id} spec={t} />)}
179 151
180 − <div className="panel">
181 − <h3>🏢 Plus grands inventaires</h3>
182 − <HBars data={withPrice(s.top_dealers)} color="#7d4fc9" />
152 + {/* --- 9. records & faits marquants --------------------------------------- */}
153 + {dash.records.length > 0 && (
154 + <section>
155 + <h2 style={{ marginBottom: 12 }}>Records & faits marquants</h2>
156 + <div style={grid(280)}>
157 + {dash.records.map((r, i) => <RecordCard key={i} r={r} />)}
158 + </div>
159 + </section>
160 + )}
183 161 </div>
184 162 </div>
185 163 );
modified requirements.txt +1 −0
@@ -6,3 +6,4 @@ requests>=2.31
6 6 beautifulsoup4>=4.12
7 7 reportlab>=4.0
8 8 pillow>=10.0
9 +fpdf2>=2.8
9 10