SPB Git

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

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

- sortika/stats.py : GET /api/stats/dashboard (contrat ka-stats SPEC.md) —
  KPI réels avec deltas, séries ajouts/jour et en-cours/jour, donut
  catégories top 8, gratuits vs payants, sources, régions, heatmap
  calendrier 26 semaines, top villes/lieux/gros jours, records ;
  cache mémoire 5 min par période
- sortika/kapdf.py (moteur PDF commun fpdf2) + GET /api/stats/report
  (complet | synthese), wordmark Sorti·Ka, accent #d6336c, filename
  kapdf.filename("sorti-ka", period)
- frontend : vue /stats vanilla JS + SVG (kit kacharts reproduit) —
  KPI auto-fit, chips de période + plage personnalisée, courbes avec
  infobulle et légende cliquable, barres, anneau, heatmap, tableaux
  triables/recherche/pagination 25, records, boutons PDF ; lien Stats
  dans la nav ; mobile 360/768/1440 sans débordement
- SEO : SSR /stats (title/canonical/og) + sitemap-pages
- requirements : fpdf2

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

9 changed files +2,444 −4

modified frontend/index.html +417 −1
@@ -252,6 +252,72 @@ button{font-family:inherit}
252 .contact-sites a:hover{background:var(--accent-soft)} 252 .contact-sites a:hover{background:var(--accent-soft)}
253 .contact-sites a small{font-family:var(--font-mono);font-weight:500;font-size:10px; 253 .contact-sites a small{font-family:var(--font-mono);font-weight:500;font-size:10px;
254 color:var(--ink-3);margin-left:8px} 254 color:var(--ink-3);margin-left:8px}
255+/* ---------- page /stats (module ka-stats, vanilla + SVG) ---------- */
256+.stats-page{padding:40px 0 60px}
257+@media(max-width:640px){.stats-page{padding:28px 0 44px}}
258+.st-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-end;justify-content:space-between}
259+.st-head h1{font-size:clamp(28px,4vw,44px);line-height:1.06;margin-top:12px}
260+.st-fresh{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 0}
261+.st-pdf{display:inline-flex;gap:8px;flex-wrap:wrap}
262+.st-periods{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:20px}
263+.st-periods .input{width:150px;padding:8px 10px;font-size:13px;min-height:44px}
264+.st-custom{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}
265+.st-kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr));gap:14px;margin-top:22px}
266+.st-kpi{padding:14px 16px;min-width:0}
267+.st-kpi .v{font-family:var(--font-display);font-weight:700;font-size:clamp(22px,2.4vw,30px);letter-spacing:-.02em}
268+.st-kpi .v small{font-size:.6em;color:var(--ink-2);font-weight:600}
269+.st-kpi .d{margin-top:8px;font-family:var(--font-mono);font-size:11px;font-weight:700}
270+.st-kpi .d .ctx{color:var(--ink-3);font-weight:500}
271+.st-sec{display:grid;gap:18px;margin-top:18px}
272+.st-2col{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}
273+@media(max-width:900px){.st-2col{grid-template-columns:1fr}}
274+/* les enfants de grille doivent pouvoir rétrécir : le contenu large (tableaux
275+ nowrap, heatmap) défile alors DANS .tbl-wrap au lieu d'élargir la page */
276+.st-sec>div,.st-2col>div,.st-records>*{min-width:0;max-width:100%}
277+.st-fig .tbl-wrap{max-width:100%}
278+.st-fig{margin:0;padding:16px;min-width:0}
279+.st-fig figcaption{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center}
280+.st-fig figcaption b{font-family:var(--font-display);font-size:15px}
281+.st-fig svg{width:100%;height:auto;margin-top:10px;touch-action:pan-y}
282+.st-legend{display:flex;gap:10px;flex-wrap:wrap}
283+.st-legend button{display:inline-flex;align-items:center;gap:6px;border:0;background:none;cursor:pointer;
284+ font-family:var(--font-mono);font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;min-height:44px;color:var(--ink)}
285+.st-legend button.off{opacity:.4}
286+.st-legend .sw{width:18px;height:0;border-top:3px solid var(--accent)}
287+.st-legend .sw.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}
288+.st-tip{margin-top:8px;display:none}
289+.st-tip.show{display:inline-flex;align-items:center}
290+.st-bars{margin-top:12px;display:grid;gap:9px}
291+.st-bar-lbl{display:flex;justify-content:space-between;gap:10px;font-size:12.5px}
292+.st-bar-lbl span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
293+.st-bar-lbl b{font-family:var(--font-mono);font-size:11.5px;flex:none}
294+.st-bar-track{margin-top:3px;height:12px;background:rgba(20,24,20,.06);border-radius:0 3px 3px 0}
295+.st-bar-fill{height:100%;background:var(--accent);border:1px solid var(--ink);border-radius:0 3px 3px 0;box-sizing:border-box}
296+.st-donut-wrap{display:flex;flex-wrap:wrap;gap:18px;align-items:center;margin-top:12px}
297+.st-donut-wrap svg{width:180px;max-width:100%;margin-top:0;flex:none}
298+.st-donut-wrap ul{list-style:none;margin:0;padding:0;display:grid;gap:6px;min-width:min(200px,100%);flex:1}
299+.st-donut-wrap li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}
300+.st-donut-wrap li .sw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);flex:none}
301+.st-donut-wrap li span.lb{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
302+.st-donut-wrap li b{font-family:var(--font-mono);font-size:11px}
303+.st-tbl-head{display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center}
304+.st-tbl-head .input{max-width:240px;padding:8px 12px;font-size:13px}
305+.st-table{width:100%;border-collapse:collapse;font-size:13px}
306+.st-table th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);color:var(--paper);
307+ font-family:var(--font-mono);font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap;user-select:none}
308+.st-table td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}
309+.st-table tr:nth-child(even){background:var(--surface-2)}
310+.st-tbl-foot{display:flex;justify-content:space-between;align-items:center;margin-top:10px;flex-wrap:wrap;gap:8px}
311+.st-tbl-foot .pg{display:flex;gap:6px;align-items:center}
312+.st-records{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(280px,100%),1fr));gap:14px;margin-top:14px}
313+.st-record{padding:12px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;background:var(--surface-2)}
314+.st-record .lb{font-size:13px;color:var(--ink-2)}
315+.st-record .rv{text-align:right}
316+.st-record .rv b{font-family:var(--font-display);font-size:15px}
317+.st-record .rv .klabel{display:block}
318+.st-empty{padding:20px;background:var(--surface-2);border-style:dashed}
319+.st-empty b{font-family:var(--font-display);font-size:14px}
320+.st-loading{padding:40px 0;font-family:var(--font-mono);font-size:12px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.08em}
255 #ssr{display:none} 321 #ssr{display:none}
256 .hidden{display:none!important} 322 .hidden{display:none!important}
257 </style> 323 </style>
@@ -278,6 +344,7 @@ button{font-family:inherit}
278 <span class="brand-tag">Sorties &amp; événements — Québec</span> 344 <span class="brand-tag">Sorties &amp; événements — Québec</span>
279 <nav class="nav"> 345 <nav class="nav">
280 <a href="/#sources">Sources</a> 346 <a href="/#sources">Sources</a>
347+ <a data-nav href="/stats">Stats</a>
281 <a data-nav href="/contact">Contact</a> 348 <a data-nav href="/contact">Contact</a>
282 <a href="/docs" target="_blank" rel="noopener">API</a> 349 <a href="/docs" target="_blank" rel="noopener">API</a>
283 </nav> 350 </nav>
@@ -361,6 +428,34 @@ button{font-family:inherit}
361 </div> 428 </div>
362 </main> 429 </main>
363 430
431+<!-- ======== VUE STATS (/stats) — module ka-stats (SPEC.md), vanilla + SVG ======== -->
432+<main id="view-stats" class="hidden">
433+ <div class="container stats-page">
434+ <div class="st-head">
435+ <div>
436+ <span class="kicker">Tableau de bord · données réelles</span>
437+ <h1>Statistiques <span class="hl">Sorti·Ka</span></h1>
438+ <p class="st-fresh" id="st-fresh"></p>
439+ </div>
440+ <div class="st-pdf">
441+ <button type="button" class="btn btn-primary" id="st-pdf-full">⬇ Télécharger le rapport PDF</button>
442+ <button type="button" class="btn btn-ghost" id="st-pdf-syn">Synthèse (2 p.)</button>
443+ </div>
444+ </div>
445+ <div class="st-periods" id="st-periods"></div>
446+ <div class="st-kpis" id="st-kpis"></div>
447+ <div class="st-sec" id="st-series"></div>
448+ <div class="st-2col" id="st-breaks"></div>
449+ <div class="st-2col" id="st-geosrc"></div>
450+ <div class="st-sec" id="st-heat"></div>
451+ <div class="st-sec" id="st-tables"></div>
452+ <section id="st-records-wrap" class="hidden" style="margin-top:34px">
453+ <span class="kicker">Records &amp; faits marquants</span>
454+ <div class="st-records" id="st-records"></div>
455+ </section>
456+ </div>
457+</main>
458+
364 <!-- ======== VUE CONTACT (/contact) — contenu : src/ka/ecosystem.json ======== --> 459 <!-- ======== VUE CONTACT (/contact) — contenu : src/ka/ecosystem.json ======== -->
365 <main id="view-contact" class="hidden"> 460 <main id="view-contact" class="hidden">
366 <div class="container contact-page"> 461 <div class="container contact-page">
@@ -619,8 +714,328 @@ async function showDetail(uid){
619 window.scrollTo({top:0}); 714 window.scrollTo({top:0});
620 } 715 }
621 716
717+/* ==================== vue /stats — module ka-stats (vanilla + SVG) =========
718+ Reproduit le kit ka-ui/stats/kacharts.tsx : cartes KPI (delta ▲/▼),
719+ sélecteur de période en chips, courbe avec infobulle + légende cliquable,
720+ barres horizontales, anneau, heatmap calendrier 26 semaines, tableaux
721+ triables/recherche/pagination 25, records, bouton PDF. Zéro dépendance. */
722+const ST = { period:"30j", from:"", to:"", data:null, busy:false };
723+const ST_PERIODS = [["auj","Aujourd'hui"],["7j","7 jours"],["30j","30 jours"],
724+ ["3m","3 mois"],["6m","6 mois"],["12m","12 mois"],["annee","Année en cours"],["tout","Tout"]];
725+const fmtI = n => Math.round(n).toLocaleString("fr-CA");
726+const fmtN = n => Number.isInteger(n) ? fmtI(n)
727+ : n.toLocaleString("fr-CA",{maximumFractionDigits:2});
728+const ST_SHADES = [1,.78,.58,.42,.3,.22,.15,.1];
729+
730+function stEmpty(title){
731+ return `<div class="gk-card st-empty"><b>${esc(title)}</b>
732+ <p class="klabel" style="margin:6px 0 0">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;
733+}
734+
735+function stParams(mode){
736+ const p = new URLSearchParams();
737+ p.set("period", ST.period);
738+ if(ST.from && ST.to){ p.set("from", ST.from); p.set("to", ST.to); }
739+ if(mode) p.set("mode", mode);
740+ return p.toString();
741+}
742+
743+async function stFetch(){
744+ if(ST.busy) return;
745+ ST.busy = true;
746+ $("st-kpis").innerHTML = `<div class="st-loading">Chargement des statistiques…</div>`;
747+ try{
748+ ST.data = await (await fetch(`/api/stats/dashboard?${stParams()}`)).json();
749+ stRender();
750+ }catch(e){
751+ $("st-kpis").innerHTML = stEmpty("Statistiques indisponibles");
752+ }
753+ ST.busy = false;
754+}
755+
756+/* ---- sélecteur de période (chips + plage personnalisée) ---- */
757+function stRenderPeriods(){
758+ const custom = !!(ST.from && ST.to);
759+ $("st-periods").innerHTML = ST_PERIODS.map(([id,lab]) =>
760+ `<button type="button" class="chip${!custom && ST.period===id ? " on":""}" data-p="${id}" aria-pressed="${!custom && ST.period===id}">${lab}</button>`).join("")
761+ + `<span class="st-custom">
762+ <input class="input" type="date" id="st-from" value="${ST.from}" aria-label="Du">
763+ <span class="klabel">au</span>
764+ <input class="input" type="date" id="st-to" value="${ST.to}" aria-label="Au"></span>`;
765+ $("st-periods").querySelectorAll(".chip[data-p]").forEach(ch => ch.onclick = () => {
766+ ST.period = ch.dataset.p; ST.from = ""; ST.to = "";
767+ stRenderPeriods(); stFetch();
768+ });
769+ const onDate = () => {
770+ ST.from = $("st-from").value; ST.to = $("st-to").value;
771+ if(ST.from && ST.to){ stRenderPeriods(); stFetch(); }
772+ };
773+ $("st-from").onchange = onDate; $("st-to").onchange = onDate;
774+}
775+
776+/* ---- KPI ---- */
777+function stKpi(k){
778+ let delta = "";
779+ if(k.delta_pct !== null && k.delta_pct !== undefined){
780+ const up = (k.direction || (k.delta_pct >= 0 ? "up" : "down")) === "up";
781+ delta = `<div class="d" style="color:${up?"var(--green)":"var(--danger)"}">
782+ ${up?"▲":"▼"} ${k.delta_pct>=0?"+":""}${fmtN(k.delta_pct)} % <span class="ctx">vs période préc.</span></div>`;
783+ }
784+ return `<article class="gk-card st-kpi">
785+ <div class="v">${typeof k.value === "number" ? fmtN(k.value) : esc(k.value)}${k.unit?` <small>${esc(k.unit)}</small>`:""}</div>
786+ <div class="klabel" style="margin-top:6px">${esc(k.label)}</div>${delta}</article>`;
787+}
788+
789+/* ---- courbe (infobulle au survol + légende cliquable + N-1) ---- */
790+function stLine(host, serie){
791+ const stt = { cur:true, cmp:true };
792+ const W=720, H=240, PL=54, PR=10, PT=14, PB=26;
793+ const pts = serie.points || [];
794+ if(pts.length < 2){ host.innerHTML = stEmpty(serie.title); return; }
795+ const X = (i,n) => PL + (W-PL-PR)*i/(n-1);
796+ const render = () => {
797+ const cmp = stt.cmp && serie.compare && serie.compare.length > 1 ? serie.compare : null;
798+ const all = [...(stt.cur?pts:[]), ...(cmp||[])];
799+ const vmax = Math.max(...all.map(p=>p.v), 1);
800+ const vmin = Math.min(0, ...all.map(p=>p.v));
801+ const Y = v => PT + (H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));
802+ const path = s => s.map((p,i)=>`${i?"L":"M"}${X(i,s.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join("");
803+ let grid = "";
804+ for(let g=0;g<5;g++){
805+ const y = PT + (H-PT-PB)*g/4, v = vmax-(vmax-vmin)*g/4;
806+ grid += `<line x1="${PL}" x2="${W-PR}" y1="${y}" y2="${y}" stroke="var(--line)" stroke-width="1"/>
807+ <text x="${PL-6}" y="${y+3}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${fmtI(v)}</text>`;
808+ }
809+ const xlab = [0, Math.floor(pts.length/2), pts.length-1].map(i =>
810+ `<text x="${X(i,pts.length)}" y="${H-8}" text-anchor="middle" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${esc(pts[i].t)}</text>`).join("");
811+ host.innerHTML = `<figure class="gk-card st-fig">
812+ <figcaption><b>${esc(serie.title)}</b>
813+ <span class="st-legend">
814+ <button type="button" data-l="cur" class="${stt.cur?"":"off"}" aria-pressed="${stt.cur}"><span class="sw"></span>Période courante</button>
815+ ${serie.compare?`<button type="button" data-l="cmp" class="${stt.cmp?"":"off"}" aria-pressed="${stt.cmp}"><span class="sw cmp"></span>Période comparée</button>`:""}
816+ </span></figcaption>
817+ <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(serie.title)}">
818+ ${grid}${xlab}
819+ ${cmp?`<path d="${path(cmp)}" fill="none" stroke="var(--ink-3)" stroke-width="1.4" stroke-dasharray="4 4"/>`:""}
820+ ${stt.cur?`<path d="${path(pts)}" fill="none" stroke="var(--accent)" stroke-width="2.4"/>`:""}
821+ <g data-marker style="visibility:hidden">
822+ <line y1="${PT}" y2="${H-PB}" stroke="var(--ink)" stroke-width="1" stroke-dasharray="2 3"/>
823+ <circle r="4" fill="var(--accent)" stroke="var(--ink)" stroke-width="1.5"/>
824+ </g>
825+ <rect x="${PL}" y="0" width="${W-PL-PR}" height="${H}" fill="transparent"/>
826+ </svg>
827+ <p class="chip st-tip"></p></figure>`;
828+ const svg = host.querySelector("svg"), mk = svg.querySelector("[data-marker]"),
829+ mline = mk.querySelector("line"), mdot = mk.querySelector("circle"),
830+ tip = host.querySelector(".st-tip");
831+ const move = clientX => {
832+ const r = svg.getBoundingClientRect();
833+ const fx = (clientX - r.left)/r.width*W;
834+ const i = Math.min(pts.length-1, Math.max(0, Math.round((fx-PL)/(W-PL-PR)*(pts.length-1))));
835+ const x = X(i, pts.length);
836+ mk.style.visibility = "visible";
837+ mline.setAttribute("x1",x); mline.setAttribute("x2",x);
838+ mdot.setAttribute("cx",x); mdot.setAttribute("cy",Y(pts[i].v));
839+ tip.classList.add("show");
840+ tip.innerHTML = `${esc(pts[i].t)} — <b>&nbsp;${fmtN(pts[i].v)}${serie.unit?` ${esc(serie.unit)}`:""}</b>`
841+ + (cmp && cmp[i] ? `<span style="color:var(--ink-3)">&nbsp;· N-1 : ${fmtN(cmp[i].v)}</span>` : "");
842+ };
843+ svg.addEventListener("mousemove", e => move(e.clientX));
844+ svg.addEventListener("touchstart", e => move(e.touches[0].clientX), {passive:true});
845+ svg.addEventListener("touchmove", e => move(e.touches[0].clientX), {passive:true});
846+ svg.addEventListener("mouseleave", () => { mk.style.visibility="hidden"; tip.classList.remove("show"); });
847+ host.querySelectorAll(".st-legend button").forEach(b => b.onclick = () => {
848+ stt[b.dataset.l] = !stt[b.dataset.l];
849+ if(!stt.cur && !stt.cmp) stt[b.dataset.l] = true;
850+ render();
851+ });
852+ };
853+ render();
854+}
855+
856+/* ---- barres horizontales ---- */
857+function stBars(host, title, items, unit){
858+ const rows = (items||[]).filter(r=>typeof r.value==="number").slice(0,14);
859+ if(!rows.length){ host.innerHTML = stEmpty(title); return; }
860+ const max = Math.max(...rows.map(r=>r.value), 1);
861+ host.innerHTML = `<figure class="gk-card st-fig">
862+ <figcaption><b>${esc(title)}</b></figcaption>
863+ <div class="st-bars">${rows.map(r=>`
864+ <div title="${esc(r.label)} — ${fmtN(r.value)}${unit?` ${unit}`:""}">
865+ <div class="st-bar-lbl"><span>${esc(r.label)}</span><b>${fmtN(r.value)}${unit?` ${esc(unit)}`:""}</b></div>
866+ <div class="st-bar-track"><div class="st-bar-fill" style="width:${Math.max(r.value/max*100,1)}%"></div></div>
867+ </div>`).join("")}</div></figure>`;
868+}
869+
870+/* ---- anneau ---- */
871+function stDonut(host, title, items){
872+ const rows = (items||[]).filter(r=>r.value>0).slice(0,8);
873+ const total = rows.reduce((s,r)=>s+r.value,0);
874+ if(!total){ host.innerHTML = stEmpty(title); return; }
875+ const R = 74, C = 2*Math.PI*R;
876+ let acc = 0;
877+ const segs = rows.map((r,i)=>{
878+ const frac = r.value/total, off = acc; acc += frac;
879+ return `<circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--accent)"
880+ stroke-opacity="${ST_SHADES[i%8]}" stroke-width="30"
881+ stroke-dasharray="${(frac*C).toFixed(2)} ${C.toFixed(2)}" stroke-dashoffset="${(-off*C).toFixed(2)}"
882+ transform="rotate(-90 100 100)"><title>${esc(r.label)} — ${fmtN(r.value)} (${(100*r.value/total).toFixed(1)} %)</title></circle>`;
883+ }).join("");
884+ host.innerHTML = `<figure class="gk-card st-fig">
885+ <figcaption><b>${esc(title)}</b></figcaption>
886+ <div class="st-donut-wrap">
887+ <svg viewBox="0 0 200 200" role="img" aria-label="${esc(title)}">${segs}
888+ <circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--ink)" stroke-width="1" opacity=".5"/></svg>
889+ <ul>${rows.map((r,i)=>`<li><span class="sw" style="opacity:${ST_SHADES[i%8]}"></span>
890+ <span class="lb">${esc(r.label)}</span><b>${(100*r.value/total).toFixed(1).replace(".",",")} %</b></li>`).join("")}</ul>
891+ </div></figure>`;
892+}
893+
894+/* ---- heatmap calendrier (26 semaines) ---- */
895+function stHeat(host, hm){
896+ const cells = hm.cells || [];
897+ if(!cells.length){ host.innerHTML = stEmpty(hm.title); return; }
898+ const max = Math.max(...cells.map(c=>c.value), 1);
899+ const weeks = Math.ceil(cells.length/7);
900+ let rects = "";
901+ cells.forEach((c,i)=>{
902+ const w = Math.floor(i/7), d = i%7;
903+ rects += `<rect x="${w*14}" y="${d*14}" width="12" height="12" rx="2.5"
904+ fill="${c.value?"var(--accent)":"rgba(20,24,20,0.07)"}"
905+ fill-opacity="${c.value?(.25+.75*c.value/max).toFixed(3):1}"
906+ stroke="rgba(20,24,20,0.15)" stroke-width=".5"><title>${esc(c.date)} — ${fmtN(c.value)} événement${c.value>1?"s":""}</title></rect>`;
907+ });
908+ host.innerHTML = `<figure class="gk-card st-fig">
909+ <figcaption><b>${esc(hm.title)}</b> <span class="klabel">26 prochaines semaines · ${esc(cells[0].date)} → ${esc(cells[cells.length-1].date)}</span></figcaption>
910+ <div class="tbl-wrap" style="margin-top:12px">
911+ <svg viewBox="0 0 ${weeks*14} ${7*14}" style="min-width:480px" role="img" aria-label="${esc(hm.title)}">${rects}</svg>
912+ </div></figure>`;
913+}
914+
915+/* ---- tableau : tri par colonne, recherche, pagination 25 ---- */
916+function stTable(host, spec){
917+ const stt = { q:"", col:null, dir:1, page:0 };
918+ const num = x => typeof x === "number" ? x
919+ : parseFloat(String(x).replace(/[^\d.,-]/g,"").replace(",","."));
920+ const render = () => {
921+ let rows = spec.rows || [];
922+ if(stt.q){
923+ const q = stt.q.toLowerCase();
924+ rows = rows.filter(r => r.some(c => String(c).toLowerCase().includes(q)));
925+ }
926+ if(stt.col !== null){
927+ rows = [...rows].sort((a,b)=>{
928+ const nx = num(a[stt.col]), ny = num(b[stt.col]);
929+ if(!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx-ny)*stt.dir;
930+ return String(a[stt.col]).localeCompare(String(b[stt.col]),"fr")*stt.dir;
931+ });
932+ }
933+ const pages = Math.max(1, Math.ceil(rows.length/25));
934+ const cur = Math.min(stt.page, pages-1);
935+ host.innerHTML = `<section class="gk-card st-fig">
936+ <div class="st-tbl-head"><b style="font-family:var(--font-display);font-size:15px">${esc(spec.title)}</b>
937+ <input class="input" placeholder="Rechercher…" value="${esc(stt.q)}" aria-label="Rechercher dans ${esc(spec.title)}"></div>
938+ <div class="tbl-wrap" style="margin-top:10px"><table class="st-table">
939+ <thead><tr>${spec.columns.map((c,i)=>
940+ `<th data-c="${i}" aria-sort="${stt.col===i?(stt.dir===1?"ascending":"descending"):"none"}">${esc(c)} ${stt.col===i?(stt.dir===1?"▲":"▼"):"↕"}</th>`).join("")}</tr></thead>
941+ <tbody>${rows.slice(cur*25,(cur+1)*25).map(r=>
942+ `<tr>${r.map(c=>`<td>${typeof c==="number"?fmtN(c):esc(c)}</td>`).join("")}</tr>`).join("")}</tbody>
943+ </table></div>
944+ <div class="st-tbl-foot"><span class="klabel">${fmtI(rows.length)} lignes</span>
945+ <span class="pg">
946+ <button type="button" class="btn btn-ghost" data-pg="-1" ${cur===0?"disabled":""}>←</button>
947+ <span class="chip">${cur+1} / ${pages}</span>
948+ <button type="button" class="btn btn-ghost" data-pg="1" ${cur>=pages-1?"disabled":""}>→</button>
949+ </span></div></section>`;
950+ const inp = host.querySelector("input");
951+ let t;
952+ inp.addEventListener("input", e => {
953+ clearTimeout(t);
954+ t = setTimeout(()=>{ stt.q = e.target.value; stt.page = 0; render();
955+ host.querySelector("input").focus(); }, 250);
956+ });
957+ host.querySelectorAll("th").forEach(th => th.onclick = () => {
958+ const c = Number(th.dataset.c);
959+ stt.dir = stt.col === c && stt.dir === 1 ? -1 : 1;
960+ stt.col = c; render();
961+ });
962+ host.querySelectorAll("[data-pg]").forEach(b => b.onclick = () => {
963+ stt.page = cur + Number(b.dataset.pg); render();
964+ });
965+ };
966+ render();
967+}
968+
969+/* ---- rendu global ---- */
970+function stRender(){
971+ const d = ST.data;
972+ if(!d) return;
973+ const upd = new Date(d.updated);
974+ $("st-fresh").innerHTML = `<span class="klabel">Mis à jour le
975+ ${upd.toLocaleString("fr-CA",{dateStyle:"medium",timeStyle:"short"})}
976+ · période : ${esc((d.period||{}).label||"")}</span>
977+ <button type="button" class="btn btn-ghost" id="st-refresh">↻ Rafraîchir</button>`;
978+ $("st-refresh").onclick = stFetch;
979+
980+ $("st-kpis").innerHTML = (d.kpis||[]).map(stKpi).join("") || stEmpty("Indicateurs");
981+
982+ const mount = (parent) => { const el = document.createElement("div"); parent.appendChild(el); return el; };
983+ $("st-series").innerHTML = "";
984+ (d.series||[]).forEach(s => {
985+ const el = mount($("st-series"));
986+ if(s.kind === "bar") stBars(el, s.title, (s.points||[]).map(p=>({label:p.t,value:p.v})), s.unit);
987+ else stLine(el, s);
988+ });
989+
990+ $("st-breaks").innerHTML = "";
991+ (d.breakdowns||[]).filter(b=>b.id!=="sources").forEach(b => {
992+ const el = mount($("st-breaks"));
993+ if(b.kind === "donut") stDonut(el, b.title, b.items);
994+ else stBars(el, b.title, b.items);
995+ });
996+
997+ $("st-geosrc").innerHTML = "";
998+ if(d.geo) stBars(mount($("st-geosrc")), d.geo.title || "Par région", d.geo.items);
999+ const src = (d.breakdowns||[]).find(b=>b.id==="sources");
1000+ if(src) stBars(mount($("st-geosrc")), src.title, src.items);
1001+
1002+ $("st-heat").innerHTML = "";
1003+ if(d.heatmap) stHeat(mount($("st-heat")), d.heatmap);
1004+
1005+ $("st-tables").innerHTML = "";
1006+ (d.tables||[]).forEach(t => stTable(mount($("st-tables")), t));
1007+
1008+ const recs = d.records || [];
1009+ $("st-records-wrap").classList.toggle("hidden", !recs.length);
1010+ $("st-records").innerHTML = recs.map(r => `<article class="gk-card st-record">
1011+ <span class="lb">${esc(r.label)}</span>
1012+ <span class="rv"><b>${esc(r.value)}</b>${r.date?`<span class="klabel">${esc(r.date)}</span>`:""}</span></article>`).join("");
1013+}
1014+
1015+/* ---- bouton PDF (complet / synthèse, indicateur si > 2 s) ---- */
1016+function stPdf(mode, btn){
1017+ const original = btn.textContent;
1018+ btn.disabled = true; btn.textContent = "Génération…";
1019+ const a = document.createElement("a");
1020+ a.href = `/api/stats/report?${stParams(mode)}`;
1021+ a.download = "";
1022+ document.body.appendChild(a); a.click(); a.remove();
1023+ setTimeout(()=>{ btn.disabled = false; btn.textContent = original; }, 2500);
1024+}
1025+
1026+function showStats(){
1027+ document.title = "Statistiques — Sorti-Ka | Un service Groupe KA";
1028+ show("view-stats");
1029+ window.scrollTo({top:0});
1030+ if(!ST.data) stFetch();
1031+}
1032+
1033+$("st-pdf-full").onclick = e => stPdf("complet", e.currentTarget);
1034+$("st-pdf-syn").onclick = e => stPdf("synthese", e.currentTarget);
1035+stRenderPeriods();
1036+
622 /* ---------- vues ---------- */ 1037 /* ---------- vues ---------- */
623−const VIEWS = ["view-list","view-detail","view-contact"]; 1038+const VIEWS = ["view-list","view-detail","view-contact","view-stats"];
624 function show(id){ 1039 function show(id){
625 VIEWS.forEach(v => $(v).classList.toggle("hidden", v !== id)); 1040 VIEWS.forEach(v => $(v).classList.toggle("hidden", v !== id));
626 } 1041 }
@@ -641,6 +1056,7 @@ function route(){
641 const m = location.pathname.match(/^\/evenement\/(.+)$/); 1056 const m = location.pathname.match(/^\/evenement\/(.+)$/);
642 if(m) showDetail(decodeURIComponent(m[1])); 1057 if(m) showDetail(decodeURIComponent(m[1]));
643 else if(location.pathname === "/contact") showContact(); 1058 else if(location.pathname === "/contact") showContact();
1059+ else if(location.pathname === "/stats") showStats();
644 else showList(); 1060 else showList();
645 } 1061 }
646 function go(path){ 1062 function go(path){
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 +558 −0
@@ -0,0 +1,558 @@
1+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2).
3+# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4+# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques
5+# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.
6+# Usage :
7+# from kapdf import GroupeKAReport
8+# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
9+# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
10+# mode="complet").build()
11+# Dépendance : pip install fpdf2 (aucune autre)
12+from __future__ import annotations
13+
14+import math
15+from datetime import datetime
16+from zoneinfo import ZoneInfo
17+
18+from fpdf import FPDF
19+
20+INK = (20, 24, 20)
21+INK2 = (77, 85, 81)
22+INK3 = (139, 146, 140)
23+PAPER = (245, 243, 238)
24+SURFACE2 = (250, 249, 245)
25+GREEN = (28, 92, 65)
26+DANGER = (179, 66, 58)
27+WHITE = (255, 255, 255)
28+
29+EMAILS = [
30+ ("contact@groupe-ka.com", "Projets, partenariats & données"),
31+ ("info@groupe-ka.com", "Médias & questions générales"),
32+ ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),
33+]
34+DISCLAIMER = (
35+ "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "
36+ "rien et ne sommes partie à aucune transaction. Données lues à la source, "
37+ "rien d'inventé, tout est traçable."
38+)
39+
40+
41+def _hex(c: str) -> tuple[int, int, int]:
42+ c = c.lstrip("#")
43+ return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore
44+
45+
46+def _fr(n) -> str:
47+ if isinstance(n, float) and not n.is_integer():
48+ return f"{n:,.2f}".replace(",", " ").replace(".", ",")
49+ return f"{int(n):,}".replace(",", " ")
50+
51+
52+_SUBST = {
53+ "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
54+ "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
55+ "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
56+}
57+
58+
59+def _latin1(s: str) -> str:
60+ for k, v in _SUBST.items():
61+ s = s.replace(k, v)
62+ return s.encode("latin-1", "replace").decode("latin-1")
63+
64+
65+class _PDF(FPDF):
66+ """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
67+ Les polices core sont latin-1 : normalize_text sanitise en amont."""
68+
69+ def normalize_text(self, text):
70+ return super().normalize_text(_latin1(text))
71+
72+ def __init__(self, brand: str, accent: tuple, period_label: str):
73+ super().__init__(orientation="P", unit="mm", format="A4")
74+ self.brand = brand
75+ self.accent = accent
76+ self.period_label = period_label
77+ self.cover_mode = False
78+ self.set_margins(18, 20, 18)
79+ self.set_auto_page_break(True, margin=22)
80+
81+ def header(self):
82+ if self.cover_mode:
83+ return
84+ self.set_font("helvetica", "B", 8.5)
85+ self.set_text_color(*INK)
86+ self.set_xy(18, 9)
87+ self.cell(0, 5, f"Groupe KA · {self.brand}")
88+ self.set_font("helvetica", "", 8)
89+ self.set_text_color(*INK3)
90+ self.set_xy(18, 9)
91+ self.cell(0, 5, "Rapport statistique", align="R")
92+ self.set_draw_color(*INK)
93+ self.set_line_width(0.5)
94+ self.line(18, 15.5, 192, 15.5)
95+ self.set_y(20)
96+
97+ def footer(self):
98+ if self.cover_mode:
99+ return
100+ self.set_y(-15)
101+ self.set_draw_color(*INK3)
102+ self.set_line_width(0.2)
103+ self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)
104+ self.set_font("helvetica", "", 7.5)
105+ self.set_text_color(*INK3)
106+ year = datetime.now(ZoneInfo("America/Toronto")).year
107+ self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")
108+ self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")
109+
110+
111+class GroupeKAReport:
112+ def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
113+ self.site = site
114+ self.d = dashboard
115+ self.mode = mode
116+ self.accent = _hex(site.get("accent", "#d9f26b"))
117+ period = dashboard.get("period", {}) or {}
118+ self.period_label = period.get("label") or "toute la période"
119+ self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
120+ self.toc: list[tuple[str, int]] = []
121+
122+ # ---------- primitives ----------
123+ def _card(self, x, y, w, h, fill=WHITE):
124+ p = self.pdf
125+ p.set_draw_color(*INK)
126+ p.set_line_width(0.45)
127+ p.set_fill_color(*fill)
128+ p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
129+
130+ def _kicker(self, text):
131+ p = self.pdf
132+ p.set_font("helvetica", "B", 8)
133+ p.set_text_color(*GREEN)
134+ p.set_draw_color(*GREEN)
135+ p.set_line_width(0.6)
136+ y = p.get_y() + 2
137+ p.line(p.l_margin, y, p.l_margin + 7, y)
138+ p.set_xy(p.l_margin + 9, y - 2.5)
139+ p.cell(0, 5, text.upper())
140+ p.ln(8)
141+
142+ def _section_title(self, title):
143+ if self.pdf.get_y() > 240:
144+ self.pdf.add_page()
145+ self._kicker("Groupe KA · " + self.site.get("wordmark", ""))
146+ self.pdf.set_font("helvetica", "B", 15)
147+ self.pdf.set_text_color(*INK)
148+ self.pdf.set_x(self.pdf.l_margin)
149+ self.pdf.cell(0, 8, title)
150+ self.toc.append((title, self.pdf.page_no()))
151+ self.pdf.ln(11)
152+
153+ # ---------- pages ----------
154+ def _cover(self):
155+ p = self.pdf
156+ p.cover_mode = True
157+ p.set_auto_page_break(False)
158+ p.add_page()
159+ p.set_fill_color(*PAPER)
160+ p.rect(0, 0, 210, 297, style="F")
161+ p.set_draw_color(*INK)
162+ p.set_line_width(1.0)
163+ p.rect(10, 10, 190, 277)
164+ # kicker
165+ p.set_font("helvetica", "B", 10)
166+ p.set_text_color(*GREEN)
167+ p.set_xy(24, 34)
168+ p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
169+ # wordmark : partie gauche + boîte encre/accent
170+ wm = self.site.get("wordmark", "")
171+ left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
172+ p.set_xy(24, 70)
173+ p.set_font("helvetica", "B", 40)
174+ p.set_text_color(*INK)
175+ p.cell(p.get_string_width(left) + 2, 20, left)
176+ if boxed:
177+ bw = p.get_string_width(boxed) + 12
178+ x = p.get_x() + 2
179+ p.set_fill_color(*INK)
180+ p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)
181+ p.set_text_color(*self.accent)
182+ p.set_xy(x + 6, 70)
183+ p.cell(bw - 12, 18, boxed)
184+ p.set_xy(24, 100)
185+ p.set_font("helvetica", "", 13)
186+ p.set_text_color(*INK2)
187+ p.multi_cell(150, 7, f"Rapport statistique — {wm}")
188+ now = datetime.now(ZoneInfo("America/Toronto"))
189+ per = self.d.get("period", {}) or {}
190+ p.set_xy(24, 125)
191+ p.set_font("helvetica", "", 10.5)
192+ rows = [
193+ ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
194+ ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
195+ ("Plateforme", "https://" + self.site.get("domain", "")),
196+ ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),
197+ ]
198+ y = 128
199+ for k, v in rows:
200+ p.set_xy(24, y)
201+ p.set_text_color(*INK3)
202+ p.cell(40, 6, k)
203+ p.set_text_color(*INK)
204+ p.set_font("helvetica", "B", 10.5)
205+ p.cell(0, 6, str(v))
206+ p.set_font("helvetica", "", 10.5)
207+ y += 8
208+ # bande encre au pied
209+ p.set_fill_color(*INK)
210+ p.rect(10, 262, 190, 25, style="F")
211+ p.set_xy(24, 270)
212+ p.set_font("helvetica", "B", 12)
213+ p.set_text_color(*WHITE)
214+ p.cell(60, 8, "par Groupe ")
215+ p.set_text_color(*self.accent)
216+ p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)
217+ p.cell(20, 8, "KA")
218+ p.set_font("helvetica", "B", 10)
219+ p.set_xy(24, 270)
220+ p.set_text_color(*self.accent)
221+ p.cell(162, 8, "groupe-ka.com", align="R")
222+ p.set_auto_page_break(True, margin=22)
223+ p.cover_mode = False
224+
225+ def _kpis(self):
226+ kpis = self.d.get("kpis") or []
227+ if not kpis:
228+ return
229+ self._section_title("Synthèse des indicateurs")
230+ p = self.pdf
231+ cols, gw, gh, gap = 3, 56, 26, 3
232+ x0, y = p.l_margin, p.get_y()
233+ for i, k in enumerate(kpis[:9]):
234+ x = x0 + (i % cols) * (gw + gap)
235+ if i and i % cols == 0:
236+ y += gh + gap
237+ if y > 250:
238+ p.add_page(); y = p.get_y()
239+ self._card(x, y, gw, gh)
240+ p.set_xy(x + 4, y + 4)
241+ p.set_font("helvetica", "B", 14)
242+ p.set_text_color(*INK)
243+ val = k.get("value")
244+ p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))
245+ p.set_xy(x + 4, y + 12)
246+ p.set_font("helvetica", "", 7.6)
247+ p.set_text_color(*INK2)
248+ p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])
249+ if k.get("delta_pct") is not None:
250+ up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"
251+ p.set_xy(x + 4, y + gh - 6.5)
252+ p.set_font("helvetica", "B", 8)
253+ p.set_text_color(*(GREEN if up else DANGER))
254+ arrow = "+" if k["delta_pct"] >= 0 else ""
255+ p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")
256+ p.set_y(y + gh + 8)
257+
258+ def _line_chart(self, s):
259+ p = self.pdf
260+ pts = s.get("points") or []
261+ if len(pts) < 2:
262+ return
263+ if p.get_y() > 200:
264+ p.add_page()
265+ p.set_font("helvetica", "B", 10)
266+ p.set_text_color(*INK)
267+ p.cell(0, 6, s.get("title", ""))
268+ p.ln(7)
269+ x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
270+ self._card(x0, y0, w, h, fill=WHITE)
271+ cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
272+ vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]
273+ vmax = max(vals) or 1
274+ vmin = min(0, min(vals))
275+ rng = (vmax - vmin) or 1
276+ # grille + graduations
277+ p.set_font("helvetica", "", 6.3)
278+ p.set_text_color(*INK3)
279+ p.set_draw_color(200, 200, 195)
280+ p.set_line_width(0.15)
281+ for g in range(5):
282+ gy = cy + ch - ch * g / 4
283+ p.line(cx, gy, cx + cw, gy)
284+ p.set_xy(x0 + 1, gy - 1.6)
285+ p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
286+
287+ def draw(series, color, width, dash=None):
288+ n = len(series)
289+ p.set_draw_color(*color)
290+ p.set_line_width(width)
291+ if dash:
292+ p.set_dash_pattern(dash=1.2, gap=1.2)
293+ last = None
294+ for i, pt in enumerate(series):
295+ px = cx + cw * (i / (n - 1))
296+ py = cy + ch - ch * ((pt["v"] - vmin) / rng)
297+ if last:
298+ p.line(last[0], last[1], px, py)
299+ last = (px, py)
300+ p.set_dash_pattern()
301+
302+ if s.get("compare"):
303+ draw(s["compare"], INK3, 0.35, dash=True)
304+ draw(pts, self.accent, 0.7)
305+ # libellés d'axe X (premier / milieu / dernier)
306+ p.set_text_color(*INK3)
307+ for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
308+ p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
309+ p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")
310+ p.set_y(y0 + h + 4)
311+ if s.get("compare"):
312+ p.set_font("helvetica", "", 6.8)
313+ p.set_text_color(*INK3)
314+ p.cell(0, 4, "— période courante (accent) · ---- période comparée")
315+ p.ln(6)
316+ else:
317+ p.ln(2)
318+
319+ def _bars(self, title, items, unit=""):
320+ p = self.pdf
321+ items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]
322+ if not items:
323+ return
324+ need = 10 + len(items) * 7
325+ if p.get_y() + need > 265:
326+ p.add_page()
327+ p.set_font("helvetica", "B", 10)
328+ p.set_text_color(*INK)
329+ p.cell(0, 6, title)
330+ p.ln(8)
331+ vmax = max(it["value"] for it in items) or 1
332+ for it in items:
333+ y = p.get_y()
334+ p.set_font("helvetica", "", 7.6)
335+ p.set_text_color(*INK)
336+ p.set_x(p.l_margin)
337+ p.cell(46, 5, str(it["label"])[:34])
338+ bw = 96 * (it["value"] / vmax)
339+ p.set_fill_color(*self.accent)
340+ p.set_draw_color(*INK)
341+ p.set_line_width(0.25)
342+ p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
343+ p.set_xy(p.l_margin + 148, y)
344+ p.set_font("helvetica", "B", 7.6)
345+ p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
346+ p.ln(6.4)
347+ p.ln(3)
348+
349+ def _donut(self, b):
350+ # anneau vectoriel simple (arcs) + légende
351+ p = self.pdf
352+ items = [it for it in (b.get("items") or []) if it.get("value")][:8]
353+ total = sum(it["value"] for it in items)
354+ if not items or not total:
355+ return
356+ if p.get_y() > 210:
357+ p.add_page()
358+ p.set_font("helvetica", "B", 10)
359+ p.set_text_color(*INK)
360+ p.cell(0, 6, b.get("title", ""))
361+ p.ln(8)
362+ cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
363+ shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
364+ start = -90.0
365+ for i, it in enumerate(items):
366+ frac = it["value"] / total
367+ f = shades[i % len(shades)]
368+ col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
369+ steps = max(2, int(72 * frac))
370+ p.set_fill_color(*col)
371+ p.set_draw_color(*col)
372+ for st in range(steps):
373+ a0 = math.radians(start + 360 * frac * st / steps)
374+ a1 = math.radians(start + 360 * frac * (st + 1) / steps)
375+ p.polygon(
376+ [(cx, cy),
377+ (cx + r * math.cos(a0), cy + r * math.sin(a0)),
378+ (cx + r * math.cos(a1), cy + r * math.sin(a1))],
379+ style="DF",
380+ )
381+ start += 360 * frac
382+ p.set_fill_color(*WHITE)
383+ p.set_draw_color(*INK)
384+ p.set_line_width(0.4)
385+ p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
386+ p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
387+ # légende
388+ ly = cy - 22
389+ for i, it in enumerate(items):
390+ f = shades[i % len(shades)]
391+ col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
392+ p.set_fill_color(*col)
393+ p.set_draw_color(*INK)
394+ p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
395+ p.set_xy(p.l_margin + 66, ly)
396+ p.set_font("helvetica", "", 7.6)
397+ p.set_text_color(*INK)
398+ pct = 100 * it["value"] / total
399+ p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))
400+ ly += 5.6
401+ p.set_y(max(cy + r, ly) + 6)
402+
403+ def _table(self, t):
404+ p = self.pdf
405+ cols = t.get("columns") or []
406+ rows = t.get("rows") or []
407+ if not cols or not rows:
408+ return
409+ self._section_title(t.get("title", "Tableau"))
410+ w = 174 / len(cols)
411+ def head():
412+ p.set_font("helvetica", "B", 7.6)
413+ p.set_fill_color(*INK)
414+ p.set_text_color(*WHITE)
415+ for c in cols:
416+ p.cell(w, 6, " " + str(c)[:30], fill=True)
417+ p.ln(6)
418+ head()
419+ p.set_text_color(*INK)
420+ for i, row in enumerate(rows[:200]):
421+ if p.get_y() > 262:
422+ p.add_page()
423+ head()
424+ p.set_text_color(*INK)
425+ p.set_font("helvetica", "", 7.4)
426+ p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))
427+ for cell in row:
428+ txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
429+ p.cell(w, 5.4, " " + txt[:34], fill=True)
430+ p.ln(5.4)
431+ if len(rows) > 200:
432+ p.set_font("helvetica", "", 7)
433+ p.set_text_color(*INK3)
434+ p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")
435+ p.ln(6)
436+
437+ def _records(self):
438+ recs = self.d.get("records") or []
439+ if not recs:
440+ return
441+ self._section_title("Records & faits marquants")
442+ p = self.pdf
443+ for r in recs[:10]:
444+ if p.get_y() > 258:
445+ p.add_page()
446+ y = p.get_y()
447+ self._card(p.l_margin, y, 174, 11, fill=SURFACE2)
448+ p.set_xy(p.l_margin + 4, y + 2)
449+ p.set_font("helvetica", "", 8.6)
450+ p.set_text_color(*INK2)
451+ p.cell(96, 7, str(r.get("label", ""))[:70])
452+ p.set_font("helvetica", "B", 9)
453+ p.set_text_color(*INK)
454+ p.cell(52, 7, str(r.get("value", ""))[:36], align="R")
455+ p.set_font("helvetica", "", 7.6)
456+ p.set_text_color(*INK3)
457+ p.cell(20, 7, str(r.get("date", "") or ""), align="R")
458+ p.set_y(y + 13.5)
459+ p.ln(4)
460+
461+ def _final_page(self):
462+ p = self.pdf
463+ p.add_page()
464+ self._kicker("Groupe KA · contact")
465+ p.set_font("helvetica", "B", 15)
466+ p.set_text_color(*INK)
467+ p.cell(0, 8, "Coordonnées du Groupe KA")
468+ p.ln(12)
469+ for email, role in EMAILS:
470+ p.set_font("helvetica", "B", 10.5)
471+ p.set_text_color(*INK)
472+ p.cell(0, 6, email)
473+ p.ln(5.5)
474+ p.set_font("helvetica", "", 8.6)
475+ p.set_text_color(*INK3)
476+ p.cell(0, 5, role)
477+ p.ln(8)
478+ p.ln(2)
479+ p.set_font("helvetica", "B", 10)
480+ p.set_text_color(*GREEN)
481+ p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")
482+ p.ln(10)
483+ p.set_draw_color(*self.accent)
484+ p.set_line_width(0.8)
485+ p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())
486+ p.ln(4)
487+ p.set_font("helvetica", "", 8.6)
488+ p.set_text_color(*INK2)
489+ p.multi_cell(160, 4.6, DISCLAIMER)
490+ p.ln(4)
491+ p.set_font("helvetica", "", 7.6)
492+ p.set_text_color(*INK3)
493+ p.multi_cell(
494+ 160, 4.2,
495+ "Mentions : rapport généré automatiquement à partir des données réelles de la "
496+ "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "
497+ "de confidentialité et protection des renseignements personnels (Loi 25) : "
498+ "groupe-ka.com/conditions · /confidentialite · /loi-25.",
499+ )
500+
501+ def _toc_page(self):
502+ # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en
503+ # page 2 en réservant la page lors du build (voir build()).
504+ pass
505+
506+ def build(self) -> bytes:
507+ p = self.pdf
508+ p.alias_nb_pages()
509+ self._cover()
510+ if self.mode == "synthese":
511+ p.add_page()
512+ self._kpis()
513+ self._records()
514+ self._final_page()
515+ else:
516+ p.add_page()
517+ toc_page_no = p.page_no()
518+ p.add_page()
519+ self._kpis()
520+ for s in self.d.get("series") or []:
521+ if s.get("kind") == "bar":
522+ self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))
523+ else:
524+ self._line_chart(s)
525+ for b in self.d.get("breakdowns") or []:
526+ if b.get("kind") == "donut":
527+ self._donut(b)
528+ else:
529+ self._bars(b.get("title", ""), b.get("items"))
530+ geo = self.d.get("geo")
531+ if geo:
532+ self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
533+ for t in self.d.get("tables") or []:
534+ self._table(t)
535+ self._records()
536+ self._final_page()
537+ # sommaire écrit sur la page réservée (page 2)
538+ last_page = p.page
539+ p.page = toc_page_no
540+ p.set_y(22)
541+ p.set_font("helvetica", "B", 15)
542+ p.set_text_color(*INK)
543+ p.cell(0, 8, "Sommaire")
544+ p.ln(12)
545+ p.set_font("helvetica", "", 9.5)
546+ for title, page_no in self.toc:
547+ p.set_text_color(*INK)
548+ p.cell(140, 6.5, title[:80])
549+ p.set_text_color(*INK3)
550+ p.cell(0, 6.5, str(page_no), align="R")
551+ p.ln(6.5)
552+ p.page = last_page
553+ return bytes(p.output())
554+
555+
556+def filename(platform_id: str, period: str) -> str:
557+ today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
558+ return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"
modified requirements.txt +1 −0
@@ -6,4 +6,5 @@
6 fastapi>=0.115 6 fastapi>=0.115
7 uvicorn>=0.30 7 uvicorn>=0.30
8 requests>=2.32 8 requests>=2.32
9+fpdf2>=2.8
9 pytest>=8.0 10 pytest>=8.0
added sortika/kapdf.py +558 −0
@@ -0,0 +1,558 @@
1+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2).
3+# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4+# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques
5+# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.
6+# Usage :
7+# from kapdf import GroupeKAReport
8+# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
9+# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
10+# mode="complet").build()
11+# Dépendance : pip install fpdf2 (aucune autre)
12+from __future__ import annotations
13+
14+import math
15+from datetime import datetime
16+from zoneinfo import ZoneInfo
17+
18+from fpdf import FPDF
19+
20+INK = (20, 24, 20)
21+INK2 = (77, 85, 81)
22+INK3 = (139, 146, 140)
23+PAPER = (245, 243, 238)
24+SURFACE2 = (250, 249, 245)
25+GREEN = (28, 92, 65)
26+DANGER = (179, 66, 58)
27+WHITE = (255, 255, 255)
28+
29+EMAILS = [
30+ ("contact@groupe-ka.com", "Projets, partenariats & données"),
31+ ("info@groupe-ka.com", "Médias & questions générales"),
32+ ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),
33+]
34+DISCLAIMER = (
35+ "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "
36+ "rien et ne sommes partie à aucune transaction. Données lues à la source, "
37+ "rien d'inventé, tout est traçable."
38+)
39+
40+
41+def _hex(c: str) -> tuple[int, int, int]:
42+ c = c.lstrip("#")
43+ return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore
44+
45+
46+def _fr(n) -> str:
47+ if isinstance(n, float) and not n.is_integer():
48+ return f"{n:,.2f}".replace(",", " ").replace(".", ",")
49+ return f"{int(n):,}".replace(",", " ")
50+
51+
52+_SUBST = {
53+ "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
54+ "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
55+ "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
56+}
57+
58+
59+def _latin1(s: str) -> str:
60+ for k, v in _SUBST.items():
61+ s = s.replace(k, v)
62+ return s.encode("latin-1", "replace").decode("latin-1")
63+
64+
65+class _PDF(FPDF):
66+ """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
67+ Les polices core sont latin-1 : normalize_text sanitise en amont."""
68+
69+ def normalize_text(self, text):
70+ return super().normalize_text(_latin1(text))
71+
72+ def __init__(self, brand: str, accent: tuple, period_label: str):
73+ super().__init__(orientation="P", unit="mm", format="A4")
74+ self.brand = brand
75+ self.accent = accent
76+ self.period_label = period_label
77+ self.cover_mode = False
78+ self.set_margins(18, 20, 18)
79+ self.set_auto_page_break(True, margin=22)
80+
81+ def header(self):
82+ if self.cover_mode:
83+ return
84+ self.set_font("helvetica", "B", 8.5)
85+ self.set_text_color(*INK)
86+ self.set_xy(18, 9)
87+ self.cell(0, 5, f"Groupe KA · {self.brand}")
88+ self.set_font("helvetica", "", 8)
89+ self.set_text_color(*INK3)
90+ self.set_xy(18, 9)
91+ self.cell(0, 5, "Rapport statistique", align="R")
92+ self.set_draw_color(*INK)
93+ self.set_line_width(0.5)
94+ self.line(18, 15.5, 192, 15.5)
95+ self.set_y(20)
96+
97+ def footer(self):
98+ if self.cover_mode:
99+ return
100+ self.set_y(-15)
101+ self.set_draw_color(*INK3)
102+ self.set_line_width(0.2)
103+ self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)
104+ self.set_font("helvetica", "", 7.5)
105+ self.set_text_color(*INK3)
106+ year = datetime.now(ZoneInfo("America/Toronto")).year
107+ self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")
108+ self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")
109+
110+
111+class GroupeKAReport:
112+ def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
113+ self.site = site
114+ self.d = dashboard
115+ self.mode = mode
116+ self.accent = _hex(site.get("accent", "#d9f26b"))
117+ period = dashboard.get("period", {}) or {}
118+ self.period_label = period.get("label") or "toute la période"
119+ self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
120+ self.toc: list[tuple[str, int]] = []
121+
122+ # ---------- primitives ----------
123+ def _card(self, x, y, w, h, fill=WHITE):
124+ p = self.pdf
125+ p.set_draw_color(*INK)
126+ p.set_line_width(0.45)
127+ p.set_fill_color(*fill)
128+ p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
129+
130+ def _kicker(self, text):
131+ p = self.pdf
132+ p.set_font("helvetica", "B", 8)
133+ p.set_text_color(*GREEN)
134+ p.set_draw_color(*GREEN)
135+ p.set_line_width(0.6)
136+ y = p.get_y() + 2
137+ p.line(p.l_margin, y, p.l_margin + 7, y)
138+ p.set_xy(p.l_margin + 9, y - 2.5)
139+ p.cell(0, 5, text.upper())
140+ p.ln(8)
141+
142+ def _section_title(self, title):
143+ if self.pdf.get_y() > 240:
144+ self.pdf.add_page()
145+ self._kicker("Groupe KA · " + self.site.get("wordmark", ""))
146+ self.pdf.set_font("helvetica", "B", 15)
147+ self.pdf.set_text_color(*INK)
148+ self.pdf.set_x(self.pdf.l_margin)
149+ self.pdf.cell(0, 8, title)
150+ self.toc.append((title, self.pdf.page_no()))
151+ self.pdf.ln(11)
152+
153+ # ---------- pages ----------
154+ def _cover(self):
155+ p = self.pdf
156+ p.cover_mode = True
157+ p.set_auto_page_break(False)
158+ p.add_page()
159+ p.set_fill_color(*PAPER)
160+ p.rect(0, 0, 210, 297, style="F")
161+ p.set_draw_color(*INK)
162+ p.set_line_width(1.0)
163+ p.rect(10, 10, 190, 277)
164+ # kicker
165+ p.set_font("helvetica", "B", 10)
166+ p.set_text_color(*GREEN)
167+ p.set_xy(24, 34)
168+ p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
169+ # wordmark : partie gauche + boîte encre/accent
170+ wm = self.site.get("wordmark", "")
171+ left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
172+ p.set_xy(24, 70)
173+ p.set_font("helvetica", "B", 40)
174+ p.set_text_color(*INK)
175+ p.cell(p.get_string_width(left) + 2, 20, left)
176+ if boxed:
177+ bw = p.get_string_width(boxed) + 12
178+ x = p.get_x() + 2
179+ p.set_fill_color(*INK)
180+ p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)
181+ p.set_text_color(*self.accent)
182+ p.set_xy(x + 6, 70)
183+ p.cell(bw - 12, 18, boxed)
184+ p.set_xy(24, 100)
185+ p.set_font("helvetica", "", 13)
186+ p.set_text_color(*INK2)
187+ p.multi_cell(150, 7, f"Rapport statistique — {wm}")
188+ now = datetime.now(ZoneInfo("America/Toronto"))
189+ per = self.d.get("period", {}) or {}
190+ p.set_xy(24, 125)
191+ p.set_font("helvetica", "", 10.5)
192+ rows = [
193+ ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
194+ ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
195+ ("Plateforme", "https://" + self.site.get("domain", "")),
196+ ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),
197+ ]
198+ y = 128
199+ for k, v in rows:
200+ p.set_xy(24, y)
201+ p.set_text_color(*INK3)
202+ p.cell(40, 6, k)
203+ p.set_text_color(*INK)
204+ p.set_font("helvetica", "B", 10.5)
205+ p.cell(0, 6, str(v))
206+ p.set_font("helvetica", "", 10.5)
207+ y += 8
208+ # bande encre au pied
209+ p.set_fill_color(*INK)
210+ p.rect(10, 262, 190, 25, style="F")
211+ p.set_xy(24, 270)
212+ p.set_font("helvetica", "B", 12)
213+ p.set_text_color(*WHITE)
214+ p.cell(60, 8, "par Groupe ")
215+ p.set_text_color(*self.accent)
216+ p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)
217+ p.cell(20, 8, "KA")
218+ p.set_font("helvetica", "B", 10)
219+ p.set_xy(24, 270)
220+ p.set_text_color(*self.accent)
221+ p.cell(162, 8, "groupe-ka.com", align="R")
222+ p.set_auto_page_break(True, margin=22)
223+ p.cover_mode = False
224+
225+ def _kpis(self):
226+ kpis = self.d.get("kpis") or []
227+ if not kpis:
228+ return
229+ self._section_title("Synthèse des indicateurs")
230+ p = self.pdf
231+ cols, gw, gh, gap = 3, 56, 26, 3
232+ x0, y = p.l_margin, p.get_y()
233+ for i, k in enumerate(kpis[:9]):
234+ x = x0 + (i % cols) * (gw + gap)
235+ if i and i % cols == 0:
236+ y += gh + gap
237+ if y > 250:
238+ p.add_page(); y = p.get_y()
239+ self._card(x, y, gw, gh)
240+ p.set_xy(x + 4, y + 4)
241+ p.set_font("helvetica", "B", 14)
242+ p.set_text_color(*INK)
243+ val = k.get("value")
244+ p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))
245+ p.set_xy(x + 4, y + 12)
246+ p.set_font("helvetica", "", 7.6)
247+ p.set_text_color(*INK2)
248+ p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])
249+ if k.get("delta_pct") is not None:
250+ up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"
251+ p.set_xy(x + 4, y + gh - 6.5)
252+ p.set_font("helvetica", "B", 8)
253+ p.set_text_color(*(GREEN if up else DANGER))
254+ arrow = "+" if k["delta_pct"] >= 0 else ""
255+ p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")
256+ p.set_y(y + gh + 8)
257+
258+ def _line_chart(self, s):
259+ p = self.pdf
260+ pts = s.get("points") or []
261+ if len(pts) < 2:
262+ return
263+ if p.get_y() > 200:
264+ p.add_page()
265+ p.set_font("helvetica", "B", 10)
266+ p.set_text_color(*INK)
267+ p.cell(0, 6, s.get("title", ""))
268+ p.ln(7)
269+ x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
270+ self._card(x0, y0, w, h, fill=WHITE)
271+ cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
272+ vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]
273+ vmax = max(vals) or 1
274+ vmin = min(0, min(vals))
275+ rng = (vmax - vmin) or 1
276+ # grille + graduations
277+ p.set_font("helvetica", "", 6.3)
278+ p.set_text_color(*INK3)
279+ p.set_draw_color(200, 200, 195)
280+ p.set_line_width(0.15)
281+ for g in range(5):
282+ gy = cy + ch - ch * g / 4
283+ p.line(cx, gy, cx + cw, gy)
284+ p.set_xy(x0 + 1, gy - 1.6)
285+ p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
286+
287+ def draw(series, color, width, dash=None):
288+ n = len(series)
289+ p.set_draw_color(*color)
290+ p.set_line_width(width)
291+ if dash:
292+ p.set_dash_pattern(dash=1.2, gap=1.2)
293+ last = None
294+ for i, pt in enumerate(series):
295+ px = cx + cw * (i / (n - 1))
296+ py = cy + ch - ch * ((pt["v"] - vmin) / rng)
297+ if last:
298+ p.line(last[0], last[1], px, py)
299+ last = (px, py)
300+ p.set_dash_pattern()
301+
302+ if s.get("compare"):
303+ draw(s["compare"], INK3, 0.35, dash=True)
304+ draw(pts, self.accent, 0.7)
305+ # libellés d'axe X (premier / milieu / dernier)
306+ p.set_text_color(*INK3)
307+ for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
308+ p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
309+ p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")
310+ p.set_y(y0 + h + 4)
311+ if s.get("compare"):
312+ p.set_font("helvetica", "", 6.8)
313+ p.set_text_color(*INK3)
314+ p.cell(0, 4, "— période courante (accent) · ---- période comparée")
315+ p.ln(6)
316+ else:
317+ p.ln(2)
318+
319+ def _bars(self, title, items, unit=""):
320+ p = self.pdf
321+ items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]
322+ if not items:
323+ return
324+ need = 10 + len(items) * 7
325+ if p.get_y() + need > 265:
326+ p.add_page()
327+ p.set_font("helvetica", "B", 10)
328+ p.set_text_color(*INK)
329+ p.cell(0, 6, title)
330+ p.ln(8)
331+ vmax = max(it["value"] for it in items) or 1
332+ for it in items:
333+ y = p.get_y()
334+ p.set_font("helvetica", "", 7.6)
335+ p.set_text_color(*INK)
336+ p.set_x(p.l_margin)
337+ p.cell(46, 5, str(it["label"])[:34])
338+ bw = 96 * (it["value"] / vmax)
339+ p.set_fill_color(*self.accent)
340+ p.set_draw_color(*INK)
341+ p.set_line_width(0.25)
342+ p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
343+ p.set_xy(p.l_margin + 148, y)
344+ p.set_font("helvetica", "B", 7.6)
345+ p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
346+ p.ln(6.4)
347+ p.ln(3)
348+
349+ def _donut(self, b):
350+ # anneau vectoriel simple (arcs) + légende
351+ p = self.pdf
352+ items = [it for it in (b.get("items") or []) if it.get("value")][:8]
353+ total = sum(it["value"] for it in items)
354+ if not items or not total:
355+ return
356+ if p.get_y() > 210:
357+ p.add_page()
358+ p.set_font("helvetica", "B", 10)
359+ p.set_text_color(*INK)
360+ p.cell(0, 6, b.get("title", ""))
361+ p.ln(8)
362+ cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
363+ shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
364+ start = -90.0
365+ for i, it in enumerate(items):
366+ frac = it["value"] / total
367+ f = shades[i % len(shades)]
368+ col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
369+ steps = max(2, int(72 * frac))
370+ p.set_fill_color(*col)
371+ p.set_draw_color(*col)
372+ for st in range(steps):
373+ a0 = math.radians(start + 360 * frac * st / steps)
374+ a1 = math.radians(start + 360 * frac * (st + 1) / steps)
375+ p.polygon(
376+ [(cx, cy),
377+ (cx + r * math.cos(a0), cy + r * math.sin(a0)),
378+ (cx + r * math.cos(a1), cy + r * math.sin(a1))],
379+ style="DF",
380+ )
381+ start += 360 * frac
382+ p.set_fill_color(*WHITE)
383+ p.set_draw_color(*INK)
384+ p.set_line_width(0.4)
385+ p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
386+ p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
387+ # légende
388+ ly = cy - 22
389+ for i, it in enumerate(items):
390+ f = shades[i % len(shades)]
391+ col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
392+ p.set_fill_color(*col)
393+ p.set_draw_color(*INK)
394+ p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
395+ p.set_xy(p.l_margin + 66, ly)
396+ p.set_font("helvetica", "", 7.6)
397+ p.set_text_color(*INK)
398+ pct = 100 * it["value"] / total
399+ p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))
400+ ly += 5.6
401+ p.set_y(max(cy + r, ly) + 6)
402+
403+ def _table(self, t):
404+ p = self.pdf
405+ cols = t.get("columns") or []
406+ rows = t.get("rows") or []
407+ if not cols or not rows:
408+ return
409+ self._section_title(t.get("title", "Tableau"))
410+ w = 174 / len(cols)
411+ def head():
412+ p.set_font("helvetica", "B", 7.6)
413+ p.set_fill_color(*INK)
414+ p.set_text_color(*WHITE)
415+ for c in cols:
416+ p.cell(w, 6, " " + str(c)[:30], fill=True)
417+ p.ln(6)
418+ head()
419+ p.set_text_color(*INK)
420+ for i, row in enumerate(rows[:200]):
421+ if p.get_y() > 262:
422+ p.add_page()
423+ head()
424+ p.set_text_color(*INK)
425+ p.set_font("helvetica", "", 7.4)
426+ p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))
427+ for cell in row:
428+ txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
429+ p.cell(w, 5.4, " " + txt[:34], fill=True)
430+ p.ln(5.4)
431+ if len(rows) > 200:
432+ p.set_font("helvetica", "", 7)
433+ p.set_text_color(*INK3)
434+ p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")
435+ p.ln(6)
436+
437+ def _records(self):
438+ recs = self.d.get("records") or []
439+ if not recs:
440+ return
441+ self._section_title("Records & faits marquants")
442+ p = self.pdf
443+ for r in recs[:10]:
444+ if p.get_y() > 258:
445+ p.add_page()
446+ y = p.get_y()
447+ self._card(p.l_margin, y, 174, 11, fill=SURFACE2)
448+ p.set_xy(p.l_margin + 4, y + 2)
449+ p.set_font("helvetica", "", 8.6)
450+ p.set_text_color(*INK2)
451+ p.cell(96, 7, str(r.get("label", ""))[:70])
452+ p.set_font("helvetica", "B", 9)
453+ p.set_text_color(*INK)
454+ p.cell(52, 7, str(r.get("value", ""))[:36], align="R")
455+ p.set_font("helvetica", "", 7.6)
456+ p.set_text_color(*INK3)
457+ p.cell(20, 7, str(r.get("date", "") or ""), align="R")
458+ p.set_y(y + 13.5)
459+ p.ln(4)
460+
461+ def _final_page(self):
462+ p = self.pdf
463+ p.add_page()
464+ self._kicker("Groupe KA · contact")
465+ p.set_font("helvetica", "B", 15)
466+ p.set_text_color(*INK)
467+ p.cell(0, 8, "Coordonnées du Groupe KA")
468+ p.ln(12)
469+ for email, role in EMAILS:
470+ p.set_font("helvetica", "B", 10.5)
471+ p.set_text_color(*INK)
472+ p.cell(0, 6, email)
473+ p.ln(5.5)
474+ p.set_font("helvetica", "", 8.6)
475+ p.set_text_color(*INK3)
476+ p.cell(0, 5, role)
477+ p.ln(8)
478+ p.ln(2)
479+ p.set_font("helvetica", "B", 10)
480+ p.set_text_color(*GREEN)
481+ p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")
482+ p.ln(10)
483+ p.set_draw_color(*self.accent)
484+ p.set_line_width(0.8)
485+ p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())
486+ p.ln(4)
487+ p.set_font("helvetica", "", 8.6)
488+ p.set_text_color(*INK2)
489+ p.multi_cell(160, 4.6, DISCLAIMER)
490+ p.ln(4)
491+ p.set_font("helvetica", "", 7.6)
492+ p.set_text_color(*INK3)
493+ p.multi_cell(
494+ 160, 4.2,
495+ "Mentions : rapport généré automatiquement à partir des données réelles de la "
496+ "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "
497+ "de confidentialité et protection des renseignements personnels (Loi 25) : "
498+ "groupe-ka.com/conditions · /confidentialite · /loi-25.",
499+ )
500+
501+ def _toc_page(self):
502+ # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en
503+ # page 2 en réservant la page lors du build (voir build()).
504+ pass
505+
506+ def build(self) -> bytes:
507+ p = self.pdf
508+ p.alias_nb_pages()
509+ self._cover()
510+ if self.mode == "synthese":
511+ p.add_page()
512+ self._kpis()
513+ self._records()
514+ self._final_page()
515+ else:
516+ p.add_page()
517+ toc_page_no = p.page_no()
518+ p.add_page()
519+ self._kpis()
520+ for s in self.d.get("series") or []:
521+ if s.get("kind") == "bar":
522+ self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))
523+ else:
524+ self._line_chart(s)
525+ for b in self.d.get("breakdowns") or []:
526+ if b.get("kind") == "donut":
527+ self._donut(b)
528+ else:
529+ self._bars(b.get("title", ""), b.get("items"))
530+ geo = self.d.get("geo")
531+ if geo:
532+ self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
533+ for t in self.d.get("tables") or []:
534+ self._table(t)
535+ self._records()
536+ self._final_page()
537+ # sommaire écrit sur la page réservée (page 2)
538+ last_page = p.page
539+ p.page = toc_page_no
540+ p.set_y(22)
541+ p.set_font("helvetica", "B", 15)
542+ p.set_text_color(*INK)
543+ p.cell(0, 8, "Sommaire")
544+ p.ln(12)
545+ p.set_font("helvetica", "", 9.5)
546+ for title, page_no in self.toc:
547+ p.set_text_color(*INK)
548+ p.cell(140, 6.5, title[:80])
549+ p.set_text_color(*INK3)
550+ p.cell(0, 6.5, str(page_no), align="R")
551+ p.ln(6.5)
552+ p.page = last_page
553+ return bytes(p.output())
554+
555+
556+def filename(platform_id: str, period: str) -> str:
557+ today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
558+ return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"
modified sortika/seo.py +33 −1
@@ -190,6 +190,37 @@ def contact_page() -> HTMLResponse:
190 return _render(head, ssr) 190 return _render(head, ssr)
191 191
192 192
193+@router.get("/stats", include_in_schema=False)
194+def stats_page() -> HTMLResponse:
195+ """Page /stats (SPA) — head SEO + contenu SSR pour les robots."""
196+ con = db.connect()
197+ n = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]
198+ con.close()
199+ title = f"Statistiques — {SITE_NAME} | Un service Groupe KA"
200+ desc = (f"Tableau de bord analytique de {SITE_NAME} : {n:,} événements actifs"
201+ .replace(",", " ") +
202+ " — ajouts par jour, catégories, gratuits vs payants, régions, "
203+ "calendrier des jours les plus chargés et rapport PDF Groupe KA.")
204+ canonical = f"{BASE_URL}/stats"
205+ head = (
206+ f"<title>{_e(title)}</title>\n"
207+ f'<meta name="description" content="{_e(desc)}">\n'
208+ f'<link rel="canonical" href="{canonical}">\n'
209+ f'<meta property="og:title" content="{_e(title)}">\n'
210+ f'<meta property="og:description" content="{_e(desc)}">\n'
211+ f'<meta property="og:type" content="website">\n'
212+ f'<meta property="og:url" content="{canonical}">\n'
213+ + OG_IMAGE_TAGS
214+ )
215+ ssr = (
216+ "<article><h1>Statistiques Sorti-Ka</h1>"
217+ f"<p>{_e(desc)}</p>"
218+ '<p><a href="/">Toutes les sorties et tous les événements du Québec</a></p>'
219+ "</article>"
220+ )
221+ return _render(head, ssr)
222+
223+
193 @router.get("/robots.txt", include_in_schema=False) 224 @router.get("/robots.txt", include_in_schema=False)
194 def robots() -> PlainTextResponse: 225 def robots() -> PlainTextResponse:
195 return PlainTextResponse( 226 return PlainTextResponse(
@@ -226,7 +257,8 @@ def sitemap_index() -> Response:
226 257
227 @router.get("/sitemap-pages.xml", include_in_schema=False) 258 @router.get("/sitemap-pages.xml", include_in_schema=False)
228 def sitemap_pages() -> Response: 259 def sitemap_pages() -> Response:
229− return _urlset([(f"{BASE_URL}/", None), (f"{BASE_URL}/contact", None)]) 260+ return _urlset([(f"{BASE_URL}/", None), (f"{BASE_URL}/stats", None),
261+ (f"{BASE_URL}/contact", None)])
230 262
231 263
232 @router.get("/sitemap-evenements-{num}.xml", include_in_schema=False) 264 @router.get("/sitemap-evenements-{num}.xml", include_in_schema=False)
added sortika/stats.py +331 −0
@@ -0,0 +1,331 @@
1+# -----------------------------------------------------------------------------
2+# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# stats.py : tableau de bord analytique /api/stats/dashboard (contrat ka-stats
5+# SPEC.md) — agrégations SQL réelles sur events/sync_log, AUCUNE
6+# statistique inventée, cache mémoire 5 min par période.
7+# -----------------------------------------------------------------------------
8+from __future__ import annotations
9+
10+import json
11+import time
12+from datetime import date, datetime, timedelta
13+from zoneinfo import ZoneInfo
14+
15+from . import db
16+
17+TZ = ZoneInfo("America/Toronto")
18+CACHE_TTL = 300 # ≥ 5 min (SPEC)
19+_cache: dict[tuple, tuple[float, dict]] = {}
20+
21+HEATMAP_DAYS = 182 # 26 semaines de calendrier des dates d'événements
22+ONGOING_DAYS = 30 # série « se déroulant par jour » (30 prochains jours)
23+BIGDAYS_HORIZON = 90 # tableau « prochains gros jours »
24+
25+# Libellés humains (mêmes que le frontend — taxonomie sortika/normalize.py)
26+CAT_LABELS = {
27+ "festival": "Festivals", "musique": "Musique", "arts-scene": "Arts de la scène",
28+ "exposition-musee": "Expos & musées", "cinema": "Cinéma", "sport": "Sport",
29+ "plein-air": "Plein air", "famille": "Famille", "gastronomie": "Gastronomie",
30+ "marche-foire": "Marchés & foires", "conference": "Conférences & ateliers",
31+ "communautaire": "Communautaire", "patrimoine": "Patrimoine", "autre": "Autre",
32+}
33+SRC_LABELS = {
34+ "sitq": "SIT Québec (MTO)", "montreal": "Ville de Montréal",
35+ "laval": "Ville de Laval", "lepointdevente": "Le point de vente",
36+ "evenko": "evenko", "atuvu": "Atuvu.ca", "lavitrine": "La Vitrine",
37+ "sherbrooke": "Ville de Sherbrooke",
38+}
39+
40+PERIOD_DAYS = {"7j": 7, "30j": 30, "3m": 91, "6m": 182, "12m": 365}
41+PERIOD_LABELS = {
42+ "auj": "Aujourd'hui", "7j": "7 jours", "30j": "30 jours", "3m": "3 mois",
43+ "6m": "6 mois", "12m": "12 mois", "annee": "Année en cours",
44+ "tout": "Toute la période",
45+}
46+
47+
48+def _parse_iso(s: str) -> date | None:
49+ try:
50+ return date.fromisoformat((s or "").strip())
51+ except ValueError:
52+ return None
53+
54+
55+def resolve_period(con, period: str, dfrom: str = "", dto: str = "") -> tuple[date, date, str]:
56+ """(from, to, label) — plage personnalisée > période nommée > 30 j."""
57+ today = datetime.now(TZ).date()
58+ f, t = _parse_iso(dfrom), _parse_iso(dto)
59+ if f and t:
60+ if f > t:
61+ f, t = t, f
62+ return f, t, f"{f.isoformat()} → {t.isoformat()}"
63+ if period == "auj":
64+ return today, today, PERIOD_LABELS["auj"]
65+ if period == "annee":
66+ return date(today.year, 1, 1), today, PERIOD_LABELS["annee"]
67+ if period == "tout":
68+ row = con.execute(
69+ "SELECT date(MIN(first_seen),'unixepoch','localtime') FROM events"
70+ ).fetchone()
71+ start = _parse_iso(row[0] or "") or today
72+ return start, today, PERIOD_LABELS["tout"]
73+ days = PERIOD_DAYS.get(period, 30)
74+ return today - timedelta(days=days - 1), today, PERIOD_LABELS.get(period, "30 jours")
75+
76+
77+def _pct(cur: float, prev: float) -> float | None:
78+ if prev <= 0:
79+ return None
80+ return round(100.0 * (cur - prev) / prev, 1)
81+
82+
83+def _kpi(id, label, value, unit="", delta_pct=None) -> dict:
84+ d = {"id": id, "label": label, "value": value, "unit": unit,
85+ "delta_pct": delta_pct}
86+ if delta_pct is not None:
87+ d["direction"] = "up" if delta_pct >= 0 else "down"
88+ return d
89+
90+
91+def _added_by_day(con, d0: date, d1: date) -> dict[str, int]:
92+ """Événements découverts (first_seen) par jour local, entre d0 et d1."""
93+ return {r[0]: r[1] for r in con.execute(
94+ "SELECT date(first_seen,'unixepoch','localtime') AS d, COUNT(*) "
95+ "FROM events WHERE d >= ? AND d <= ? GROUP BY d",
96+ (d0.isoformat(), d1.isoformat()))}
97+
98+
99+def _ongoing_counts(con, start: date, days: int) -> list[int]:
100+ """Nb d'événements actifs SE DÉROULANT chaque jour (tableau de différences :
101+ +1 au début effectif, -1 au lendemain de la fin — O(événements + jours))."""
102+ diff = [0] * (days + 1)
103+ horizon = start + timedelta(days=days - 1)
104+ for r in con.execute(
105+ "SELECT start_date, COALESCE(end_date, start_date) AS e FROM events "
106+ "WHERE active=1 AND start_date IS NOT NULL "
107+ "AND start_date <= ? AND COALESCE(end_date, start_date) >= ?",
108+ (horizon.isoformat(), start.isoformat())):
109+ s = max(_parse_iso(r["start_date"]) or start, start)
110+ e = min(_parse_iso(r["e"]) or s, horizon)
111+ if e < s:
112+ continue
113+ diff[(s - start).days] += 1
114+ diff[(e - start).days + 1] -= 1
115+ out, acc = [], 0
116+ for i in range(days):
117+ acc += diff[i]
118+ out.append(acc)
119+ return out
120+
121+
122+def _upcoming_where() -> str:
123+ return ("active=1 AND (end_date >= :today OR "
124+ "(end_date IS NULL AND start_date >= :today))")
125+
126+
127+def dashboard(period: str = "30j", dfrom: str = "", dto: str = "") -> dict:
128+ key = (period, dfrom, dto)
129+ hit = _cache.get(key)
130+ if hit and time.time() - hit[0] < CACHE_TTL:
131+ return hit[1]
132+
133+ con = db.connect()
134+ now = datetime.now(TZ)
135+ today = now.date()
136+ p_from, p_to, p_label = resolve_period(con, period, dfrom, dto)
137+ length = (p_to - p_from).days + 1
138+ prev_to = p_from - timedelta(days=1)
139+ prev_from = prev_to - timedelta(days=length - 1)
140+ tp = {"today": today.isoformat()}
141+
142+ # ---------- KPI ----------
143+ active = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]
144+ upcoming = con.execute(
145+ f"SELECT COUNT(*) FROM events WHERE {_upcoming_where()}", tp).fetchone()[0]
146+ free_up = con.execute(
147+ f"SELECT COUNT(*) FROM events WHERE {_upcoming_where()} AND is_free=1",
148+ tp).fetchone()[0]
149+ cities = con.execute(
150+ "SELECT COUNT(DISTINCT city) FROM events WHERE active=1 AND city != ''"
151+ ).fetchone()[0]
152+
153+ added_map = _added_by_day(con, prev_from, p_to)
154+ added_cur = sum(v for d, v in added_map.items()
155+ if p_from.isoformat() <= d <= p_to.isoformat())
156+ added_prev = sum(v for d, v in added_map.items()
157+ if prev_from.isoformat() <= d <= prev_to.isoformat())
158+
159+ # « actifs » au début de la période, reconstruit depuis les journaux réels :
160+ # actifs_avant = actifs_maintenant − ajoutés depuis (toujours actifs)
161+ # + désactivés depuis (updated_at du passage active=0),
162+ # seulement s'ils existaient déjà avant la période
163+ added_active = con.execute(
164+ "SELECT COUNT(*) FROM events WHERE active=1 AND "
165+ "date(first_seen,'unixepoch','localtime') >= ?",
166+ (p_from.isoformat(),)).fetchone()[0]
167+ deactivated = con.execute(
168+ "SELECT COUNT(*) FROM events WHERE active=0 AND "
169+ "date(updated_at,'unixepoch','localtime') >= ? AND "
170+ "date(first_seen,'unixepoch','localtime') < ?",
171+ (p_from.isoformat(), p_from.isoformat())).fetchone()[0]
172+ prev_active = active - added_active + deactivated
173+
174+ kpis = [
175+ _kpi("actifs", "Événements actifs", active, "",
176+ _pct(active, prev_active)),
177+ _kpi("avenir", "Événements à venir", upcoming),
178+ _kpi("gratuits", "Gratuits à venir", free_up),
179+ _kpi("villes", "Villes couvertes", cities),
180+ _kpi("ajouts", f"Ajoutés sur la période ({p_label})", added_cur, "",
181+ _pct(added_cur, added_prev)),
182+ ]
183+ prices = con.execute(
184+ f"SELECT COUNT(*), AVG(price_min) FROM events WHERE {_upcoming_where()} "
185+ "AND is_free=0 AND price_min IS NOT NULL AND price_min > 0",
186+ tp).fetchone()
187+ if prices[0] >= 10: # champ prix exploitable seulement si assez de relevés
188+ kpis.append(_kpi("prix", f"Prix moyen des billets ({prices[0]} relevés)",
189+ round(prices[1], 2), "$"))
190+
191+ # ---------- séries ----------
192+ series = []
193+ pts = [{"t": (p_from + timedelta(days=i)).isoformat(),
194+ "v": added_map.get((p_from + timedelta(days=i)).isoformat(), 0)}
195+ for i in range(length)]
196+ s_added = {"id": "ajouts", "title": "Événements ajoutés par jour",
197+ "unit": "événements", "kind": "line", "points": pts}
198+ if period not in ("tout", "annee") and not (dfrom and dto):
199+ s_added["compare"] = [
200+ {"t": (prev_from + timedelta(days=i)).isoformat(),
201+ "v": added_map.get((prev_from + timedelta(days=i)).isoformat(), 0)}
202+ for i in range(length)]
203+ series.append(s_added)
204+
205+ horizon_counts = _ongoing_counts(con, today, HEATMAP_DAYS)
206+ series.append({
207+ "id": "en_cours", "title":
208+ f"Événements se déroulant par jour ({ONGOING_DAYS} prochains jours)",
209+ "unit": "événements", "kind": "line",
210+ "points": [{"t": (today + timedelta(days=i)).isoformat(),
211+ "v": horizon_counts[i]} for i in range(ONGOING_DAYS)]})
212+
213+ # ---------- répartitions ----------
214+ cat_counts: dict[str, int] = {}
215+ for r in con.execute(
216+ f"SELECT categories FROM events WHERE {_upcoming_where()}", tp):
217+ for c in json.loads(r["categories"] or "[]"):
218+ cat_counts[c] = cat_counts.get(c, 0) + 1
219+ top_cats = sorted(cat_counts.items(), key=lambda kv: -kv[1])[:8]
220+ breakdowns = [
221+ {"id": "categories", "title": "Événements à venir par catégorie (top 8)",
222+ "kind": "donut",
223+ "items": [{"label": CAT_LABELS.get(c, c), "value": n}
224+ for c, n in top_cats]},
225+ {"id": "gratuite", "title": "Gratuits vs payants (à venir)", "kind": "bar",
226+ "items": [
227+ {"label": "Gratuits", "value": free_up},
228+ {"label": "Payants", "value": con.execute(
229+ f"SELECT COUNT(*) FROM events WHERE {_upcoming_where()} "
230+ "AND is_free=0", tp).fetchone()[0]},
231+ {"label": "Prix non précisé", "value": con.execute(
232+ f"SELECT COUNT(*) FROM events WHERE {_upcoming_where()} "
233+ "AND is_free IS NULL", tp).fetchone()[0]},
234+ ]},
235+ {"id": "sources", "title": "Événements actifs par source", "kind": "bar",
236+ "items": [{"label": SRC_LABELS.get(r["source"], r["source"]),
237+ "value": r["n"]}
238+ for r in con.execute(
239+ "SELECT source, COUNT(*) AS n FROM events WHERE active=1 "
240+ "GROUP BY source ORDER BY n DESC")]},
241+ ]
242+
243+ # ---------- géographie ----------
244+ geo = {"title": "Événements à venir par région", "items": [
245+ {"label": r["region"] or "Non rattachée", "value": r["n"]}
246+ for r in con.execute(
247+ f"SELECT region, COUNT(*) AS n FROM events WHERE {_upcoming_where()} "
248+ "GROUP BY region ORDER BY n DESC LIMIT 17", tp)]}
249+
250+ # ---------- heatmap : calendrier des dates d'événements (26 semaines) ----
251+ heatmap = {"title": "Calendrier des événements — jours les plus chargés",
252+ "cells": [{"date": (today + timedelta(days=i)).isoformat(),
253+ "value": horizon_counts[i]}
254+ for i in range(HEATMAP_DAYS)]}
255+
256+ # ---------- tableaux ----------
257+ city_rows = [[r["city"], r["n"], r["g"],
258+ f"{100.0 * r['n'] / upcoming:.1f} %".replace(".", ",")]
259+ for r in con.execute(
260+ f"SELECT city, COUNT(*) AS n, "
261+ f"SUM(CASE WHEN is_free=1 THEN 1 ELSE 0 END) AS g "
262+ f"FROM events WHERE {_upcoming_where()} AND city != '' "
263+ "GROUP BY city ORDER BY n DESC LIMIT 40", tp)]
264+ venue_rows = [[r["venue"], r["city"] or "—", r["n"]]
265+ for r in con.execute(
266+ f"SELECT venue, city, COUNT(*) AS n FROM events "
267+ f"WHERE {_upcoming_where()} AND venue != '' "
268+ "GROUP BY venue, city ORDER BY n DESC LIMIT 40", tp)]
269+ starts_by_day = {r[0]: r[1] for r in con.execute(
270+ "SELECT start_date, COUNT(*) FROM events WHERE active=1 "
271+ "AND start_date >= ? GROUP BY start_date", (today.isoformat(),))}
272+ big_days = sorted(
273+ ((today + timedelta(days=i), horizon_counts[i])
274+ for i in range(min(BIGDAYS_HORIZON, HEATMAP_DAYS))),
275+ key=lambda dv: -dv[1])[:15]
276+ bigday_rows = [[d.isoformat(), n, starts_by_day.get(d.isoformat(), 0)]
277+ for d, n in big_days]
278+ tables = [
279+ {"id": "villes", "title": "Top villes (événements à venir)",
280+ "columns": ["Ville", "À venir", "Gratuits", "Part"], "rows": city_rows},
281+ {"id": "lieux", "title": "Top lieux d'événements (à venir)",
282+ "columns": ["Lieu", "Ville", "À venir"], "rows": venue_rows},
283+ {"id": "gros_jours",
284+ "title": f"Prochains gros jours ({BIGDAYS_HORIZON} jours)",
285+ "columns": ["Date", "Événements ce jour-là", "Débutent ce jour-là"],
286+ "rows": bigday_rows},
287+ ]
288+
289+ # ---------- records ----------
290+ records = []
291+ if horizon_counts:
292+ i_max = max(range(HEATMAP_DAYS), key=lambda i: horizon_counts[i])
293+ records.append({"label": "Jour le plus chargé (26 prochaines semaines)",
294+ "value": f"{horizon_counts[i_max]:,} événements".replace(",", " "),
295+ "date": (today + timedelta(days=i_max)).isoformat()})
296+ add_rec = con.execute(
297+ "SELECT date(first_seen,'unixepoch','localtime') AS d, COUNT(*) AS n "
298+ "FROM events GROUP BY d ORDER BY n DESC LIMIT 1").fetchone()
299+ if add_rec:
300+ records.append({"label": "Plus grosse journée d'ajouts",
301+ "value": f"{add_rec['n']:,} événements".replace(",", " "),
302+ "date": add_rec["d"]})
303+ if city_rows:
304+ records.append({"label": "Ville la plus active (à venir)",
305+ "value": f"{city_rows[0][0]} — "
306+ f"{city_rows[0][1]:,} événements".replace(",", " ")})
307+ if top_cats:
308+ records.append({"label": "Catégorie dominante (à venir)",
309+ "value": f"{CAT_LABELS.get(top_cats[0][0], top_cats[0][0])}"
310+ f" — {top_cats[0][1]:,} événements".replace(",", " ")})
311+ longest = con.execute(
312+ f"SELECT title, start_date, end_date, "
313+ f"julianday(end_date) - julianday(start_date) + 1 AS j "
314+ f"FROM events WHERE {_upcoming_where()} AND end_date > start_date "
315+ "ORDER BY j DESC LIMIT 1", tp).fetchone()
316+ if longest:
317+ records.append({"label": "Plus longue affiche à venir — "
318+ f"{longest['title'][:36]}",
319+ "value": f"{int(longest['j']):,} jours".replace(",", " "),
320+ "date": longest["start_date"]})
321+ con.close()
322+
323+ payload = {
324+ "updated": now.isoformat(timespec="seconds"),
325+ "period": {"from": p_from.isoformat(), "to": p_to.isoformat(),
326+ "label": p_label},
327+ "kpis": kpis, "series": series, "breakdowns": breakdowns,
328+ "geo": geo, "heatmap": heatmap, "tables": tables, "records": records,
329+ }
330+ _cache[key] = (time.time(), payload)
331+ return payload
modified sortika/web.py +34 −2
@@ -13,10 +13,11 @@ from pathlib import Path
13 from fastapi import FastAPI, HTTPException, Query 13 from fastapi import FastAPI, HTTPException, Query
14 from fastapi.middleware.cors import CORSMiddleware 14 from fastapi.middleware.cors import CORSMiddleware
15 from fastapi.middleware.gzip import GZipMiddleware 15 from fastapi.middleware.gzip import GZipMiddleware
16−from fastapi.responses import FileResponse 16+from fastapi.responses import FileResponse, Response
17 from fastapi.staticfiles import StaticFiles 17 from fastapi.staticfiles import StaticFiles
18 18
19−from . import auth, db, seo 19+from . import auth, db, kapdf, seo
20+from . import stats as kstats
20 from .normalize import CATEGORIES 21 from .normalize import CATEGORIES
21 from .regions import REGIONS 22 from .regions import REGIONS
22 23
@@ -168,6 +169,37 @@ def stats() -> dict:
168 key=lambda kv: -kv[1]))} 169 key=lambda kv: -kv[1]))}
169 170
170 171
172+# --- module Stats commun Groupe KA (contrat ka-ui/stats/SPEC.md) -------------
173+SITE = {"wordmark": "Sorti·Ka", "accent": "#d6336c", "domain": "www.sorti-ka.com",
174+ "tagline": "Toutes les sorties du Québec, un seul endroit."}
175+
176+
177+@app.get("/api/stats/dashboard")
178+def stats_dashboard(
179+ period: str = Query("30j", description="auj|7j|30j|3m|6m|12m|annee|tout"),
180+ date_from: str = Query("", alias="from", description="ISO — plage personnalisée"),
181+ date_to: str = Query("", alias="to", description="ISO — plage personnalisée"),
182+) -> dict:
183+ return kstats.dashboard(period, date_from, date_to)
184+
185+
186+@app.get("/api/stats/report")
187+def stats_report(
188+ period: str = Query("30j"),
189+ date_from: str = Query("", alias="from"),
190+ date_to: str = Query("", alias="to"),
191+ mode: str = Query("complet", description="complet | synthese"),
192+) -> Response:
193+ if mode not in ("complet", "synthese"):
194+ raise HTTPException(422, "mode invalide (complet | synthese)")
195+ dash = kstats.dashboard(period, date_from, date_to)
196+ pdf = kapdf.GroupeKAReport(site=SITE, dashboard=dash, mode=mode).build()
197+ fname = kapdf.filename("sorti-ka", period)
198+ return Response(pdf, media_type="application/pdf",
199+ headers={"Content-Disposition":
200+ f'attachment; filename="{fname}"'})
201+
202+
171 @app.get("/api/regions") 203 @app.get("/api/regions")
172 def regions() -> list[str]: 204 def regions() -> list[str]:
173 return REGIONS 205 return REGIONS
174 206
Menu

Theme