SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%

Stats : tableau de bord analytique + rapport PDF Groupe-KA

- /api/stats/dashboard (contrat commun ka-ui/stats/SPEC.md) : KPI avec
  deltas, séries quotidiennes (nouveaux restos, plats suivis en cumul),
  répartitions (cuisines, gammes de prix, types), géo par région,
  heatmap des nouveautés, tableaux (villes, chaînes, nouveaux, syncs),
  records — 100 % calculé de la DB réelle, cache mémoire 5 min.
- /api/stats/report : PDF estampillé Groupe-KA (kapdf/fpdf2), modes
  complet et synthèse, filename normalisé groupe-ka_resto-ka_stats_*.
- Page /stats réécrite sur le kit kacharts (KPI, sélecteur de période,
  courbes, anneau, barres, heatmap, DataTable, records, bouton PDF).
- kapdf : correctif — la couverture ne porte plus le pied de page
  standard (footer déclenché après la retombée de cover_mode).
- fpdf2 ajouté aux dépendances.

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

9 changed files +2,253 −102

added frontend/src/ka/stats/SPEC.md +123 −0
@@ -0,0 +1,123 @@
1 +# ka-stats — module Stats commun Groupe KA (spec v1)
2 +
3 +Contrat partagé par les 12 plateformes pour leurs pages **/stats** (tableau de
4 +bord analytique) et l'**export PDF** estampillé Groupe-KA. Le visuel suit le
5 +design system ka-ui (tokens.css) avec l'accent de la marque.
6 +
7 +## 1. Page /stats — structure obligatoire (dans cet ordre)
8 +
9 +1. **Bandeau KPI** : 4–6 grandes cartes (`KpiCard`) — valeur, libellé,
10 + variation vs période précédente (▲/▼ + %, vert `--green` / rouge `--danger`).
11 +2. **Sélecteur de période global** (`PeriodSelector`) : `aujourd'hui · 7 j ·
12 + 30 j · 3 m · 6 m · 12 m · année en cours · tout` + plage personnalisée
13 + (2 champs date). Toute la page se recalcule (state → refetch dashboard).
14 +3. **Graphiques** : courbes d'évolution (`LineChart`, survol = infobulle,
15 + légende cliquable pour masquer une série, comparaison N vs N-1 en
16 + pointillé), barres (`BarChart`), anneaux (`Donut`), calendrier de chaleur
17 + (`CalendarHeatmap`) quand pertinent.
18 +4. **Répartition géographique** (par ville/région) quand pertinent — barres
19 + horizontales triées (pas besoin de vraie carte).
20 +5. **Tableaux détaillés** (`DataTable`) : tri par colonne, recherche interne,
21 + pagination (25/pg), débordement horizontal propre sur mobile (.tbl-wrap).
22 +6. **Records & faits marquants** : générés depuis les données (jour record,
23 + plus forte croissance, meilleure entrée…) — cartes compactes.
24 +7. **Fraîcheur** : « Mis à jour le {date heure} » + bouton Rafraîchir.
25 +8. **Bouton PDF** bien visible en haut : « Télécharger le rapport PDF » avec
26 + deux choix (Rapport complet / Synthèse 2 pages). Indicateur de progression
27 + si > 2 s.
28 +
29 +Responsive : KPI empilés < 768 px, graphiques pleine largeur redimensionnés
30 +(SVG viewBox), tableaux en défilement horizontal contenu, tactile ≥ 44 px.
31 +AUCUNE donnée inventée : une stat indisponible = bloc « Pas encore mesuré »
32 +(carte grise propre), jamais un faux chiffre.
33 +
34 +## 2. API — contrat commun
35 +
36 +`GET /api/stats/dashboard?period=7j|30j|3m|6m|12m|annee|tout|auj&from=YYYY-MM-DD&to=YYYY-MM-DD`
37 +
38 +```jsonc
39 +{
40 + "updated": "2026-08-17T21:04:00-04:00",
41 + "period": { "from": "2026-07-18", "to": "2026-08-17", "label": "30 jours" },
42 + "kpis": [ { "id": "total", "label": "Annonces actives", "value": 33744,
43 + "unit": "", "delta_pct": 4.2, "direction": "up" } ],
44 + "series": [ { "id": "vol", "title": "Annonces actives par jour", "unit": "annonces",
45 + "kind": "line", "points": [{ "t": "2026-07-18", "v": 31200 }],
46 + "compare": [{ "t": "2025-07-18", "v": 24100 }] } ],
47 + "breakdowns": [ { "id": "types", "title": "Par type", "kind": "donut",
48 + "items": [{ "label": "4½", "value": 9120 }] } ],
49 + "geo": { "title": "Par région", "items": [{ "label": "Montréal", "value": 15680 }] },
50 + "heatmap": { "title": "Activité", "cells": [{ "date": "2026-08-01", "value": 210 }] },
51 + "tables": [ { "id": "top", "title": "Top villes", "columns": ["Ville", "Annonces", "Δ 30 j"],
52 + "rows": [["Montréal", 15680, "+3,1 %"]] } ],
53 + "records": [ { "label": "Jour record d'ajouts", "value": "412 annonces", "date": "2026-08-09" } ]
54 +}
55 +```
56 +
57 +Champs absents = section masquée. Cache serveur recommandé (≥ 5 min par
58 +période). Les valeurs proviennent des données réelles (DB de la plateforme,
59 +journaux de sync des connecteurs, /api/v1/runs d'API-KA…).
60 +
61 +`GET /api/stats/report?period=…&from=&to=&mode=complet|synthese`
62 +→ `application/pdf`, en-tête `Content-Disposition: attachment; filename=
63 +groupe-ka_<plateforme>_stats_<periode>_<YYYY-MM-DD>.pdf`.
64 +
65 +## 3. PDF — gabarit Groupe-KA (implémentations : `kapdf.py` fpdf2 pour les
66 +apps Python ; les apps Next portent le même gabarit en pdfkit)
67 +
68 +- **Couverture** : cadre encre, kicker « GROUPE KA · RAPPORT STATISTIQUE »,
69 + wordmark de la plateforme (boîte encre + accent), sous-titre, période
70 + couverte, date/heure de génération, bande encre au pied avec
71 + « par Groupe KA — groupe-ka.com ».
72 +- **Sommaire** avec numéros de pages (mode complet).
73 +- **KPI** : grille de cartes (bordure encre, valeur en gros, delta coloré).
74 +- **Graphiques VECTORIELS** (dessinés en primitives, jamais de capture) :
75 + courbes, barres, anneaux — accent de la plateforme, axes/graduations encre.
76 +- **Tableaux** paginés proprement (lignes zébrées `--surface-2`, jamais
77 + coupés en deux à cheval sur une ligne).
78 +- **Records** puis **page de fin** : coordonnées Groupe KA (3 courriels +
79 + rôles d'ecosystem.json, groupe-ka.com), avertissement d'agrégateur,
80 + mentions légales courtes.
81 +- **Chaque page** : en-tête discret (« Groupe KA · {Plateforme} », filet
82 + encre) + pied (« © Groupe-KA — {année} — groupe-ka.com · {période} · p. X/Y »).
83 +- A4 portrait, marges 18 mm, typo : Helvetica (fallback sûr) ou fonts TTF du
84 + DS si présentes. Mode « synthese » = couverture + 1 page KPI/records.
85 +
86 +## 4. Spécifique par plateforme (sections métier attendues)
87 +
88 +- **groupe-ka** : tableau de bord maître — consolidation des 12 (volume total,
89 + croissance), classement des plateformes, bloc résumé par plateforme + lien
90 + vers sa page /stats ; « Rapport écosystème complet » = PDF consolidé.
91 +- **lou-ka** : annonces actives/nouvelles/retirées, loyers moyens/médians par
92 + ville & taille, évolution, répartition par type, top villes.
93 +- **immo-ka** : annonces actives/nouvelles/vendues-retirées, prix moyen/médian
94 + par ville/région/type, délai de présence, top villes, tension du marché.
95 +- **vrai-prix** : couverture du rôle (unités, valeur totale), estimations
96 + servies si journalisées, répartitions par municipalité/type, indices marché.
97 +- **auto-ka** : volume par marque/modèle/année/carburant/boîte, prix moyens et
98 + km moyens par segment, top marques/modèles.
99 +- **fabri-ka** : produits par catégorie/région/boutique, fourchettes de prix,
100 + nouveautés par période, top catégories.
101 +- **food-ka** : produits suivis, relevés de prix, soldes détectés (baisses/
102 + hausses, amplitude), top produits en solde, prix moyens par catégorie.
103 +- **resto-ka** : restos par cuisine/ville/gamme, menus & plats, nouveautés/
104 + fermetures détectées, top établissements.
105 +- **sorti-ka** : événements à venir/passés par catégorie/ville, gratuits vs
106 + payants, heatmap calendrier, top lieux.
107 +- **crea-ka** : créateurs par plateforme/niche/tier, comptes reliés, top
108 + créateurs, croissance du répertoire.
109 +- **trouve-ka** : pages indexées, domaines, rythme de crawl (indexées/h),
110 + erreurs, file frontier, tendances si les requêtes sont journalisées.
111 +- **api-ka** : appels par endpoint/jour/heure, latences moyennes + p95, taux
112 + d'erreur, top endpoints, uptime (données des middlewares de logging + runs).
113 +- **Transverse (tous)** : volume total agrégé + croissance, connecteurs actifs
114 + et éléments ajoutés/mis à jour par période (journaux de sync), complétude/
115 + fraîcheur moyenne des fiches quand mesurable. Trafic web : seulement si des
116 + journaux d'accès existent — sinon état vide propre.
117 +
118 +## 5. Ajouter une métrique / un graphique / une plateforme
119 +
120 +1 métrique = 1 entrée `kpis[]` ou `series[]` côté API (requête SQL agrégée +
121 +cache) — le front la rend automatiquement. 1 plateforme = implémenter les 2
122 +endpoints du contrat + une page /stats montée sur les composants du kit +
123 +`kapdf.py` (ou gabarit pdfkit) branché sur le même JSON de dashboard.
added frontend/src/ka/stats/kacharts.tsx +389 −0
@@ -0,0 +1,389 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// ka-ui/stats/kacharts.tsx — kit de graphiques SVG du module Stats commun
3 +// Groupe KA (zéro dépendance, React 18+). Style : design system ka-ui
4 +// (bordures encre, accent de la plateforme via var(--accent)).
5 +// Composants : KpiCard, PeriodSelector, LineChart (infobulle + légende
6 +// cliquable + comparaison N-1), BarChart, Donut, CalendarHeatmap, DataTable
7 +// (tri/recherche/pagination), RecordCard, PdfButton, EmptyBlock, Fraicheur.
8 +import { useMemo, useState } from "react";
9 +
10 +/* ---------- types (contrat SPEC.md) ---------- */
11 +export type Kpi = {
12 + id: string; label: string; value: number | string; unit?: string;
13 + delta_pct?: number | null; direction?: "up" | "down";
14 +};
15 +export type Point = { t: string; v: number };
16 +export type Serie = {
17 + id: string; title: string; unit?: string; kind?: "line" | "bar";
18 + points: Point[]; compare?: Point[];
19 +};
20 +export type BreakItem = { label: string; value: number };
21 +export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] };
22 +export type RecordFact = { label: string; value: string; date?: string };
23 +
24 +export const PERIODS: { id: string; label: string }[] = [
25 + { id: "auj", label: "Aujourd'hui" },
26 + { id: "7j", label: "7 jours" },
27 + { id: "30j", label: "30 jours" },
28 + { id: "3m", label: "3 mois" },
29 + { id: "6m", label: "6 mois" },
30 + { id: "12m", label: "12 mois" },
31 + { id: "annee", label: "Année en cours" },
32 + { id: "tout", label: "Tout" },
33 +];
34 +
35 +export const fmtInt = (n: number) => n.toLocaleString("fr-CA");
36 +export const fmtNum = (n: number) =>
37 + Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("fr-CA", { maximumFractionDigits: 2 });
38 +
39 +/* ---------- KPI ---------- */
40 +export function KpiCard({ k }: { k: Kpi }) {
41 + const up = (k.direction ?? ((k.delta_pct ?? 0) >= 0 ? "up" : "down")) === "up";
42 + return (
43 + <article className="card" style={{ padding: "14px 16px", minWidth: 0 }}>
44 + <p style={{ margin: 0, fontFamily: "var(--font-display)", fontWeight: 700, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em" }}>
45 + {typeof k.value === "number" ? fmtNum(k.value) : k.value}
46 + {k.unit ? <span style={{ fontSize: "0.6em", color: "var(--ink-2)" }}> {k.unit}</span> : null}
47 + </p>
48 + <p className="klabel" style={{ margin: "6px 0 0" }}>{k.label}</p>
49 + {k.delta_pct !== undefined && k.delta_pct !== null && (
50 + <p style={{ margin: "8px 0 0", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, color: up ? "var(--green)" : "var(--danger)" }}>
51 + {up ? "▲" : "▼"} {k.delta_pct >= 0 ? "+" : ""}{fmtNum(k.delta_pct)} % <span style={{ color: "var(--ink-3)", fontWeight: 500 }}>vs période préc.</span>
52 + </p>
53 + )}
54 + </article>
55 + );
56 +}
57 +
58 +/* ---------- Sélecteur de période ---------- */
59 +export function PeriodSelector({
60 + value, onChange, custom, onCustom,
61 +}: {
62 + value: string; onChange: (p: string) => void;
63 + custom?: { from: string; to: string }; onCustom?: (from: string, to: string) => void;
64 +}) {
65 + return (
66 + <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
67 + {PERIODS.map((p) => (
68 + <button key={p.id} type="button" onClick={() => onChange(p.id)}
69 + className="chip" aria-pressed={value === p.id}
70 + style={{ cursor: "pointer", minHeight: 44, background: value === p.id ? "var(--accent)" : "var(--surface)", color: value === p.id ? "var(--on-accent)" : "var(--ink)" }}>
71 + {p.label}
72 + </button>
73 + ))}
74 + {onCustom && (
75 + <span style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>
76 + <input className="input" type="date" style={{ width: 150 }} value={custom?.from ?? ""} aria-label="Du"
77 + onChange={(e) => onCustom(e.target.value, custom?.to ?? "")} />
78 + <span className="klabel">au</span>
79 + <input className="input" type="date" style={{ width: 150 }} value={custom?.to ?? ""} aria-label="Au"
80 + onChange={(e) => onCustom(custom?.from ?? "", e.target.value)} />
81 + </span>
82 + )}
83 + </div>
84 + );
85 +}
86 +
87 +/* ---------- Courbe ---------- */
88 +export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) {
89 + const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false });
90 + const [hover, setHover] = useState<number | null>(null);
91 + const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;
92 + const pts = serie.points ?? [];
93 + if (pts.length < 2) return <EmptyBlock title={serie.title} />;
94 + const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])];
95 + const vmax = Math.max(...all.map((p) => p.v), 1);
96 + const vmin = Math.min(0, ...all.map((p) => p.v));
97 + const X = (i: number, n: number) => PL + ((W - PL - PR) * i) / (n - 1);
98 + const Y = (v: number) => PT + (H - PT - PB) * (1 - (v - vmin) / (vmax - vmin || 1));
99 + const path = (s: Point[]) => s.map((p, i) => `${i ? "L" : "M"}${X(i, s.length)},${Y(p.v)}`).join("");
100 + const hi = hover !== null ? Math.min(pts.length - 1, Math.max(0, hover)) : null;
101 + return (
102 + <figure className="card" style={{ margin: 0, padding: 16 }}>
103 + <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
104 + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{serie.title}</b>
105 + <span style={{ display: "flex", gap: 10 }}>
106 + <LegendChip label="Période courante" color="var(--accent)" off={hide.cur} onClick={() => setHide((h) => ({ ...h, cur: !h.cur }))} />
107 + {serie.compare && <LegendChip label="Période comparée" color="var(--ink-3)" dashed off={hide.cmp} onClick={() => setHide((h) => ({ ...h, cmp: !h.cmp }))} />}
108 + </span>
109 + </figcaption>
110 + <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10, touchAction: "pan-y" }} role="img" aria-label={serie.title}
111 + onMouseMove={(e) => {
112 + const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
113 + const fx = ((e.clientX - r.left) / r.width) * W;
114 + setHover(Math.round(((fx - PL) / (W - PL - PR)) * (pts.length - 1)));
115 + }}
116 + onMouseLeave={() => setHover(null)}>
117 + {[0, 1, 2, 3, 4].map((g) => {
118 + const y = PT + ((H - PT - PB) * g) / 4;
119 + const v = vmax - ((vmax - vmin) * g) / 4;
120 + return (
121 + <g key={g}>
122 + <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />
123 + <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>
124 + </g>
125 + );
126 + })}
127 + {[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => (
128 + <text key={i} x={X(i, pts.length)} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>
129 + ))}
130 + {!hide.cmp && serie.compare && serie.compare.length > 1 && (
131 + <path d={path(serie.compare)} fill="none" stroke="var(--ink-3)" strokeWidth={1.4} strokeDasharray="4 4" />
132 + )}
133 + {!hide.cur && <path d={path(pts)} fill="none" stroke="var(--accent)" strokeWidth={2.4} />}
134 + {hi !== null && (
135 + <g>
136 + <line x1={X(hi, pts.length)} x2={X(hi, pts.length)} y1={PT} y2={H - PB} stroke="var(--ink)" strokeWidth={1} strokeDasharray="2 3" />
137 + <circle cx={X(hi, pts.length)} cy={Y(pts[hi].v)} r={4} fill="var(--accent)" stroke="var(--ink)" strokeWidth={1.5} />
138 + </g>
139 + )}
140 + </svg>
141 + {hi !== null && (
142 + <p className="chip" style={{ marginTop: 8 }}>
143 + {pts[hi].t} — <b>{fmtNum(pts[hi].v)}{serie.unit ? ` ${serie.unit}` : ""}</b>
144 + {serie.compare?.[hi] && !hide.cmp ? <span style={{ color: "var(--ink-3)" }}> · N-1 : {fmtNum(serie.compare[hi].v)}</span> : null}
145 + </p>
146 + )}
147 + </figure>
148 + );
149 +}
150 +
151 +function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off: boolean; dashed?: boolean; onClick: () => void }) {
152 + return (
153 + <button type="button" onClick={onClick} aria-pressed={!off}
154 + style={{ display: "inline-flex", alignItems: "center", gap: 6, border: 0, background: "none", cursor: "pointer", opacity: off ? 0.4 : 1, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", minHeight: 44 }}>
155 + <span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} />
156 + {label}
157 + </button>
158 + );
159 +}
160 +
161 +/* ---------- Barres horizontales (répartitions, géo) ---------- */
162 +export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) {
163 + const rows = (items ?? []).slice(0, 14);
164 + if (!rows.length) return <EmptyBlock title={title} />;
165 + const max = Math.max(...rows.map((r) => r.value), 1);
166 + return (
167 + <figure className="card" style={{ margin: 0, padding: 16 }}>
168 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption>
169 + <div style={{ marginTop: 12, display: "grid", gap: 9 }}>
170 + {rows.map((r) => (
171 + <div key={r.label} title={`${r.label} — ${fmtNum(r.value)}${unit ? ` ${unit}` : ""}`}>
172 + <div style={{ display: "flex", justifyContent: "space-between", fontSize: 12.5 }}>
173 + <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
174 + <b style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}>{fmtNum(r.value)}{unit ? ` ${unit}` : ""}</b>
175 + </div>
176 + <div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}>
177 + <div style={{ height: "100%", width: `${Math.max((r.value / max) * 100, 1)}%`, background: "var(--accent)", border: "1px solid var(--ink)", borderRadius: "0 3px 3px 0", boxSizing: "border-box" }} />
178 + </div>
179 + </div>
180 + ))}
181 + </div>
182 + </figure>
183 + );
184 +}
185 +
186 +/* ---------- Anneau ---------- */
187 +export function Donut({ title, items }: { title: string; items: BreakItem[] }) {
188 + const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8);
189 + const total = rows.reduce((s, r) => s + r.value, 0);
190 + if (!total) return <EmptyBlock title={title} />;
191 + const R = 74, C = 2 * Math.PI * R;
192 + let acc = 0;
193 + const shades = [1, 0.78, 0.58, 0.42, 0.3, 0.22, 0.15, 0.1];
194 + return (
195 + <figure className="card" style={{ margin: 0, padding: 16 }}>
196 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption>
197 + <div style={{ display: "flex", flexWrap: "wrap", gap: 18, alignItems: "center", marginTop: 12 }}>
198 + <svg viewBox="0 0 200 200" style={{ width: 180, maxWidth: "100%" }} role="img" aria-label={title}>
199 + {rows.map((r, i) => {
200 + const frac = r.value / total;
201 + const off = acc; acc += frac;
202 + return (
203 + <circle key={r.label} cx={100} cy={100} r={R} fill="none"
204 + stroke="var(--accent)" strokeOpacity={shades[i % shades.length]}
205 + strokeWidth={30} strokeDasharray={`${frac * C} ${C}`} strokeDashoffset={-off * C}
206 + transform="rotate(-90 100 100)">
207 + <title>{`${r.label} — ${fmtNum(r.value)} (${((100 * r.value) / total).toFixed(1)} %)`}</title>
208 + </circle>
209 + );
210 + })}
211 + <circle cx={100} cy={100} r={R} fill="none" stroke="var(--ink)" strokeWidth={1} opacity={0.5} />
212 + </svg>
213 + <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 6, minWidth: 200, flex: 1 }}>
214 + {rows.map((r, i) => (
215 + <li key={r.label} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5 }}>
216 + <span style={{ width: 11, height: 11, borderRadius: 3, border: "1px solid var(--ink)", background: "var(--accent)", opacity: shades[i % shades.length] }} />
217 + <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
218 + <b style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}>{((100 * r.value) / total).toFixed(1)} %</b>
219 + </li>
220 + ))}
221 + </ul>
222 + </div>
223 + </figure>
224 + );
225 +}
226 +
227 +/* ---------- Calendrier de chaleur ---------- */
228 +export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) {
229 + if (!cells?.length) return <EmptyBlock title={title} />;
230 + const byDate = new Map(cells.map((c) => [c.date, c.value]));
231 + const dates = cells.map((c) => c.date).sort();
232 + const end = new Date(dates[dates.length - 1] + "T12:00:00");
233 + const max = Math.max(...cells.map((c) => c.value), 1);
234 + const weeks = 26, cols: { date: string; v: number }[][] = [];
235 + const cur = new Date(end);
236 + cur.setDate(cur.getDate() - (weeks * 7 - 1));
237 + for (let w = 0; w < weeks; w++) {
238 + const col: { date: string; v: number }[] = [];
239 + for (let d = 0; d < 7; d++) {
240 + const iso = cur.toISOString().slice(0, 10);
241 + col.push({ date: iso, v: byDate.get(iso) ?? 0 });
242 + cur.setDate(cur.getDate() + 1);
243 + }
244 + cols.push(col);
245 + }
246 + return (
247 + <figure className="card" style={{ margin: 0, padding: 16 }}>
248 + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b> <span className="klabel">26 dernières semaines</span></figcaption>
249 + <div className="tbl-wrap" style={{ marginTop: 12 }}>
250 + <svg viewBox={`0 0 ${weeks * 14} ${7 * 14}`} style={{ minWidth: 480, width: "100%", height: "auto" }} role="img" aria-label={title}>
251 + {cols.map((col, w) => col.map((c, d) => (
252 + <rect key={c.date} x={w * 14} y={d * 14} width={12} height={12} rx={2.5}
253 + fill={c.v ? "var(--accent)" : "rgba(20,24,20,0.07)"} fillOpacity={c.v ? 0.25 + 0.75 * (c.v / max) : 1}
254 + stroke="rgba(20,24,20,0.15)" strokeWidth={0.5}>
255 + <title>{`${c.date} — ${fmtNum(c.v)}`}</title>
256 + </rect>
257 + )))}
258 + </svg>
259 + </div>
260 + </figure>
261 + );
262 +}
263 +
264 +/* ---------- Tableau : tri, recherche, pagination ---------- */
265 +export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) {
266 + const [q, setQ] = useState("");
267 + const [sort, setSort] = useState<{ col: number; dir: 1 | -1 } | null>(null);
268 + const [page, setPage] = useState(0);
269 + const rows = useMemo(() => {
270 + let r = spec.rows ?? [];
271 + if (q) r = r.filter((row) => row.some((c) => String(c).toLowerCase().includes(q.toLowerCase())));
272 + if (sort) r = [...r].sort((a, b) => {
273 + const x = a[sort.col], y = b[sort.col];
274 + const nx = typeof x === "number" ? x : parseFloat(String(x).replace(/[^\d.,-]/g, "").replace(",", "."));
275 + const ny = typeof y === "number" ? y : parseFloat(String(y).replace(/[^\d.,-]/g, "").replace(",", "."));
276 + if (!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx - ny) * sort.dir;
277 + return String(x).localeCompare(String(y), "fr") * sort.dir;
278 + });
279 + return r;
280 + }, [spec.rows, q, sort]);
281 + const pages = Math.max(1, Math.ceil(rows.length / pageSize));
282 + const cur = Math.min(page, pages - 1);
283 + return (
284 + <section className="card" style={{ padding: 16 }}>
285 + <div style={{ display: "flex", flexWrap: "wrap", gap: 10, justifyContent: "space-between", alignItems: "center" }}>
286 + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{spec.title}</b>
287 + <input className="input" style={{ maxWidth: 240 }} placeholder="Rechercher…" value={q}
288 + onChange={(e) => { setQ(e.target.value); setPage(0); }} aria-label={`Rechercher dans ${spec.title}`} />
289 + </div>
290 + <div className="tbl-wrap" style={{ marginTop: 10 }}>
291 + <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
292 + <thead>
293 + <tr>
294 + {spec.columns.map((c, i) => (
295 + <th key={c} onClick={() => setSort((s) => ({ col: i, dir: s?.col === i && s.dir === 1 ? -1 : 1 }))}
296 + style={{ cursor: "pointer", textAlign: "left", padding: "8px 10px", background: "var(--ink)", color: "var(--paper)", fontFamily: "var(--font-mono)", fontSize: 10.5, textTransform: "uppercase", letterSpacing: "0.06em", whiteSpace: "nowrap", userSelect: "none" }}
297 + aria-sort={sort?.col === i ? (sort.dir === 1 ? "ascending" : "descending") : "none"}>
298 + {c} {sort?.col === i ? (sort.dir === 1 ? "▲" : "▼") : "↕"}
299 + </th>
300 + ))}
301 + </tr>
302 + </thead>
303 + <tbody>
304 + {rows.slice(cur * pageSize, (cur + 1) * pageSize).map((row, ri) => (
305 + <tr key={ri} style={{ background: ri % 2 ? "var(--surface-2)" : "var(--surface)" }}>
306 + {row.map((c, ci) => (
307 + <td key={ci} style={{ padding: "7px 10px", borderBottom: "1px solid var(--line)", whiteSpace: "nowrap" }}>
308 + {typeof c === "number" ? fmtNum(c) : c}
309 + </td>
310 + ))}
311 + </tr>
312 + ))}
313 + </tbody>
314 + </table>
315 + </div>
316 + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, flexWrap: "wrap", gap: 8 }}>
317 + <span className="klabel">{fmtInt(rows.length)} lignes</span>
318 + <span style={{ display: "flex", gap: 6 }}>
319 + <button type="button" className="btn btn-ghost" disabled={cur === 0} onClick={() => setPage(cur - 1)}>←</button>
320 + <span className="chip">{cur + 1} / {pages}</span>
321 + <button type="button" className="btn btn-ghost" disabled={cur >= pages - 1} onClick={() => setPage(cur + 1)}>→</button>
322 + </span>
323 + </div>
324 + </section>
325 + );
326 +}
327 +
328 +/* ---------- Records / faits marquants ---------- */
329 +export function RecordCard({ r }: { r: RecordFact }) {
330 + return (
331 + <article className="card" style={{ padding: "12px 16px", display: "flex", justifyContent: "space-between", gap: 12, alignItems: "baseline", background: "var(--surface-2)" }}>
332 + <span style={{ fontSize: 13, color: "var(--ink-2)" }}>{r.label}</span>
333 + <span style={{ textAlign: "right" }}>
334 + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{r.value}</b>
335 + {r.date && <span className="klabel" style={{ display: "block" }}>{r.date}</span>}
336 + </span>
337 + </article>
338 + );
339 +}
340 +
341 +/* ---------- Bouton PDF ---------- */
342 +export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) {
343 + const [busy, setBusy] = useState(false);
344 + const url = (mode: string) => {
345 + const p = new URLSearchParams({ period, mode });
346 + if (from) p.set("from", from);
347 + if (to) p.set("to", to);
348 + return `${endpoint}?${p}`;
349 + };
350 + const dl = (mode: string) => {
351 + setBusy(true);
352 + const a = document.createElement("a");
353 + a.href = url(mode);
354 + a.download = "";
355 + document.body.appendChild(a);
356 + a.click();
357 + a.remove();
358 + setTimeout(() => setBusy(false), 2500);
359 + };
360 + return (
361 + <span style={{ display: "inline-flex", gap: 8, flexWrap: "wrap" }}>
362 + <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={busy}>
363 + {busy ? "Génération…" : "⬇ Télécharger le rapport PDF"}
364 + </button>
365 + <button type="button" className="btn btn-ghost" onClick={() => dl("synthese")} disabled={busy}>
366 + Synthèse (2 p.)
367 + </button>
368 + </span>
369 + );
370 +}
371 +
372 +/* ---------- États ---------- */
373 +export function EmptyBlock({ title }: { title: string }) {
374 + return (
375 + <div className="card" style={{ padding: 20, background: "var(--surface-2)", borderStyle: "dashed" }}>
376 + <b style={{ fontFamily: "var(--font-display)", fontSize: 14 }}>{title}</b>
377 + <p className="klabel" style={{ margin: "6px 0 0" }}>Pas encore mesuré — aucune donnée disponible pour cette période.</p>
378 + </div>
379 + );
380 +}
381 +
382 +export function Fraicheur({ updated, onRefresh }: { updated: string; onRefresh: () => void }) {
383 + return (
384 + <p style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap", margin: 0 }}>
385 + <span className="klabel">Mis à jour le {new Date(updated).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" })}</span>
386 + <button type="button" className="btn btn-ghost" onClick={onRefresh}>↻ Rafraîchir</button>
387 + </p>
388 + );
389 +}
added frontend/src/ka/stats/kapdf.py +563 −0
@@ -0,0 +1,563 @@
1 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2).
3 +# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4 +# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques
5 +# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.
6 +# Usage :
7 +# from kapdf import GroupeKAReport
8 +# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
9 +# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
10 +# mode="complet").build()
11 +# Dépendance : pip install fpdf2 (aucune autre)
12 +from __future__ import annotations
13 +
14 +import math
15 +from datetime import datetime
16 +from zoneinfo import ZoneInfo
17 +
18 +from fpdf import FPDF
19 +
20 +INK = (20, 24, 20)
21 +INK2 = (77, 85, 81)
22 +INK3 = (139, 146, 140)
23 +PAPER = (245, 243, 238)
24 +SURFACE2 = (250, 249, 245)
25 +GREEN = (28, 92, 65)
26 +DANGER = (179, 66, 58)
27 +WHITE = (255, 255, 255)
28 +
29 +EMAILS = [
30 + ("contact@groupe-ka.com", "Projets, partenariats & données"),
31 + ("info@groupe-ka.com", "Médias & questions générales"),
32 + ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),
33 +]
34 +DISCLAIMER = (
35 + "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "
36 + "rien et ne sommes partie à aucune transaction. Données lues à la source, "
37 + "rien d'inventé, tout est traçable."
38 +)
39 +
40 +
41 +def _hex(c: str) -> tuple[int, int, int]:
42 + c = c.lstrip("#")
43 + return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore
44 +
45 +
46 +def _fr(n) -> str:
47 + if isinstance(n, float) and not n.is_integer():
48 + return f"{n:,.2f}".replace(",", " ").replace(".", ",")
49 + return f"{int(n):,}".replace(",", " ")
50 +
51 +
52 +_SUBST = {
53 + "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
54 + "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
55 + "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
56 +}
57 +
58 +
59 +def _latin1(s: str) -> str:
60 + for k, v in _SUBST.items():
61 + s = s.replace(k, v)
62 + return s.encode("latin-1", "replace").decode("latin-1")
63 +
64 +
65 +class _PDF(FPDF):
66 + """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
67 + Les polices core sont latin-1 : normalize_text sanitise en amont."""
68 +
69 + def normalize_text(self, text):
70 + return super().normalize_text(_latin1(text))
71 +
72 + def __init__(self, brand: str, accent: tuple, period_label: str):
73 + super().__init__(orientation="P", unit="mm", format="A4")
74 + self.brand = brand
75 + self.accent = accent
76 + self.period_label = period_label
77 + self.cover_mode = False
78 + self.has_cover = 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 or (self.has_cover and self.page_no() == 1):
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 + # la couverture (page 1) ne porte jamais le pied de page standard :
100 + # son footer se déclenche au add_page suivant, quand cover_mode est
101 + # déjà retombé — on la repère donc par son numéro de page.
102 + if self.cover_mode or (self.has_cover and self.page_no() == 1):
103 + return
104 + self.set_y(-15)
105 + self.set_draw_color(*INK3)
106 + self.set_line_width(0.2)
107 + self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)
108 + self.set_font("helvetica", "", 7.5)
109 + self.set_text_color(*INK3)
110 + year = datetime.now(ZoneInfo("America/Toronto")).year
111 + self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")
112 + self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")
113 +
114 +
115 +class GroupeKAReport:
116 + def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
117 + self.site = site
118 + self.d = dashboard
119 + self.mode = mode
120 + self.accent = _hex(site.get("accent", "#d9f26b"))
121 + period = dashboard.get("period", {}) or {}
122 + self.period_label = period.get("label") or "toute la période"
123 + self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
124 + self.toc: list[tuple[str, int]] = []
125 +
126 + # ---------- primitives ----------
127 + def _card(self, x, y, w, h, fill=WHITE):
128 + p = self.pdf
129 + p.set_draw_color(*INK)
130 + p.set_line_width(0.45)
131 + p.set_fill_color(*fill)
132 + p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
133 +
134 + def _kicker(self, text):
135 + p = self.pdf
136 + p.set_font("helvetica", "B", 8)
137 + p.set_text_color(*GREEN)
138 + p.set_draw_color(*GREEN)
139 + p.set_line_width(0.6)
140 + y = p.get_y() + 2
141 + p.line(p.l_margin, y, p.l_margin + 7, y)
142 + p.set_xy(p.l_margin + 9, y - 2.5)
143 + p.cell(0, 5, text.upper())
144 + p.ln(8)
145 +
146 + def _section_title(self, title):
147 + if self.pdf.get_y() > 240:
148 + self.pdf.add_page()
149 + self._kicker("Groupe KA · " + self.site.get("wordmark", ""))
150 + self.pdf.set_font("helvetica", "B", 15)
151 + self.pdf.set_text_color(*INK)
152 + self.pdf.set_x(self.pdf.l_margin)
153 + self.pdf.cell(0, 8, title)
154 + self.toc.append((title, self.pdf.page_no()))
155 + self.pdf.ln(11)
156 +
157 + # ---------- pages ----------
158 + def _cover(self):
159 + p = self.pdf
160 + p.cover_mode = True
161 + p.has_cover = True
162 + p.set_auto_page_break(False)
163 + p.add_page()
164 + p.set_fill_color(*PAPER)
165 + p.rect(0, 0, 210, 297, style="F")
166 + p.set_draw_color(*INK)
167 + p.set_line_width(1.0)
168 + p.rect(10, 10, 190, 277)
169 + # kicker
170 + p.set_font("helvetica", "B", 10)
171 + p.set_text_color(*GREEN)
172 + p.set_xy(24, 34)
173 + p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
174 + # wordmark : partie gauche + boîte encre/accent
175 + wm = self.site.get("wordmark", "")
176 + left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
177 + p.set_xy(24, 70)
178 + p.set_font("helvetica", "B", 40)
179 + p.set_text_color(*INK)
180 + p.cell(p.get_string_width(left) + 2, 20, left)
181 + if boxed:
182 + bw = p.get_string_width(boxed) + 12
183 + x = p.get_x() + 2
184 + p.set_fill_color(*INK)
185 + p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)
186 + p.set_text_color(*self.accent)
187 + p.set_xy(x + 6, 70)
188 + p.cell(bw - 12, 18, boxed)
189 + p.set_xy(24, 100)
190 + p.set_font("helvetica", "", 13)
191 + p.set_text_color(*INK2)
192 + p.multi_cell(150, 7, f"Rapport statistique — {wm}")
193 + now = datetime.now(ZoneInfo("America/Toronto"))
194 + per = self.d.get("period", {}) or {}
195 + p.set_xy(24, 125)
196 + p.set_font("helvetica", "", 10.5)
197 + rows = [
198 + ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
199 + ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
200 + ("Plateforme", "https://" + self.site.get("domain", "")),
201 + ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),
202 + ]
203 + y = 128
204 + for k, v in rows:
205 + p.set_xy(24, y)
206 + p.set_text_color(*INK3)
207 + p.cell(40, 6, k)
208 + p.set_text_color(*INK)
209 + p.set_font("helvetica", "B", 10.5)
210 + p.cell(0, 6, str(v))
211 + p.set_font("helvetica", "", 10.5)
212 + y += 8
213 + # bande encre au pied
214 + p.set_fill_color(*INK)
215 + p.rect(10, 262, 190, 25, style="F")
216 + p.set_xy(24, 270)
217 + p.set_font("helvetica", "B", 12)
218 + p.set_text_color(*WHITE)
219 + p.cell(60, 8, "par Groupe ")
220 + p.set_text_color(*self.accent)
221 + p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)
222 + p.cell(20, 8, "KA")
223 + p.set_font("helvetica", "B", 10)
224 + p.set_xy(24, 270)
225 + p.set_text_color(*self.accent)
226 + p.cell(162, 8, "groupe-ka.com", align="R")
227 + p.set_auto_page_break(True, margin=22)
228 + p.cover_mode = False
229 +
230 + def _kpis(self):
231 + kpis = self.d.get("kpis") or []
232 + if not kpis:
233 + return
234 + self._section_title("Synthèse des indicateurs")
235 + p = self.pdf
236 + cols, gw, gh, gap = 3, 56, 26, 3
237 + x0, y = p.l_margin, p.get_y()
238 + for i, k in enumerate(kpis[:9]):
239 + x = x0 + (i % cols) * (gw + gap)
240 + if i and i % cols == 0:
241 + y += gh + gap
242 + if y > 250:
243 + p.add_page(); y = p.get_y()
244 + self._card(x, y, gw, gh)
245 + p.set_xy(x + 4, y + 4)
246 + p.set_font("helvetica", "B", 14)
247 + p.set_text_color(*INK)
248 + val = k.get("value")
249 + p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))
250 + p.set_xy(x + 4, y + 12)
251 + p.set_font("helvetica", "", 7.6)
252 + p.set_text_color(*INK2)
253 + p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])
254 + if k.get("delta_pct") is not None:
255 + up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"
256 + p.set_xy(x + 4, y + gh - 6.5)
257 + p.set_font("helvetica", "B", 8)
258 + p.set_text_color(*(GREEN if up else DANGER))
259 + arrow = "+" if k["delta_pct"] >= 0 else ""
260 + p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")
261 + p.set_y(y + gh + 8)
262 +
263 + def _line_chart(self, s):
264 + p = self.pdf
265 + pts = s.get("points") or []
266 + if len(pts) < 2:
267 + return
268 + if p.get_y() > 200:
269 + p.add_page()
270 + p.set_font("helvetica", "B", 10)
271 + p.set_text_color(*INK)
272 + p.cell(0, 6, s.get("title", ""))
273 + p.ln(7)
274 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
275 + self._card(x0, y0, w, h, fill=WHITE)
276 + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
277 + vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]
278 + vmax = max(vals) or 1
279 + vmin = min(0, min(vals))
280 + rng = (vmax - vmin) or 1
281 + # grille + graduations
282 + p.set_font("helvetica", "", 6.3)
283 + p.set_text_color(*INK3)
284 + p.set_draw_color(200, 200, 195)
285 + p.set_line_width(0.15)
286 + for g in range(5):
287 + gy = cy + ch - ch * g / 4
288 + p.line(cx, gy, cx + cw, gy)
289 + p.set_xy(x0 + 1, gy - 1.6)
290 + p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
291 +
292 + def draw(series, color, width, dash=None):
293 + n = len(series)
294 + p.set_draw_color(*color)
295 + p.set_line_width(width)
296 + if dash:
297 + p.set_dash_pattern(dash=1.2, gap=1.2)
298 + last = None
299 + for i, pt in enumerate(series):
300 + px = cx + cw * (i / (n - 1))
301 + py = cy + ch - ch * ((pt["v"] - vmin) / rng)
302 + if last:
303 + p.line(last[0], last[1], px, py)
304 + last = (px, py)
305 + p.set_dash_pattern()
306 +
307 + if s.get("compare"):
308 + draw(s["compare"], INK3, 0.35, dash=True)
309 + draw(pts, self.accent, 0.7)
310 + # libellés d'axe X (premier / milieu / dernier)
311 + p.set_text_color(*INK3)
312 + for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
313 + p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
314 + p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")
315 + p.set_y(y0 + h + 4)
316 + if s.get("compare"):
317 + p.set_font("helvetica", "", 6.8)
318 + p.set_text_color(*INK3)
319 + p.cell(0, 4, "— période courante (accent) · ---- période comparée")
320 + p.ln(6)
321 + else:
322 + p.ln(2)
323 +
324 + def _bars(self, title, items, unit=""):
325 + p = self.pdf
326 + items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]
327 + if not items:
328 + return
329 + need = 10 + len(items) * 7
330 + if p.get_y() + need > 265:
331 + p.add_page()
332 + p.set_font("helvetica", "B", 10)
333 + p.set_text_color(*INK)
334 + p.cell(0, 6, title)
335 + p.ln(8)
336 + vmax = max(it["value"] for it in items) or 1
337 + for it in items:
338 + y = p.get_y()
339 + p.set_font("helvetica", "", 7.6)
340 + p.set_text_color(*INK)
341 + p.set_x(p.l_margin)
342 + p.cell(46, 5, str(it["label"])[:34])
343 + bw = 96 * (it["value"] / vmax)
344 + p.set_fill_color(*self.accent)
345 + p.set_draw_color(*INK)
346 + p.set_line_width(0.25)
347 + p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
348 + p.set_xy(p.l_margin + 148, y)
349 + p.set_font("helvetica", "B", 7.6)
350 + p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
351 + p.ln(6.4)
352 + p.ln(3)
353 +
354 + def _donut(self, b):
355 + # anneau vectoriel simple (arcs) + légende
356 + p = self.pdf
357 + items = [it for it in (b.get("items") or []) if it.get("value")][:8]
358 + total = sum(it["value"] for it in items)
359 + if not items or not total:
360 + return
361 + if p.get_y() > 210:
362 + p.add_page()
363 + p.set_font("helvetica", "B", 10)
364 + p.set_text_color(*INK)
365 + p.cell(0, 6, b.get("title", ""))
366 + p.ln(8)
367 + cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
368 + shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
369 + start = -90.0
370 + for i, it in enumerate(items):
371 + frac = it["value"] / total
372 + f = shades[i % len(shades)]
373 + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
374 + steps = max(2, int(72 * frac))
375 + p.set_fill_color(*col)
376 + p.set_draw_color(*col)
377 + for st in range(steps):
378 + a0 = math.radians(start + 360 * frac * st / steps)
379 + a1 = math.radians(start + 360 * frac * (st + 1) / steps)
380 + p.polygon(
381 + [(cx, cy),
382 + (cx + r * math.cos(a0), cy + r * math.sin(a0)),
383 + (cx + r * math.cos(a1), cy + r * math.sin(a1))],
384 + style="DF",
385 + )
386 + start += 360 * frac
387 + p.set_fill_color(*WHITE)
388 + p.set_draw_color(*INK)
389 + p.set_line_width(0.4)
390 + p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
391 + p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
392 + # légende
393 + ly = cy - 22
394 + for i, it in enumerate(items):
395 + f = shades[i % len(shades)]
396 + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
397 + p.set_fill_color(*col)
398 + p.set_draw_color(*INK)
399 + p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
400 + p.set_xy(p.l_margin + 66, ly)
401 + p.set_font("helvetica", "", 7.6)
402 + p.set_text_color(*INK)
403 + pct = 100 * it["value"] / total
404 + p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))
405 + ly += 5.6
406 + p.set_y(max(cy + r, ly) + 6)
407 +
408 + def _table(self, t):
409 + p = self.pdf
410 + cols = t.get("columns") or []
411 + rows = t.get("rows") or []
412 + if not cols or not rows:
413 + return
414 + self._section_title(t.get("title", "Tableau"))
415 + w = 174 / len(cols)
416 + def head():
417 + p.set_font("helvetica", "B", 7.6)
418 + p.set_fill_color(*INK)
419 + p.set_text_color(*WHITE)
420 + for c in cols:
421 + p.cell(w, 6, " " + str(c)[:30], fill=True)
422 + p.ln(6)
423 + head()
424 + p.set_text_color(*INK)
425 + for i, row in enumerate(rows[:200]):
426 + if p.get_y() > 262:
427 + p.add_page()
428 + head()
429 + p.set_text_color(*INK)
430 + p.set_font("helvetica", "", 7.4)
431 + p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))
432 + for cell in row:
433 + txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
434 + p.cell(w, 5.4, " " + txt[:34], fill=True)
435 + p.ln(5.4)
436 + if len(rows) > 200:
437 + p.set_font("helvetica", "", 7)
438 + p.set_text_color(*INK3)
439 + p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")
440 + p.ln(6)
441 +
442 + def _records(self):
443 + recs = self.d.get("records") or []
444 + if not recs:
445 + return
446 + self._section_title("Records & faits marquants")
447 + p = self.pdf
448 + for r in recs[:10]:
449 + if p.get_y() > 258:
450 + p.add_page()
451 + y = p.get_y()
452 + self._card(p.l_margin, y, 174, 11, fill=SURFACE2)
453 + p.set_xy(p.l_margin + 4, y + 2)
454 + p.set_font("helvetica", "", 8.6)
455 + p.set_text_color(*INK2)
456 + p.cell(96, 7, str(r.get("label", ""))[:70])
457 + p.set_font("helvetica", "B", 9)
458 + p.set_text_color(*INK)
459 + p.cell(52, 7, str(r.get("value", ""))[:36], align="R")
460 + p.set_font("helvetica", "", 7.6)
461 + p.set_text_color(*INK3)
462 + p.cell(20, 7, str(r.get("date", "") or ""), align="R")
463 + p.set_y(y + 13.5)
464 + p.ln(4)
465 +
466 + def _final_page(self):
467 + p = self.pdf
468 + p.add_page()
469 + self._kicker("Groupe KA · contact")
470 + p.set_font("helvetica", "B", 15)
471 + p.set_text_color(*INK)
472 + p.cell(0, 8, "Coordonnées du Groupe KA")
473 + p.ln(12)
474 + for email, role in EMAILS:
475 + p.set_font("helvetica", "B", 10.5)
476 + p.set_text_color(*INK)
477 + p.cell(0, 6, email)
478 + p.ln(5.5)
479 + p.set_font("helvetica", "", 8.6)
480 + p.set_text_color(*INK3)
481 + p.cell(0, 5, role)
482 + p.ln(8)
483 + p.ln(2)
484 + p.set_font("helvetica", "B", 10)
485 + p.set_text_color(*GREEN)
486 + p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")
487 + p.ln(10)
488 + p.set_draw_color(*self.accent)
489 + p.set_line_width(0.8)
490 + p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())
491 + p.ln(4)
492 + p.set_font("helvetica", "", 8.6)
493 + p.set_text_color(*INK2)
494 + p.multi_cell(160, 4.6, DISCLAIMER)
495 + p.ln(4)
496 + p.set_font("helvetica", "", 7.6)
497 + p.set_text_color(*INK3)
498 + p.multi_cell(
499 + 160, 4.2,
500 + "Mentions : rapport généré automatiquement à partir des données réelles de la "
501 + "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "
502 + "de confidentialité et protection des renseignements personnels (Loi 25) : "
503 + "groupe-ka.com/conditions · /confidentialite · /loi-25.",
504 + )
505 +
506 + def _toc_page(self):
507 + # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en
508 + # page 2 en réservant la page lors du build (voir build()).
509 + pass
510 +
511 + def build(self) -> bytes:
512 + p = self.pdf
513 + p.alias_nb_pages()
514 + self._cover()
515 + if self.mode == "synthese":
516 + p.add_page()
517 + self._kpis()
518 + self._records()
519 + self._final_page()
520 + else:
521 + p.add_page()
522 + toc_page_no = p.page_no()
523 + p.add_page()
524 + self._kpis()
525 + for s in self.d.get("series") or []:
526 + if s.get("kind") == "bar":
527 + self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))
528 + else:
529 + self._line_chart(s)
530 + for b in self.d.get("breakdowns") or []:
531 + if b.get("kind") == "donut":
532 + self._donut(b)
533 + else:
534 + self._bars(b.get("title", ""), b.get("items"))
535 + geo = self.d.get("geo")
536 + if geo:
537 + self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
538 + for t in self.d.get("tables") or []:
539 + self._table(t)
540 + self._records()
541 + self._final_page()
542 + # sommaire écrit sur la page réservée (page 2)
543 + last_page = p.page
544 + p.page = toc_page_no
545 + p.set_y(22)
546 + p.set_font("helvetica", "B", 15)
547 + p.set_text_color(*INK)
548 + p.cell(0, 8, "Sommaire")
549 + p.ln(12)
550 + p.set_font("helvetica", "", 9.5)
551 + for title, page_no in self.toc:
552 + p.set_text_color(*INK)
553 + p.cell(140, 6.5, title[:80])
554 + p.set_text_color(*INK3)
555 + p.cell(0, 6.5, str(page_no), align="R")
556 + p.ln(6.5)
557 + p.page = last_page
558 + return bytes(p.output())
559 +
560 +
561 +def filename(platform_id: str, period: str) -> str:
562 + today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
563 + return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"
modified frontend/src/pages/Stats.tsx +116 −101
@@ -1,128 +1,143 @@
1 1 // ==============================================================================
2 2 // Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 3 // File: pages/Stats.tsx
4 −// Desc: Statistiques de couverture — tuiles + barres horizontales par région
5 −// et par contexte de prix, journal des synchronisations (patron des
6 −// tuiles/hbars de la page Stats de Lou·Ka).
4 +// Desc: Tableau de bord analytique — module Stats commun Groupe KA
5 +// (ka-ui/stats/SPEC.md §1) : bandeau KPI, sélecteur de période,
6 +// graphiques SVG (kacharts), répartition géographique, tableaux,
7 +// records, export PDF estampillé Groupe-KA. Données 100 % réelles
8 +// servies par /api/stats/dashboard (cache serveur 5 min).
7 9 // ==============================================================================
8 −import { useEffect, useState } from "react";
9 −import { Link } from "react-router-dom";
10 −import { CONTEXT_LABELS, Stats, fetchStats, fmtTs, sourceName } from "../api";
10 +import { useCallback, useEffect, useState } from "react";
11 +import {
12 + BarChart, BreakItem, CalendarHeatmap, DataTable, Donut, EmptyBlock,
13 + Fraicheur, Kpi, KpiCard, LineChart, PdfButton, PeriodSelector, RecordCard,
14 + RecordFact, Serie, TableSpec,
15 +} from "../ka/stats/kacharts";
11 16
12 −function HBars({ rows }: { rows: { label: string; n: number; to?: string }[] }) {
13 − const max = Math.max(1, ...rows.map((r) => r.n));
14 − return (
15 − <div className="hbars">
16 − {rows.map((r) => (
17 − <div className="hbar-row" key={r.label}>
18 − <span className="hbar-label">
19 − {r.to ? <Link to={r.to}>{r.label}</Link> : r.label}
20 − </span>
21 − <span className="hbar-track">
22 − <span className="hbar-fill" style={{ width: `${(r.n / max) * 100}%` }} />
23 − </span>
24 − <span className="hbar-value">{r.n.toLocaleString("fr-CA")}</span>
25 − </div>
26 − ))}
27 − </div>
28 − );
17 +/* ---------- contrat /api/stats/dashboard (SPEC.md §2) ---------- */
18 +type Breakdown = { id: string; title: string; kind?: "donut" | "bar"; items: BreakItem[] };
19 +type Dashboard = {
20 + updated: string;
21 + period: { from: string; to: string; label: string };
22 + kpis?: Kpi[];
23 + series?: Serie[];
24 + breakdowns?: Breakdown[];
25 + geo?: { title: string; items: BreakItem[] };
26 + heatmap?: { title: string; cells: { date: string; value: number }[] };
27 + tables?: TableSpec[];
28 + records?: RecordFact[];
29 +};
30 +
31 +async function fetchDashboard(period: string, from?: string, to?: string): Promise<Dashboard> {
32 + const p = new URLSearchParams({ period });
33 + if (from && to) { p.set("from", from); p.set("to", to); }
34 + const res = await fetch(`/api/stats/dashboard?${p}`);
35 + if (!res.ok) throw new Error(`HTTP ${res.status}`);
36 + return res.json();
29 37 }
30 38
31 39 export default function StatsPage() {
32 − const [stats, setStats] = useState<Stats | null>(null);
40 + const [period, setPeriod] = useState("30j");
41 + const [custom, setCustom] = useState<{ from: string; to: string }>({ from: "", to: "" });
42 + const [dash, setDash] = useState<Dashboard | null>(null);
33 43 const [error, setError] = useState(false);
44 + const [loading, setLoading] = useState(true);
34 45
35 − useEffect(() => {
36 − fetchStats().then(setStats).catch(() => setError(true));
37 − }, []);
46 + const load = useCallback(() => {
47 + setLoading(true);
48 + const useCustom = custom.from && custom.to;
49 + fetchDashboard(period, useCustom ? custom.from : undefined, useCustom ? custom.to : undefined)
50 + .then((d) => { setDash(d); setError(false); })
51 + .catch(() => setError(true))
52 + .finally(() => setLoading(false));
53 + }, [period, custom]);
38 54
39 − if (error) return <div className="notice container"><h2>Stats indisponibles</h2></div>;
40 − if (!stats) return <div className="notice container"><p>Chargement…</p></div>;
55 + useEffect(() => { load(); }, [load]);
56 +
57 + if (error) {
58 + return (
59 + <div className="notice container">
60 + <h2>Stats indisponibles</h2>
61 + <p>Le tableau de bord n'a pas pu être chargé. Réessayez dans un instant.</p>
62 + </div>
63 + );
64 + }
65 + if (!dash) return <div className="notice container"><p>Chargement…</p></div>;
66 +
67 + const donuts = (dash.breakdowns ?? []).filter((b) => b.kind === "donut");
68 + const bars = (dash.breakdowns ?? []).filter((b) => b.kind !== "donut");
41 69
42 70 return (
43 − <div className="container stats-page">
44 − <span className="kicker">Couverture</span>
45 − <h1 className="stats-title">La table du Québec, en chiffres</h1>
46 − <p className="sub">
47 − Volumes agrégés en direct — restaurants actifs, plats avec prix,
48 − régions couvertes et fraîcheur des synchronisations.
49 − </p>
50 −
51 − <div className="tiles">
52 − <div className="tile hero-tile">
53 − <div className="tile-v">{stats.restaurants.toLocaleString("fr-CA")}</div>
54 − <div className="tile-k">Restaurants actifs</div>
55 − </div>
56 − <div className="tile">
57 − <div className="tile-v">{stats.items.toLocaleString("fr-CA")}</div>
58 − <div className="tile-k">Plats avec prix</div>
59 − </div>
60 − <div className="tile">
61 − <div className="tile-v">{(stats.with_menu ?? 0).toLocaleString("fr-CA")}</div>
62 − <div className="tile-k">Restos avec menu complet</div>
71 + <div className="container stats-page" style={{ opacity: loading ? 0.55 : 1, transition: "opacity 0.15s" }}>
72 + {/* ---- en-tête : titre + PDF + fraîcheur (SPEC §1.7-8) ---- */}
73 + <div className="stats-head">
74 + <div>
75 + <span className="kicker">Statistiques · {dash.period.label}</span>
76 + <h1 className="stats-title">La table du Québec, en chiffres</h1>
77 + <p className="sub">
78 + Tableau de bord analytique en direct — restos référencés, menus,
79 + plats & prix suivis. Données réelles, rien d'inventé.
80 + </p>
63 81 </div>
64 − <div className="tile">
65 − <div className="tile-v">{stats.regions}</div>
66 − <div className="tile-k">Régions couvertes / 17</div>
67 − </div>
68 − <div className="tile">
69 − <div className="tile-v">{stats.chains.toLocaleString("fr-CA")}</div>
70 − <div className="tile-k">Chaînes suivies</div>
82 + <div className="stats-actions">
83 + <PdfButton period={period}
84 + from={custom.from && custom.to ? custom.from : undefined}
85 + to={custom.from && custom.to ? custom.to : undefined} />
86 + <Fraicheur updated={dash.updated} onRefresh={load} />
71 87 </div>
72 88 </div>
73 89
74 − <div className="viz-grid">
75 − <div className="viz-card">
76 − <h2>Restos par région</h2>
77 − <div className="viz-sub">Fiches actives, doublons masqués</div>
78 − <HBars rows={stats.by_region.map((r) => ({
79 − label: r.region, n: r.n,
80 − to: `/?region=${encodeURIComponent(r.region)}`,
81 − }))} />
82 − </div>
83 − <div className="viz-card">
84 − <h2>Menus par contexte de prix</h2>
85 − <div className="viz-sub">Un prix n'est jamais présenté sans son contexte</div>
86 − <HBars rows={stats.by_context.map((c) => ({
87 − label: CONTEXT_LABELS[c.price_context] ?? c.price_context, n: c.n,
88 − }))} />
90 + {/* ---- 1. bandeau KPI ---- */}
91 + {dash.kpis?.length ? (
92 + <div className="kpi-grid">
93 + {dash.kpis.map((k) => <KpiCard key={k.id} k={k} />)}
89 94 </div>
95 + ) : <EmptyBlock title="Indicateurs" />}
96 +
97 + {/* ---- 2. sélecteur de période global ---- */}
98 + <div className="card period-bar">
99 + <PeriodSelector
100 + value={period}
101 + onChange={(p) => { setPeriod(p); setCustom({ from: "", to: "" }); }}
102 + custom={custom}
103 + onCustom={(from, to) => setCustom({ from, to })}
104 + />
90 105 </div>
91 106
92 − <div className="viz-card">
93 − <h2>Dernières synchronisations</h2>
94 − <div className="viz-sub">Journal du pipeline d'ingestion</div>
95 − <div className="src-wrap" style={{ boxShadow: "none", border: "none" }}>
96 − <table className="src-table">
97 − <thead>
98 − <tr>
99 − <th>Source</th><th>Quand</th><th>Trouvés</th><th>Ajoutés</th>
100 − <th>Mis à jour</th><th>Retirés</th><th>État</th>
101 − </tr>
102 − </thead>
103 − <tbody>
104 − {stats.recent_syncs.map((s, i) => (
105 − <tr key={i}>
106 − <td>{sourceName(s.source)}</td>
107 − <td>{fmtTs(s.ts)}</td>
108 − <td>{s.found}</td>
109 − <td>{s.added}</td>
110 − <td>{s.updated}</td>
111 − <td>{s.removed}</td>
112 − <td>
113 − <span className={`pill ${s.ok ? "ok" : "todo"}`}>
114 − {s.ok ? (s.message === "ok" ? "ok" : "alerte") : "échec"}
115 − </span>
116 − </td>
117 − </tr>
118 − ))}
119 − </tbody>
120 − </table>
121 − </div>
107 + {/* ---- 3. graphiques : courbes d'évolution ---- */}
108 + <div className="stats-grid2">
109 + {(dash.series ?? []).map((s) => <LineChart key={s.id} serie={s} />)}
110 + </div>
111 +
112 + {/* ---- 3b. répartitions : anneau + barres ---- */}
113 + <div className="stats-grid2">
114 + {donuts.map((b) => <Donut key={b.id} title={b.title} items={b.items} />)}
115 + {bars.map((b) => <BarChart key={b.id} title={b.title} items={b.items} />)}
116 + {/* ---- 4. répartition géographique ---- */}
117 + {dash.geo ? <BarChart title={dash.geo.title} items={dash.geo.items} unit="restos" />
118 + : <EmptyBlock title="Répartition géographique" />}
122 119 </div>
120 +
121 + {/* ---- 3c. calendrier de chaleur ---- */}
122 + {dash.heatmap && <CalendarHeatmap title={dash.heatmap.title} cells={dash.heatmap.cells} />}
123 +
124 + {/* ---- 5. tableaux détaillés ---- */}
125 + {(dash.tables ?? []).map((t) => <DataTable key={t.id} spec={t} />)}
126 +
127 + {/* ---- 6. records & faits marquants ---- */}
128 + {dash.records?.length ? (
129 + <section>
130 + <span className="kicker">Records & faits marquants</span>
131 + <div className="records-grid">
132 + {dash.records.map((r) => <RecordCard key={r.label} r={r} />)}
133 + </div>
134 + </section>
135 + ) : null}
136 +
123 137 <div className="stats-foot">
124 138 Les prix appartiennent aux restaurants et à leurs plateformes ;
125 139 Resto-Ka les agrège pour la découverte et renvoie toujours à la source.
140 + Rapport PDF estampillé Groupe-KA disponible en haut de page.
126 141 </div>
127 142 </div>
128 143 );
modified frontend/src/styles.css +17 −0
@@ -861,3 +861,20 @@ img { display: block; }
861 861 }
862 862 .contact-sites a:hover { background: var(--accent); color: var(--on-accent); transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.2); }
863 863 .contact-sites .dot { width: 9px; height: 9px; border-radius: 50%; border: 1px solid rgba(20, 24, 20, 0.35); flex: none; }
864 +
865 +/* ================= Stats — tableau de bord Groupe KA (SPEC ka-ui/stats) ==== */
866 +.stats-page { display: grid; gap: 26px; }
867 +.stats-page .sub { margin-bottom: 0; }
868 +.stats-head { display: flex; flex-wrap: wrap; gap: 18px; justify-content: space-between; align-items: flex-end; }
869 +.stats-actions { display: grid; gap: 10px; justify-items: start; }
870 +.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(175px, 1fr)); gap: 14px; }
871 +.period-bar { padding: 12px 14px; }
872 +.stats-grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(430px, 100%), 1fr)); gap: 18px; align-items: start; }
873 +.records-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); gap: 12px; margin-top: 12px; }
874 +.stats-foot { margin-top: 12px; }
875 +@media (max-width: 767px) {
876 + .stats-page { gap: 20px; }
877 + .kpi-grid { grid-template-columns: 1fr 1fr; }
878 + .stats-head { align-items: flex-start; flex-direction: column; }
879 +}
880 +@media (max-width: 420px) { .kpi-grid { grid-template-columns: 1fr; } }
modified requirements.txt +1 −0
@@ -8,3 +8,4 @@ uvicorn>=0.29
8 8 requests>=2.31
9 9 beautifulsoup4>=4.12
10 10 anthropic>=0.116
11 +fpdf2>=2.8
added restoka/kapdf.py +563 −0
@@ -0,0 +1,563 @@
1 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2).
3 +# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4 +# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques
5 +# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.
6 +# Usage :
7 +# from kapdf import GroupeKAReport
8 +# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
9 +# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
10 +# mode="complet").build()
11 +# Dépendance : pip install fpdf2 (aucune autre)
12 +from __future__ import annotations
13 +
14 +import math
15 +from datetime import datetime
16 +from zoneinfo import ZoneInfo
17 +
18 +from fpdf import FPDF
19 +
20 +INK = (20, 24, 20)
21 +INK2 = (77, 85, 81)
22 +INK3 = (139, 146, 140)
23 +PAPER = (245, 243, 238)
24 +SURFACE2 = (250, 249, 245)
25 +GREEN = (28, 92, 65)
26 +DANGER = (179, 66, 58)
27 +WHITE = (255, 255, 255)
28 +
29 +EMAILS = [
30 + ("contact@groupe-ka.com", "Projets, partenariats & données"),
31 + ("info@groupe-ka.com", "Médias & questions générales"),
32 + ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),
33 +]
34 +DISCLAIMER = (
35 + "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "
36 + "rien et ne sommes partie à aucune transaction. Données lues à la source, "
37 + "rien d'inventé, tout est traçable."
38 +)
39 +
40 +
41 +def _hex(c: str) -> tuple[int, int, int]:
42 + c = c.lstrip("#")
43 + return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore
44 +
45 +
46 +def _fr(n) -> str:
47 + if isinstance(n, float) and not n.is_integer():
48 + return f"{n:,.2f}".replace(",", " ").replace(".", ",")
49 + return f"{int(n):,}".replace(",", " ")
50 +
51 +
52 +_SUBST = {
53 + "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
54 + "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
55 + "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
56 +}
57 +
58 +
59 +def _latin1(s: str) -> str:
60 + for k, v in _SUBST.items():
61 + s = s.replace(k, v)
62 + return s.encode("latin-1", "replace").decode("latin-1")
63 +
64 +
65 +class _PDF(FPDF):
66 + """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
67 + Les polices core sont latin-1 : normalize_text sanitise en amont."""
68 +
69 + def normalize_text(self, text):
70 + return super().normalize_text(_latin1(text))
71 +
72 + def __init__(self, brand: str, accent: tuple, period_label: str):
73 + super().__init__(orientation="P", unit="mm", format="A4")
74 + self.brand = brand
75 + self.accent = accent
76 + self.period_label = period_label
77 + self.cover_mode = False
78 + self.has_cover = 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 or (self.has_cover and self.page_no() == 1):
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 + # la couverture (page 1) ne porte jamais le pied de page standard :
100 + # son footer se déclenche au add_page suivant, quand cover_mode est
101 + # déjà retombé — on la repère donc par son numéro de page.
102 + if self.cover_mode or (self.has_cover and self.page_no() == 1):
103 + return
104 + self.set_y(-15)
105 + self.set_draw_color(*INK3)
106 + self.set_line_width(0.2)
107 + self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)
108 + self.set_font("helvetica", "", 7.5)
109 + self.set_text_color(*INK3)
110 + year = datetime.now(ZoneInfo("America/Toronto")).year
111 + self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")
112 + self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")
113 +
114 +
115 +class GroupeKAReport:
116 + def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
117 + self.site = site
118 + self.d = dashboard
119 + self.mode = mode
120 + self.accent = _hex(site.get("accent", "#d9f26b"))
121 + period = dashboard.get("period", {}) or {}
122 + self.period_label = period.get("label") or "toute la période"
123 + self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
124 + self.toc: list[tuple[str, int]] = []
125 +
126 + # ---------- primitives ----------
127 + def _card(self, x, y, w, h, fill=WHITE):
128 + p = self.pdf
129 + p.set_draw_color(*INK)
130 + p.set_line_width(0.45)
131 + p.set_fill_color(*fill)
132 + p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
133 +
134 + def _kicker(self, text):
135 + p = self.pdf
136 + p.set_font("helvetica", "B", 8)
137 + p.set_text_color(*GREEN)
138 + p.set_draw_color(*GREEN)
139 + p.set_line_width(0.6)
140 + y = p.get_y() + 2
141 + p.line(p.l_margin, y, p.l_margin + 7, y)
142 + p.set_xy(p.l_margin + 9, y - 2.5)
143 + p.cell(0, 5, text.upper())
144 + p.ln(8)
145 +
146 + def _section_title(self, title):
147 + if self.pdf.get_y() > 240:
148 + self.pdf.add_page()
149 + self._kicker("Groupe KA · " + self.site.get("wordmark", ""))
150 + self.pdf.set_font("helvetica", "B", 15)
151 + self.pdf.set_text_color(*INK)
152 + self.pdf.set_x(self.pdf.l_margin)
153 + self.pdf.cell(0, 8, title)
154 + self.toc.append((title, self.pdf.page_no()))
155 + self.pdf.ln(11)
156 +
157 + # ---------- pages ----------
158 + def _cover(self):
159 + p = self.pdf
160 + p.cover_mode = True
161 + p.has_cover = True
162 + p.set_auto_page_break(False)
163 + p.add_page()
164 + p.set_fill_color(*PAPER)
165 + p.rect(0, 0, 210, 297, style="F")
166 + p.set_draw_color(*INK)
167 + p.set_line_width(1.0)
168 + p.rect(10, 10, 190, 277)
169 + # kicker
170 + p.set_font("helvetica", "B", 10)
171 + p.set_text_color(*GREEN)
172 + p.set_xy(24, 34)
173 + p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
174 + # wordmark : partie gauche + boîte encre/accent
175 + wm = self.site.get("wordmark", "")
176 + left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
177 + p.set_xy(24, 70)
178 + p.set_font("helvetica", "B", 40)
179 + p.set_text_color(*INK)
180 + p.cell(p.get_string_width(left) + 2, 20, left)
181 + if boxed:
182 + bw = p.get_string_width(boxed) + 12
183 + x = p.get_x() + 2
184 + p.set_fill_color(*INK)
185 + p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)
186 + p.set_text_color(*self.accent)
187 + p.set_xy(x + 6, 70)
188 + p.cell(bw - 12, 18, boxed)
189 + p.set_xy(24, 100)
190 + p.set_font("helvetica", "", 13)
191 + p.set_text_color(*INK2)
192 + p.multi_cell(150, 7, f"Rapport statistique — {wm}")
193 + now = datetime.now(ZoneInfo("America/Toronto"))
194 + per = self.d.get("period", {}) or {}
195 + p.set_xy(24, 125)
196 + p.set_font("helvetica", "", 10.5)
197 + rows = [
198 + ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
199 + ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
200 + ("Plateforme", "https://" + self.site.get("domain", "")),
201 + ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),
202 + ]
203 + y = 128
204 + for k, v in rows:
205 + p.set_xy(24, y)
206 + p.set_text_color(*INK3)
207 + p.cell(40, 6, k)
208 + p.set_text_color(*INK)
209 + p.set_font("helvetica", "B", 10.5)
210 + p.cell(0, 6, str(v))
211 + p.set_font("helvetica", "", 10.5)
212 + y += 8
213 + # bande encre au pied
214 + p.set_fill_color(*INK)
215 + p.rect(10, 262, 190, 25, style="F")
216 + p.set_xy(24, 270)
217 + p.set_font("helvetica", "B", 12)
218 + p.set_text_color(*WHITE)
219 + p.cell(60, 8, "par Groupe ")
220 + p.set_text_color(*self.accent)
221 + p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)
222 + p.cell(20, 8, "KA")
223 + p.set_font("helvetica", "B", 10)
224 + p.set_xy(24, 270)
225 + p.set_text_color(*self.accent)
226 + p.cell(162, 8, "groupe-ka.com", align="R")
227 + p.set_auto_page_break(True, margin=22)
228 + p.cover_mode = False
229 +
230 + def _kpis(self):
231 + kpis = self.d.get("kpis") or []
232 + if not kpis:
233 + return
234 + self._section_title("Synthèse des indicateurs")
235 + p = self.pdf
236 + cols, gw, gh, gap = 3, 56, 26, 3
237 + x0, y = p.l_margin, p.get_y()
238 + for i, k in enumerate(kpis[:9]):
239 + x = x0 + (i % cols) * (gw + gap)
240 + if i and i % cols == 0:
241 + y += gh + gap
242 + if y > 250:
243 + p.add_page(); y = p.get_y()
244 + self._card(x, y, gw, gh)
245 + p.set_xy(x + 4, y + 4)
246 + p.set_font("helvetica", "B", 14)
247 + p.set_text_color(*INK)
248 + val = k.get("value")
249 + p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))
250 + p.set_xy(x + 4, y + 12)
251 + p.set_font("helvetica", "", 7.6)
252 + p.set_text_color(*INK2)
253 + p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])
254 + if k.get("delta_pct") is not None:
255 + up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"
256 + p.set_xy(x + 4, y + gh - 6.5)
257 + p.set_font("helvetica", "B", 8)
258 + p.set_text_color(*(GREEN if up else DANGER))
259 + arrow = "+" if k["delta_pct"] >= 0 else ""
260 + p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")
261 + p.set_y(y + gh + 8)
262 +
263 + def _line_chart(self, s):
264 + p = self.pdf
265 + pts = s.get("points") or []
266 + if len(pts) < 2:
267 + return
268 + if p.get_y() > 200:
269 + p.add_page()
270 + p.set_font("helvetica", "B", 10)
271 + p.set_text_color(*INK)
272 + p.cell(0, 6, s.get("title", ""))
273 + p.ln(7)
274 + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
275 + self._card(x0, y0, w, h, fill=WHITE)
276 + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
277 + vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]
278 + vmax = max(vals) or 1
279 + vmin = min(0, min(vals))
280 + rng = (vmax - vmin) or 1
281 + # grille + graduations
282 + p.set_font("helvetica", "", 6.3)
283 + p.set_text_color(*INK3)
284 + p.set_draw_color(200, 200, 195)
285 + p.set_line_width(0.15)
286 + for g in range(5):
287 + gy = cy + ch - ch * g / 4
288 + p.line(cx, gy, cx + cw, gy)
289 + p.set_xy(x0 + 1, gy - 1.6)
290 + p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
291 +
292 + def draw(series, color, width, dash=None):
293 + n = len(series)
294 + p.set_draw_color(*color)
295 + p.set_line_width(width)
296 + if dash:
297 + p.set_dash_pattern(dash=1.2, gap=1.2)
298 + last = None
299 + for i, pt in enumerate(series):
300 + px = cx + cw * (i / (n - 1))
301 + py = cy + ch - ch * ((pt["v"] - vmin) / rng)
302 + if last:
303 + p.line(last[0], last[1], px, py)
304 + last = (px, py)
305 + p.set_dash_pattern()
306 +
307 + if s.get("compare"):
308 + draw(s["compare"], INK3, 0.35, dash=True)
309 + draw(pts, self.accent, 0.7)
310 + # libellés d'axe X (premier / milieu / dernier)
311 + p.set_text_color(*INK3)
312 + for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
313 + p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
314 + p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")
315 + p.set_y(y0 + h + 4)
316 + if s.get("compare"):
317 + p.set_font("helvetica", "", 6.8)
318 + p.set_text_color(*INK3)
319 + p.cell(0, 4, "— période courante (accent) · ---- période comparée")
320 + p.ln(6)
321 + else:
322 + p.ln(2)
323 +
324 + def _bars(self, title, items, unit=""):
325 + p = self.pdf
326 + items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]
327 + if not items:
328 + return
329 + need = 10 + len(items) * 7
330 + if p.get_y() + need > 265:
331 + p.add_page()
332 + p.set_font("helvetica", "B", 10)
333 + p.set_text_color(*INK)
334 + p.cell(0, 6, title)
335 + p.ln(8)
336 + vmax = max(it["value"] for it in items) or 1
337 + for it in items:
338 + y = p.get_y()
339 + p.set_font("helvetica", "", 7.6)
340 + p.set_text_color(*INK)
341 + p.set_x(p.l_margin)
342 + p.cell(46, 5, str(it["label"])[:34])
343 + bw = 96 * (it["value"] / vmax)
344 + p.set_fill_color(*self.accent)
345 + p.set_draw_color(*INK)
346 + p.set_line_width(0.25)
347 + p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
348 + p.set_xy(p.l_margin + 148, y)
349 + p.set_font("helvetica", "B", 7.6)
350 + p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
351 + p.ln(6.4)
352 + p.ln(3)
353 +
354 + def _donut(self, b):
355 + # anneau vectoriel simple (arcs) + légende
356 + p = self.pdf
357 + items = [it for it in (b.get("items") or []) if it.get("value")][:8]
358 + total = sum(it["value"] for it in items)
359 + if not items or not total:
360 + return
361 + if p.get_y() > 210:
362 + p.add_page()
363 + p.set_font("helvetica", "B", 10)
364 + p.set_text_color(*INK)
365 + p.cell(0, 6, b.get("title", ""))
366 + p.ln(8)
367 + cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
368 + shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
369 + start = -90.0
370 + for i, it in enumerate(items):
371 + frac = it["value"] / total
372 + f = shades[i % len(shades)]
373 + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
374 + steps = max(2, int(72 * frac))
375 + p.set_fill_color(*col)
376 + p.set_draw_color(*col)
377 + for st in range(steps):
378 + a0 = math.radians(start + 360 * frac * st / steps)
379 + a1 = math.radians(start + 360 * frac * (st + 1) / steps)
380 + p.polygon(
381 + [(cx, cy),
382 + (cx + r * math.cos(a0), cy + r * math.sin(a0)),
383 + (cx + r * math.cos(a1), cy + r * math.sin(a1))],
384 + style="DF",
385 + )
386 + start += 360 * frac
387 + p.set_fill_color(*WHITE)
388 + p.set_draw_color(*INK)
389 + p.set_line_width(0.4)
390 + p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
391 + p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
392 + # légende
393 + ly = cy - 22
394 + for i, it in enumerate(items):
395 + f = shades[i % len(shades)]
396 + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
397 + p.set_fill_color(*col)
398 + p.set_draw_color(*INK)
399 + p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
400 + p.set_xy(p.l_margin + 66, ly)
401 + p.set_font("helvetica", "", 7.6)
402 + p.set_text_color(*INK)
403 + pct = 100 * it["value"] / total
404 + p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))
405 + ly += 5.6
406 + p.set_y(max(cy + r, ly) + 6)
407 +
408 + def _table(self, t):
409 + p = self.pdf
410 + cols = t.get("columns") or []
411 + rows = t.get("rows") or []
412 + if not cols or not rows:
413 + return
414 + self._section_title(t.get("title", "Tableau"))
415 + w = 174 / len(cols)
416 + def head():
417 + p.set_font("helvetica", "B", 7.6)
418 + p.set_fill_color(*INK)
419 + p.set_text_color(*WHITE)
420 + for c in cols:
421 + p.cell(w, 6, " " + str(c)[:30], fill=True)
422 + p.ln(6)
423 + head()
424 + p.set_text_color(*INK)
425 + for i, row in enumerate(rows[:200]):
426 + if p.get_y() > 262:
427 + p.add_page()
428 + head()
429 + p.set_text_color(*INK)
430 + p.set_font("helvetica", "", 7.4)
431 + p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))
432 + for cell in row:
433 + txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
434 + p.cell(w, 5.4, " " + txt[:34], fill=True)
435 + p.ln(5.4)
436 + if len(rows) > 200:
437 + p.set_font("helvetica", "", 7)
438 + p.set_text_color(*INK3)
439 + p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")
440 + p.ln(6)
441 +
442 + def _records(self):
443 + recs = self.d.get("records") or []
444 + if not recs:
445 + return
446 + self._section_title("Records & faits marquants")
447 + p = self.pdf
448 + for r in recs[:10]:
449 + if p.get_y() > 258:
450 + p.add_page()
451 + y = p.get_y()
452 + self._card(p.l_margin, y, 174, 11, fill=SURFACE2)
453 + p.set_xy(p.l_margin + 4, y + 2)
454 + p.set_font("helvetica", "", 8.6)
455 + p.set_text_color(*INK2)
456 + p.cell(96, 7, str(r.get("label", ""))[:70])
457 + p.set_font("helvetica", "B", 9)
458 + p.set_text_color(*INK)
459 + p.cell(52, 7, str(r.get("value", ""))[:36], align="R")
460 + p.set_font("helvetica", "", 7.6)
461 + p.set_text_color(*INK3)
462 + p.cell(20, 7, str(r.get("date", "") or ""), align="R")
463 + p.set_y(y + 13.5)
464 + p.ln(4)
465 +
466 + def _final_page(self):
467 + p = self.pdf
468 + p.add_page()
469 + self._kicker("Groupe KA · contact")
470 + p.set_font("helvetica", "B", 15)
471 + p.set_text_color(*INK)
472 + p.cell(0, 8, "Coordonnées du Groupe KA")
473 + p.ln(12)
474 + for email, role in EMAILS:
475 + p.set_font("helvetica", "B", 10.5)
476 + p.set_text_color(*INK)
477 + p.cell(0, 6, email)
478 + p.ln(5.5)
479 + p.set_font("helvetica", "", 8.6)
480 + p.set_text_color(*INK3)
481 + p.cell(0, 5, role)
482 + p.ln(8)
483 + p.ln(2)
484 + p.set_font("helvetica", "B", 10)
485 + p.set_text_color(*GREEN)
486 + p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")
487 + p.ln(10)
488 + p.set_draw_color(*self.accent)
489 + p.set_line_width(0.8)
490 + p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())
491 + p.ln(4)
492 + p.set_font("helvetica", "", 8.6)
493 + p.set_text_color(*INK2)
494 + p.multi_cell(160, 4.6, DISCLAIMER)
495 + p.ln(4)
496 + p.set_font("helvetica", "", 7.6)
497 + p.set_text_color(*INK3)
498 + p.multi_cell(
499 + 160, 4.2,
500 + "Mentions : rapport généré automatiquement à partir des données réelles de la "
501 + "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "
502 + "de confidentialité et protection des renseignements personnels (Loi 25) : "
503 + "groupe-ka.com/conditions · /confidentialite · /loi-25.",
504 + )
505 +
506 + def _toc_page(self):
507 + # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en
508 + # page 2 en réservant la page lors du build (voir build()).
509 + pass
510 +
511 + def build(self) -> bytes:
512 + p = self.pdf
513 + p.alias_nb_pages()
514 + self._cover()
515 + if self.mode == "synthese":
516 + p.add_page()
517 + self._kpis()
518 + self._records()
519 + self._final_page()
520 + else:
521 + p.add_page()
522 + toc_page_no = p.page_no()
523 + p.add_page()
524 + self._kpis()
525 + for s in self.d.get("series") or []:
526 + if s.get("kind") == "bar":
527 + self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))
528 + else:
529 + self._line_chart(s)
530 + for b in self.d.get("breakdowns") or []:
531 + if b.get("kind") == "donut":
532 + self._donut(b)
533 + else:
534 + self._bars(b.get("title", ""), b.get("items"))
535 + geo = self.d.get("geo")
536 + if geo:
537 + self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
538 + for t in self.d.get("tables") or []:
539 + self._table(t)
540 + self._records()
541 + self._final_page()
542 + # sommaire écrit sur la page réservée (page 2)
543 + last_page = p.page
544 + p.page = toc_page_no
545 + p.set_y(22)
546 + p.set_font("helvetica", "B", 15)
547 + p.set_text_color(*INK)
548 + p.cell(0, 8, "Sommaire")
549 + p.ln(12)
550 + p.set_font("helvetica", "", 9.5)
551 + for title, page_no in self.toc:
552 + p.set_text_color(*INK)
553 + p.cell(140, 6.5, title[:80])
554 + p.set_text_color(*INK3)
555 + p.cell(0, 6.5, str(page_no), align="R")
556 + p.ln(6.5)
557 + p.page = last_page
558 + return bytes(p.output())
559 +
560 +
561 +def filename(platform_id: str, period: str) -> str:
562 + today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
563 + return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"
added restoka/stats.py +436 −0
@@ -0,0 +1,436 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: restoka/stats.py
4 +# Desc: Tableau de bord analytique — contrat commun Groupe KA (ka-ui/stats/
5 +# SPEC.md §2). Agrège la DB réelle (restaurants, menus, item_price_log,
6 +# sync_log) : KPI avec deltas, séries quotidiennes, répartitions,
7 +# géographie, heatmap, tableaux et records. AUCUNE stat inventée :
8 +# une mesure indisponible est simplement absente du JSON.
9 +# Cache mémoire 5 min par période.
10 +# ==============================================================================
11 +from __future__ import annotations
12 +
13 +import json
14 +import statistics
15 +import threading
16 +import time
17 +from collections import Counter
18 +from datetime import date, datetime, timedelta
19 +from zoneinfo import ZoneInfo
20 +
21 +from . import db
22 +
23 +TZ = ZoneInfo("America/Toronto")
24 +CACHE_TTL = 300 # ≥ 5 min (SPEC.md §2)
25 +
26 +_cache: dict[tuple, tuple[float, dict]] = {}
27 +_cache_lock = threading.Lock()
28 +
29 +# libellés FR compacts pour cuisines/types (sous-ensemble de api.ts)
30 +_CUISINE_LABELS = {
31 + "cafe-dessert": "Café & desserts", "autre": "Autre", "burgers": "Burgers",
32 + "pizza": "Pizza", "fast-food": "Restauration rapide", "poulet": "Poulet",
33 + "quebecois": "Québécois", "sushi-japonais": "Sushi & japonais",
34 + "italien": "Italien", "bbq-grillades": "BBQ & grillades",
35 + "chinois": "Chinois", "dejeuner-brunch": "Déjeuner & brunch",
36 + "mexicain": "Mexicain", "libanais-moyen-orient": "Libanais & M-O",
37 + "thai": "Thaï", "indien": "Indien", "grec": "Grec",
38 + "vietnamien": "Vietnamien", "coreen": "Coréen", "francais": "Français",
39 + "fruits-de-mer": "Fruits de mer", "vegetarien": "Végétarien",
40 +}
41 +_TYPE_LABELS = {
42 + "restaurant": "Restaurant", "fast-food": "Restauration rapide",
43 + "cafe": "Café", "bar": "Bar", "boulangerie-patisserie":
44 + "Boulangerie-pâtisserie", "casse-croute": "Casse-croûte",
45 + "traiteur": "Traiteur", "creme-glacee": "Crème glacée",
46 +}
47 +_PRICE_BUCKETS = [
48 + ("moins de 10 $", 0, 10), ("10 à 15 $", 10, 15), ("15 à 20 $", 15, 20),
49 + ("20 à 30 $", 20, 30), ("30 à 50 $", 30, 50), ("50 $ et plus", 50, 1e9),
50 +]
51 +
52 +PERIOD_LABELS = {
53 + "auj": "Aujourd'hui", "7j": "7 jours", "30j": "30 jours",
54 + "3m": "3 mois", "6m": "6 mois", "12m": "12 mois",
55 + "annee": "Année en cours", "tout": "Toute la période",
56 +}
57 +
58 +
59 +def _day(ts: float) -> str:
60 + return datetime.fromtimestamp(ts, TZ).strftime("%Y-%m-%d")
61 +
62 +
63 +def _epoch(d: date, end: bool = False) -> float:
64 + dt = datetime(d.year, d.month, d.day, tzinfo=TZ)
65 + if end:
66 + dt += timedelta(days=1)
67 + return dt.timestamp()
68 +
69 +
70 +def _resolve_period(con, period: str, dfrom: str | None,
71 + dto: str | None) -> tuple[date, date, str]:
72 + """(from, to, label) — bornes inclusives en dates locales."""
73 + today = datetime.now(TZ).date()
74 + if dfrom and dto:
75 + try:
76 + f = date.fromisoformat(dfrom)
77 + t = date.fromisoformat(dto)
78 + if f <= t:
79 + return f, t, f"du {f} au {t}"
80 + except ValueError:
81 + pass
82 + days = {"7j": 7, "30j": 30, "3m": 91, "6m": 182, "12m": 365}
83 + if period == "auj":
84 + return today, today, PERIOD_LABELS["auj"]
85 + if period in days:
86 + return today - timedelta(days=days[period] - 1), today, \
87 + PERIOD_LABELS[period]
88 + if period == "annee":
89 + return date(today.year, 1, 1), today, f"Année {today.year}"
90 + # tout : depuis la première fiche référencée
91 + row = con.execute("SELECT MIN(first_seen) m FROM restaurants").fetchone()
92 + start = date.fromtimestamp(row["m"]) if row and row["m"] else today
93 + return start, today, PERIOD_LABELS["tout"]
94 +
95 +
96 +def _delta(cur: float, prev: float) -> float | None:
97 + if not prev:
98 + return None
99 + return round(100.0 * (cur - prev) / prev, 1)
100 +
101 +
102 +def _kpi(id_, label, value, unit="", delta_pct=None, positive_is_up=True):
103 + k = {"id": id_, "label": label, "value": value, "unit": unit,
104 + "delta_pct": delta_pct}
105 + if delta_pct is not None:
106 + up = delta_pct >= 0 if positive_is_up else delta_pct < 0
107 + k["direction"] = "up" if up else "down"
108 + return k
109 +
110 +
111 +def _daily(rows: list, f: date, t: date, cumulative: bool = False,
112 + base: int = 0) -> list[dict]:
113 + """Série quotidienne bouchée à zéro sur [f, t] à partir de {jour: n}."""
114 + by_day = dict(rows)
115 + n_days = (t - f).days + 1
116 + pts, acc = [], base
117 + step = max(1, n_days // 366) # plafonne le nombre de points
118 + d = f
119 + while d <= t:
120 + v = 0
121 + for k in range(step):
122 + v += by_day.get((d + timedelta(days=k)).isoformat(), 0)
123 + acc += v
124 + pts.append({"t": d.isoformat(), "v": acc if cumulative else v})
125 + d += timedelta(days=step)
126 + if pts and pts[-1]["t"] != t.isoformat():
127 + pts.append({"t": t.isoformat(), "v": acc if cumulative else 0})
128 + return pts
129 +
130 +
131 +def _iter_menu_items(con):
132 + for r in con.execute(
133 + "SELECT m.sections, r.chain, r.name FROM menus m"
134 + " JOIN restaurants r ON r.uid=m.uid"
135 + " WHERE r.active=1 AND r.dup_of IS NULL"):
136 + try:
137 + sections = json.loads(r["sections"] or "[]")
138 + except ValueError:
139 + continue
140 + for sec in sections:
141 + for it in sec.get("items") or []:
142 + yield r, it
143 +
144 +
145 +def _build(period: str, dfrom: str | None, dto: str | None) -> dict:
146 + con = db.connect()
147 + try:
148 + f, t, label = _resolve_period(con, period, dfrom, dto)
149 + f_ts, t_ts = _epoch(f), _epoch(t, end=True)
150 + span = (t - f).days + 1
151 + pf, pt = f - timedelta(days=span), f - timedelta(days=1)
152 + pf_ts, pt_ts = _epoch(pf), _epoch(pt, end=True)
153 +
154 + A = "active=1 AND dup_of IS NULL" # restos comptés partout
155 +
156 + # ------------------------------------------------------------ KPI ---
157 + total = con.execute(
158 + f"SELECT COUNT(*) n FROM restaurants WHERE {A}").fetchone()["n"]
159 + # proxy de stock par first_seen (croissance sur la période)
160 + stock_end = con.execute(
161 + f"SELECT COUNT(*) n FROM restaurants WHERE {A} AND first_seen<?",
162 + (t_ts,)).fetchone()["n"]
163 + stock_start = con.execute(
164 + f"SELECT COUNT(*) n FROM restaurants WHERE {A} AND first_seen<?",
165 + (f_ts,)).fetchone()["n"]
166 +
167 + with_menu = con.execute(
168 + f"SELECT COUNT(*) n FROM restaurants WHERE {A} AND EXISTS"
169 + " (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"
170 + ).fetchone()["n"]
171 +
172 + items = con.execute(
173 + "SELECT COALESCE(SUM(m.item_count),0) n FROM menus m"
174 + " JOIN restaurants r ON r.uid=m.uid"
175 + " WHERE r.active=1 AND r.dup_of IS NULL").fetchone()["n"]
176 +
177 + chains = con.execute(
178 + f"SELECT COUNT(DISTINCT chain) n FROM restaurants WHERE {A}"
179 + " AND chain IS NOT NULL").fetchone()["n"]
180 +
181 + new_cur = con.execute(
182 + f"SELECT COUNT(*) n FROM restaurants WHERE {A}"
183 + " AND first_seen>=? AND first_seen<?", (f_ts, t_ts)).fetchone()["n"]
184 + new_prev = con.execute(
185 + f"SELECT COUNT(*) n FROM restaurants WHERE {A}"
186 + " AND first_seen>=? AND first_seen<?", (pf_ts, pt_ts)).fetchone()["n"]
187 +
188 + closed_cur = con.execute(
189 + "SELECT COUNT(*) n FROM restaurants WHERE dup_of IS NULL"
190 + " AND status IN ('temporarily_closed','closed')"
191 + " AND updated_at>=? AND updated_at<?", (f_ts, t_ts)).fetchone()["n"]
192 + closed_prev = con.execute(
193 + "SELECT COUNT(*) n FROM restaurants WHERE dup_of IS NULL"
194 + " AND status IN ('temporarily_closed','closed')"
195 + " AND updated_at>=? AND updated_at<?", (pf_ts, pt_ts)).fetchone()["n"]
196 +
197 + kpis = [
198 + _kpi("total", "Restaurants référencés", total,
199 + delta_pct=_delta(stock_end, stock_start)),
200 + _kpi("with_menu", "Restos avec menu & prix", with_menu),
201 + _kpi("items", "Plats & prix suivis", items),
202 + _kpi("chains", "Chaînes suivies", chains),
203 + _kpi("new", "Nouveaux référencés (période)", new_cur,
204 + delta_pct=_delta(new_cur, new_prev)),
205 + _kpi("closed", "Fermetures détectées (période)", closed_cur,
206 + delta_pct=_delta(closed_cur, closed_prev),
207 + positive_is_up=False),
208 + ]
209 +
210 + # --------------------------------------------------------- séries ---
211 + new_by_day = [(r["d"], r["n"]) for r in con.execute(
212 + "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n"
213 + f" FROM restaurants WHERE {A} AND first_seen>=? AND first_seen<?"
214 + " GROUP BY d", (f_ts, t_ts))]
215 + # plats suivis : 1re apparition de chaque item dans l'historique
216 + items_first = [(r["d"], r["n"]) for r in con.execute(
217 + "SELECT date(m0,'unixepoch','localtime') d, COUNT(*) n FROM"
218 + " (SELECT MIN(ts) m0 FROM item_price_log"
219 + " GROUP BY uid, price_context, item_key)"
220 + " WHERE m0>=? AND m0<? GROUP BY d", (f_ts, t_ts))]
221 + items_base = con.execute(
222 + "SELECT COUNT(*) n FROM (SELECT MIN(ts) m0 FROM item_price_log"
223 + " GROUP BY uid, price_context, item_key) WHERE m0<?",
224 + (f_ts,)).fetchone()["n"]
225 +
226 + series = [
227 + {"id": "new_restos", "title": "Nouveaux restos référencés par jour",
228 + "unit": "restos", "kind": "line",
229 + "points": _daily(new_by_day, f, t)},
230 + {"id": "items_cum", "title": "Plats & prix suivis (cumul)",
231 + "unit": "plats", "kind": "line",
232 + "points": _daily(items_first, f, t, cumulative=True,
233 + base=items_base)},
234 + ]
235 +
236 + # ---------------------------------------------- répartitions (stock) ---
237 + cuisine_counts: Counter = Counter()
238 + for r in con.execute(
239 + f"SELECT cuisines FROM restaurants WHERE {A}"):
240 + for c in json.loads(r["cuisines"] or "[]"):
241 + cuisine_counts[c] += 1
242 + top_cuisines = [
243 + {"label": _CUISINE_LABELS.get(c, c.capitalize()), "value": n}
244 + for c, n in cuisine_counts.most_common(8)]
245 +
246 + price_counts = Counter()
247 + max_item = (None, 0.0) # (desc, prix) — record réel
248 + all_prices: list[float] = []
249 + for r, it in _iter_menu_items(con):
250 + p = it.get("price")
251 + if not isinstance(p, (int, float)) or p <= 0:
252 + continue
253 + all_prices.append(float(p))
254 + for lbl, lo, hi in _PRICE_BUCKETS:
255 + if lo <= p < hi:
256 + price_counts[lbl] += 1
257 + break
258 + if p > max_item[1]:
259 + max_item = (f"{it.get('name')} — {r['chain'] or r['name']}", p)
260 + price_items = [{"label": lbl, "value": price_counts[lbl]}
261 + for lbl, _, _ in _PRICE_BUCKETS if price_counts[lbl]]
262 +
263 + types = [{"label": _TYPE_LABELS.get(r["t"], r["t"] or "Autre"),
264 + "value": r["n"]} for r in con.execute(
265 + f"SELECT establishment_type t, COUNT(*) n FROM restaurants"
266 + f" WHERE {A} AND establishment_type<>'' GROUP BY t"
267 + " ORDER BY n DESC")]
268 +
269 + breakdowns = [
270 + {"id": "cuisines", "title": "Restos par type de cuisine (top 8)",
271 + "kind": "donut", "items": top_cuisines},
272 + {"id": "prix", "title": "Plats par gamme de prix",
273 + "kind": "bar", "items": price_items},
274 + {"id": "types", "title": "Par type d'établissement",
275 + "kind": "bar", "items": types},
276 + ]
277 +
278 + # -------------------------------------------------------------- géo ---
279 + geo = {"title": "Restos par région", "items": [
280 + {"label": r["region"], "value": r["n"]} for r in con.execute(
281 + f"SELECT region, COUNT(*) n FROM restaurants WHERE {A}"
282 + " AND region<>'' GROUP BY region ORDER BY n DESC")]}
283 +
284 + # ---------------------------------------------------------- heatmap ---
285 + heat = [{"date": r["d"], "value": r["n"]} for r in con.execute(
286 + "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n"
287 + f" FROM restaurants WHERE {A} GROUP BY d ORDER BY d")]
288 + heatmap = {"title": "Nouveaux restos référencés", "cells": heat}
289 +
290 + # --------------------------------------------------------- tableaux ---
291 + top_cities = [[r["city"], r["n"], r["wm"],
292 + f"{100.0 * r['n'] / total:.1f} %".replace(".", ",")]
293 + for r in con.execute(
294 + f"SELECT city, COUNT(*) n, SUM(EXISTS (SELECT 1 FROM menus m"
295 + f" WHERE m.uid=restaurants.uid)) wm FROM restaurants WHERE {A}"
296 + " AND city<>'' GROUP BY city ORDER BY n DESC LIMIT 25")]
297 +
298 + # top chaînes : succursales, plats du menu le plus complet, prix moyen
299 + chain_rows: dict[str, dict] = {}
300 + for r in con.execute(
301 + f"SELECT chain, COUNT(*) n FROM restaurants WHERE {A}"
302 + " AND chain IS NOT NULL GROUP BY chain"):
303 + chain_rows[r["chain"]] = {"locs": r["n"], "items": 0, "prices": []}
304 + for r in con.execute(
305 + "SELECT r.chain, m.item_count, m.sections FROM menus m"
306 + " JOIN restaurants r ON r.uid=m.uid"
307 + " WHERE r.active=1 AND r.dup_of IS NULL"
308 + " AND r.chain IS NOT NULL"):
309 + cr = chain_rows.get(r["chain"])
310 + if cr is None or (r["item_count"] or 0) <= cr["items"]:
311 + continue
312 + cr["items"] = r["item_count"] or 0
313 + try:
314 + secs = json.loads(r["sections"] or "[]")
315 + except ValueError:
316 + continue
317 + cr["prices"] = [it["price"] for s in secs
318 + for it in s.get("items") or []
319 + if isinstance(it.get("price"), (int, float))
320 + and it["price"] > 0]
321 + top_chains = []
322 + for name, cr in sorted(chain_rows.items(),
323 + key=lambda kv: -kv[1]["locs"])[:25]:
324 + avg = (f"{statistics.mean(cr['prices']):.2f} $".replace(".", ",")
325 + if cr["prices"] else "—")
326 + top_chains.append([name, cr["locs"], cr["items"] or "—", avg])
327 +
328 + newest = [[r["name"], r["city"] or "—", r["region"] or "—",
329 + _day(r["first_seen"])] for r in con.execute(
330 + f"SELECT name, city, region, first_seen FROM restaurants"
331 + f" WHERE {A} AND first_seen>=? AND first_seen<?"
332 + " ORDER BY first_seen DESC LIMIT 100", (f_ts, t_ts))]
333 +
334 + syncs = [[r["source"], datetime.fromtimestamp(r["ts"], TZ)
335 + .strftime("%Y-%m-%d %H:%M"), r["found"], r["added"],
336 + r["updated"], r["removed"],
337 + "ok" if r["ok"] and r["message"] == "ok"
338 + else ("alerte" if r["ok"] else "échec")]
339 + for r in con.execute(
340 + "SELECT source, ts, found, added, updated, removed, ok, message"
341 + " FROM sync_log WHERE ts>=? AND ts<? ORDER BY ts DESC LIMIT 50",
342 + (f_ts, t_ts))]
343 +
344 + tables = [
345 + {"id": "villes", "title": "Top villes",
346 + "columns": ["Ville", "Restos", "Avec menu", "Part"],
347 + "rows": top_cities},
348 + {"id": "chaines", "title": "Top chaînes",
349 + "columns": ["Chaîne", "Succursales", "Plats au menu",
350 + "Prix moyen"],
351 + "rows": top_chains},
352 + ]
353 + if newest:
354 + tables.append(
355 + {"id": "nouveaux", "title": "Nouveaux restos de la période"
356 + " (100 plus récents)",
357 + "columns": ["Restaurant", "Ville", "Région", "Ajouté le"],
358 + "rows": newest})
359 + if syncs:
360 + tables.append(
361 + {"id": "syncs", "title": "Journal des synchronisations",
362 + "columns": ["Source", "Quand", "Trouvés", "Ajoutés",
363 + "Mis à jour", "Retirés", "État"],
364 + "rows": syncs})
365 +
366 + # ---------------------------------------------------------- records ---
367 + records = []
368 + rec_day = con.execute(
369 + "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n"
370 + f" FROM restaurants WHERE {A} GROUP BY d ORDER BY n DESC LIMIT 1"
371 + ).fetchone()
372 + if rec_day:
373 + records.append({"label": "Jour record de référencement",
374 + "value": f"{rec_day['n']:,} restos".replace(",", " "),
375 + "date": rec_day["d"]})
376 + big_chain = max(chain_rows.items(), key=lambda kv: kv[1]["items"],
377 + default=None)
378 + if big_chain and big_chain[1]["items"]:
379 + records.append({"label": "Chaîne au menu le plus étoffé",
380 + "value": f"{big_chain[0]} — "
381 + f"{big_chain[1]['items']} plats"})
382 + if top_cities:
383 + records.append({"label": "Ville la plus couverte",
384 + "value": f"{top_cities[0][0]} — "
385 + f"{top_cities[0][1]:,} restos".replace(",", " ")})
386 + if geo["items"]:
387 + g0 = geo["items"][0]
388 + records.append({"label": "Région la plus couverte",
389 + "value": f"{g0['label']} — "
390 + f"{g0['value']:,} restos".replace(",", " ")})
391 + if all_prices:
392 + med = statistics.median(all_prices)
393 + records.append({"label": "Prix médian d'un plat suivi",
394 + "value": f"{med:.2f} $".replace(".", ",")})
395 + if max_item[0]:
396 + records.append({"label": "Plat le plus cher observé",
397 + "value": f"{max_item[0][:44]} — "
398 + f"{max_item[1]:.2f} $".replace(".", ",")})
399 + rec_price_day = con.execute(
400 + "SELECT date(ts,'unixepoch','localtime') d, COUNT(*) n"
401 + " FROM item_price_log GROUP BY d ORDER BY n DESC LIMIT 1"
402 + ).fetchone()
403 + if rec_price_day:
404 + records.append({"label": "Jour record de relevés de prix",
405 + "value": f"{rec_price_day['n']:,} relevés"
406 + .replace(",", " "),
407 + "date": rec_price_day["d"]})
408 +
409 + return {
410 + "updated": datetime.now(TZ).isoformat(timespec="seconds"),
411 + "period": {"from": f.isoformat(), "to": t.isoformat(),
412 + "label": label},
413 + "kpis": kpis,
414 + "series": series,
415 + "breakdowns": breakdowns,
416 + "geo": geo,
417 + "heatmap": heatmap,
418 + "tables": tables,
419 + "records": records,
420 + }
421 + finally:
422 + con.close()
423 +
424 +
425 +def dashboard(period: str = "30j", dfrom: str | None = None,
426 + dto: str | None = None) -> dict:
427 + key = (period, dfrom or "", dto or "")
428 + now = time.time()
429 + with _cache_lock:
430 + hit = _cache.get(key)
431 + if hit and now - hit[0] < CACHE_TTL:
432 + return hit[1]
433 + data = _build(period, dfrom, dto)
434 + with _cache_lock:
435 + _cache[key] = (time.time(), data)
436 + return data
modified restoka/web.py +45 −1
@@ -16,7 +16,7 @@ from pathlib import Path
16 16 from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
17 17 from fastapi.middleware.cors import CORSMiddleware
18 18 from fastapi.middleware.gzip import GZipMiddleware
19 −from fastapi.responses import FileResponse
19 +from fastapi.responses import FileResponse, Response
20 20 from fastapi.staticfiles import StaticFiles
21 21
22 22 from . import db, ingest
@@ -352,6 +352,50 @@ def sources():
352 352 return {"sources": registry}
353 353
354 354
355 +# --- Module Stats commun Groupe KA (ka-ui/stats/SPEC.md) --------------------
356 +_SITE = {
357 + "wordmark": "Resto·Ka",
358 + "accent": "#f08c00", # safran — on-accent encre #141814
359 + "domain": "www.resto-ka.com",
360 + "tagline": "Chaque resto, chaque plat, chaque prix.",
361 +}
362 +
363 +
364 +@app.get("/api/stats/dashboard")
365 +def stats_dashboard(
366 + period: str = "30j",
367 + from_: str | None = Query(None, alias="from"),
368 + to: str | None = Query(None),
369 +):
370 + """Tableau de bord analytique (contrat commun Groupe KA, cache 5 min)."""
371 + from . import stats as kastats
372 + if period not in kastats.PERIOD_LABELS and not (from_ and to):
373 + raise HTTPException(400, "période inconnue")
374 + return kastats.dashboard(period, from_, to)
375 +
376 +
377 +@app.get("/api/stats/report")
378 +def stats_report(
379 + period: str = "30j",
380 + from_: str | None = Query(None, alias="from"),
381 + to: str | None = Query(None),
382 + mode: str = "complet",
383 +):
384 + """Rapport PDF estampillé Groupe-KA (complet ou synthèse 2 pages)."""
385 + from . import kapdf
386 + from . import stats as kastats
387 + if mode not in ("complet", "synthese"):
388 + raise HTTPException(400, "mode invalide (complet|synthese)")
389 + if period not in kastats.PERIOD_LABELS and not (from_ and to):
390 + raise HTTPException(400, "période inconnue")
391 + dash = kastats.dashboard(period, from_, to)
392 + pdf = kapdf.GroupeKAReport(site=_SITE, dashboard=dash, mode=mode).build()
393 + fname = kapdf.filename("resto-ka", period)
394 + return Response(content=pdf, media_type="application/pdf",
395 + headers={"Content-Disposition":
396 + f'attachment; filename="{fname}"'})
397 +
398 +
355 399 @app.get("/api/stats")
356 400 def stats():
357 401 con = db.connect()
358 402