SPB Git forge

spb/groupe-ka

Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%
39.9 KB · 899 lines typescript
Raw Blame History
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