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%

stats v3 : rapports PDF personnalisés (catalogue, rendu au choix, ReportBuilder)

- moteur pdfkit local src/lib/report-stats.ts (gabarit Groupe-KA, palette lime/vert du hub) : catalogFromDashboard() sur EcoPayload + buildCustomReport() — primitives line/area/bar/donut/hbars/stacked/multi/gauges/kpi/heatmap quotidienne ET horaire/tables/records, sommaire, couverture à libellé libre
- routes GET /api/stats/catalog et POST /api/stats/report/custom (filename groupe-ka_groupe-ka_stats_<periode>_personnalise_<date>.pdf) ; rapport écosystème v2 (proxy API-KA) intact
- kit src/ka/stats (kacharts.tsx v3 avec ReportBuilder dans PdfButton, SPEC.md §3bis, kapdf.py) synchronisé depuis /tmp/ka-stats-v3
- pdfkit ^0.19.1 (+types) en serverExternalPackages, polices du gabarit sous assets/fonts/
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent f666bb7

13 changed files +1,844 −11

added assets/fonts/Inter-Regular.ttf +0 −0

Binary file not shown.

added assets/fonts/JetBrainsMono-Bold.ttf +0 −0

Binary file not shown.

added assets/fonts/JetBrainsMono-Regular.ttf +0 −0

Binary file not shown.

added assets/fonts/SpaceGrotesk-Bold.ttf +0 −0

Binary file not shown.

modified next.config.ts +1 −1
@@ -2,7 +2,7 @@
2 2 import type { NextConfig } from "next";
3 3
4 4 const nextConfig: NextConfig = {
5 − serverExternalPackages: ["better-sqlite3", "sharp"],
5 + serverExternalPackages: ["better-sqlite3", "sharp", "pdfkit"],
6 6 };
7 7
8 8 export default nextConfig;
modified package-lock.json +203 −0
@@ -11,6 +11,7 @@
11 11 "better-sqlite3": "^13.0.3",
12 12 "jose": "^6.2.8",
13 13 "next": "16.3.0",
14 + "pdfkit": "^0.19.1",
14 15 "qrcode": "^1.5.4",
15 16 "react": "19.2.8",
16 17 "react-dom": "19.2.8",
@@ -20,6 +21,7 @@
20 21 "@tailwindcss/postcss": "^4",
21 22 "@types/better-sqlite3": "^9.6.0",
22 23 "@types/node": "^20",
24 + "@types/pdfkit": "^0.17.6",
23 25 "@types/qrcode": "^1.5.6",
24 26 "@types/react": "^19",
25 27 "@types/react-dom": "^19",
@@ -794,6 +796,30 @@
794 796 "node": ">= 10"
795 797 }
796 798 },
799 + "node_modules/@noble/ciphers": {
800 + "version": "1.3.0",
801 + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
802 + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
803 + "license": "MIT",
804 + "engines": {
805 + "node": "^14.21.3 || >=16"
806 + },
807 + "funding": {
808 + "url": "https://paulmillr.com/funding/"
809 + }
810 + },
811 + "node_modules/@noble/hashes": {
812 + "version": "1.8.0",
813 + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
814 + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
815 + "license": "MIT",
816 + "engines": {
817 + "node": "^14.21.3 || >=16"
818 + },
819 + "funding": {
820 + "url": "https://paulmillr.com/funding/"
821 + }
822 + },
797 823 "node_modules/@swc/helpers": {
798 824 "version": "0.5.15",
799 825 "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1106,6 +1132,16 @@
1106 1132 "undici-types": "~6.21.0"
1107 1133 }
1108 1134 },
1135 + "node_modules/@types/pdfkit": {
1136 + "version": "0.17.6",
1137 + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
1138 + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
1139 + "dev": true,
1140 + "license": "MIT",
1141 + "dependencies": {
1142 + "@types/node": "*"
1143 + }
1144 + },
1109 1145 "node_modules/@types/qrcode": {
1110 1146 "version": "1.5.6",
1111 1147 "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
@@ -1160,6 +1196,26 @@
1160 1196 "url": "https://github.com/chalk/ansi-styles?sponsor=1"
1161 1197 }
1162 1198 },
1199 + "node_modules/base64-js": {
1200 + "version": "1.5.1",
1201 + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
1202 + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
1203 + "funding": [
1204 + {
1205 + "type": "github",
1206 + "url": "https://github.com/sponsors/feross"
1207 + },
1208 + {
1209 + "type": "patreon",
1210 + "url": "https://www.patreon.com/feross"
1211 + },
1212 + {
1213 + "type": "consulting",
1214 + "url": "https://feross.org/support"
1215 + }
1216 + ],
1217 + "license": "MIT"
1218 + },
1163 1219 "node_modules/baseline-browser-mapping": {
1164 1220 "version": "2.11.13",
1165 1221 "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
@@ -1184,6 +1240,24 @@
1184 1240 "node": ">=22"
1185 1241 }
1186 1242 },
1243 + "node_modules/brotli": {
1244 + "version": "1.3.3",
1245 + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
1246 + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
1247 + "license": "MIT",
1248 + "dependencies": {
1249 + "base64-js": "^1.1.2"
1250 + }
1251 + },
1252 + "node_modules/browserify-zlib": {
1253 + "version": "0.2.0",
1254 + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
1255 + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
1256 + "license": "MIT",
1257 + "dependencies": {
1258 + "pako": "~1.0.5"
1259 + }
1260 + },
1187 1261 "node_modules/camelcase": {
1188 1262 "version": "5.3.1",
1189 1263 "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
@@ -1230,6 +1304,15 @@
1230 1304 "wrap-ansi": "^6.2.0"
1231 1305 }
1232 1306 },
1307 + "node_modules/clone": {
1308 + "version": "2.1.2",
1309 + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
1310 + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
1311 + "license": "MIT",
1312 + "engines": {
1313 + "node": ">=0.8"
1314 + }
1315 + },
1233 1316 "node_modules/color-convert": {
1234 1317 "version": "2.0.1",
1235 1318 "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1273,6 +1356,12 @@
1273 1356 "node": ">=8"
1274 1357 }
1275 1358 },
1359 + "node_modules/dfa": {
1360 + "version": "1.2.0",
1361 + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
1362 + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
1363 + "license": "MIT"
1364 + },
1276 1365 "node_modules/dijkstrajs": {
1277 1366 "version": "1.0.3",
1278 1367 "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
@@ -1299,6 +1388,12 @@
1299 1388 "node": ">=10.13.0"
1300 1389 }
1301 1390 },
1391 + "node_modules/fast-deep-equal": {
1392 + "version": "3.1.3",
1393 + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
1394 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
1395 + "license": "MIT"
1396 + },
1302 1397 "node_modules/find-up": {
1303 1398 "version": "4.1.0",
1304 1399 "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -1312,6 +1407,23 @@
1312 1407 "node": ">=8"
1313 1408 }
1314 1409 },
1410 + "node_modules/fontkit": {
1411 + "version": "2.0.4",
1412 + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
1413 + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
1414 + "license": "MIT",
1415 + "dependencies": {
1416 + "@swc/helpers": "^0.5.12",
1417 + "brotli": "^1.3.2",
1418 + "clone": "^2.1.2",
1419 + "dfa": "^1.2.0",
1420 + "fast-deep-equal": "^3.1.3",
1421 + "restructure": "^3.0.0",
1422 + "tiny-inflate": "^1.0.3",
1423 + "unicode-properties": "^1.4.0",
1424 + "unicode-trie": "^2.0.0"
1425 + }
1426 + },
1315 1427 "node_modules/get-caller-file": {
1316 1428 "version": "2.0.5",
1317 1429 "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -1356,6 +1468,12 @@
1356 1468 "url": "https://github.com/sponsors/panva"
1357 1469 }
1358 1470 },
1471 + "node_modules/js-md5": {
1472 + "version": "0.8.3",
1473 + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
1474 + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
1475 + "license": "MIT"
1476 + },
1359 1477 "node_modules/lightningcss": {
1360 1478 "version": "1.32.0",
1361 1479 "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1629,6 +1747,25 @@
1629 1747 "url": "https://opencollective.com/parcel"
1630 1748 }
1631 1749 },
1750 + "node_modules/linebreak": {
1751 + "version": "1.1.0",
1752 + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
1753 + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
1754 + "license": "MIT",
1755 + "dependencies": {
1756 + "base64-js": "0.0.8",
1757 + "unicode-trie": "^2.0.0"
1758 + }
1759 + },
1760 + "node_modules/linebreak/node_modules/base64-js": {
1761 + "version": "0.0.8",
1762 + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
1763 + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
1764 + "license": "MIT",
1765 + "engines": {
1766 + "node": ">= 0.4"
1767 + }
1768 + },
1632 1769 "node_modules/locate-path": {
1633 1770 "version": "5.0.0",
1634 1771 "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -1795,6 +1932,12 @@
1795 1932 "node": ">=6"
1796 1933 }
1797 1934 },
1935 + "node_modules/pako": {
1936 + "version": "1.0.11",
1937 + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
1938 + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
1939 + "license": "(MIT AND Zlib)"
1940 + },
1798 1941 "node_modules/path-exists": {
1799 1942 "version": "4.0.0",
1800 1943 "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -1804,12 +1947,34 @@
1804 1947 "node": ">=8"
1805 1948 }
1806 1949 },
1950 + "node_modules/pdfkit": {
1951 + "version": "0.19.1",
1952 + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
1953 + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
1954 + "license": "MIT",
1955 + "dependencies": {
1956 + "@noble/ciphers": "^1.0.0",
1957 + "@noble/hashes": "^1.6.0",
1958 + "fontkit": "^2.0.4",
1959 + "js-md5": "^0.8.3",
1960 + "linebreak": "^1.1.0",
1961 + "png-js": "^1.1.0"
1962 + }
1963 + },
1807 1964 "node_modules/picocolors": {
1808 1965 "version": "1.1.1",
1809 1966 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1810 1967 "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1811 1968 "license": "ISC"
1812 1969 },
1970 + "node_modules/png-js": {
1971 + "version": "1.1.0",
1972 + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
1973 + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
1974 + "dependencies": {
1975 + "browserify-zlib": "^0.2.0"
1976 + }
1977 + },
1813 1978 "node_modules/pngjs": {
1814 1979 "version": "5.0.0",
1815 1980 "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@@ -1901,6 +2066,12 @@
1901 2066 "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
1902 2067 "license": "ISC"
1903 2068 },
2069 + "node_modules/restructure": {
2070 + "version": "3.0.2",
2071 + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
2072 + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
2073 + "license": "MIT"
2074 + },
1904 2075 "node_modules/scheduler": {
1905 2076 "version": "0.27.0",
1906 2077 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2053,6 +2224,12 @@
2053 2224 "url": "https://opencollective.com/webpack"
2054 2225 }
2055 2226 },
2227 + "node_modules/tiny-inflate": {
2228 + "version": "1.0.3",
2229 + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
2230 + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
2231 + "license": "MIT"
2232 + },
2056 2233 "node_modules/tslib": {
2057 2234 "version": "2.8.1",
2058 2235 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -2080,6 +2257,32 @@
2080 2257 "dev": true,
2081 2258 "license": "MIT"
2082 2259 },
2260 + "node_modules/unicode-properties": {
2261 + "version": "1.4.1",
2262 + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
2263 + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
2264 + "license": "MIT",
2265 + "dependencies": {
2266 + "base64-js": "^1.3.0",
2267 + "unicode-trie": "^2.0.0"
2268 + }
2269 + },
2270 + "node_modules/unicode-trie": {
2271 + "version": "2.0.0",
2272 + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
2273 + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
2274 + "license": "MIT",
2275 + "dependencies": {
2276 + "pako": "^0.2.5",
2277 + "tiny-inflate": "^1.0.0"
2278 + }
2279 + },
2280 + "node_modules/unicode-trie/node_modules/pako": {
2281 + "version": "0.2.9",
2282 + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
2283 + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
2284 + "license": "MIT"
2285 + },
2083 2286 "node_modules/which-module": {
2084 2287 "version": "2.0.1",
2085 2288 "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
modified package.json +2 −0
@@ -13,6 +13,7 @@
13 13 "better-sqlite3": "^13.0.3",
14 14 "jose": "^6.2.8",
15 15 "next": "16.3.0",
16 + "pdfkit": "^0.19.1",
16 17 "qrcode": "^1.5.4",
17 18 "react": "19.2.8",
18 19 "react-dom": "19.2.8",
@@ -22,6 +23,7 @@
22 23 "@tailwindcss/postcss": "^4",
23 24 "@types/better-sqlite3": "^9.6.0",
24 25 "@types/node": "^20",
26 + "@types/pdfkit": "^0.17.6",
25 27 "@types/qrcode": "^1.5.6",
26 28 "@types/react": "^19",
27 29 "@types/react-dom": "^19",
added src/app/api/stats/catalog/route.ts +22 −0
@@ -0,0 +1,22 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * GET /api/stats/catalog — v3 : blocs composables pour le constructeur de
4 + * rapports personnalisés (src/ka/stats/SPEC.md §3bis). Dérivé de l'EcoPayload
5 + * consolidé (src/lib/ecostats.ts — mêmes chiffres que la page /stats, cache
6 + * amont 10 min par plateforme) ; `period` accepté, `from`/`to` non
7 + * applicables (les dashboards amont ne parlent que par période).
8 + */
9 +import { type NextRequest, NextResponse } from "next/server";
10 +import { catalogFromDashboard, getReportPayload } from "@/lib/report-stats";
11 +
12 +export const dynamic = "force-dynamic";
13 +
14 +export async function GET(req: NextRequest) {
15 + const payload = await getReportPayload(req.nextUrl.searchParams.get("period") ?? undefined);
16 + return NextResponse.json(
17 + { updated: payload.freshness ?? null,
18 + period: { label: payload.periodLabel },
19 + blocks: catalogFromDashboard(payload) },
20 + { headers: { "Cache-Control": "public, max-age=300" } },
21 + );
22 +}
added src/app/api/stats/report/custom/route.ts +45 −0
@@ -0,0 +1,45 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * POST /api/stats/report/custom — v3 : rapport PDF personnalisé
4 + * (src/ka/stats/SPEC.md §3bis). Corps : {"title", "period", "from", "to",
5 + * "blocks": [{"key": "series:…", "render": "bar"}, …]} — ordre respecté,
6 + * clés inconnues ignorées, aucune clé valide → 400. Généré LOCALEMENT
7 + * depuis l'EcoPayload consolidé (le rapport écosystème v2 à 5 modes reste,
8 + * lui, proxifié vers API-KA via /api/stats/ecosystem-report).
9 + */
10 +import { type NextRequest, NextResponse } from "next/server";
11 +import {
12 + buildCustomReport,
13 + customReportFilename,
14 + getReportPayload,
15 + type CustomSpec,
16 +} from "@/lib/report-stats";
17 +import { isPeriod } from "@/lib/ecostats";
18 +
19 +export const dynamic = "force-dynamic";
20 +
21 +export async function POST(req: NextRequest) {
22 + let spec: CustomSpec;
23 + try {
24 + spec = (await req.json()) as CustomSpec;
25 + } catch {
26 + return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
27 + }
28 + const period = isPeriod(spec?.period) ? spec.period : "30j";
29 + const payload = await getReportPayload(period);
30 + try {
31 + const pdf = await buildCustomReport(spec ?? {}, payload);
32 + return new NextResponse(new Uint8Array(pdf), {
33 + headers: {
34 + "Content-Type": "application/pdf",
35 + "Content-Disposition": `attachment; filename="${customReportFilename(period)}"`,
36 + "Cache-Control": "no-store",
37 + },
38 + });
39 + } catch (e) {
40 + if (e instanceof Error && e.message === "aucun-bloc") {
41 + return NextResponse.json({ error: "Aucun bloc valide dans la composition" }, { status: 400 });
42 + }
43 + throw e;
44 + }
45 +}
modified src/ka/stats/SPEC.md +62 −1
@@ -1,4 +1,4 @@
1 −# ka-stats — module Stats commun Groupe KA (spec v2)
1 +# ka-stats — module Stats commun Groupe KA (spec v3)
2 2
3 3 Contrat partagé par les plateformes pour leurs pages **/stats** (tableau de
4 4 bord analytique) et les **exports PDF** estampillés Groupe-KA. Le visuel suit
@@ -10,6 +10,15 @@ sur les répartitions, statistiques de séries (min/max/moy/méd/σ), et **5
10 10 rapports PDF** au lieu de 2. Tous les nouveaux champs sont **optionnels** :
11 11 un dashboard v1 reste valide et se rend tel quel.
12 12
13 +**v3 (2026-08-23)** : **rapports personnalisés** — l'utilisateur compose son
14 +propre rapport PDF bloc par bloc : choix des données (catalogue dérivé du
15 +dashboard), du **rendu par bloc** (courbe/aire/barres/anneau/histogramme/
16 +heatmap/tableau…), de l'ordre, avec **modèles sauvegardés** (localStorage du
17 +site). Deux endpoints (`/api/stats/catalog`, `POST /api/stats/report/custom`,
18 +voir §3bis) + un constructeur dans la page /stats (`ReportBuilder` du kit —
19 +bouton « 🛠 Rapport personnalisé » à côté du menu PDF). Le PDF garde le
20 +gabarit estampillé Groupe-KA avec l'accent de la plateforme. v2 inchangée.
21 +
13 22 ## 1. Page /stats — structure obligatoire (dans cet ordre)
14 23
15 24 1. **Bandeau KPI** : 6–10 grandes cartes (`KpiCard`) — valeur, libellé,
@@ -135,6 +144,58 @@ gabarit en pdfkit) :
135 144 - A4 portrait, marges 18 mm, typo : Helvetica (fallback sûr) ou fonts TTF du
136 145 DS si présentes.
137 146
147 +## 3bis. Rapports personnalisés (v3)
148 +
149 +### Catalogue
150 +
151 +`GET /api/stats/catalog?period=…&from=&to=` →
152 +
153 +```jsonc
154 +{ "updated": "…", "period": { … },
155 + "blocks": [ { "key": "series:ajouts", // section[:id] — clé stable
156 + "section": "series",
157 + "title": "Événements ajoutés par jour",
158 + "renders": ["line","area","bar","table"], // rendus compatibles
159 + "default_render": "line",
160 + "count": 30 } ] } // taille indicative (optionnel)
161 +```
162 +
163 +Sections → rendus : `kpis` cards|table · `gauges` gauges|table ·
164 +`series:<id>` line|area|bar|table · `multiseries:<id>` lines|table ·
165 +`stacked:<id>` stacked|table · `breakdowns:<id>` donut|bars|table ·
166 +`distributions:<id>` histogram|table · `geo` bars|table ·
167 +`heatmap` heatmap|table · `hourly` heatmap|table · `tables:<id>` table ·
168 +`records` cards|table. **Toute donnée a un équivalent tableau.** Le catalogue
169 +est dérivé du dashboard (implémentation : `kapdf.catalog(dash)` /
170 +`catalogFromDashboard()` en TS) — zéro maintenance quand une métrique s'ajoute.
171 +
172 +### Génération
173 +
174 +`POST /api/stats/report/custom` — corps JSON :
175 +
176 +```jsonc
177 +{ "title": "Revue mensuelle", // ≤ 80 car., affiché en couverture
178 + "period": "30j", "from": "", "to": "", // mêmes règles que le dashboard
179 + "blocks": [ { "key": "kpis", "render": "cards" },
180 + { "key": "series:ajouts", "render": "bar" } ] } // ordre = ordre du PDF
181 +```
182 +
183 +→ `application/pdf`, filename `groupe-ka_<plateforme>_stats_<periode>_personnalise_<date>.pdf`.
184 +Clés inconnues ignorées ; rendu incompatible → rendu par défaut ; aucun bloc
185 +valide → **400**. Maximum 40 blocs. Couverture : type = « Rapport
186 +personnalisé — {title} » ; sommaire ; page de fin habituelle. Un même bloc
187 +peut apparaître plusieurs fois (ex. graphique + tableau).
188 +
189 +### Constructeur (front, kit)
190 +
191 +`ReportBuilder` (kacharts.tsx ; port vanilla pour les SPA sans React) :
192 +panneau modal 2 colonnes — catalogue groupé par section à gauche, composition
193 +ordonnée à droite (↑ ↓ ✕, sélecteur de rendu par bloc, titre). **Modèles** :
194 +sauvegarde/chargement/suppression nommés en localStorage (clé
195 +`ka-stats-rapports`, propre à l'origine du site). Bouton « Générer le PDF »
196 +→ POST + téléchargement blob. États busy/erreur propres, tactile ≥ 44 px,
197 +z-index `var(--z-modal, 900)`.
198 +
138 199 ## 4. Spécifique par plateforme (sections métier attendues)
139 200
140 201 - **groupe-ka** : tableau de bord maître — consolidation des plateformes
modified src/ka/stats/kacharts.tsx +213 −1
@@ -676,10 +676,11 @@ export function RecordCard({ r }: { r: RecordFact }) {
676 676 );
677 677 }
678 678
679 −/* ---------- Menu de rapports PDF (5 rapports) ---------- */
679 +/* ---------- Menu de rapports PDF (5 rapports + personnalisé v3) ---------- */
680 680 export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) {
681 681 const [open, setOpen] = useState(false);
682 682 const [busy, setBusy] = useState<string | null>(null);
683 + const [builder, setBuilder] = useState(false);
683 684 const box = useRef<HTMLSpanElement>(null);
684 685 useEffect(() => {
685 686 if (!open) return;
@@ -715,6 +716,13 @@ export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }:
715 716 aria-haspopup="menu" aria-expanded={open}>
716 717 Autres rapports ▾
717 718 </button>
719 + <button type="button" className="btn btn-ghost" onClick={() => setBuilder(true)} disabled={!!busy}>
720 + 🛠 Rapport personnalisé
721 + </button>
722 + {builder && (
723 + <ReportBuilder period={period} from={from} to={to} endpoint={endpoint}
724 + onClose={() => setBuilder(false)} />
725 + )}
718 726 {open && (
719 727 <div role="menu" className="card" style={{ position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 50, minWidth: 300, padding: 6, background: "var(--surface)", boxShadow: "0 10px 28px rgba(20,24,20,0.18)" }}>
720 728 {REPORT_MODES.map((m) => (
@@ -732,6 +740,210 @@ export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }:
732 740 );
733 741 }
734 742
743 +/* ---------- v3 : constructeur de rapports personnalisés ----------
744 + Compose un PDF bloc par bloc : catalogue dérivé du dashboard
745 + (GET /api/stats/catalog), rendu au choix par bloc, ordre libre, modèles
746 + sauvegardés en localStorage (clé ka-stats-rapports, propre au site).
747 + Contrat : SPEC.md §3bis. Rendu dans PdfButton — aucune modif des pages. */
748 +export type CatalogBlock = {
749 + key: string; section: string; title: string;
750 + renders: string[]; default_render: string; count?: number;
751 +};
752 +type BuilderSel = { key: string; render: string };
753 +type BuilderTpl = { name: string; title: string; blocks: BuilderSel[] };
754 +
755 +const RENDER_LABELS: Record<string, string> = {
756 + line: "Courbe", area: "Aire", bar: "Barres verticales",
757 + bars: "Barres horizontales", donut: "Anneau", lines: "Multi-courbes",
758 + stacked: "Barres empilées", histogram: "Histogramme", heatmap: "Heatmap",
759 + cards: "Cartes", gauges: "Jauges", table: "Tableau",
760 +};
761 +const SECTION_LABELS: Record<string, string> = {
762 + kpis: "Indicateurs", gauges: "Jauges", series: "Évolution",
763 + multiseries: "Multi-courbes", stacked: "Compositions",
764 + breakdowns: "Répartitions", distributions: "Distributions",
765 + geo: "Géographie", heatmap: "Calendrier", hourly: "Activité horaire",
766 + tables: "Tableaux", records: "Records",
767 +};
768 +const TPL_KEY = "ka-stats-rapports";
769 +
770 +function loadTemplates(): BuilderTpl[] {
771 + try { return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]"); }
772 + catch { return []; }
773 +}
774 +function saveTemplates(t: BuilderTpl[]) {
775 + try { localStorage.setItem(TPL_KEY, JSON.stringify(t)); } catch { /* plein/privé */ }
776 +}
777 +
778 +export function ReportBuilder({
779 + period, from, to, endpoint = "/api/stats/report", onClose,
780 +}: {
781 + period: string; from?: string; to?: string; endpoint?: string; onClose: () => void;
782 +}) {
783 + const [cat, setCat] = useState<CatalogBlock[] | null>(null);
784 + const [err, setErr] = useState("");
785 + const [sel, setSel] = useState<BuilderSel[]>([]);
786 + const [title, setTitle] = useState("");
787 + const [busy, setBusy] = useState(false);
788 + const [tpls, setTpls] = useState<BuilderTpl[]>(loadTemplates);
789 + const catalogUrl = endpoint.replace(/\/report$/, "/catalog");
790 +
791 + useEffect(() => {
792 + const p = new URLSearchParams({ period });
793 + if (from) p.set("from", from);
794 + if (to) p.set("to", to);
795 + fetch(`${catalogUrl}?${p}`)
796 + .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
797 + .then((d) => setCat(d.blocks ?? []))
798 + .catch(() => setErr("Catalogue indisponible — réessayez plus tard."));
799 + }, [period, from, to, catalogUrl]);
800 +
801 + useEffect(() => {
802 + const esc = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
803 + document.addEventListener("keydown", esc);
804 + const prev = document.body.style.overflow;
805 + document.body.style.overflow = "hidden";
806 + return () => { document.removeEventListener("keydown", esc); document.body.style.overflow = prev; };
807 + }, [onClose]);
808 +
809 + const add = (b: CatalogBlock) =>
810 + setSel((s) => s.some((x) => x.key === b.key && x.render === b.default_render)
811 + ? s : [...s, { key: b.key, render: b.default_render }]);
812 + const move = (i: number, d: number) => setSel((s) => {
813 + const j = i + d;
814 + if (j < 0 || j >= s.length) return s;
815 + const n = [...s]; [n[i], n[j]] = [n[j], n[i]]; return n;
816 + });
817 +
818 + const generate = async () => {
819 + if (busy || !sel.length) return;
820 + setBusy(true); setErr("");
821 + try {
822 + const body: Record<string, unknown> = { title, period, blocks: sel };
823 + if (from && to) { body.from = from; body.to = to; }
824 + const r = await fetch(`${endpoint}/custom`, {
825 + method: "POST", headers: { "Content-Type": "application/json" },
826 + body: JSON.stringify(body),
827 + });
828 + if (!r.ok) throw new Error(String(r.status));
829 + const blob = await r.blob();
830 + const m = (r.headers.get("Content-Disposition") ?? "").match(/filename="?([^";]+)/);
831 + const a = document.createElement("a");
832 + a.href = URL.createObjectURL(blob);
833 + a.download = m ? m[1] : "rapport-personnalise.pdf";
834 + document.body.appendChild(a); a.click(); a.remove();
835 + setTimeout(() => URL.revokeObjectURL(a.href), 4000);
836 + } catch {
837 + setErr("La génération a échoué — réessayez.");
838 + }
839 + setBusy(false);
840 + };
841 +
842 + const groups: [string, CatalogBlock[]][] = [];
843 + for (const b of cat ?? []) {
844 + const g = groups.find(([s]) => s === b.section);
845 + if (g) g[1].push(b); else groups.push([b.section, [b]]);
846 + }
847 + const selKeys = new Set(sel.map((s) => s.key));
848 + const mono: React.CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em" };
849 +
850 + return (
851 + <div role="dialog" aria-modal="true" aria-label="Rapport personnalisé"
852 + onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
853 + style={{ position: "fixed", inset: 0, zIndex: "var(--z-modal, 900)" as never, background: "rgba(20,24,20,0.45)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "4vh 14px", overflow: "auto" }}>
854 + <div className="card" style={{ width: "min(980px,100%)", maxHeight: "92vh", display: "flex", flexDirection: "column", background: "var(--surface)", padding: 0, textAlign: "left", cursor: "default" }}>
855 + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, padding: "16px 20px", borderBottom: "1px solid var(--line)" }}>
856 + <b style={{ fontFamily: "var(--font-display)", fontSize: 18 }}>
857 + Rapport personnalisé <span className="klabel">· période : {from && to ? `${from} → ${to}` : (PERIODS.find((p) => p.id === period)?.label ?? period)}</span>
858 + </b>
859 + <button type="button" className="btn btn-ghost" onClick={onClose}>✕ Fermer</button>
860 + </div>
861 + <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", overflow: "auto", flex: 1 }}>
862 + <div style={{ padding: "14px 20px", minWidth: 0, borderRight: "1px solid var(--line)" }}>
863 + <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Blocs disponibles ({cat?.length ?? "…"})</h3>
864 + {!cat && !err && <p className="klabel">Chargement du catalogue…</p>}
865 + {groups.map(([secId, bs]) => (
866 + <div key={secId}>
867 + <p style={{ ...mono, color: "var(--ink-2)", margin: "12px 0 6px" }}>{SECTION_LABELS[secId] ?? secId}</p>
868 + {bs.map((b) => (
869 + <div key={b.key} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, padding: "7px 10px", border: "1px solid var(--line)", borderRadius: 8, marginBottom: 6, fontSize: 13, opacity: selKeys.has(b.key) ? 0.45 : 1 }}>
870 + <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}>{b.title}</span>
871 + <button type="button" className="btn btn-ghost" onClick={() => add(b)} aria-label={`Ajouter ${b.title}`} style={{ flex: "none" }}>+</button>
872 + </div>
873 + ))}
874 + </div>
875 + ))}
876 + </div>
877 + <div style={{ padding: "14px 20px", minWidth: 0 }}>
878 + <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Composition du rapport ({sel.length})</h3>
879 + <label className="klabel" htmlFor="rb-title">Titre du rapport</label>
880 + <input id="rb-title" className="input" style={{ width: "100%", margin: "4px 0 12px", boxSizing: "border-box" }}
881 + maxLength={80} placeholder="Ex. : Revue mensuelle" value={title} onChange={(e) => setTitle(e.target.value)} />
882 + {sel.length ? sel.map((s, i) => {
883 + const b = (cat ?? []).find((x) => x.key === s.key) ?? { title: s.key, renders: [s.render] } as CatalogBlock;
884 + return (
885 + <div key={`${s.key}:${s.render}:${i}`} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", border: "1px solid var(--ink)", borderRadius: 8, marginBottom: 6, background: "var(--surface-2)", fontSize: 13 }}>
886 + <button type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label="Monter" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === 0 ? 0.25 : 1 }}>▲</button>
887 + <button type="button" onClick={() => move(i, 1)} disabled={i === sel.length - 1} aria-label="Descendre" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === sel.length - 1 ? 0.25 : 1 }}>▼</button>
888 + <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}><b>{i + 1}.</b> {b.title}</span>
889 + {b.renders.length > 1 ? (
890 + <select className="input" value={s.render} aria-label="Rendu" style={{ maxWidth: 150, padding: "4px 6px", fontSize: 12 }}
891 + onChange={(e) => setSel((xs) => xs.map((x, j) => j === i ? { ...x, render: e.target.value } : x))}>
892 + {b.renders.map((r) => <option key={r} value={r}>{RENDER_LABELS[r] ?? r}</option>)}
893 + </select>
894 + ) : <span className="klabel">{RENDER_LABELS[s.render] ?? s.render}</span>}
895 + <button type="button" onClick={() => setSel((xs) => xs.filter((_, j) => j !== i))} aria-label="Retirer" style={{ border: 0, background: "none", cursor: "pointer" }}>✕</button>
896 + </div>
897 + );
898 + }) : (
899 + <div style={{ border: "1px dashed var(--line)", borderRadius: 8, padding: 16, color: "var(--ink-3)", fontSize: 13, textAlign: "center" }}>
900 + Aucun bloc — ajoutez des blocs depuis la colonne de gauche, ou chargez un modèle ci-dessous.
901 + </div>
902 + )}
903 + <p style={{ display: "flex", gap: 8, margin: "10px 0 0" }}>
904 + <button type="button" className="btn btn-ghost" disabled={!cat?.length}
905 + onClick={() => setSel((cat ?? []).map((b) => ({ key: b.key, render: b.default_render })))}>Tout ajouter</button>
906 + <button type="button" className="btn btn-ghost" disabled={!sel.length} onClick={() => setSel([])}>Vider</button>
907 + </p>
908 + {err && <p style={{ color: "var(--danger)", fontSize: 12.5, margin: "6px 0 0" }}>{err}</p>}
909 + </div>
910 + </div>
911 + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, flexWrap: "wrap", padding: "14px 20px", borderTop: "1px solid var(--line)" }}>
912 + <span style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
913 + <select className="input" aria-label="Modèles sauvegardés" style={{ maxWidth: 210 }} value=""
914 + onChange={(e) => {
915 + const t = tpls[Number(e.target.value)];
916 + if (!t) return;
917 + setTitle(t.title || t.name);
918 + setSel((t.blocks ?? []).filter((s) => (cat ?? []).some((b) => b.key === s.key)).map((s) => ({ ...s })));
919 + }}>
920 + <option value="">Modèles ({tpls.length})…</option>
921 + {tpls.map((t, i) => <option key={t.name} value={i}>{t.name}</option>)}
922 + </select>
923 + <button type="button" className="btn btn-ghost" disabled={!sel.length}
924 + onClick={() => {
925 + const name = window.prompt("Nom du modèle :", title || "Mon rapport");
926 + if (!name) return;
927 + const next = [...tpls.filter((t) => t.name !== name), { name, title, blocks: sel.map((s) => ({ ...s })) }];
928 + setTpls(next); saveTemplates(next);
929 + }}>💾 Sauvegarder</button>
930 + <button type="button" className="btn btn-ghost" disabled={!tpls.length}
931 + onClick={() => {
932 + const name = window.prompt(`Nom du modèle à supprimer :\n${tpls.map((t) => `· ${t.name}`).join("\n")}`);
933 + if (!name) return;
934 + const next = tpls.filter((t) => t.name !== name);
935 + setTpls(next); saveTemplates(next);
936 + }}>🗑 Supprimer</button>
937 + </span>
938 + <button type="button" className="btn btn-primary" disabled={!sel.length || busy} onClick={generate}>
939 + {busy ? "Génération…" : "⬇ Générer le PDF"}
940 + </button>
941 + </div>
942 + </div>
943 + </div>
944 + );
945 +}
946 +
735 947 /* ---------- États ---------- */
736 948 export function EmptyBlock({ title }: { title: string }) {
737 949 return (
modified src/ka/stats/kapdf.py +398 −8
@@ -1,7 +1,7 @@
1 1 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v2
2 +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v3
3 3 # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4 −# les rapports estampillés Groupe-KA. 5 modes :
4 +# les rapports estampillés Groupe-KA. 5 modes fixes :
5 5 # complet — toutes les sections (KPI, jauges, séries + stats, multi-
6 6 # séries, empilées, distributions, répartitions, géo,
7 7 # heatmap horaire, tableaux, records)
@@ -9,12 +9,19 @@
9 9 # tendances — KPI + toutes les séries temporelles + stats de séries
10 10 # repartitions — breakdowns, distributions, géo, activité horaire
11 11 # donnees — tous les tableaux en version longue (400 lignes max)
12 +# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par
13 +# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/
14 +# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport
15 +# suit une spec {"title": str, "blocks": [{"key": "series:ajouts",
16 +# "render": "bar"}, …]} et respecte l'ordre demandé.
12 17 # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.
13 18 # Usage :
14 −# from kapdf import GroupeKAReport, REPORT_MODES, filename
19 +# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename
15 20 # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
16 21 # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
17 22 # mode="complet").build()
23 +# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise",
24 +# spec={"title": "Mon rapport", "blocks": [...]}).build()
18 25 # Dépendance : pip install fpdf2 (aucune autre)
19 26 from __future__ import annotations
20 27
@@ -40,6 +47,100 @@ REPORT_MODES = {
40 47 "repartitions": "Répartitions & géographie",
41 48 "donnees": "Données détaillées",
42 49 }
50 +# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes)
51 +CUSTOM_MODE = "personnalise"
52 +CUSTOM_LABEL = "Rapport personnalisé"
53 +
54 +# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ;
55 +# « table » est toujours offert : toute donnée a un équivalent tableau)
56 +RENDER_LABELS = {
57 + "line": "Courbe", "area": "Aire", "bar": "Barres verticales",
58 + "bars": "Barres horizontales", "donut": "Anneau",
59 + "lines": "Multi-courbes", "stacked": "Barres empilées",
60 + "histogram": "Histogramme", "heatmap": "Heatmap",
61 + "cards": "Cartes", "gauges": "Jauges", "table": "Tableau",
62 +}
63 +SECTION_LABELS = {
64 + "kpis": "Indicateurs", "gauges": "Taux & couvertures",
65 + "series": "Évolution", "multiseries": "Comparaisons",
66 + "stacked": "Compositions", "breakdowns": "Répartitions",
67 + "distributions": "Distributions", "geo": "Géographie",
68 + "heatmap": "Calendrier", "hourly": "Activité horaire",
69 + "tables": "Tableaux", "records": "Records",
70 +}
71 +
72 +
73 +def catalog(dash: dict) -> list[dict]:
74 + """v3 — blocs composables d'un dashboard : ce que le constructeur de
75 + rapports personnalisés peut inclure, avec les rendus compatibles.
76 + key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard."""
77 + out: list[dict] = []
78 +
79 + def add(key, title, renders, default=None, count=None):
80 + b = {"key": key, "section": key.split(":")[0], "title": title,
81 + "renders": renders, "default_render": default or renders[0]}
82 + if count is not None:
83 + b["count"] = count
84 + out.append(b)
85 +
86 + if dash.get("kpis"):
87 + add("kpis", "Indicateurs clés (KPI)", ["cards", "table"],
88 + count=len(dash["kpis"]))
89 + gs = [g for g in (dash.get("gauges") or [])
90 + if isinstance(g.get("value"), (int, float)) and g.get("max")]
91 + if gs:
92 + add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"],
93 + count=len(gs))
94 + for s in dash.get("series") or []:
95 + if len(s.get("points") or []) < 2:
96 + continue
97 + kind = s.get("kind") or "line"
98 + default = kind if kind in ("line", "area", "bar") else "line"
99 + add(f"series:{s.get('id')}", s.get("title", ""),
100 + ["line", "area", "bar", "table"], default,
101 + len(s.get("points") or []))
102 + for ms in dash.get("multiseries") or []:
103 + if not (ms.get("series") or []):
104 + continue
105 + add(f"multiseries:{ms.get('id')}", ms.get("title", ""),
106 + ["lines", "table"], count=len(ms["series"]))
107 + for st in dash.get("stacked") or []:
108 + if not (st.get("points") or []):
109 + continue
110 + add(f"stacked:{st.get('id')}", st.get("title", ""),
111 + ["stacked", "table"], count=len(st.get("keys") or []))
112 + for b in dash.get("breakdowns") or []:
113 + if not (b.get("items") or []):
114 + continue
115 + default = "donut" if b.get("kind") == "donut" else "bars"
116 + add(f"breakdowns:{b.get('id')}", b.get("title", ""),
117 + ["donut", "bars", "table"], default, len(b["items"]))
118 + for d in dash.get("distributions") or []:
119 + if not (d.get("bins") or []):
120 + continue
121 + add(f"distributions:{d.get('id')}", d.get("title", ""),
122 + ["histogram", "table"], count=len(d["bins"]))
123 + geo = dash.get("geo") or {}
124 + if geo.get("items"):
125 + add("geo", geo.get("title", "Répartition géographique"),
126 + ["bars", "table"], count=len(geo["items"]))
127 + hm = dash.get("heatmap") or {}
128 + if hm.get("cells"):
129 + add("heatmap", hm.get("title", "Calendrier d'activité"),
130 + ["heatmap", "table"])
131 + hr = dash.get("hourly") or {}
132 + if hr.get("cells"):
133 + add("hourly", hr.get("title", "Activité par jour et heure"),
134 + ["heatmap", "table"])
135 + for t in dash.get("tables") or []:
136 + if not (t.get("rows") or []):
137 + continue
138 + add(f"tables:{t.get('id')}", t.get("title", ""), ["table"],
139 + count=len(t["rows"]))
140 + if dash.get("records"):
141 + add("records", "Records & faits marquants", ["cards", "table"],
142 + count=len(dash["records"]))
143 + return out
43 144
44 145 EMAILS = [
45 146 ("contact@groupe-ka.com", "Projets, partenariats & données"),
@@ -127,16 +228,25 @@ class _PDF(FPDF):
127 228
128 229
129 230 class GroupeKAReport:
130 − def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):
231 + def __init__(self, site: dict, dashboard: dict, mode: str = "complet",
232 + spec: dict | None = None):
131 233 self.site = site
132 234 self.d = dashboard
133 − self.mode = mode if mode in REPORT_MODES else "complet"
235 + self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet"
236 + self.spec = spec or {}
134 237 self.accent = _hex(site.get("accent", "#d9f26b"))
135 238 period = dashboard.get("period", {}) or {}
136 239 self.period_label = period.get("label") or "toute la période"
137 240 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
138 241 self.toc: list[tuple[str, int]] = []
139 242
243 + @property
244 + def mode_label(self) -> str:
245 + if self.mode == CUSTOM_MODE:
246 + t = str(self.spec.get("title") or "").strip()
247 + return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL
248 + return REPORT_MODES[self.mode]
249 +
140 250 # ---------- primitives ----------
141 251 def _card(self, x, y, w, h, fill=WHITE):
142 252 p = self.pdf
@@ -213,7 +323,7 @@ class GroupeKAReport:
213 323 p.set_xy(24, 100)
214 324 p.set_font("helvetica", "", 13)
215 325 p.set_text_color(*INK2)
216 − p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}")
326 + p.multi_cell(150, 7, f"{self.mode_label} — {wm}")
217 327 now = datetime.now(ZoneInfo("America/Toronto"))
218 328 per = self.d.get("period", {}) or {}
219 329 p.set_xy(24, 125)
@@ -222,7 +332,7 @@ class GroupeKAReport:
222 332 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
223 333 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
224 334 ("Plateforme", "https://" + self.site.get("domain", "")),
225 − ("Type de rapport", REPORT_MODES[self.mode]),
335 + ("Type de rapport", self.mode_label),
226 336 ]
227 337 y = 128
228 338 for k, v in rows:
@@ -685,6 +795,271 @@ class GroupeKAReport:
685 795 p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")
686 796 p.set_y(y0 + ly + 7 * chh + 5)
687 797
798 + def _calheat(self, hm):
799 + """v3 — calendrier de chaleur 26 semaines (équivalent PDF du
800 + CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours."""
801 + from datetime import date as _date, timedelta as _td
802 + cells = hm.get("cells") or []
803 + vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")}
804 + if not vals:
805 + return
806 + p = self.pdf
807 + if p.get_y() > 215:
808 + p.add_page()
809 + self._chart_title(hm.get("title", "Calendrier d'activité"))
810 + try:
811 + end = _date.fromisoformat(max(vals))
812 + except ValueError:
813 + return
814 + weeks = 26
815 + start = end - _td(days=weeks * 7 - 1)
816 + start -= _td(days=start.weekday()) # lundi
817 + vmax = max(vals.values()) or 1
818 + x0, y0 = p.l_margin, p.get_y()
819 + cw, lx, ly = 6.3, 10, 4
820 + dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"]
821 + p.set_font("helvetica", "", 5.8)
822 + p.set_text_color(*INK3)
823 + for d in range(7):
824 + if dows[d]:
825 + p.set_xy(x0, y0 + ly + d * cw + 1.2)
826 + p.cell(lx - 1, 3, dows[d], align="R")
827 + for w in range(weeks):
828 + monday = start + _td(days=7 * w)
829 + if monday.day <= 7: # étiquette de mois à la 1re semaine du mois
830 + p.set_xy(x0 + lx + w * cw, y0)
831 + p.cell(cw * 4, 3, monday.strftime("%m"))
832 + for d in range(7):
833 + day = monday + _td(days=d)
834 + v = vals.get(day.isoformat(), 0)
835 + f = 0.15 + 0.85 * (v / vmax) if v else 0.0
836 + col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f)
837 + for j in range(3)) if v else (235, 233, 228))
838 + p.set_fill_color(*col)
839 + p.set_draw_color(215, 213, 207)
840 + p.set_line_width(0.1)
841 + p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5,
842 + style="DF")
843 + p.set_y(y0 + ly + 7 * cw + 5)
844 +
845 + # ---------- v3 : conversions bloc → tableau ----------
846 + @staticmethod
847 + def _serie_as_table(s):
848 + unit = s.get("unit") or "Valeur"
849 + cols = ["Date", unit.capitalize()]
850 + cmp_ = s.get("compare") or []
851 + if cmp_:
852 + cols.append("Période comparée")
853 + rows = []
854 + for i, pt in enumerate(s.get("points") or []):
855 + row = [str(pt.get("t", "")), pt.get("v", "")]
856 + if cmp_:
857 + row.append(cmp_[i]["v"] if i < len(cmp_) else "")
858 + rows.append(row)
859 + return {"id": s.get("id"), "title": s.get("title", ""),
860 + "columns": cols, "rows": rows}
861 +
862 + @staticmethod
863 + def _multi_as_table(ms):
864 + labels = [s.get("label", "") for s in (ms.get("series") or [])][:4]
865 + by_t: dict[str, dict] = {}
866 + for s in (ms.get("series") or [])[:4]:
867 + for pt in s.get("points") or []:
868 + by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v")
869 + rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels]
870 + for t in sorted(by_t)]
871 + return {"id": ms.get("id"), "title": ms.get("title", ""),
872 + "columns": ["Date"] + labels, "rows": rows}
873 +
874 + @staticmethod
875 + def _stacked_as_table(st):
876 + keys = (st.get("keys") or [])[:6]
877 + rows = []
878 + for pt in st.get("points") or []:
879 + vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0
880 + for j in range(len(keys))]
881 + rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)])
882 + return {"id": st.get("id"), "title": st.get("title", ""),
883 + "columns": ["Date"] + list(keys) + ["Total"], "rows": rows}
884 +
885 + @staticmethod
886 + def _items_as_table(id_, title, items, label_col="Libellé"):
887 + items = items or []
888 + with_delta = any(it.get("delta_pct") is not None for it in items)
889 + cols = [label_col, "Valeur"] + (["delta %"] if with_delta else [])
890 + rows = []
891 + for it in items:
892 + row = [str(it.get("label", "")), it.get("value", "")]
893 + if with_delta:
894 + d = it.get("delta_pct")
895 + row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %")
896 + rows.append(row)
897 + return {"id": id_, "title": title, "columns": cols, "rows": rows}
898 +
899 + def _kpis_as_table(self):
900 + rows = []
901 + for k in self.d.get("kpis") or []:
902 + v = k.get("value")
903 + val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \
904 + ((" " + k["unit"]) if k.get("unit") else "")
905 + d = k.get("delta_pct")
906 + rows.append([str(k.get("label", "")), val,
907 + "" if d is None else f"{'+' if d >= 0 else ''}{d} %"])
908 + return {"id": "kpis", "title": "Indicateurs clés",
909 + "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows}
910 +
911 + def _gauges_as_table(self):
912 + rows = [[str(g.get("label", "")),
913 + f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}",
914 + _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"]
915 + for g in self.d.get("gauges") or []
916 + if isinstance(g.get("value"), (int, float)) and g.get("max")]
917 + return {"id": "gauges", "title": "Taux & couvertures",
918 + "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows}
919 +
920 + def _records_as_table(self):
921 + rows = [[str(r.get("label", "")), str(r.get("value", "")),
922 + str(r.get("date", "") or "")]
923 + for r in self.d.get("records") or []]
924 + return {"id": "records", "title": "Records & faits marquants",
925 + "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows}
926 +
927 + @staticmethod
928 + def _heatmap_as_table(hm, title):
929 + cells = sorted((hm.get("cells") or []),
930 + key=lambda c: -(c.get("value") or 0))[:40]
931 + return {"id": "heatmap", "title": title + " — jours les plus chargés",
932 + "columns": ["Date", "Valeur"],
933 + "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]}
934 +
935 + @staticmethod
936 + def _hourly_as_table(hr, title):
937 + days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi",
938 + "Dimanche"]
939 + cells = sorted((hr.get("cells") or []),
940 + key=lambda c: -(c.get("value") or 0))[:40]
941 + return {"id": "hourly", "title": title + " — créneaux les plus actifs",
942 + "columns": ["Jour", "Heure", "Valeur"],
943 + "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?",
944 + f"{c.get('hour', '?')} h", c.get("value") or 0]
945 + for c in cells]}
946 +
947 + # ---------- v3 : rendu d'un bloc du rapport personnalisé ----------
948 + def _find(self, coll: str, id_: str):
949 + for it in self.d.get(coll) or []:
950 + if str(it.get("id")) == id_:
951 + return it
952 + return None
953 +
954 + def _toc_mark(self, title: str):
955 + """Blocs graphiques du mode personnalisé : entrée de sommaire sans
956 + _section_title (le graphique porte déjà son titre)."""
957 + if self.pdf.get_y() > 235:
958 + self.pdf.add_page()
959 + self.toc.append((title, self.pdf.page_no()))
960 +
961 + def _render_block(self, key: str, render: str):
962 + section, _, id_ = key.partition(":")
963 + if section == "kpis":
964 + self._table(self._kpis_as_table()) if render == "table" else self._kpis()
965 + elif section == "gauges":
966 + self._table(self._gauges_as_table()) if render == "table" else self._gauges()
967 + elif section == "records":
968 + self._table(self._records_as_table()) if render == "table" else self._records()
969 + elif section == "series":
970 + s = self._find("series", id_)
971 + if not s:
972 + return
973 + if render == "table":
974 + self._table(self._serie_as_table(s), max_rows=400)
975 + else:
976 + s2 = dict(s)
977 + if render in ("line", "area", "bar"):
978 + s2["kind"] = render
979 + self._toc_mark(s2.get("title", ""))
980 + if s2.get("kind") == "bar":
981 + self._vbars(s2)
982 + else:
983 + self._line_chart(s2, with_stats=True)
984 + elif section == "multiseries":
985 + ms = self._find("multiseries", id_)
986 + if not ms:
987 + return
988 + if render == "table":
989 + self._table(self._multi_as_table(ms), max_rows=400)
990 + else:
991 + self._toc_mark(ms.get("title", ""))
992 + self._multiline(ms)
993 + elif section == "stacked":
994 + st = self._find("stacked", id_)
995 + if not st:
996 + return
997 + if render == "table":
998 + self._table(self._stacked_as_table(st), max_rows=400)
999 + else:
1000 + self._toc_mark(st.get("title", ""))
1001 + self._stacked(st)
1002 + elif section == "breakdowns":
1003 + b = self._find("breakdowns", id_)
1004 + if not b:
1005 + return
1006 + if render == "table":
1007 + self._table(self._items_as_table(id_, b.get("title", ""),
1008 + b.get("items")), max_rows=400)
1009 + else:
1010 + self._toc_mark(b.get("title", ""))
1011 + if render == "donut":
1012 + self._donut(b)
1013 + else:
1014 + self._bars(b.get("title", ""), b.get("items"))
1015 + elif section == "distributions":
1016 + d = self._find("distributions", id_)
1017 + if not d:
1018 + return
1019 + if render == "table":
1020 + bins = [{"label": bn.get("label"), "value": bn.get("value")}
1021 + for bn in d.get("bins") or []]
1022 + self._table(self._items_as_table(id_, d.get("title", ""), bins,
1023 + label_col="Tranche"))
1024 + else:
1025 + self._toc_mark(d.get("title", ""))
1026 + self._vbars(d)
1027 + elif section == "geo":
1028 + geo = self.d.get("geo") or {}
1029 + if not geo.get("items"):
1030 + return
1031 + title = geo.get("title", "Répartition géographique")
1032 + if render == "table":
1033 + self._table(self._items_as_table("geo", title, geo["items"],
1034 + label_col="Zone"), max_rows=400)
1035 + else:
1036 + self._toc_mark(title)
1037 + self._bars(title, geo["items"])
1038 + elif section == "heatmap":
1039 + hm = self.d.get("heatmap") or {}
1040 + if not hm.get("cells"):
1041 + return
1042 + title = hm.get("title", "Calendrier d'activité")
1043 + if render == "table":
1044 + self._table(self._heatmap_as_table(hm, title))
1045 + else:
1046 + self._toc_mark(title)
1047 + self._calheat(hm)
1048 + elif section == "hourly":
1049 + hr = self.d.get("hourly") or {}
1050 + if not hr.get("cells"):
1051 + return
1052 + title = hr.get("title", "Activité par jour et heure")
1053 + if render == "table":
1054 + self._table(self._hourly_as_table(hr, title))
1055 + else:
1056 + self._toc_mark(title)
1057 + self._hourly()
1058 + elif section == "tables":
1059 + t = self._find("tables", id_)
1060 + if t:
1061 + self._table(t, max_rows=400)
1062 +
688 1063 def _table(self, t, max_rows=200):
689 1064 p = self.pdf
690 1065 cols = t.get("columns") or []
@@ -809,7 +1184,7 @@ class GroupeKAReport:
809 1184 p = self.pdf
810 1185 p.alias_nb_pages()
811 1186 self._cover()
812 − with_toc = self.mode in ("complet", "donnees")
1187 + with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE)
813 1188 toc_page_no = None
814 1189 if self.mode == "synthese":
815 1190 p.add_page()
@@ -835,6 +1210,21 @@ class GroupeKAReport:
835 1210 for t in self.d.get("tables") or []:
836 1211 self._table(t, max_rows=400)
837 1212 self._final_page()
1213 + elif self.mode == CUSTOM_MODE:
1214 + p.add_page()
1215 + toc_page_no = p.page_no()
1216 + p.add_page()
1217 + known = {b["key"]: b for b in catalog(self.d)}
1218 + for blk in self.spec.get("blocks") or []:
1219 + key = str(blk.get("key", ""))
1220 + b = known.get(key)
1221 + if not b:
1222 + continue
1223 + render = str(blk.get("render") or "")
1224 + if render not in b["renders"]:
1225 + render = b["default_render"]
1226 + self._render_block(key, render)
1227 + self._final_page()
838 1228 else: # complet
839 1229 p.add_page()
840 1230 toc_page_no = p.page_no()
added src/lib/report-stats.ts +898 −0
@@ -0,0 +1,898 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Rapports statistiques PDF locaux du hub — GABARIT COMMUN GROUPE KA
4 + * (src/ka/stats/SPEC.md), moteur pdfkit (équivalent TS de kapdf.py) :
5 + * couverture estampillée « GROUPE KA · RAPPORT STATISTIQUE », en-tête/pied
6 + * 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 (proxy
12 + * /api/stats/ecosystem-report) — ce module ne porte QUE la v3 :
13 + * rapports personnalisés (SPEC §3bis) composés depuis l'EcoPayload
14 + * consolidé (src/lib/ecostats.ts — mêmes chiffres que la page /stats,
15 + * aucune donnée inventée).
16 + */
17 +import PDFDocument from "pdfkit";
18 +import path from "path";
19 +import eco from "@/ka/ecosystem.json";
20 +import {
21 + PERIODS,
22 + isPeriod,
23 + buildEcoPayload,
24 + getEcosystemStats,
25 + type EcoPayload,
26 + type PeriodKey,
27 +} from "@/lib/ecostats";
28 +import { getLiveMetrics } from "@/lib/live";
29 +import 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";
39 +
40 +const INK = "#141814";
41 +const INK2 = "#4d5551";
42 +const INK3 = "#8b928c";
43 +const PAPER = "#f5f3ee";
44 +const SURFACE2 = "#faf9f5";
45 +const GREEN = "#123f2e"; // accentDeep Groupe-KA (ecosystem.json)
46 +const GREEN_DEEP = "#0b2a1e";
47 +const LIME = "#d9f26b"; // accent Groupe-KA (ecosystem.json)
48 +const WHITE = "#ffffff";
49 +
50 +/* A4 portrait (gabarit Groupe-KA) */
51 +const W = 595.28;
52 +const H = 841.89;
53 +const M = 44;
54 +const CW = W - 2 * M;
55 +const TOP = 56; // début du contenu sous l'en-tête
56 +const BOT = H - 58; // fin du contenu au-dessus du pied
57 +
58 +const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);
59 +const num = (v: number) => v.toLocaleString("fr-CA");
60 +/** Nombre court pour axes/valeurs de graphiques. */
61 +const 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. */
69 +const count = (v: number) => (Math.abs(v) >= 1e6 ? short(v) : num(v));
70 +const DOW_FR = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
71 +
72 +type Doc = InstanceType<typeof PDFDocument>;
73 +
74 +/* 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). */
76 +let PERIOD_LABEL = "";
77 +
78 +/** Wordmark Groupe-KA : « Groupe » encre + boîte encre / « KA » lime. */
79 +function 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 +}
88 +
89 +function 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 +}
94 +
95 +/** En-tête + pied normalisés Groupe-KA (numéros de page ajoutés en post-passe). */
96 +function 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 +}
108 +
109 +function 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 +}
113 +
114 +function 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 +}
123 +
124 +/* ============================ curseur de page ============================ */
125 +class 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 +}
147 +
148 +/* ============================ primitives graphiques ============================ */
149 +
150 +/** Cadre de graphique : carte + titre, renvoie la zone de tracé. */
151 +function 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 +}
161 +
162 +function 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 +}
177 +
178 +function 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 + valeur
206 + 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 +}
211 +
212 +/** Min/max/moyenne/médiane/écart-type sous une courbe (SPEC §1.4). */
213 +function 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 +}
232 +
233 +const 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 +];
239 +
240 +function 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 +}
273 +
274 +const SHADES = [1, 0.72, 0.5, 0.34, 0.22, 0.13];
275 +
276 +function 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 +}
307 +
308 +/** Anneau vectoriel (arcs tracés en petits segments) + légende. */
309 +function 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égende
334 + 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 +}
346 +
347 +/** Barres horizontales (classements / répartitions / géo). */
348 +function 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 +}
362 +
363 +/** Jauges demi-arc (accent), 4 par rangée. */
364 +function 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 +}
395 +
396 +/** Grille de cartes KPI (valeur, libellé, delta coloré à 1 décimale). */
397 +function 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 +}
420 +
421 +/** Calendrier d'activité (grille par semaine, opacité ∝ valeur). */
422 +function 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 +}
453 +
454 +/** Heatmap horaire 7 jours × 24 heures (opacité ∝ valeur). */
455 +function 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 +}
489 +
490 +/** Tableau zébré paginé proprement (jamais de ligne coupée). */
491 +function 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 reste
500 + 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 +}
550 +
551 +/** Records & faits marquants — cartes compactes 2 par rangée. */
552 +function 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 +}
570 +
571 +/* ------------------------------- COUVERTURE ------------------------------- */
572 +function 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 +}
603 +
604 +/* ------------------------------ PAGE DE FIN ------------------------------ */
605 +function 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 +}
635 +
636 +/* ===================== v3 : rapports personnalisés (SPEC §3bis) =====================
637 + Catalogue de blocs dérivé de l'EcoPayload consolidé + génération d'un PDF
638 + composé bloc par bloc (rendu au choix), même gabarit estampillé Groupe-KA. */
639 +export type CatalogBlock = {
640 + key: string; section: string; title: string;
641 + renders: string[]; default_render: string; count?: number;
642 +};
643 +export type CustomBlock = { key: string; render?: string };
644 +export type CustomSpec = {
645 + title?: string; period?: string; from?: string; to?: string;
646 + blocks?: CustomBlock[];
647 +};
648 +
649 +/** EcoPayload consolidé pour une période (mêmes règles que la page /stats). */
650 +export 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 +}
665 +
666 +/** Série d'un bloc `series:<id>` (eco-vol agrégé ou tendance d'une plateforme). */
667 +function 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 +}
682 +
683 +export 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 +}
720 +
721 +type AnyRow = (string | number | null)[];
722 +const 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 +});
727 +
728 +function 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 +}
824 +
825 +/** PDF personnalisé — lève Error("aucun-bloc") si la composition est vide. */
826 +export 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;
835 +
836 + 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))));
851 +
852 + 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);
865 +
866 + 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 +}
893 +
894 +/** Nom de fichier normalisé Groupe-KA (SPEC §3bis). */
895 +export 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