Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.
TypeScript 70.4%
HTML 18.4%
JavaScript 4%
Python 3.8%
CSS 3.4%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Rapports statistiques PDF locaux du hub — GABARIT COMMUN GROUPE KA4 * (src/ka/stats/SPEC.md), moteur pdfkit (équivalent TS de kapdf.py) :5 * couverture estampillée « GROUPE KA · RAPPORT STATISTIQUE », en-tête/pied6 * normalisés, sommaire paginé, graphiques 100 % VECTORIELS (courbes, aires,7 * barres, multi-courbes à motifs distincts, empilées, anneaux, jauges,8 * calendrier quotidien ET horaire), statistiques de séries (min/max/moy/méd/σ),9 * tableaux zébrés paginés, records, page de fin avec coordonnées.10 *11 * Les 5 modes v2 du hub restent générés par API-KA (proxy12 * /api/stats/ecosystem-report) — ce module ne porte QUE la v3 :13 * rapports personnalisés (SPEC §3bis) composés depuis l'EcoPayload14 * consolidé (src/lib/ecostats.ts — mêmes chiffres que la page /stats,15 * aucune donnée inventée).16 */17import PDFDocument from "pdfkit";18import path from "path";19import eco from "@/ka/ecosystem.json";20import {21 PERIODS,22 isPeriod,23 buildEcoPayload,24 getEcosystemStats,25 type EcoPayload,26 type PeriodKey,27} from "@/lib/ecostats";28import { getLiveMetrics } from "@/lib/live";29import type {30 Kpi as ChartKpi,31 Serie as ChartSerie,32 MultiSerie as ChartMultiSerie,33 StackedSerie as ChartStacked,34 BreakItem,35 Gauge as ChartGauge,36 HourCell,37 RecordFact,38} from "@/ka/stats/kacharts";3940const INK = "#141814";41const INK2 = "#4d5551";42const INK3 = "#8b928c";43const PAPER = "#f5f3ee";44const SURFACE2 = "#faf9f5";45const GREEN = "#123f2e"; // accentDeep Groupe-KA (ecosystem.json)46const GREEN_DEEP = "#0b2a1e";47const LIME = "#d9f26b"; // accent Groupe-KA (ecosystem.json)48const WHITE = "#ffffff";4950/* A4 portrait (gabarit Groupe-KA) */51const W = 595.28;52const H = 841.89;53const M = 44;54const CW = W - 2 * M;55const TOP = 56; // début du contenu sous l'en-tête56const BOT = H - 58; // fin du contenu au-dessus du pied5758const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);59const num = (v: number) => v.toLocaleString("fr-CA");60/** Nombre court pour axes/valeurs de graphiques. */61const short = (v: number) => {62 if (Math.abs(v) >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} B`;63 if (Math.abs(v) >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G`;64 if (Math.abs(v) >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M`;65 if (Math.abs(v) >= 1e4) return `${(v / 1e3).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} k`;66 return v.toLocaleString("fr-CA", { maximumFractionDigits: 1 });67};68/** Valeur de comptage : entière lisible, compacte au-delà du million. */69const count = (v: number) => (Math.abs(v) >= 1e6 ? short(v) : num(v));70const DOW_FR = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];7172type Doc = InstanceType<typeof PDFDocument>;7374/* Libellé de période du rapport en cours (le dessin pdfkit est synchrone :75 la variable est posée au début de chaque build, aucun entrelacement). */76let PERIOD_LABEL = "";7778/** Wordmark Groupe-KA : « Groupe » encre + boîte encre / « KA » lime. */79function wordmark(doc: Doc, x: number, y: number, size = 24) {80 doc.font("SG-Bold").fontSize(size);81 doc.fillColor(INK).text("Groupe", x, y, { lineBreak: false });82 const w = doc.widthOfString("Groupe");83 const bx = x + w + 0.22 * size;84 const bw = doc.widthOfString("KA") + 0.5 * size;85 doc.roundedRect(bx, y - 0.14 * size, bw, 1.28 * size, 0.18 * size).fill(INK);86 doc.fillColor(LIME).text("KA", bx + 0.25 * size, y, { lineBreak: false });87}8889function kicker(doc: Doc, txt: string, x: number, y: number, size = 8) {90 doc.rect(x, y + size * 0.44, 2.5 * size, size / 4).fill(GREEN);91 doc.font("JB-Bold").fontSize(size).fillColor(GREEN)92 .text(txt.toUpperCase(), x + 2.5 * size + 6, y, { characterSpacing: 0.175 * size, lineBreak: false, width: CW - 2.5 * size - 6, height: size * 1.6, ellipsis: true });93}9495/** En-tête + pied normalisés Groupe-KA (numéros de page ajoutés en post-passe). */96function chrome(doc: Doc) {97 const year = new Date().getFullYear();98 doc.rect(0, 0, W, H).fill(PAPER);99 doc.font("SG-Bold").fontSize(9.5).fillColor(INK)100 .text("Groupe KA · Portail de l'écosystème", M, 22, { lineBreak: false });101 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)102 .text("RAPPORT STATISTIQUE", W - M - 180, 24.5, { width: 180, align: "right", characterSpacing: 0.8 });103 doc.rect(M, 37, CW, 1.1).fill(INK);104 doc.rect(M, H - 42, CW, 0.7).fill(INK3);105 doc.font("JB-Reg").fontSize(6.8).fillColor(INK3)106 .text(`© Groupe-KA — ${year} — groupe-ka.com · ${PERIOD_LABEL}`, M, H - 34, { lineBreak: false });107}108109function card(doc: Doc, x: number, y: number, w: number, h: number, fill = "#ffffff") {110 doc.roundedRect(x + 3, y + 3, w, h, 8).fill("#e3e1d9");111 doc.roundedRect(x, y, w, h, 8).lineWidth(1.3).fillAndStroke(fill, INK);112}113114function hbarRow(doc: Doc, x: number, y: number, w: number, label: string, frac: number, txt: string, sub: string | null, dark = false) {115 doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(label, x, y + 3, { width: 82, height: 9, ellipsis: true, lineBreak: false });116 const tx = x + 88;117 const tw = w - 88 - 118;118 doc.rect(tx, y, tw, 13).fill(SURFACE2);119 doc.rect(tx, y, Math.max(2, frac * tw), 13).fill(dark ? INK : GREEN);120 doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(txt, x + w - 112, y + (sub ? 0 : 3), { width: 112, align: "right" });121 if (sub) doc.font("JB-Reg").fontSize(5.8).fillColor(INK3).text(sub, x + w - 112, y + 9, { width: 112, align: "right" });122}123124/* ============================ curseur de page ============================ */125class Cursor {126 y = TOP;127 sections: { label: string; page: number }[] = [];128 constructor(public doc: Doc) {}129 page() {130 // bufferedPageRange().count = pages déjà créées (1-based pour l'humain)131 return this.doc.bufferedPageRange().count;132 }133 ensure(h: number) {134 if (this.y + h > BOT) {135 this.doc.addPage({ size: "A4", margin: 0 });136 chrome(this.doc);137 this.y = TOP;138 }139 }140 section(label: string, remember = true) {141 this.ensure(30);142 if (remember) this.sections.push({ label, page: this.page() });143 kicker(this.doc, label, M, this.y);144 this.y += 20;145 }146}147148/* ============================ primitives graphiques ============================ */149150/** Cadre de graphique : carte + titre, renvoie la zone de tracé. */151function chartFrame(c: Cursor, title: string, h: number) {152 c.ensure(h + 34);153 const { doc } = c;154 card(doc, M, c.y, CW, h + 26);155 doc.font("SG-Bold").fontSize(9.5).fillColor(INK)156 .text(title, M + 14, c.y + 10, { width: CW - 28, height: 12, ellipsis: true, lineBreak: false });157 const area = { x: M + 52, y: c.y + 30, w: CW - 52 - 22, h: h - 14 };158 c.y += h + 26 + 12;159 return area;160}161162function axes(doc: Doc, a: { x: number; y: number; w: number; h: number }, vmin: number, vmax: number, labels: string[]) {163 for (let g = 0; g <= 4; g++) {164 const y = a.y + (a.h * g) / 4;165 const v = vmax - ((vmax - vmin) * g) / 4;166 doc.rect(a.x, y, a.w, 0.5).fill("#e3e1d9");167 doc.font("JB-Reg").fontSize(5.8).fillColor(INK3)168 .text(short(v), a.x - 40, y - 2.5, { width: 36, align: "right", lineBreak: false });169 }170 const idx = [0, Math.floor(labels.length / 2), labels.length - 1].filter((v, i, arr) => arr.indexOf(v) === i);171 idx.forEach((i) => {172 const x = a.x + (labels.length > 1 ? (a.w * i) / (labels.length - 1) : 0);173 doc.font("JB-Reg").fontSize(5.8).fillColor(INK3)174 .text(labels[i] ?? "", x - 24, a.y + a.h + 4, { width: 48, align: "center", lineBreak: false });175 });176}177178function linePdf(c: Cursor, s: ChartSerie, h = 120) {179 const pts = s.points ?? [];180 if (pts.length < 2) return;181 const a = chartFrame(c, s.title, h);182 const { doc } = c;183 const vmax = Math.max(...pts.map((p) => p.v), 1);184 const vmin = Math.min(0, ...pts.map((p) => p.v));185 axes(doc, a, vmin, vmax, pts.map((p) => p.t));186 const X = (i: number) => a.x + (a.w * i) / (pts.length - 1);187 const Y = (v: number) => a.y + a.h * (1 - (v - vmin) / (vmax - vmin || 1));188 if (s.kind === "bar") {189 const bw = Math.max(1.2, a.w / pts.length - 1.2);190 pts.forEach((p, i) => {191 const bh = a.h * ((p.v - Math.max(0, vmin)) / (vmax - vmin || 1));192 doc.rect(a.x + (a.w * i) / pts.length, a.y + a.h - bh, bw, Math.max(bh, 0.6)).fill(GREEN);193 });194 return;195 }196 if (s.kind === "area") {197 doc.moveTo(X(0), Y(pts[0].v));198 pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v)));199 doc.lineTo(X(pts.length - 1), a.y + a.h).lineTo(a.x, a.y + a.h).closePath();200 doc.fillOpacity(0.16).fill(GREEN).fillOpacity(1);201 }202 doc.moveTo(X(0), Y(pts[0].v));203 pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v)));204 doc.lineWidth(1.6).strokeColor(GREEN).stroke();205 // point final + valeur206 const lastX = X(pts.length - 1), lastY = Y(pts[pts.length - 1].v);207 doc.circle(lastX, lastY, 2).fill(INK);208 doc.font("JB-Bold").fontSize(6).fillColor(INK)209 .text(short(pts[pts.length - 1].v), lastX - 60, lastY - 10, { width: 58, align: "right", lineBreak: false });210}211212/** Min/max/moyenne/médiane/écart-type sous une courbe (SPEC §1.4). */213function statLine(c: Cursor, s: ChartSerie) {214 const vs = (s.points ?? []).map((p) => p.v);215 if (vs.length < 2) return;216 const sorted = [...vs].sort((x, y) => x - y);217 const mean = vs.reduce((x, y) => x + y, 0) / vs.length;218 const sd = Math.sqrt(vs.reduce((x, v) => x + (v - mean) ** 2, 0) / vs.length);219 const items: [string, number][] = [220 ["MIN", sorted[0]], ["MAX", sorted[sorted.length - 1]], ["MOYENNE", mean],221 ["MÉDIANE", sorted[Math.floor(sorted.length / 2)]], ["ÉCART-TYPE", sd],222 ];223 c.ensure(16);224 c.y -= 6;225 const w = CW / items.length;226 items.forEach(([l, v], i) => {227 c.doc.font("JB-Reg").fontSize(5.5).fillColor(INK3).text(l, M + i * w, c.y, { width: w - 6, lineBreak: false });228 c.doc.font("JB-Bold").fontSize(7).fillColor(INK).text(short(v), M + i * w, c.y + 7.5, { width: w - 6, lineBreak: false });229 });230 c.y += 24;231}232233const MULTI_PDF = [234 { color: GREEN, dash: null as number[] | null, width: 1.8 },235 { color: INK, dash: null, width: 1.2 },236 { color: GREEN_DEEP, dash: [5, 2.5], width: 1.5 },237 { color: INK3, dash: [1.5, 2.5], width: 1.5 },238];239240function multiLinePdf(c: Cursor, ms: { title: string; unit?: string; series: { label: string; points: { t: string; v: number }[] }[] }, h = 130) {241 const series = (ms.series ?? []).filter((s) => (s.points ?? []).length > 1).slice(0, 4);242 if (!series.length) return;243 const a = chartFrame(c, ms.title + (ms.unit ? ` (${ms.unit})` : ""), h + 14);244 const { doc } = c;245 // légende (motif + libellé — jamais la couleur seule)246 let lx = a.x;247 series.forEach((s, i) => {248 const st = MULTI_PDF[i];249 doc.lineWidth(2).strokeColor(st.color);250 if (st.dash) doc.dash(st.dash[0], { space: st.dash[1] }); else doc.undash();251 doc.moveTo(lx, a.y + 2).lineTo(lx + 16, a.y + 2).stroke();252 doc.undash();253 doc.font("JB-Bold").fontSize(6).fillColor(INK).text(s.label.toUpperCase(), lx + 20, a.y - 1, { lineBreak: false });254 lx += 26 + doc.widthOfString(s.label.toUpperCase()) + 14;255 });256 const area = { ...a, y: a.y + 12, h: a.h - 12 };257 const all = series.flatMap((s) => s.points.map((p) => p.v));258 const vmax = Math.max(...all, 1);259 const vmin = Math.min(0, ...all);260 axes(doc, area, vmin, vmax, series[0].points.map((p) => p.t));261 series.forEach((s, i) => {262 const st = MULTI_PDF[i];263 const X = (j: number) => area.x + (area.w * j) / (s.points.length - 1);264 const Y = (v: number) => area.y + area.h * (1 - (v - vmin) / (vmax - vmin || 1));265 doc.moveTo(X(0), Y(s.points[0].v));266 s.points.forEach((p, j) => doc.lineTo(X(j), Y(p.v)));267 doc.lineWidth(st.width).strokeColor(st.color);268 if (st.dash) doc.dash(st.dash[0], { space: st.dash[1] }); else doc.undash();269 doc.stroke();270 doc.undash();271 });272}273274const SHADES = [1, 0.72, 0.5, 0.34, 0.22, 0.13];275276function stackedPdf(c: Cursor, st: { title: string; unit?: string; keys: string[]; points: { t: string; values: number[] }[] }, h = 130) {277 const keys = (st.keys ?? []).slice(0, 6);278 const pts = st.points ?? [];279 if (!keys.length || !pts.length) return;280 const a = chartFrame(c, st.title, h + 14);281 const { doc } = c;282 let lx = a.x;283 keys.forEach((k, i) => {284 doc.fillOpacity(SHADES[i]).rect(lx, a.y - 1, 8, 8).fill(GREEN).fillOpacity(1);285 doc.rect(lx, a.y - 1, 8, 8).lineWidth(0.5).stroke(INK);286 doc.font("JB-Bold").fontSize(6).fillColor(INK).text(k.toUpperCase(), lx + 12, a.y, { lineBreak: false });287 lx += 18 + doc.widthOfString(k.toUpperCase()) + 12;288 });289 const area = { ...a, y: a.y + 12, h: a.h - 12 };290 const totals = pts.map((p) => p.values.slice(0, keys.length).reduce((s, v) => s + (v || 0), 0));291 const vmax = Math.max(...totals, 1);292 axes(doc, area, 0, vmax, pts.map((p) => p.t));293 const bw = Math.max(1, area.w / pts.length - 1);294 pts.forEach((p, i) => {295 const x = area.x + (area.w * i) / pts.length;296 let yAcc = area.y + area.h;297 keys.forEach((k, j) => {298 const v = p.values[j] || 0;299 const bh = area.h * (v / vmax);300 yAcc -= bh;301 if (bh > 0.3) {302 doc.fillOpacity(SHADES[j]).rect(x, yAcc, bw, bh).fill(GREEN).fillOpacity(1);303 }304 });305 });306}307308/** Anneau vectoriel (arcs tracés en petits segments) + légende. */309function donutPdf(c: Cursor, title: string, items: { label: string; value: number }[]) {310 const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8);311 const total = rows.reduce((s, r) => s + r.value, 0);312 if (!total) return;313 const h = 150;314 c.ensure(h + 40);315 const { doc } = c;316 card(doc, M, c.y, CW, h + 26);317 doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text(title, M + 14, c.y + 10, { width: CW - 28, height: 12, ellipsis: true, lineBreak: false });318 const cx = M + 90, cy = c.y + 30 + (h - 20) / 2, R = 52;319 let angle = -Math.PI / 2;320 rows.forEach((r, i) => {321 const sweep = (2 * Math.PI * r.value) / total;322 const steps = Math.max(2, Math.ceil(sweep / 0.06));323 doc.lineWidth(24).strokeColor(GREEN).strokeOpacity(SHADES[i % SHADES.length] * 0.9 + 0.1);324 doc.moveTo(cx + R * Math.cos(angle), cy + R * Math.sin(angle));325 for (let s = 1; s <= steps; s++) {326 const t = angle + (sweep * s) / steps;327 doc.lineTo(cx + R * Math.cos(t), cy + R * Math.sin(t));328 }329 doc.stroke().strokeOpacity(1);330 angle += sweep;331 });332 doc.circle(cx, cy, R + 12).lineWidth(0.7).strokeOpacity(0.5).stroke(INK).strokeOpacity(1);333 // légende334 const lx = M + 190;335 let ly = c.y + 34;336 rows.forEach((r, i) => {337 doc.fillOpacity(SHADES[i % SHADES.length]).rect(lx, ly, 8, 8).fill(GREEN).fillOpacity(1);338 doc.rect(lx, ly, 8, 8).lineWidth(0.5).stroke(INK);339 doc.font("SG-Bold").fontSize(7.5).fillColor(INK).text(r.label, lx + 14, ly, { width: 200, height: 9, ellipsis: true, lineBreak: false });340 doc.font("JB-Bold").fontSize(7).fillColor(INK2)341 .text(`${((100 * r.value) / total).toFixed(1).replace(".", ",")} % · ${count(r.value)}`, lx + 220, ly + 0.5, { width: CW - 220 - (lx - M) - 14, align: "right", lineBreak: false });342 ly += Math.min(17, (h - 10) / rows.length);343 });344 c.y += h + 26 + 12;345}346347/** Barres horizontales (classements / répartitions / géo). */348function hbarsPdf(c: Cursor, title: string, items: { label: string; value: number }[], fmt: (v: number) => string = count) {349 const rows = (items ?? []).slice(0, 15);350 if (!rows.length) return;351 const boxH = rows.length * 17 + 24;352 c.ensure(boxH + 30);353 const { doc } = c;354 c.section(title, false);355 card(doc, M, c.y, CW, boxH);356 const max = Math.max(...rows.map((r) => r.value), 1);357 rows.forEach((r, i) => {358 hbarRow(doc, M + 14, c.y + 12 + i * 17, CW - 28, r.label, r.value / max, fmt(r.value), null, i === 0);359 });360 c.y += boxH + 14;361}362363/** Jauges demi-arc (accent), 4 par rangée. */364function gaugesPdf(c: Cursor, gauges: ChartGauge[]) {365 if (!gauges?.length) return;366 const gw = (CW - 3 * 10) / 4;367 const rowsN = Math.ceil(gauges.length / 4);368 c.ensure(rowsN * 86 + 10);369 const { doc } = c;370 gauges.forEach((g, i) => {371 const x = M + (i % 4) * (gw + 10);372 const y = c.y + Math.floor(i / 4) * 86;373 card(doc, x, y, gw, 78);374 const cx = x + gw / 2, cy = y + 44, R = 26;375 const arc = (from: number, to: number, color: string, lw: number) => {376 const steps = Math.max(2, Math.ceil(((to - from) / Math.PI) * 24));377 doc.lineWidth(lw).strokeColor(color);378 doc.moveTo(cx + R * Math.cos(from), cy + R * Math.sin(from));379 for (let s = 1; s <= steps; s++) {380 const t = from + ((to - from) * s) / steps;381 doc.lineTo(cx + R * Math.cos(t), cy + R * Math.sin(t));382 }383 doc.stroke();384 };385 arc(Math.PI, 2 * Math.PI, "#e3e1d9", 8);386 const frac = Math.max(0, Math.min(1, g.max ? g.value / g.max : 0));387 if (frac > 0.01) arc(Math.PI, Math.PI + Math.PI * frac, GREEN, 8);388 doc.font("SG-Bold").fontSize(13).fillColor(INK)389 .text(`${g.value.toLocaleString("fr-CA", { maximumFractionDigits: 1 })}${g.unit ?? ""}`, x, cy - 12, { width: gw, align: "center", lineBreak: false });390 doc.font("JB-Bold").fontSize(5.2).fillColor(INK3)391 .text(g.label.toUpperCase(), x + 8, y + 58, { width: gw - 16, align: "center", characterSpacing: 0.3, height: 16 });392 });393 c.y += rowsN * 86 + 8;394}395396/** Grille de cartes KPI (valeur, libellé, delta coloré à 1 décimale). */397function kpiGridPdf(c: Cursor, kpis: ChartKpi[]) {398 if (!kpis?.length) return;399 const kw = (CW - 3 * 10) / 4;400 const rowsN = Math.ceil(kpis.length / 4);401 c.ensure(rowsN * 64 + 6);402 const { doc } = c;403 kpis.forEach((k, i) => {404 const x = M + (i % 4) * (kw + 10);405 const y = c.y + Math.floor(i / 4) * 64;406 card(doc, x, y, kw, 56);407 const val = typeof k.value === "number" ? count(k.value) : k.value;408 doc.font("SG-Bold").fontSize(12).fillColor(INK)409 .text(val + (k.unit && typeof k.value === "number" ? ` ${k.unit}` : ""), x + 9, y + 9, { width: kw - 18, height: 15, ellipsis: true, lineBreak: false });410 if (k.delta_pct !== undefined && k.delta_pct !== null) {411 const up = (k.direction ?? (k.delta_pct >= 0 ? "up" : "down")) === "up";412 doc.font("JB-Bold").fontSize(6.5).fillColor(up ? "#1c5c41" : "#b3423a")413 .text(`${up ? "▲" : "▼"} ${k.delta_pct >= 0 ? "+" : ""}${k.delta_pct.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`, x + 9, y + 26, { lineBreak: false });414 }415 doc.font("JB-Bold").fontSize(5).fillColor(INK3)416 .text(k.label.toUpperCase(), x + 9, y + 37, { width: kw - 18, characterSpacing: 0.3, height: 15 });417 });418 c.y += rowsN * 64 + 10;419}420421/** Calendrier d'activité (grille par semaine, opacité ∝ valeur). */422function heatmapPdf(c: Cursor, hm: { title: string; cells: { date: string; value: number }[] }) {423 if (!hm?.cells?.length) return;424 const byDate = new Map(hm.cells.map((x) => [x.date, x.value]));425 const dates = hm.cells.map((x) => x.date).sort();426 const end = new Date(dates[dates.length - 1] + "T12:00:00");427 const max = Math.max(...hm.cells.map((x) => x.value), 1);428 const weeks = 26;429 const cell = (CW - 28) / weeks;430 const gridH = 7 * cell;431 c.ensure(gridH + 60);432 const { doc } = c;433 c.section(hm.title, false);434 card(doc, M, c.y, CW, gridH + 24);435 const cur = new Date(end);436 cur.setDate(cur.getDate() - (weeks * 7 - 1));437 for (let w = 0; w < weeks; w++) {438 for (let d = 0; d < 7; d++) {439 const iso = cur.toISOString().slice(0, 10);440 const v = byDate.get(iso) ?? 0;441 const x = M + 14 + w * cell;442 const y = c.y + 12 + d * cell;443 if (v > 0) {444 doc.fillOpacity(0.2 + 0.8 * (v / max)).rect(x, y, cell - 1.6, cell - 1.6).fill(GREEN).fillOpacity(1);445 } else {446 doc.rect(x, y, cell - 1.6, cell - 1.6).fill("#eceae2");447 }448 cur.setDate(cur.getDate() + 1);449 }450 }451 c.y += gridH + 24 + 14;452}453454/** Heatmap horaire 7 jours × 24 heures (opacité ∝ valeur). */455function hourlyPdf(c: Cursor, hm: { title: string; cells: HourCell[] }) {456 if (!hm?.cells?.length) return;457 const labW = 28;458 const cellW = (CW - 28 - labW) / 24;459 const cellH = 13;460 const gridH = 7 * cellH;461 c.ensure(gridH + 74);462 const { doc } = c;463 c.section(hm.title, false);464 card(doc, M, c.y, CW, gridH + 36);465 const max = Math.max(...hm.cells.map((x) => x.value), 1);466 const byKey = new Map(hm.cells.map((x) => [`${x.dow}-${x.hour}`, x.value]));467 const gx = M + 14 + labW;468 const gy = c.y + 12;469 DOW_FR.forEach((d, r) => {470 doc.font("JB-Reg").fontSize(5.6).fillColor(INK3)471 .text(d.toUpperCase(), M + 14, gy + r * cellH + 3.5, { lineBreak: false });472 for (let h = 0; h < 24; h++) {473 const v = byKey.get(`${r}-${h}`) ?? 0;474 const x = gx + h * cellW;475 const y = gy + r * cellH;476 if (v > 0) {477 doc.fillOpacity(0.15 + 0.85 * (v / max)).rect(x, y, cellW - 1.4, cellH - 1.4).fill(GREEN).fillOpacity(1);478 } else {479 doc.rect(x, y, cellW - 1.4, cellH - 1.4).fill("#eceae2");480 }481 }482 });483 [0, 6, 12, 18, 23].forEach((h) => {484 doc.font("JB-Reg").fontSize(5.6).fillColor(INK3)485 .text(`${h} h`, gx + h * cellW, gy + gridH + 4, { lineBreak: false });486 });487 c.y += gridH + 36 + 14;488}489490/** Tableau zébré paginé proprement (jamais de ligne coupée). */491function tablePdf(c: Cursor, t: { title: string; columns: string[]; rows: (string | number | null)[][] }, maxRows: number, remember = true) {492 const rows = t.rows.slice(0, maxRows);493 if (!rows.length) return;494 const { doc } = c;495 c.ensure(90);496 if (remember) c.sections.push({ label: t.title, page: c.page() });497 kicker(doc, t.title, M, c.y);498 c.y += 18;499 // largeur des colonnes : numériques 88 pt à droite, texte se partage le reste500 const isNum = t.columns.map((_, ci) => rows.every((r) => r[ci] === null || typeof r[ci] === "number"));501 const numW = 88;502 const rankW = 30;503 const widths: number[] = t.columns.map((col, ci) => {504 if (ci === 0 && (col === "Rang" || col === "#")) return rankW;505 return isNum[ci] ? numW : 0;506 });507 const fixed = widths.reduce((a, b) => a + b, 0);508 const flexN = widths.filter((w) => w === 0).length || 1;509 const flexW = (CW - fixed) / flexN;510 const xs: number[] = [];511 let xAcc = M;512 widths.forEach((w) => { xs.push(xAcc); xAcc += w || flexW; });513 const header = () => {514 doc.rect(M, c.y, CW, 15).fill(INK);515 doc.font("JB-Bold").fontSize(5.6).fillColor(WHITE);516 t.columns.forEach((col, ci) => {517 const w = (widths[ci] || flexW) - 10;518 doc.text(col.toUpperCase(), xs[ci] + 5, c.y + 5, { width: w, align: isNum[ci] && ci > 0 ? "right" : "left", lineBreak: false });519 });520 c.y += 15;521 };522 header();523 const fmtCell = (v: string | number | null) => {524 if (v === null || v === undefined) return "—";525 if (typeof v !== "number") return String(v);526 return num(v);527 };528 rows.forEach((r, ri) => {529 if (c.y + 13 > BOT) {530 doc.rect(M, c.y, CW, 0.9).fill(INK);531 doc.addPage({ size: "A4", margin: 0 });532 chrome(doc);533 c.y = TOP;534 kicker(doc, `${t.title} (suite)`, M, c.y);535 c.y += 18;536 header();537 }538 if (ri % 2 === 0) doc.rect(M, c.y, CW, 13).fill(SURFACE2);539 t.columns.forEach((col, ci) => {540 const w = (widths[ci] || flexW) - 10;541 const right = isNum[ci] && ci > 0;542 doc.font(ci === 1 && !isNum[1] ? "SG-Bold" : "JB-Reg").fontSize(6.6).fillColor(INK)543 .text(fmtCell(r[ci]), xs[ci] + 5, c.y + 3.6, { width: w, align: right ? "right" : "left", height: 9, ellipsis: true, lineBreak: false });544 });545 c.y += 13;546 });547 doc.rect(M, c.y, CW, 0.9).fill(INK);548 c.y += 14;549}550551/** Records & faits marquants — cartes compactes 2 par rangée. */552function recordsPdf(c: Cursor, records: RecordFact[], remember = true) {553 if (!records?.length) return;554 c.section("Records & faits marquants", remember);555 const { doc } = c;556 const rw = (CW - 10) / 2;557 records.forEach((r, i) => {558 if (i % 2 === 0) c.ensure(52);559 const x = M + (i % 2) * (rw + 10);560 const y = c.y;561 card(doc, x, y, rw, 44, SURFACE2);562 doc.font("SG-Bold").fontSize(9).fillColor(INK)563 .text(r.value, x + 10, y + 8, { width: rw - 20, height: 11, ellipsis: true, lineBreak: false });564 doc.font("JB-Bold").fontSize(5.2).fillColor(INK3)565 .text(r.label.toUpperCase() + (r.date ? ` · ${r.date}` : ""), x + 10, y + 24, { width: rw - 20, characterSpacing: 0.3, height: 14 });566 if (i % 2 === 1 || i === records.length - 1) c.y += 52;567 });568 c.y += 6;569}570571/* ------------------------------- COUVERTURE ------------------------------- */572function cover(doc: Doc, p: EcoPayload, generated: string, modeLabel: string) {573 doc.rect(0, 0, W, H).fill(PAPER);574 doc.lineWidth(2).rect(28, 28, W - 56, H - 56).stroke(INK);575 kicker(doc, "Groupe KA · Rapport statistique", 64, 100, 10.5);576 wordmark(doc, 64, 172, 46);577 doc.font("Inter").fontSize(13).fillColor(INK2).text(578 "Le portail de l'écosystème ·Ka — rapport consolidé des tableaux de bord statistiques publiés en direct par les plateformes de données du Groupe KA.",579 64, 256, { width: W - 168, lineGap: 3 });580 const rows: [string, string][] = [581 ["Type de rapport", modeLabel],582 ["Période couverte", p.periodLabel],583 ["Généré le", `${generated} (heure de l'Est)`],584 ["Plateforme", "www.groupe-ka.com"],585 ["Données", `${p.reachable}/${p.total} plateformes jointes · lues à la source`],586 ];587 doc.rect(64, 318, 34, 3).fill(LIME);588 let y = 336;589 rows.forEach(([k, v]) => {590 doc.font("JB-Reg").fontSize(8).fillColor(INK3)591 .text(k.toUpperCase(), 64, y + 2.5, { width: 150, characterSpacing: 0.6, lineBreak: false });592 doc.font("SG-Bold").fontSize(11.5).fillColor(INK)593 .text(v, 214, y, { width: W - 214 - 64, height: 14, ellipsis: true, lineBreak: false });594 y += 24;595 });596 doc.rect(28, H - 28 - 64, W - 56, 64).fill(INK);597 const by = H - 28 - 64 + 24;598 doc.font("SG-Bold").fontSize(15).fillColor(WHITE).text("par Groupe ", 64, by, { lineBreak: false });599 doc.fillColor(LIME).text("KA", 64 + doc.widthOfString("par Groupe "), by, { lineBreak: false });600 doc.font("JB-Bold").fontSize(9.5).fillColor(LIME)601 .text("groupe-ka.com", W - 64 - 180, by + 4, { width: 180, align: "right", characterSpacing: 0.8 });602}603604/* ------------------------------ PAGE DE FIN ------------------------------ */605function finalPage(doc: Doc) {606 let y = 60;607 kicker(doc, "Groupe KA · Contact", M, y);608 y += 20;609 doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Coordonnées du Groupe KA", M, y);610 y += 40;611 eco.contacts.forEach((c) => {612 doc.font("SG-Bold").fontSize(12.5).fillColor(INK).text(c.email, M, y, { lineBreak: false });613 doc.font("JB-Reg").fontSize(8).fillColor(INK3).text(c.role.toUpperCase(), M, y + 17, { characterSpacing: 0.5, lineBreak: false });614 y += 42;615 });616 y += 6;617 doc.font("JB-Bold").fontSize(9.5).fillColor(GREEN)618 .text("groupe-ka.com — le portail de l'écosystème ·Ka", M, y, { lineBreak: false });619 y += 26;620 doc.rect(M, y, 44, 3).fill(LIME);621 y += 14;622 doc.font("Inter").fontSize(10).fillColor(INK2)623 .text(eco.org.disclaimer + " Données lues à la source, rien d'inventé, tout est traçable.", M, y, { width: 420, lineGap: 3 });624 y = doc.y + 16;625 doc.font("Inter").fontSize(8).fillColor(INK3).text(626 "Mentions : rapport généré automatiquement à partir des tableaux de bord statistiques publiés par les " +627 "plateformes de l'écosystème ·Ka (endpoint commun /api/stats/dashboard) au moment indiqué en couverture. " +628 "Les agrégats (sommes, indices, calendriers) sont calculés sur les seules dates communes aux séries " +629 "publiées — jamais d'extrapolation ; une plateforme injoignable n'est jamais remplacée par un chiffre " +630 "inventé. Conditions d'utilisation, politique de confidentialité et protection des renseignements " +631 "personnels (Loi 25) : groupe-ka.com/conditions · /confidentialite · /loi-25.",632 M, y, { width: 420, lineGap: 2.5 });633 wordmark(doc, M, H - 92, 15);634}635636/* ===================== v3 : rapports personnalisés (SPEC §3bis) =====================637 Catalogue de blocs dérivé de l'EcoPayload consolidé + génération d'un PDF638 composé bloc par bloc (rendu au choix), même gabarit estampillé Groupe-KA. */639export type CatalogBlock = {640 key: string; section: string; title: string;641 renders: string[]; default_render: string; count?: number;642};643export type CustomBlock = { key: string; render?: string };644export type CustomSpec = {645 title?: string; period?: string; from?: string; to?: string;646 blocks?: CustomBlock[];647};648649/** EcoPayload consolidé pour une période (mêmes règles que la page /stats). */650export async function getReportPayload(rawPeriod?: string): Promise<EcoPayload> {651 const period: PeriodKey = isPeriod(rawPeriod) ? rawPeriod : "30j";652 const [platforms, live] = await Promise.all([653 getEcosystemStats(period),654 getLiveMetrics().catch(() => null),655 ]);656 const periodLabel =657 platforms.find((p) => p.dash?.period?.label)?.dash?.period?.label ??658 (PERIODS.find(([k]) => k === period)?.[1] ?? period);659 return buildEcoPayload(platforms, {660 period,661 periodLabel,662 connectors: live?.totals.connectors ?? 0,663 });664}665666/** Série d'un bloc `series:<id>` (eco-vol agrégé ou tendance d'une plateforme). */667function findSerie(p: EcoPayload, id: string): ChartSerie | null {668 if (id === "eco-vol") return p.volume?.serie ?? null;669 if (id.startsWith("pf-")) {670 const s = p.platforms.find((x) => x.site.id === id.slice(3));671 const spark = s?.kpis[0]?.spark ?? [];672 if (!s || spark.length < 2) return null;673 return {674 id,675 title: `${s.site.wordmark} — tendance (${s.kpis[0].label})`,676 kind: "line",677 points: spark,678 };679 }680 return null;681}682683export function catalogFromDashboard(p: EcoPayload): CatalogBlock[] {684 const out: CatalogBlock[] = [];685 const add = (key: string, title: string, renders: string[], def?: string, count?: number) =>686 out.push({ key, section: key.split(":")[0], title, renders,687 default_render: def ?? renders[0],688 ...(count !== undefined ? { count } : {}) });689 if (p.banner?.length) add("kpis", "Indicateurs clés de l'écosystème", ["cards", "table"], undefined, p.banner.length);690 if (p.gauges?.length) add("gauges", "Couverture du hub (jauges)", ["gauges", "table"], undefined, p.gauges.length);691 if ((p.volume?.serie.points?.length ?? 0) >= 2)692 add("series:eco-vol", p.volume!.serie.title, ["line", "area", "bar", "table"], "area", p.volume!.serie.points.length);693 for (const s of p.platforms) {694 const spark = s.kpis[0]?.spark ?? [];695 if (!s.ok || spark.length < 2) continue;696 add(`series:pf-${s.site.id}`, `${s.site.wordmark} — tendance (${s.kpis[0].label})`,697 ["line", "area", "bar", "table"], "line", spark.length);698 }699 if (p.growthIndex?.ms.series?.length)700 add("multiseries:eco-growth", p.growthIndex.ms.title, ["lines", "table"], undefined, p.growthIndex.ms.series.length);701 if (p.stacked?.st.points?.length)702 add("stacked:eco-stack", p.stacked.st.title, ["stacked", "table"], undefined, p.stacked.st.keys?.length);703 if (p.ranking?.length)704 add("breakdowns:classement", "Classement des plateformes par volume", ["donut", "bars", "table"], "bars", p.ranking.length);705 for (const s of p.platforms) {706 if (!s.ok || !s.breakdown?.items?.length) continue;707 add(`breakdowns:pf-${s.site.id}`, `${s.site.wordmark} — ${s.breakdown.title}`,708 ["donut", "bars", "table"], s.breakdown.kind === "donut" ? "donut" : "bars", s.breakdown.items.length);709 }710 for (const s of p.platforms) {711 if (!s.ok || !s.geo?.items?.length) continue;712 add(`geo:pf-${s.site.id}`, `${s.site.wordmark} — ${s.geo.title}`, ["bars", "table"], undefined, s.geo.items.length);713 }714 if (p.heat?.cells?.length) add("heatmap", p.heat.title, ["heatmap", "table"]);715 if (p.hourly?.cells?.length) add("hourly", p.hourly.title, ["heatmap", "table"]);716 if (p.table?.rows?.length) add(`tables:${p.table.id}`, p.table.title, ["table"], undefined, p.table.rows.length);717 if (p.records?.length) add("records", "Records & faits marquants", ["cards", "table"], undefined, p.records.length);718 return out;719}720721type AnyRow = (string | number | null)[];722const seriesAsTable = (s: ChartSerie) => ({723 title: s.title,724 columns: ["Date", (s.unit ?? "Valeur")] as string[],725 rows: (s.points ?? []).map((pt) => [pt.t, pt.v] as AnyRow),726});727728function renderCustomBlock(c: Cursor, p: EcoPayload, key: string, render: string, title: string) {729 const [section, id] = [key.split(":")[0], key.split(":").slice(1).join(":")];730 const mark = () => { c.ensure(160); c.sections.push({ label: title, page: c.page() }); };731 if (section === "kpis") {732 if (render === "table") {733 tablePdf(c, { title: "Indicateurs clés de l'écosystème", columns: ["Indicateur", "Valeur", "Δ %"],734 rows: p.banner.map((k) => [k.label, typeof k.value === "number" ? num(k.value) + (k.unit ? ` ${k.unit}` : "") : String(k.value),735 k.delta_pct == null ? "" : `${k.delta_pct >= 0 ? "+" : ""}${k.delta_pct} %`] as AnyRow) }, 400);736 } else { c.section("Indicateurs clés de l'écosystème"); kpiGridPdf(c, p.banner); }737 } else if (section === "gauges") {738 if (render === "table") {739 tablePdf(c, { title: "Couverture du hub", columns: ["Mesure", "Valeur", "Max", "Part"],740 rows: p.gauges.map((g) => [g.label, `${num(g.value)}${g.unit ? ` ${g.unit}` : ""}`, num(g.max),741 `${Math.round((100 * g.value) / (g.max || 1))} %`] as AnyRow) }, 400);742 } else { c.section("Couverture du hub"); gaugesPdf(c, p.gauges); }743 } else if (section === "records") {744 if (render === "table") {745 tablePdf(c, { title: "Records & faits marquants", columns: ["Fait marquant", "Valeur", "Date"],746 rows: p.records.map((r) => [r.label, r.value, r.date ?? ""] as AnyRow) }, 400);747 } else recordsPdf(c, p.records);748 } else if (section === "series") {749 const s = findSerie(p, id);750 if (!s) return;751 if (render === "table") tablePdf(c, seriesAsTable(s), 400);752 else {753 mark();754 linePdf(c, { ...s, kind: (["line", "area", "bar"].includes(render) ? render : s.kind) as ChartSerie["kind"] });755 statLine(c, s);756 }757 } else if (section === "multiseries") {758 const ms: ChartMultiSerie | undefined = id === "eco-growth" ? p.growthIndex?.ms : undefined;759 if (!ms) return;760 if (render === "table") {761 const labels = ms.series.slice(0, 4).map((x) => x.label);762 const byT = new Map<string, Record<string, number>>();763 for (const se of ms.series.slice(0, 4))764 for (const pt of se.points) {765 const m = byT.get(pt.t) ?? {};766 m[se.label] = pt.v; byT.set(pt.t, m);767 }768 tablePdf(c, { title: ms.title, columns: ["Date", ...labels],769 rows: [...byT.keys()].sort().map((t) => [t, ...labels.map((l) => byT.get(t)?.[l] ?? "")] as AnyRow) }, 400);770 } else { mark(); multiLinePdf(c, ms); }771 } else if (section === "stacked") {772 const st: ChartStacked | undefined = id === "eco-stack" ? p.stacked?.st : undefined;773 if (!st) return;774 if (render === "table") {775 const keys = (st.keys ?? []).slice(0, 6);776 tablePdf(c, { title: st.title, columns: ["Date", ...keys, "Total"],777 rows: st.points.map((pt) => {778 const vs = keys.map((_, j) => pt.values[j] ?? 0);779 return [pt.t, ...vs, vs.reduce((a, b) => a + b, 0)] as AnyRow;780 }) }, 400);781 } else { mark(); stackedPdf(c, st); }782 } else if (section === "breakdowns") {783 let items: BreakItem[] = [];784 if (id === "classement") items = p.ranking ?? [];785 else if (id.startsWith("pf-")) items = p.platforms.find((x) => x.site.id === id.slice(3))?.breakdown?.items ?? [];786 if (!items.length) return;787 if (render === "table") {788 tablePdf(c, { title, columns: ["Libellé", "Valeur"],789 rows: items.map((it) => [it.label, it.value] as AnyRow) }, 400);790 } else {791 mark();792 if (render === "donut") donutPdf(c, title, items);793 else hbarsPdf(c, title, items);794 }795 } else if (section === "geo") {796 const items = id.startsWith("pf-")797 ? p.platforms.find((x) => x.site.id === id.slice(3))?.geo?.items ?? []798 : [];799 if (!items.length) return;800 if (render === "table") {801 tablePdf(c, { title, columns: ["Zone", "Valeur"],802 rows: items.map((it) => [it.label, it.value] as AnyRow) }, 400);803 } else { mark(); hbarsPdf(c, title, items); }804 } else if (section === "heatmap") {805 if (!p.heat?.cells?.length) return;806 if (render === "table") {807 const cells = [...p.heat.cells].sort((a, b) => b.value - a.value).slice(0, 40);808 tablePdf(c, { title: `${p.heat.title} — jours les plus chargés`,809 columns: ["Date", "Valeur"], rows: cells.map((x) => [x.date, x.value] as AnyRow) }, 400);810 } else { mark(); heatmapPdf(c, p.heat); }811 } else if (section === "hourly") {812 if (!p.hourly?.cells?.length) return;813 if (render === "table") {814 const cells = [...p.hourly.cells].sort((a, b) => b.value - a.value).slice(0, 40);815 tablePdf(c, { title: `${p.hourly.title} — créneaux les plus actifs`,816 columns: ["Jour", "Heure", "Valeur"],817 rows: cells.map((x) => [DOW_FR[x.dow] ?? String(x.dow), `${x.hour} h`, x.value] as AnyRow) }, 400);818 } else { mark(); hourlyPdf(c, p.hourly); }819 } else if (section === "tables") {820 if (String(p.table?.id) === id && p.table.rows?.length)821 tablePdf(c, p.table, 400);822 }823}824825/** PDF personnalisé — lève Error("aucun-bloc") si la composition est vide. */826export async function buildCustomReport(spec: CustomSpec, payload: EcoPayload): Promise<Buffer> {827 const cat = new Map(catalogFromDashboard(payload).map((b) => [b.key, b]));828 const blocks = (spec.blocks ?? [])829 .filter((b): b is CustomBlock => !!b && typeof b === "object" && cat.has(String(b.key)))830 .slice(0, 40);831 if (!blocks.length) throw new Error("aucun-bloc");832 const title = String(spec.title ?? "").slice(0, 80).trim();833 const label = title ? `Rapport personnalisé — ${title}` : "Rapport personnalisé";834 PERIOD_LABEL = payload.periodLabel;835836 const generated = new Date().toLocaleString("fr-CA", {837 timeZone: "America/Toronto", year: "numeric", month: "2-digit", day: "2-digit",838 hour: "2-digit", minute: "2-digit", hour12: false,839 }).replace(",", " ·");840 const doc = new PDFDocument({841 size: "A4", margin: 0, bufferPages: true,842 info: { Title: `Groupe KA · Portail de l'écosystème — ${label}`, Author: "Groupe KA — groupe-ka.com" },843 });844 doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));845 doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));846 doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));847 doc.registerFont("Inter", F("Inter-Regular.ttf"));848 const chunks: Buffer[] = [];849 doc.on("data", (b: Buffer) => chunks.push(b));850 const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));851852 cover(doc, payload, generated, label);853 doc.addPage({ size: "A4", margin: 0 }); // page 2 : sommaire (post-passe)854 doc.addPage({ size: "A4", margin: 0 });855 chrome(doc);856 const c = new Cursor(doc);857 for (const blk of blocks) {858 const b = cat.get(String(blk.key))!;859 const render = b.renders.includes(String(blk.render ?? "")) ? String(blk.render) : b.default_render;860 renderCustomBlock(c, payload, b.key, render, b.title);861 }862 doc.addPage({ size: "A4", margin: 0 });863 chrome(doc);864 finalPage(doc);865866 const range = doc.bufferedPageRange();867 const total = range.count;868 doc.switchToPage(1);869 chrome(doc);870 let y = TOP + 4;871 kicker(doc, label, M, y);872 y += 20;873 doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Sommaire", M, y);874 y += 40;875 const entries: [string, string][] = [876 ...c.sections.map((sec) => [sec.label, String(sec.page)] as [string, string]),877 ["Coordonnées du Groupe KA & mentions", String(total)],878 ];879 entries.slice(0, 16).forEach(([lab, pg]) => {880 doc.rect(M, y + 22, CW, 0.7).fill("#e3e1d9");881 doc.font("SG-Bold").fontSize(11).fillColor(INK).text(lab, M, y, { width: CW - 70, height: 26, ellipsis: true });882 doc.font("JB-Bold").fontSize(10.5).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" });883 y += 34;884 });885 for (let i = 1; i < total; i++) {886 doc.switchToPage(i);887 doc.font("JB-Bold").fontSize(7.5).fillColor(INK)888 .text(`p. ${i + 1}/${total}`, W - M - 60, H - 34.5, { width: 60, align: "right", lineBreak: false });889 }890 doc.end();891 return done;892}893894/** Nom de fichier normalisé Groupe-KA (SPEC §3bis). */895export function customReportFilename(period: string): string {896 const today = new Date().toLocaleDateString("fr-CA", { timeZone: "America/Toronto" });897 return `groupe-ka_groupe-ka_stats_${period}_personnalise_${today}.pdf`;898}899