API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
Python 60.9%
HTML 21%
TypeScript 7.3%
JavaScript 5.2%
CSS 4.8%
Shell 0.8%
1<!DOCTYPE html>2<!--3============================================4Projet : API-KA5Fichier : src/api/web/stats.html6Node : m3u96b7Author : Simon-Pierre Boucher8Contact : contact@spboucher.ai9Date : 2026-08-1910============================================11Page /stats — tableau de bord analytique de la plateforme (module Stats commun12Groupe KA, SPEC.md v2). Vanilla JS + SVG : reproduit le langage visuel du kit13ka-ui/stats/kacharts.tsx (KPI + delta + sparkline, chips de période, jauges,14courbe avec infobulle et légende cliquable, multi-courbes à motifs distincts,15barres empilées, histogrammes, anneau, heatmap calendrier + horaire 7×24,16stats de séries, tableaux triables, records, menu PDF 5 rapports).17Données : GET /api/stats/dashboard — rien d'inventé.18-->19<html lang="fr">20<head>21<meta charset="utf-8">22<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">23<title>Statistiques — API-Ka — Un service Groupe KA</title>24<meta name="description" content="Tableau de bord analytique d'API-Ka : appels API par endpoint, latences moyennes et p95, taux d'erreur, runs de collecte quotidiens des 9 services KA et export PDF Groupe-KA.">25<meta name="theme-color" content="#f5f3ee">26<link rel="icon" type="image/svg+xml" href="/favicon.svg">27<link rel="apple-touch-icon" href="/apple-touch-icon.png">28<meta property="og:title" content="Statistiques — API-Ka — Un service Groupe KA">29<meta property="og:description" content="Tableau de bord analytique d'API-Ka : appels API, latences, taux d'erreur, collectes quotidiennes des 9 services KA et export PDF Groupe-KA.">30<meta property="og:url" content="https://www.api-ka.com/stats">31<meta property="og:type" content="website">32<meta property="og:image" content="https://www.api-ka.com/og.png">33<meta property="og:image:width" content="1200">34<meta property="og:image:height" content="630">35<meta name="twitter:card" content="summary_large_image">36<meta name="twitter:image" content="https://www.api-ka.com/og.png">37<link rel="preconnect" href="https://fonts.googleapis.com">38<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>39<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">40<link rel="stylesheet" href="/ka/tokens.css">41<style>42/* ---- Accent API-Ka : INDIGO (identique à l'accueil) ---- */43:root{44 --accent:#3b5bdb; --accent-soft:#e4eafb; --accent-deep:#2b44a8; --on-accent:#ffffff;45}4647/* ---- topbar (identique à l'accueil) ---- */48.topbar{position:sticky;top:0;z-index:60;background:rgba(245,243,238,.94);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-bottom:1.5px solid var(--ink)}49.topbar-in{display:flex;align-items:center;gap:14px;min-height:68px;flex-wrap:wrap;padding-top:8px;padding-bottom:8px}50.brand{font-family:var(--font-display);font-weight:700;font-size:24px;letter-spacing:-.03em;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;min-height:var(--touch);white-space:nowrap}51.brand .ka{display:inline-block;background:var(--ink);color:var(--accent);border-radius:6px;padding:0 7px 2px;margin-left:5px;transform:rotate(-2deg);transition:transform .15s}52.brand:hover .ka{transform:rotate(0)}53.topnav{display:flex;gap:2px;list-style:none;margin:0 0 0 auto;padding:0;overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}54.topnav a{display:inline-flex;align-items:center;min-height:var(--touch);text-decoration:none;font-family:var(--font-mono);font-size:11.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-2);padding:0 12px;border-radius:var(--r-ctl);white-space:nowrap;transition:background .13s,color .13s}55.topnav a:hover,.topnav a[aria-current]{background:var(--accent-soft);color:var(--accent-deep)}56#ka-auth{display:flex;align-items:center;gap:8px}57#ka-auth .btn{font-size:12.5px;padding:8px 14px}58.ka-user{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2)}59.ka-user b{font-family:var(--font-display);font-size:13px;text-transform:none;letter-spacing:-.01em;color:var(--ink)}60.ka-avatar{width:28px;height:28px;border-radius:50%;border:1.5px solid var(--ink);object-fit:cover}61@media(max-width:1023px){.topnav{display:none}.ka-mnav-btn{display:flex}}6263/* ---- entête de page ---- */64.hero-s{padding:52px 0 40px;border-bottom:1.5px solid var(--ink);background:linear-gradient(rgba(59,91,219,.05),transparent 60%),var(--paper)}65.hero-s h1{max-width:640px;margin-top:16px}66.lede{margin-top:14px;max-width:62ch;color:var(--ink-2);font-size:16px}67.hero-tools{display:flex;flex-wrap:wrap;gap:12px;margin-top:24px;align-items:center}68.fresh{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:0}6970/* ---- page ---- */71section{padding:44px 0}72.stats-head{display:flex;flex-wrap:wrap;gap:14px;align-items:center;justify-content:space-between;margin-bottom:22px}73.periods{display:flex;flex-wrap:wrap;gap:8px;align-items:center}74.periods .chip{cursor:pointer;min-height:44px;display:inline-flex;align-items:center;border:1.5px solid var(--ink);background:var(--surface);transition:background .12s,color .12s}75.periods .chip[aria-pressed="true"]{background:var(--accent);color:var(--on-accent)}76.custom-range{display:inline-flex;gap:6px;align-items:center}77.custom-range input{min-height:44px;width:148px}7879.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(175px,1fr));gap:14px;margin-bottom:26px}80.kpi{padding:14px 16px;min-width:0}81.kpi .v{margin:0;font-family:var(--font-display);font-weight:700;font-size:clamp(22px,2.4vw,30px);letter-spacing:-.02em;color:var(--ink);font-variant-numeric:tabular-nums}82.kpi .v small{font-size:.55em;color:var(--ink-2);font-weight:600}83.kpi .klabel{margin:6px 0 0;display:block}84.kpi .delta{margin:8px 0 0;font-family:var(--font-mono);font-size:11px;font-weight:700}85.kpi .delta span{color:var(--ink-3);font-weight:500}86.d-good{color:var(--green)}.d-bad{color:var(--danger)}87.kpi .spark{margin-top:8px;display:block}88.kpi .spark svg{width:100%;height:26px;display:block}8990/* ---- jauges ---- */91.gauge-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;margin:26px 0 0}92.gauge{padding:14px 16px;text-align:center}93.gauge svg{width:120px;max-width:100%;margin:0 auto;display:block}94.gauge .gv{font-family:var(--font-display);font-weight:700;font-size:22px;letter-spacing:-.02em;margin:-34px 0 0;font-variant-numeric:tabular-nums}95.gauge .gv small{font-size:.55em;color:var(--ink-2)}96.gauge .klabel{display:block;margin-top:10px}9798/* ---- menu PDF ---- */99.pdf-wrap{position:relative;display:inline-block}100.pdf-menu{position:absolute;top:calc(100% + 6px);left:0;z-index:70;min-width:250px;background:var(--surface);border:1.5px solid var(--ink);border-radius:10px;box-shadow:4px 4px 0 rgba(20,24,20,.14);padding:6px;display:none}101.pdf-menu.open{display:block}102.pdf-menu button{display:block;width:100%;text-align:left;background:none;border:0;cursor:pointer;padding:10px 12px;border-radius:7px;font-family:var(--font-body,inherit);font-size:13.5px;color:var(--ink);min-height:44px}103.pdf-menu button:hover{background:var(--accent-soft);color:var(--accent-deep)}104.pdf-menu button small{display:block;color:var(--ink-3);font-size:11px;margin-top:1px}105106/* ---- stats de série ---- */107.statsum{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 0}108.statsum .chip{font-size:10.5px}109110/* ---- légende multi-séries / empilées ---- */111.legend .sw.p2{border-top-color:var(--ink)}112.legend .sw.p3{border-top-style:dashed;border-top-color:var(--accent)}113.legend .sw.p4{border-top-style:dotted;border-top-color:var(--ink-3);border-top-width:3.5px}114.legend .sq{width:12px;height:12px;border-radius:3px;border:1px solid var(--ink);background:var(--accent)}115116/* ---- heatmap horaire 7×24 ---- */117.hourly-wrap{margin-top:12px}118.hourly-wrap svg{min-width:560px}119120.charts-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}121.charts-grid .full{grid-column:1/-1}122@media(max-width:1023px){.charts-grid{grid-template-columns:1fr}}123figure.chart{margin:0;padding:16px}124figure.chart figcaption{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:baseline}125figure.chart figcaption b{font-family:var(--font-display);font-size:15px}126.legend{display:flex;gap:10px;flex-wrap:wrap}127.legend button{display:inline-flex;align-items:center;gap:6px;border:0;background:none;cursor:pointer;font-family:var(--font-mono);font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;min-height:44px;color:var(--ink-2)}128.legend button[aria-pressed="false"]{opacity:.4}129.legend .sw{width:18px;height:0;border-top:3px solid var(--accent)}130.legend .sw.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}131.chart svg{width:100%;height:auto;margin-top:10px;touch-action:pan-y;display:block}132.tip{margin-top:8px;min-height:30px}133.tip .chip{font-size:11.5px}134.tip .n1{color:var(--ink-3)}135136.bars-rows{margin-top:12px;display:grid;gap:9px}137.bar-row .bl{display:flex;justify-content:space-between;font-size:12.5px;gap:10px}138.bar-row .bl span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}139.bar-row .bl b{font-family:var(--font-mono);font-size:11.5px;font-variant-numeric:tabular-nums}140.bar-row .track{margin-top:3px;height:12px;background:rgba(20,24,20,.06);border-radius:0 3px 3px 0}141.bar-row .fill{height:100%;background:var(--accent);border:1px solid var(--ink);border-radius:0 3px 3px 0;box-sizing:border-box;min-width:2px}142143.donut-wrap{display:flex;flex-wrap:wrap;gap:18px;align-items:center;margin-top:12px}144.donut-wrap svg{width:180px;max-width:100%;margin:0}145.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:6px;min-width:200px;flex:1}146.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px}147.donut-legend .sq{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);flex:none}148.donut-legend span.lbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}149.donut-legend b{font-family:var(--font-mono);font-size:11px}150151.tbl-card{padding:16px;margin-top:16px}152.tbl-top{display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center}153.tbl-top b{font-family:var(--font-display);font-size:15px}154.tbl-top .input{max-width:240px}155table.dt{width:100%;border-collapse:collapse;font-size:13px;margin-top:10px}156table.dt th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);color:var(--paper);font-family:var(--font-mono);font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap;user-select:none}157table.dt td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}158table.dt tbody tr:nth-child(even){background:var(--surface-2)}159.tbl-foot{display:flex;justify-content:space-between;align-items:center;margin-top:10px;flex-wrap:wrap;gap:8px}160.tbl-foot .pg{display:flex;gap:6px;align-items:center}161162.records-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}163.record{padding:12px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;background:var(--surface-2)}164.record .rl{font-size:13px;color:var(--ink-2)}165.record .rv{text-align:right}166.record .rv b{font-family:var(--font-display);font-size:15px}167.record .rv .klabel{display:block}168169.empty{padding:20px;background:var(--surface-2);border-style:dashed}170.empty b{font-family:var(--font-display);font-size:14px}171.empty p{margin:6px 0 0}172173.pdf-note{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em}174#pdf-busy:not(:empty){font-family:var(--font-mono);font-size:11px;color:var(--accent-deep);font-weight:700}175.sec-gap{margin-top:30px}176@media(max-width:767px){section{padding:32px 0}.hero-s{padding:40px 0 32px}}177@media(prefers-reduced-motion:reduce){*{transition:none!important}}178/* ---------- v3 : constructeur de rapports personnalisés ---------- */179.stb-overlay{position:fixed;inset:0;z-index:var(--z-modal,900);background:rgba(20,24,20,.45);180 display:flex;align-items:flex-start;justify-content:center;padding:4vh 14px;overflow:auto}181.stb-panel{background:var(--surface);border:1px solid var(--ink);border-radius:12px;182 width:min(980px,100%);max-height:92vh;display:flex;flex-direction:column;183 box-shadow:0 18px 48px rgba(20,24,20,.3)}184.stb-head{display:flex;justify-content:space-between;align-items:center;gap:10px;185 padding:16px 20px;border-bottom:1px solid var(--line)}186.stb-head h2{margin:0;font-size:18px}187.stb-body{display:grid;grid-template-columns:1fr 1fr;gap:0;overflow:auto;flex:1}188@media(max-width:760px){.stb-body{grid-template-columns:1fr}}189.stb-col{padding:14px 20px;min-width:0}190.stb-col+.stb-col{border-left:1px solid var(--line)}191@media(max-width:760px){.stb-col+.stb-col{border-left:0;border-top:1px solid var(--line)}}192.stb-col h3{margin:4px 0 10px;font-size:12px;font-family:var(--font-mono);193 text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3)}194.stb-group{margin:0 0 6px;font-family:var(--font-mono);font-size:10.5px;font-weight:700;195 text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);margin-top:12px}196.stb-av{display:flex;justify-content:space-between;align-items:center;gap:8px;197 padding:7px 10px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;font-size:13px}198.stb-av button{flex:none}199.stb-av.stb-in{opacity:.45}200.stb-item{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--ink);201 border-radius:8px;margin-bottom:6px;background:var(--surface-2);font-size:13px}202.stb-item .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}203.stb-item select{border:1px solid var(--line);border-radius:6px;background:var(--surface);204 padding:4px 6px;font-size:12px;max-width:150px}205.stb-item .mv{border:0;background:none;cursor:pointer;font-size:14px;padding:2px 4px;min-height:0}206.stb-item .mv:disabled{opacity:.25;cursor:default}207.stb-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;208 padding:14px 20px;border-top:1px solid var(--line)}209.stb-tpl{display:flex;gap:8px;align-items:center;flex-wrap:wrap}210.stb-err{color:var(--danger);font-size:12.5px;margin:6px 0 0}211.stb-empty{border:1px dashed var(--line);border-radius:8px;padding:16px;color:var(--ink-3);212 font-size:13px;text-align:center}213.hidden{display:none!important}214215/* ===== KA Nav v2 - menu mobile plein ecran (standard 2026-08-25) ===== */216.ka-mnav-btn{display:none;width:44px;height:44px;margin-left:auto;flex:none;217 flex-direction:column;align-items:center;justify-content:center;gap:5px;218 background:var(--paper);border:1.5px solid var(--ink);border-radius:999px;219 cursor:pointer;padding:0}220.ka-mnav-btn span{display:block;width:18px;height:2px;background:var(--ink);221 border-radius:2px;transition:transform .18s ease,opacity .14s ease}222.ka-mnav-btn[aria-expanded="true"] span:nth-child(1){transform:translateY(7px) rotate(45deg)}223.ka-mnav-btn[aria-expanded="true"] span:nth-child(2){opacity:0}224.ka-mnav-btn[aria-expanded="true"] span:nth-child(3){transform:translateY(-7px) rotate(-45deg)}225.ka-mnav{position:fixed;inset:0;z-index:var(--z-modal,900);background:var(--paper);226 display:none;flex-direction:column;overflow-y:auto;overscroll-behavior:contain}227.ka-mnav.open{display:flex;animation:ka-mnav-in .16s ease}228@keyframes ka-mnav-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}229.ka-mnav-top{display:flex;align-items:center;justify-content:space-between;flex:none;230 min-height:64px;padding:0 18px;border-bottom:2px solid var(--ink);background:var(--paper)}231.ka-mnav-close{width:44px;height:44px;flex:none;display:flex;align-items:center;232 justify-content:center;border:1.5px solid var(--ink);border-radius:999px;233 background:var(--ink);color:var(--paper);font-size:17px;font-weight:700;234 cursor:pointer;padding:0;line-height:1}235.ka-mnav-links{display:flex;flex-direction:column;justify-content:center;flex:1;236 padding:28px 24px;counter-reset:mnav}237.ka-mnav-links a{display:flex;align-items:baseline;gap:14px;padding:16px 0;238 border-bottom:1.5px dashed rgba(20,24,20,.18);color:var(--ink);text-decoration:none}239.ka-mnav-links a:last-child{border-bottom:0}240.ka-mnav-links a::before{counter-increment:mnav;content:"0" counter(mnav);241 font-family:var(--font-mono,ui-monospace,monospace);font-size:12px;font-weight:700;242 color:var(--accent-deep,var(--ink-3))}243.ka-mnav-links a b{font-family:var(--font-display,system-ui);font-weight:700;244 font-size:25px;line-height:1;letter-spacing:-.03em;text-transform:uppercase}245.ka-mnav-links a[aria-current] b{background:var(--ink);color:var(--paper);246 border-radius:6px;padding:4px 10px 6px}247.ka-mnav-foot{flex:none;border-top:2px solid var(--ink);padding:16px 24px;248 padding-bottom:max(20px,env(safe-area-inset-bottom))}249html.ka-menu-locked,html.ka-menu-locked body{overflow:hidden}250251/* ============================================================================252 KA ÉDITORIAL v2 (2026-08-25) — aligné sur la refonte lou-ka / vrai-prix :253 typo géante, soulignés « brush » SVG, tuiles KPI à filet (zéro boîte),254 cartes de graphique en hairline. Bloc en FIN de style : surcharge la v1.255 ========================================================================= */256:root{--hairline:rgba(20,24,20,.14);--hairline-strong:rgba(20,24,20,.6)}257258/* .hl : souligné brush irrégulier au lieu de la pastille tournée */259.hl{background:transparent;color:inherit;border-radius:0;transform:none;position:relative;display:inline-block;padding:0 .06em;z-index:0}260.hl::after{content:"";position:absolute;left:-3%;right:-3%;bottom:.05em;height:.34em;z-index:-1;transform:rotate(-.6deg);background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 26' preserveAspectRatio='none'%3E%3Cpath d='M5 17 C 60 8, 118 21, 168 13 S 258 10, 295 15' fill='none' stroke='%233b5bdb' stroke-width='11' stroke-linecap='round' opacity='.4'/%3E%3C/svg%3E") no-repeat center/100% 100%}261262/* entête : composition typographique, plus de bande teintée */263.hero-s{background:var(--paper);border-bottom:1px solid var(--hairline-strong)}264.hero-s h1{max-width:860px;font-size:clamp(38px,5.4vw,68px);line-height:1.03;letter-spacing:-.045em}265266/* cartes : hairline discrète au lieu de l'encre + ombre décalée */267.card{background:var(--surface);border:1px solid var(--hairline-strong);box-shadow:none}268.card-hover:hover{transform:none;box-shadow:none}269270/* KPI et jauges : tuiles magazine à filet, zéro boîte */271.kpi-grid{gap:24px 30px;margin-bottom:34px}272.kpi.card,.kpi{background:transparent;border:0;border-radius:0;border-top:2px solid var(--ink);padding:14px 2px 0}273.kpi .v{font-size:clamp(26px,2.8vw,38px);letter-spacing:-.03em}274.gauge-grid{gap:24px 30px}275.gauge.card,.gauge{background:transparent;border:0;border-radius:0;border-top:2px solid var(--ink)}276277/* chips et périodes : filets légers */278.chip{border-width:1px;border-color:var(--hairline-strong);background:transparent}279.periods .chip{border-width:1px;border-color:var(--hairline-strong)}280.periods .chip[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:var(--paper)}281282/* tableaux : en-tête éditorial (filet d'encre, plus de bandeau inversé) */283table.dt th{background:transparent;color:var(--ink-3);border-bottom:1.5px solid var(--ink)}284285/* records : lignes à filet */286.record{background:transparent;border:0;border-radius:0;border-top:1px solid var(--hairline-strong);padding:12px 2px 0}287.records-grid{gap:20px 28px}288289/* sections rythmées par des filets */290section + section{border-top:1px solid var(--hairline)}291292/* boutons : plus de fond encre (l'accent indigo y était illisible) */293.btn-primary{background:var(--accent);color:var(--on-accent)}294.btn-primary:hover{background:var(--accent-deep);color:#fff}295</style>296<link rel="canonical" href="https://www.api-ka.com/stats">297<link rel="alternate" hreflang="fr-ca" href="https://www.api-ka.com/stats">298<link rel="alternate" hreflang="x-default" href="https://www.api-ka.com/stats">299<meta property="og:site_name" content="API-Ka">300<meta property="og:locale" content="fr_CA">301<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Accueil", "item": "https://www.api-ka.com/"}, {"@type": "ListItem", "position": 2, "name": "Statistiques", "item": "https://www.api-ka.com/stats"}]}</script>302</head>303<body>304305<div class="topbar"><div class="container topbar-in">306 <a class="brand" href="/" aria-label="API-Ka — accueil">API<span class="ka">·Ka</span></a>307 <a class="gk-badge" href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"308 aria-label="Un service Groupe KA — visiter le portail de l'écosystème">Un service <b>Groupe<span class="ka">KA</span></b></a>309 <ul class="topnav">310 <li><a href="/#services">Services</a></li>311 <li><a href="/#endpoints">Endpoints</a></li>312 <li><a href="/#playground">Playground</a></li>313 <li><a href="/stats" aria-current="page">Stats</a></li>314 <li><a href="/docs">Swagger</a></li>315 <li><a href="/contact">Contact</a></li>316 </ul>317 <button id="ka-mnav-btn" class="ka-mnav-btn" aria-label="Ouvrir le menu"318 aria-expanded="false" aria-controls="ka-mnav">319 <span></span><span></span><span></span>320 </button>321 <div id="ka-auth"></div>322</div></div>323324<!-- KA Nav v2 : panneau mobile plein ecran (fixed inset:0) -->325<div id="ka-mnav" class="ka-mnav" role="dialog" aria-modal="true" aria-label="Menu">326 <div class="ka-mnav-top">327 <a class="brand" href="/" aria-label="API-Ka — accueil">API<span class="ka">·Ka</span></a>328 <button class="ka-mnav-close" data-ka-mnav-close aria-label="Fermer le menu">✕</button>329 </div>330 <nav class="ka-mnav-links">331 <a href="/#services"><b>Services</b></a>332 <a href="/#endpoints"><b>Endpoints</b></a>333 <a href="/#playground"><b>Playground</b></a>334 <a href="/stats" aria-current="page"><b>Stats</b></a>335 <a href="/docs"><b>Swagger</b></a>336 <a href="/contact"><b>Contact</b></a>337 </nav>338 <div class="ka-mnav-foot">339 <a class="gk-badge" href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"340 aria-label="Un service Groupe KA — visiter le portail de l'écosystème">341 Un service <b>Groupe<span class="ka">KA</span></b></a>342 </div>343</div>344345<header class="hero-s"><div class="container">346 <span class="kicker">Statistiques · Plateforme</span>347 <h1>Les <span class="hl">stats</span> de la plateforme</h1>348 <p class="lede">Appels API par endpoint, jour et heure, latences moyennes et p95,349 taux d'erreur, distributions, runs de collecte quotidiens des neuf services KA et350 santé des connecteurs — mesurés en continu par la plateforme, rien d'inventé.</p>351 <div class="hero-tools">352 <button type="button" class="btn btn-primary" id="pdf-complet">⬇ Rapport PDF complet</button>353 <span class="pdf-wrap">354 <button type="button" class="btn btn-ghost" id="pdf-more" aria-haspopup="true" aria-expanded="false">Autres rapports ▾</button>355 <span class="pdf-menu" id="pdf-menu" role="menu">356 <button type="button" data-mode="synthese" role="menuitem">Synthèse exécutive<small>Couverture, KPI, jauges et records — 2-3 pages</small></button>357 <button type="button" data-mode="tendances" role="menuitem">Tendances & évolution<small>Toutes les séries temporelles + statistiques</small></button>358 <button type="button" data-mode="repartitions" role="menuitem">Répartitions & géographie<small>Répartitions, distributions, activité horaire</small></button>359 <button type="button" data-mode="donnees" role="menuitem">Données détaillées<small>Tous les tableaux en version longue</small></button>360 </span>361 </span>362 <button type="button" class="btn btn-ghost" id="st-custom-btn">🛠 Rapport personnalisé</button>363 <span id="pdf-busy" role="status"></span>364 </div>365 <p class="fresh" style="margin-top:18px">366 <span class="klabel" id="updated">Chargement…</span>367 <button type="button" class="btn btn-ghost" id="refresh">↻ Rafraîchir</button>368 <span class="pdf-note" id="coverage"></span>369 </p>370</div></header>371372<section><div class="container">373 <div class="stats-head">374 <div class="periods" id="periods" role="group" aria-label="Période"></div>375 <span class="custom-range">376 <input class="input" type="date" id="d-from" aria-label="Du">377 <span class="klabel">au</span>378 <input class="input" type="date" id="d-to" aria-label="Au">379 </span>380 </div>381382 <div class="kpi-grid" id="kpis"></div>383384 <div class="gauge-grid" id="gauges" hidden></div>385386 <div class="sec-gap" id="evo-wrap" hidden>387 <span class="kicker">Évolution</span>388 <div class="charts-grid" id="charts" style="margin-top:14px"></div>389 </div>390391 <div class="sec-gap" id="parts-wrap" hidden>392 <span class="kicker">Répartitions & distributions</span>393 <div class="charts-grid" id="parts" style="margin-top:14px"></div>394 </div>395396 <div id="heatmap" class="sec-gap"></div>397398 <div id="tables"></div>399400 <div class="sec-gap">401 <span class="kicker">Records & faits marquants</span>402 <div class="records-grid" id="records" style="margin-top:14px"></div>403 </div>404</div></section>405406<footer class="ka-footer"><div class="container" id="ka-footer-root"></div></footer>407408<script src="/ka/ka-shell.js" defer></script>409<script>410"use strict";411/* Reproduction vanilla JS + SVG du kit ka-ui/stats/kacharts.tsx. */412const NF = new Intl.NumberFormat("fr-CA");413const fmt = n => Number.isInteger(n) ? NF.format(n) : new Intl.NumberFormat("fr-CA",{maximumFractionDigits:2}).format(n);414const esc = s => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/"/g,""");415416const PERIODS = [417 {id:"auj", label:"Aujourd'hui"},418 {id:"7j", label:"7 jours"},419 {id:"30j", label:"30 jours"},420 {id:"3m", label:"3 mois"},421 {id:"6m", label:"6 mois"},422 {id:"12m", label:"12 mois"},423 {id:"annee", label:"Année en cours"},424 {id:"tout", label:"Tout"},425];426const state = {period:"30j", from:"", to:""};427428/* ---------- sélecteur de période ---------- */429const periodsEl = document.getElementById("periods");430PERIODS.forEach(p => {431 const b = document.createElement("button");432 b.type = "button"; b.className = "chip"; b.textContent = p.label;433 b.setAttribute("aria-pressed", String(p.id === state.period));434 b.addEventListener("click", () => {435 state.period = p.id; state.from = ""; state.to = "";436 document.getElementById("d-from").value = "";437 document.getElementById("d-to").value = "";438 syncChips(); load();439 });440 b.dataset.p = p.id;441 periodsEl.appendChild(b);442});443function syncChips(){444 periodsEl.querySelectorAll(".chip").forEach(c =>445 c.setAttribute("aria-pressed", String(!state.from && c.dataset.p === state.period)));446}447["d-from","d-to"].forEach(id => document.getElementById(id).addEventListener("change", () => {448 const f = document.getElementById("d-from").value, t = document.getElementById("d-to").value;449 if (f && t) { state.from = f; state.to = t; syncChips(); load(); }450}));451document.getElementById("refresh").addEventListener("click", load);452453/* ---------- bouton PDF ---------- */454function pdfUrl(mode){455 const p = new URLSearchParams({period: state.period, mode});456 if (state.from && state.to) { p.set("from", state.from); p.set("to", state.to); }457 return "/api/stats/report?" + p;458}459function dlPdf(mode){460 const busy = document.getElementById("pdf-busy");461 busy.textContent = "Génération du PDF…";462 const a = document.createElement("a");463 a.href = pdfUrl(mode); a.download = "";464 document.body.appendChild(a); a.click(); a.remove();465 setTimeout(() => { busy.textContent = ""; }, 2600);466}467document.getElementById("pdf-complet").addEventListener("click", () => dlPdf("complet"));468const pdfMore = document.getElementById("pdf-more"), pdfMenu = document.getElementById("pdf-menu");469pdfMore.addEventListener("click", () => {470 const open = pdfMenu.classList.toggle("open");471 pdfMore.setAttribute("aria-expanded", String(open));472});473pdfMenu.querySelectorAll("button").forEach(b => b.addEventListener("click", () => {474 pdfMenu.classList.remove("open"); pdfMore.setAttribute("aria-expanded", "false");475 dlPdf(b.dataset.mode);476}));477document.addEventListener("click", e => {478 if (!e.target.closest(".pdf-wrap")) { pdfMenu.classList.remove("open"); pdfMore.setAttribute("aria-expanded", "false"); }479});480481/* ---------- blocs vides (jamais de faux chiffres) ---------- */482function emptyBlock(title){483 const d = document.createElement("div");484 d.className = "card empty";485 d.innerHTML = `<b>${esc(title)}</b><p class="klabel">Pas encore mesuré — aucune donnée disponible pour cette période.</p>`;486 return d;487}488489/* ---------- KPI (avec sparkline) ---------- */490function sparkSvg(spark){491 const pts = (spark || []).filter(p => typeof p.v === "number");492 if (pts.length < 2) return "";493 const W = 160, H = 26, P = 2;494 const vmax = Math.max(...pts.map(p => p.v), 1), vmin = Math.min(0, ...pts.map(p => p.v));495 const X = i => P + (W - 2 * P) * i / (pts.length - 1);496 const Y = v => P + (H - 2 * P) * (1 - (v - vmin) / ((vmax - vmin) || 1));497 const d = pts.map((p, i) => `${i ? "L" : "M"}${X(i).toFixed(1)},${Y(p.v).toFixed(1)}`).join("");498 const last = pts[pts.length - 1];499 return `<span class="spark" aria-hidden="true"><svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">500 <path d="${d}" fill="none" stroke="var(--accent)" stroke-width="1.8"/>501 <circle cx="${X(pts.length-1).toFixed(1)}" cy="${Y(last.v).toFixed(1)}" r="2.4" fill="var(--accent)"/></svg></span>`;502}503function renderKpis(kpis){504 const grid = document.getElementById("kpis");505 grid.innerHTML = "";506 if (!kpis || !kpis.length) { grid.appendChild(emptyBlock("Indicateurs")); return; }507 kpis.forEach(k => {508 const card = document.createElement("article");509 card.className = "card kpi";510 let delta = "";511 if (k.delta_pct !== undefined && k.delta_pct !== null) {512 const arrowUp = k.delta_pct >= 0;513 const good = (k.direction ?? (arrowUp ? "up" : "down")) === "up";514 const dv = Math.round(k.delta_pct * 10) / 10;515 delta = `<p class="delta ${good ? "d-good" : "d-bad"}">${arrowUp ? "▲" : "▼"} ${arrowUp ? "+" : ""}${fmt(dv)} % <span>vs période préc.</span></p>`;516 }517 card.innerHTML = `518 <p class="v">${typeof k.value === "number" ? fmt(k.value) : esc(k.value)}${k.unit ? `<small> ${esc(k.unit)}</small>` : ""}</p>519 <span class="klabel">${esc(k.label)}</span>${delta}${sparkSvg(k.spark)}`;520 grid.appendChild(card);521 });522}523524/* ---------- jauges (demi-arc accent) ---------- */525function renderGauges(gauges){526 const grid = document.getElementById("gauges");527 grid.innerHTML = "";528 const gs = (gauges || []).filter(g => typeof g.value === "number" && g.max);529 grid.hidden = !gs.length;530 if (!gs.length) return;531 gs.forEach(g => {532 const frac = Math.max(0, Math.min(1, g.value / g.max));533 const R = 50, C = Math.PI * R;534 const card = document.createElement("article");535 card.className = "card gauge";536 card.innerHTML = `537 <svg viewBox="0 0 120 66" role="img" aria-label="${esc(g.label)} : ${fmt(g.value)}${g.unit ? " " + esc(g.unit) : ""} sur ${fmt(g.max)}">538 <path d="M10,60 A${R},${R} 0 0 1 110,60" fill="none" stroke="rgba(20,24,20,.1)" stroke-width="10" stroke-linecap="round"/>539 <path d="M10,60 A${R},${R} 0 0 1 110,60" fill="none" stroke="var(--accent)" stroke-width="10" stroke-linecap="round"540 stroke-dasharray="${(frac * C).toFixed(1)} ${C.toFixed(1)}"/>541 </svg>542 <p class="gv">${fmt(g.value)}${g.unit ? `<small> ${esc(g.unit)}</small>` : ""}</p>543 <span class="klabel">${esc(g.label)}</span>`;544 grid.appendChild(card);545 });546}547548/* ---------- statistiques de série (min/max/moy/méd/σ) ---------- */549function statSummary(points){550 const vs = (points || []).map(p => p.v).filter(v => typeof v === "number");551 if (vs.length < 2) return "";552 const sv = [...vs].sort((a, b) => a - b);553 const mean = vs.reduce((s, v) => s + v, 0) / vs.length;554 const med = sv[Math.floor(sv.length / 2)];555 const sd = Math.sqrt(vs.reduce((s, v) => s + (v - mean) ** 2, 0) / vs.length);556 const f = v => fmt(Math.round(v * 100) / 100);557 return `<div class="statsum">558 <span class="chip">min <b> ${f(sv[0])}</b></span><span class="chip">max <b> ${f(sv[sv.length-1])}</b></span>559 <span class="chip">moyenne <b> ${f(mean)}</b></span><span class="chip">médiane <b> ${f(med)}</b></span>560 <span class="chip">écart-type <b> ${f(sd)}</b></span></div>`;561}562563/* ---------- barres verticales (séries kind=bar & histogrammes) ---------- */564function vBarChart(title, pts, unit){565 const rows = (pts || []).filter(p => typeof p.v === "number");566 if (!rows.length) return emptyBlock(title);567 const W = 720, H = 240, PL = 54, PR = 10, PT = 14, PB = 30;568 const vmax = Math.max(...rows.map(p => p.v), 1);569 const bw = (W - PL - PR) / rows.length;570 let g = "";571 for (let k = 0; k < 5; k++) {572 const y = PT + (H - PT - PB) * k / 4;573 g += `<line x1="${PL}" x2="${W-PR}" y1="${y}" y2="${y}" stroke="var(--line)" stroke-width="1"/>574 <text x="${PL-6}" y="${y+3}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${NF.format(Math.round(vmax - vmax * k / 4))}</text>`;575 }576 rows.forEach((p, i) => {577 const bh = (H - PT - PB) * p.v / vmax;578 g += `<rect x="${(PL + i * bw + bw * 0.12).toFixed(1)}" y="${(H - PB - bh).toFixed(1)}" width="${(bw * 0.76).toFixed(1)}" height="${Math.max(bh, p.v > 0 ? 1.5 : 0).toFixed(1)}"579 fill="var(--accent)" stroke="var(--ink)" stroke-width="0.6" rx="1.5"><title>${esc(p.t)} — ${fmt(p.v)}${unit ? " " + esc(unit) : ""}</title></rect>`;580 });581 const lab = rows.length > 3 ? [0, Math.floor(rows.length/2), rows.length-1] : rows.map((_, i) => i);582 lab.forEach(i => {583 g += `<text x="${(PL + i * bw + bw / 2).toFixed(1)}" y="${H-10}" text-anchor="middle" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${esc(rows[i].t)}</text>`;584 });585 const fig = document.createElement("figure");586 fig.className = "card chart";587 fig.innerHTML = `<figcaption><b>${esc(title)}</b></figcaption>588 <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(title)}">${g}</svg>`;589 return fig;590}591592/* ---------- multi-courbes ≤ 4 (motifs de trait distincts + légende) ---------- */593const MULTI_STYLES = [594 {stroke:"var(--accent)", dash:"", w:2.4, sw:""},595 {stroke:"var(--ink)", dash:"", w:1.8, sw:"p2"},596 {stroke:"var(--accent)", dash:"6 4", w:2, sw:"p3"},597 {stroke:"var(--ink-3)", dash:"2 3.5", w:2.2, sw:"p4"},598];599function multiLineChart(ms){600 const series = (ms.series || []).filter(s => (s.points || []).length > 1).slice(0, 4);601 if (!series.length) return emptyBlock(ms.title);602 const W = 720, H = 250, PL = 54, PR = 10, PT = 14, PB = 26;603 const fig = document.createElement("figure");604 fig.className = "card chart";605 fig.innerHTML = `606 <figcaption><b>${esc(ms.title)}</b>607 <span class="legend">${series.map((s, i) =>608 `<button type="button" data-i="${i}" aria-pressed="true"><span class="sw ${MULTI_STYLES[i].sw}"></span>${esc(s.label)}</button>`).join("")}609 </span></figcaption>610 <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(ms.title)}"></svg>611 <p class="tip" aria-live="polite"></p>`;612 const svg = fig.querySelector("svg"), tip = fig.querySelector(".tip");613 const hide = series.map(() => false);614 const ref = series[0].points;615 let hover = null;616 function draw(){617 const vis = series.filter((_, i) => !hide[i]);618 const all = vis.flatMap(s => s.points.map(p => p.v));619 const vmax = Math.max(...(all.length ? all : [1]), 1), vmin = Math.min(0, ...(all.length ? all : [0]));620 const X = (i, n) => PL + (W - PL - PR) * i / (n - 1);621 const Y = v => PT + (H - PT - PB) * (1 - (v - vmin) / ((vmax - vmin) || 1));622 let g = "";623 for (let k = 0; k < 5; k++) {624 const y = PT + (H - PT - PB) * k / 4;625 g += `<line x1="${PL}" x2="${W-PR}" y1="${y}" y2="${y}" stroke="var(--line)" stroke-width="1"/>626 <text x="${PL-6}" y="${y+3}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${NF.format(Math.round(vmax - (vmax - vmin) * k / 4))}</text>`;627 }628 [0, Math.floor(ref.length/2), ref.length-1].forEach((i, k) => {629 g += `<text x="${X(i, ref.length)}" y="${H-8}" text-anchor="${k===0?"start":k===2?"end":"middle"}" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${esc(ref[i].t)}</text>`;630 });631 series.forEach((s, si) => {632 if (hide[si]) return;633 const st = MULTI_STYLES[si];634 const d = s.points.map((p, i) => `${i ? "L" : "M"}${X(i, s.points.length)},${Y(p.v)}`).join("");635 g += `<path d="${d}" fill="none" stroke="${st.stroke}" stroke-width="${st.w}"${st.dash ? ` stroke-dasharray="${st.dash}"` : ""}/>`;636 });637 if (hover !== null) {638 const x = X(hover, ref.length);639 g += `<line x1="${x}" x2="${x}" y1="${PT}" y2="${H-PB}" stroke="var(--ink)" stroke-width="1" stroke-dasharray="2 3"/>`;640 series.forEach((s, si) => {641 if (hide[si] || !s.points[hover]) return;642 g += `<circle cx="${x}" cy="${Y(s.points[hover].v)}" r="3.6" fill="${MULTI_STYLES[si].stroke}" stroke="var(--ink)" stroke-width="1.2"/>`;643 });644 }645 svg.innerHTML = g;646 tip.innerHTML = hover !== null647 ? `<span class="chip">${esc(ref[hover].t)} — ${series.map((s, si) => hide[si] || !s.points[hover] ? "" :648 `${esc(s.label)} : <b> ${fmt(s.points[hover].v)}</b>`).filter(Boolean).join(" · ")}${ms.unit ? " " + esc(ms.unit) : ""}</span>`649 : "";650 }651 function setHover(clientX){652 const r = svg.getBoundingClientRect();653 const fx = (clientX - r.left) / r.width * W;654 hover = Math.max(0, Math.min(ref.length - 1, Math.round((fx - PL) / (W - PL - PR) * (ref.length - 1))));655 draw();656 }657 svg.addEventListener("mousemove", e => setHover(e.clientX));658 svg.addEventListener("touchstart", e => setHover(e.touches[0].clientX), {passive:true});659 svg.addEventListener("touchmove", e => setHover(e.touches[0].clientX), {passive:true});660 svg.addEventListener("mouseleave", () => { hover = null; draw(); });661 fig.querySelectorAll(".legend button").forEach(b => b.addEventListener("click", () => {662 const i = +b.dataset.i;663 hide[i] = !hide[i];664 b.setAttribute("aria-pressed", String(!hide[i]));665 draw();666 }));667 draw();668 return fig;669}670671/* ---------- barres empilées (composition dans le temps) ---------- */672function stackedBarChart(st){673 const keys = (st.keys || []).slice(0, 6);674 const pts = st.points || [];675 if (!keys.length || !pts.length) return emptyBlock(st.title);676 const shades = [1, .74, .52, .36, .24, .15];677 const W = 720, H = 250, PL = 54, PR = 10, PT = 14, PB = 30;678 const totals = pts.map(p => (p.values || []).slice(0, keys.length).reduce((s, v) => s + (v || 0), 0));679 const vmax = Math.max(...totals, 1);680 const bw = (W - PL - PR) / pts.length;681 let g = "";682 for (let k = 0; k < 5; k++) {683 const y = PT + (H - PT - PB) * k / 4;684 g += `<line x1="${PL}" x2="${W-PR}" y1="${y}" y2="${y}" stroke="var(--line)" stroke-width="1"/>685 <text x="${PL-6}" y="${y+3}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${NF.format(Math.round(vmax - vmax * k / 4))}</text>`;686 }687 pts.forEach((p, i) => {688 let acc = H - PB;689 keys.forEach((k, j) => {690 const v = (p.values || [])[j] || 0;691 if (!v) return;692 const bh = (H - PT - PB) * v / vmax;693 acc -= bh;694 g += `<rect x="${(PL + i * bw + bw * 0.12).toFixed(1)}" y="${acc.toFixed(1)}" width="${(bw * 0.76).toFixed(1)}" height="${Math.max(bh - 0.5, 0.8).toFixed(1)}"695 fill="var(--accent)" fill-opacity="${shades[j]}" stroke="var(--ink)" stroke-width="0.4"><title>${esc(p.t)} · ${esc(k)} — ${fmt(v)}${st.unit ? " " + esc(st.unit) : ""}</title></rect>`;696 });697 });698 const lab = pts.length > 3 ? [0, Math.floor(pts.length/2), pts.length-1] : pts.map((_, i) => i);699 lab.forEach(i => {700 g += `<text x="${(PL + i * bw + bw / 2).toFixed(1)}" y="${H-10}" text-anchor="middle" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${esc(pts[i].t)}</text>`;701 });702 const fig = document.createElement("figure");703 fig.className = "card chart";704 fig.innerHTML = `705 <figcaption><b>${esc(st.title)}</b>706 <span class="legend">${keys.map((k, j) =>707 `<button type="button" disabled style="cursor:default;opacity:1"><span class="sq" style="opacity:${shades[j]}"></span>${esc(k)}</button>`).join("")}708 </span></figcaption>709 <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(st.title)}">${g}</svg>`;710 return fig;711}712713/* ---------- heatmap horaire 7 jours × 24 h ---------- */714function hourHeatmap(hh){715 const cells = (hh && hh.cells) || [];716 if (!cells.length) return null;717 const byKey = new Map(cells.map(c => [`${c.dow}-${c.hour}`, c.value]));718 const max = Math.max(...cells.map(c => c.value), 1);719 const DOWS = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];720 const CS = 22, LX = 40, LY = 18;721 let g = "";722 for (let h = 0; h < 24; h += 3)723 g += `<text x="${LX + h * CS + CS/2}" y="12" text-anchor="middle" font-size="9" fill="var(--ink-3)" font-family="var(--font-mono)">${h}h</text>`;724 for (let d = 0; d < 7; d++) {725 g += `<text x="${LX - 8}" y="${LY + d * CS + CS/2 + 3}" text-anchor="end" font-size="9.5" fill="var(--ink-3)" font-family="var(--font-mono)">${DOWS[d]}</text>`;726 for (let h = 0; h < 24; h++) {727 const v = byKey.get(`${d}-${h}`) ?? 0;728 g += `<rect x="${LX + h * CS}" y="${LY + d * CS}" width="${CS-2}" height="${CS-2}" rx="3"729 fill="${v ? "var(--accent)" : "rgba(20,24,20,0.06)"}" fill-opacity="${v ? (0.18 + 0.82 * v / max).toFixed(3) : 1}"730 stroke="rgba(20,24,20,0.14)" stroke-width="0.5"><title>${DOWS[d]} ${String(h).padStart(2,"0")}h — ${fmt(v)} appels</title></rect>`;731 }732 }733 const fig = document.createElement("figure");734 fig.className = "card chart";735 fig.innerHTML = `736 <figcaption><b>${esc(hh.title || "Activité par jour et heure")}</b> <span class="klabel">7 jours × 24 h</span></figcaption>737 <div class="tbl-wrap hourly-wrap">738 <svg viewBox="0 0 ${LX + 24 * CS} ${LY + 7 * CS}" style="min-width:560px" role="img" aria-label="${esc(hh.title || "Activité horaire")}">${g}</svg>739 </div>`;740 return fig;741}742743/* ---------- courbe (infobulle + légende cliquable + N-1 pointillé) ---------- */744function lineChart(serie){745 const pts = serie.points || [];746 if (pts.length < 2) return emptyBlock(serie.title);747 const W = 720, H = 240, PL = 54, PR = 10, PT = 14, PB = 26;748 const fig = document.createElement("figure");749 fig.className = "card chart";750 const hasCmp = Array.isArray(serie.compare) && serie.compare.length > 1;751 fig.innerHTML = `752 <figcaption><b>${esc(serie.title)}</b>753 <span class="legend">754 <button type="button" data-s="cur" aria-pressed="true"><span class="sw"></span>Période courante</button>755 ${hasCmp ? '<button type="button" data-s="cmp" aria-pressed="true"><span class="sw cmp"></span>Période comparée</button>' : ""}756 </span>757 </figcaption>758 <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(serie.title)}"></svg>759 <p class="tip" aria-live="polite"></p>`;760 const svg = fig.querySelector("svg");761 const tip = fig.querySelector(".tip");762 const hide = {cur:false, cmp:false};763 let hover = null;764765 function draw(){766 const all = [...(hide.cur ? [] : pts), ...((!hide.cmp && hasCmp) ? serie.compare : [])];767 const vmax = Math.max(...all.map(p => p.v), 1);768 const vmin = Math.min(0, ...all.map(p => p.v));769 const X = (i, n) => PL + (W - PL - PR) * i / (n - 1);770 const Y = v => PT + (H - PT - PB) * (1 - (v - vmin) / ((vmax - vmin) || 1));771 const path = s => s.map((p, i) => `${i ? "L" : "M"}${X(i, s.length)},${Y(p.v)}`).join("");772 let g = "";773 for (let k = 0; k < 5; k++) {774 const y = PT + (H - PT - PB) * k / 4;775 const v = vmax - (vmax - vmin) * k / 4;776 g += `<line x1="${PL}" x2="${W-PR}" y1="${y}" y2="${y}" stroke="var(--line)" stroke-width="1"/>777 <text x="${PL-6}" y="${y+3}" text-anchor="end" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${NF.format(Math.round(v))}</text>`;778 }779 [0, Math.floor(pts.length/2), pts.length-1].forEach((i, k) => {780 const anchor = k === 0 ? "start" : k === 2 ? "end" : "middle";781 g += `<text x="${X(i, pts.length)}" y="${H-8}" text-anchor="${anchor}" font-size="10" fill="var(--ink-3)" font-family="var(--font-mono)">${esc(pts[i].t)}</text>`;782 });783 if (hasCmp && !hide.cmp) g += `<path d="${path(serie.compare)}" fill="none" stroke="var(--ink-3)" stroke-width="1.4" stroke-dasharray="4 4"/>`;784 if (!hide.cur) g += `<path d="${path(pts)}" fill="none" stroke="var(--accent)" stroke-width="2.4"/>`;785 if (hover !== null) {786 const x = X(hover, pts.length);787 g += `<line x1="${x}" x2="${x}" y1="${PT}" y2="${H-PB}" stroke="var(--ink)" stroke-width="1" stroke-dasharray="2 3"/>`;788 if (!hide.cur) g += `<circle cx="${x}" cy="${Y(pts[hover].v)}" r="4" fill="var(--accent)" stroke="var(--ink)" stroke-width="1.5"/>`;789 }790 svg.innerHTML = g;791 if (hover !== null) {792 const cmpTxt = (hasCmp && !hide.cmp && serie.compare[hover])793 ? ` <span class="n1">· N-1 : ${fmt(serie.compare[hover].v)}</span>` : "";794 tip.innerHTML = `<span class="chip">${esc(pts[hover].t)} — <b> ${fmt(pts[hover].v)}${serie.unit ? " " + esc(serie.unit) : ""}</b>${cmpTxt}</span>`;795 } else tip.innerHTML = "";796 }797 function setHover(clientX){798 const r = svg.getBoundingClientRect();799 const fx = (clientX - r.left) / r.width * W;800 hover = Math.max(0, Math.min(pts.length - 1, Math.round((fx - PL) / (W - PL - PR) * (pts.length - 1))));801 draw();802 }803 svg.addEventListener("mousemove", e => setHover(e.clientX));804 svg.addEventListener("touchstart", e => setHover(e.touches[0].clientX), {passive:true});805 svg.addEventListener("touchmove", e => setHover(e.touches[0].clientX), {passive:true});806 svg.addEventListener("mouseleave", () => { hover = null; draw(); });807 fig.querySelectorAll(".legend button").forEach(b => b.addEventListener("click", () => {808 hide[b.dataset.s] = !hide[b.dataset.s];809 b.setAttribute("aria-pressed", String(!hide[b.dataset.s]));810 draw();811 }));812 draw();813 return fig;814}815816/* ---------- barres horizontales ---------- */817function barChart(title, items, unit){818 const rows = (items || []).slice(0, 14);819 if (!rows.length) return emptyBlock(title);820 const max = Math.max(...rows.map(r => r.value), 1);821 const fig = document.createElement("figure");822 fig.className = "card chart";823 fig.innerHTML = `<figcaption><b>${esc(title)}</b></figcaption><div class="bars-rows"></div>`;824 const zone = fig.querySelector(".bars-rows");825 rows.forEach(r => {826 const d = document.createElement("div");827 d.className = "bar-row";828 d.title = `${r.label} — ${fmt(r.value)}${unit ? " " + unit : ""}`;829 let delta = "";830 if (r.delta_pct !== undefined && r.delta_pct !== null) {831 const up = r.delta_pct >= 0;832 delta = ` <b class="${up ? "d-good" : "d-bad"}">${up ? "+" : ""}${fmt(Math.round(r.delta_pct * 10) / 10)} %</b>`;833 }834 d.innerHTML = `835 <div class="bl"><span>${esc(r.label)}</span><span style="white-space:nowrap"><b>${fmt(r.value)}${unit ? " " + esc(unit) : ""}</b>${delta}</span></div>836 <div class="track"><div class="fill" style="width:${Math.max(r.value / max * 100, 1)}%"></div></div>`;837 zone.appendChild(d);838 });839 return fig;840}841842/* ---------- anneau ---------- */843function donut(title, items){844 const rows = (items || []).filter(i => i.value > 0).slice(0, 8);845 const total = rows.reduce((s, r) => s + r.value, 0);846 if (!total) return emptyBlock(title);847 const R = 74, C = 2 * Math.PI * R;848 const shades = [1, .78, .58, .42, .3, .22, .15, .1];849 let acc = 0, segs = "";850 rows.forEach((r, i) => {851 const frac = r.value / total, off = acc; acc += frac;852 segs += `<circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--accent)" stroke-opacity="${shades[i % 8]}"853 stroke-width="30" stroke-dasharray="${frac * C} ${C}" stroke-dashoffset="${-off * C}" transform="rotate(-90 100 100)">854 <title>${esc(r.label)} — ${fmt(r.value)} (${(100 * r.value / total).toFixed(1)} %)</title></circle>`;855 });856 const fig = document.createElement("figure");857 fig.className = "card chart";858 fig.innerHTML = `859 <figcaption><b>${esc(title)}</b></figcaption>860 <div class="donut-wrap">861 <svg viewBox="0 0 200 200" role="img" aria-label="${esc(title)}">${segs}862 <circle cx="100" cy="100" r="${R}" fill="none" stroke="var(--ink)" stroke-width="1" opacity=".5"/></svg>863 <ul class="donut-legend">864 ${rows.map((r, i) => `<li><span class="sq" style="opacity:${shades[i % 8]}"></span>865 <span class="lbl">${esc(r.label)}</span><b>${(100 * r.value / total).toFixed(1).replace(".", ",")} %</b></li>`).join("")}866 </ul>867 </div>`;868 return fig;869}870871/* ---------- calendrier de chaleur ---------- */872function heatmap(title, cells){873 if (!cells || !cells.length) return null;874 const byDate = new Map(cells.map(c => [c.date, c.value]));875 const dates = cells.map(c => c.date).sort();876 const end = new Date(dates[dates.length - 1] + "T12:00:00");877 const max = Math.max(...cells.map(c => c.value), 1);878 const weeks = 26;879 const cur = new Date(end);880 cur.setDate(cur.getDate() - (weeks * 7 - 1));881 let rects = "";882 for (let w = 0; w < weeks; w++) for (let d = 0; d < 7; d++) {883 const iso = cur.toISOString().slice(0, 10);884 const v = byDate.get(iso) ?? 0;885 rects += `<rect x="${w * 14}" y="${d * 14}" width="12" height="12" rx="2.5"886 fill="${v ? "var(--accent)" : "rgba(20,24,20,0.07)"}" fill-opacity="${v ? (0.25 + 0.75 * v / max).toFixed(3) : 1}"887 stroke="rgba(20,24,20,0.15)" stroke-width="0.5"><title>${iso} — ${fmt(v)}</title></rect>`;888 cur.setDate(cur.getDate() + 1);889 }890 const fig = document.createElement("figure");891 fig.className = "card chart";892 fig.innerHTML = `893 <figcaption><b>${esc(title)}</b> <span class="klabel">26 dernières semaines</span></figcaption>894 <div class="tbl-wrap" style="margin-top:12px">895 <svg viewBox="0 0 ${weeks * 14} ${7 * 14}" style="min-width:480px" role="img" aria-label="${esc(title)}">${rects}</svg>896 </div>`;897 return fig;898}899900/* ---------- tableau : tri, recherche, pagination 25/pg ---------- */901function dataTable(spec, pageSize = 25){902 if (!spec.rows || !spec.rows.length) return emptyBlock(spec.title);903 const sec = document.createElement("section");904 sec.className = "card tbl-card";905 sec.innerHTML = `906 <div class="tbl-top"><b>${esc(spec.title)}</b>907 <input class="input" placeholder="Rechercher…" aria-label="Rechercher dans ${esc(spec.title)}"></div>908 <div class="tbl-wrap"><table class="dt"><thead><tr>909 ${spec.columns.map((c, i) => `<th data-i="${i}" aria-sort="none">${esc(c)} <span class="si">↕</span></th>`).join("")}910 </tr></thead><tbody></tbody></table></div>911 <div class="tbl-foot"><span class="klabel"></span>912 <span class="pg">913 <button type="button" class="btn btn-ghost" data-d="-1" aria-label="Page précédente">←</button>914 <span class="chip"></span>915 <button type="button" class="btn btn-ghost" data-d="1" aria-label="Page suivante">→</button>916 </span></div>`;917 const st = {q:"", sort:null, page:0};918 const num = x => typeof x === "number" ? x : parseFloat(String(x).replace(/[^\d.,-]/g, "").replace(",", "."));919 function rowsFiltered(){920 let r = spec.rows;921 if (st.q) r = r.filter(row => row.some(c => String(c).toLowerCase().includes(st.q.toLowerCase())));922 if (st.sort) r = [...r].sort((a, b) => {923 const nx = num(a[st.sort.col]), ny = num(b[st.sort.col]);924 if (!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx - ny) * st.sort.dir;925 return String(a[st.sort.col]).localeCompare(String(b[st.sort.col]), "fr") * st.sort.dir;926 });927 return r;928 }929 function render(){930 const rows = rowsFiltered();931 const pages = Math.max(1, Math.ceil(rows.length / pageSize));932 st.page = Math.min(st.page, pages - 1);933 sec.querySelector("tbody").innerHTML = rows.slice(st.page * pageSize, (st.page + 1) * pageSize)934 .map(row => `<tr>${row.map(c => `<td>${typeof c === "number" ? fmt(c) : esc(c)}</td>`).join("")}</tr>`).join("");935 sec.querySelector(".tbl-foot .klabel").textContent = NF.format(rows.length) + " lignes";936 sec.querySelector(".pg .chip").textContent = `${st.page + 1} / ${pages}`;937 sec.querySelector('[data-d="-1"]').disabled = st.page === 0;938 sec.querySelector('[data-d="1"]').disabled = st.page >= pages - 1;939 sec.querySelectorAll("th").forEach(th => {940 const i = +th.dataset.i;941 const on = st.sort && st.sort.col === i;942 th.setAttribute("aria-sort", on ? (st.sort.dir === 1 ? "ascending" : "descending") : "none");943 th.querySelector(".si").textContent = on ? (st.sort.dir === 1 ? "▲" : "▼") : "↕";944 });945 }946 sec.querySelector(".tbl-top .input").addEventListener("input", e => { st.q = e.target.value; st.page = 0; render(); });947 sec.querySelectorAll("th").forEach(th => th.addEventListener("click", () => {948 const i = +th.dataset.i;949 st.sort = { col:i, dir: st.sort && st.sort.col === i && st.sort.dir === 1 ? -1 : 1 };950 render();951 }));952 sec.querySelectorAll(".pg button").forEach(b => b.addEventListener("click", () => { st.page += +b.dataset.d; render(); }));953 render();954 return sec;955}956957/* ---------- records ---------- */958function renderRecords(records){959 const grid = document.getElementById("records");960 grid.innerHTML = "";961 if (!records || !records.length) { grid.appendChild(emptyBlock("Records")); return; }962 records.forEach(r => {963 const a = document.createElement("article");964 a.className = "card record";965 a.innerHTML = `<span class="rl">${esc(r.label)}</span>966 <span class="rv"><b>${esc(r.value)}</b>${r.date ? `<span class="klabel">${esc(r.date)}</span>` : ""}</span>`;967 grid.appendChild(a);968 });969}970971/* ---------- chargement & rendu ---------- */972async function load(){973 const upd = document.getElementById("updated");974 upd.textContent = "Chargement…";975 const p = new URLSearchParams({period: state.period});976 if (state.from && state.to) { p.set("from", state.from); p.set("to", state.to); }977 let dash;978 try {979 const r = await fetch("/api/stats/dashboard?" + p, {headers:{Accept:"application/json"}});980 dash = (await r.json()).data;981 } catch {982 upd.textContent = "Impossible de charger les statistiques.";983 return;984 }985 upd.textContent = "Mis à jour le " + new Date(dash.updated).toLocaleString("fr-CA", {dateStyle:"medium", timeStyle:"short"})986 + (dash.period ? ` · ${dash.period.label} (${dash.period.from} → ${dash.period.to})` : "");987 const cov = document.getElementById("coverage");988 cov.textContent = dash.coverage && dash.coverage.api_requests_since989 ? "Appels API journalisés depuis le " + new Date(dash.coverage.api_requests_since).toLocaleDateString("fr-CA")990 : "";991992 renderKpis(dash.kpis);993 renderGauges(dash.gauges);994995 /* --- Évolution : séries, multi-courbes, empilées (+ stats de séries) --- */996 const charts = document.getElementById("charts");997 charts.innerHTML = "";998 const series = dash.series || [];999 series.forEach((s, i) => {1000 let el;1001 if (s.kind === "bar") el = vBarChart(s.title, s.points, s.unit);1002 else {1003 el = lineChart(s);1004 if (el.classList && el.classList.contains("chart")) el.insertAdjacentHTML("beforeend", statSummary(s.points));1005 }1006 if (i === 0) el.classList.add("full");1007 charts.appendChild(el);1008 });1009 (dash.multiseries || []).forEach(ms => {1010 const el = multiLineChart(ms);1011 el.classList.add("full");1012 charts.appendChild(el);1013 });1014 (dash.stacked || []).forEach(st => {1015 const el = stackedBarChart(st);1016 el.classList.add("full");1017 charts.appendChild(el);1018 });1019 document.getElementById("evo-wrap").hidden = !charts.children.length;10201021 /* --- Répartitions & distributions --- */1022 const parts = document.getElementById("parts");1023 parts.innerHTML = "";1024 (dash.breakdowns || []).forEach(b => {1025 parts.appendChild(b.kind === "donut" ? donut(b.title, b.items) : barChart(b.title, b.items));1026 });1027 (dash.distributions || []).forEach(d => {1028 parts.appendChild(vBarChart(d.title, (d.bins || []).map(b => ({t:b.label, v:b.value})), d.unit));1029 });1030 if (dash.geo && dash.geo.items) parts.appendChild(barChart(dash.geo.title || "Répartition géographique", dash.geo.items));1031 document.getElementById("parts-wrap").hidden = !parts.children.length;10321033 /* --- Calendriers : heatmap 26 semaines + heatmap horaire 7×24 --- */1034 const hm = document.getElementById("heatmap");1035 hm.innerHTML = "";1036 const hmEl = dash.heatmap ? heatmap(dash.heatmap.title, dash.heatmap.cells) : null;1037 if (hmEl) hm.appendChild(hmEl);1038 const hhEl = dash.hourly ? hourHeatmap(dash.hourly) : null;1039 if (hhEl) { if (hmEl) hhEl.style.marginTop = "16px"; hm.appendChild(hhEl); }10401041 const tbls = document.getElementById("tables");1042 tbls.innerHTML = "";1043 (dash.tables || []).forEach(t => tbls.appendChild(dataTable(t)));10441045 renderRecords(dash.records);1046}1047load();10481049/* ==================== v3 : constructeur de rapports personnalisés ==========1050 Compose un PDF bloc par bloc : choix des données (catalogue du dashboard),1051 du rendu par bloc (courbe/aire/barres/anneau/heatmap/tableau…), de l'ordre,1052 et modèles sauvegardés (localStorage, propre au site = origine). */1053const $ = id => document.getElementById(id);1054function sbParams(){1055 const p = new URLSearchParams({period: state.period});1056 if(state.from && state.to){ p.set("from", state.from); p.set("to", state.to); }1057 return p.toString();1058}1059const SB = { cat:null, sel:[], title:"", busy:false, err:"" };1060const SB_TPL_KEY = "ka-stats-rapports";1061const SB_RENDERS = { line:"Courbe", area:"Aire", bar:"Barres verticales",1062 bars:"Barres horizontales", donut:"Anneau", lines:"Multi-courbes",1063 stacked:"Barres empilées", histogram:"Histogramme", heatmap:"Heatmap",1064 cards:"Cartes", gauges:"Jauges", table:"Tableau" };1065const SB_SECTIONS = { kpis:"Indicateurs", gauges:"Jauges", series:"Évolution",1066 multiseries:"Multi-courbes", stacked:"Compositions", breakdowns:"Répartitions",1067 distributions:"Distributions", geo:"Géographie", heatmap:"Calendrier",1068 hourly:"Activité horaire", tables:"Tableaux", records:"Records" };10691070function sbTemplates(){1071 try{ return JSON.parse(localStorage.getItem(SB_TPL_KEY) || "[]"); }1072 catch(e){ return []; }1073}1074function sbSaveTemplates(t){ localStorage.setItem(SB_TPL_KEY, JSON.stringify(t)); }10751076async function sbOpen(){1077 let ov = $("st-builder");1078 if(!ov){1079 ov = document.createElement("div");1080 ov.id = "st-builder";1081 ov.className = "stb-overlay hidden";1082 ov.onclick = e => { if(e.target === ov) sbClose(); };1083 document.body.appendChild(ov);1084 }1085 ov.classList.remove("hidden");1086 document.body.style.overflow = "hidden";1087 SB.err = "";1088 if(!SB.cat){1089 ov.innerHTML = `<div class="stb-panel"><div class="stb-head">1090 <h2>Rapport personnalisé</h2></div>1091 <div class="stb-col"><p class="klabel">Chargement du catalogue…</p></div></div>`;1092 try{1093 SB.cat = (await (await fetch(`/api/stats/catalog?${sbParams()}`)).json()).blocks || [];1094 }catch(e){1095 ov.innerHTML = `<div class="stb-panel"><div class="stb-head"><h2>Rapport personnalisé</h2>1096 <button type="button" class="btn btn-ghost" onclick="sbClose()">✕ Fermer</button></div>1097 <div class="stb-col"><p class="stb-err">Catalogue indisponible — réessayez plus tard.</p></div></div>`;1098 return;1099 }1100 }1101 sbRender();1102}1103function sbClose(){1104 const ov = $("st-builder");1105 if(ov) ov.classList.add("hidden");1106 document.body.style.overflow = "";1107}11081109function sbAdd(key){1110 const b = SB.cat.find(x => x.key === key);1111 if(!b || SB.sel.some(s => s.key === key && s.render === b.default_render)) return;1112 SB.sel.push({ key, render: b.default_render });1113 sbRender();1114}1115function sbDel(i){ SB.sel.splice(i, 1); sbRender(); }1116function sbMove(i, d){1117 const j = i + d;1118 if(j < 0 || j >= SB.sel.length) return;1119 [SB.sel[i], SB.sel[j]] = [SB.sel[j], SB.sel[i]];1120 sbRender();1121}1122function sbAll(){1123 SB.sel = SB.cat.map(b => ({ key: b.key, render: b.default_render }));1124 sbRender();1125}11261127function sbRender(){1128 const ov = $("st-builder");1129 const groups = {};1130 SB.cat.forEach(b => (groups[b.section] = groups[b.section] || []).push(b));1131 const selKeys = new Set(SB.sel.map(s => s.key));1132 const perLabel = (state.from && state.to) ? `${state.from} → ${state.to}`1133 : (PERIODS.find(p => p.id === state.period) || {label:"30 jours"}).label;1134 const tpls = sbTemplates();11351136 ov.innerHTML = `<div class="stb-panel">1137 <div class="stb-head">1138 <h2>Rapport personnalisé <span class="klabel">· période : ${esc(perLabel)}</span></h2>1139 <button type="button" class="btn btn-ghost" id="stb-close">✕ Fermer</button>1140 </div>1141 <div class="stb-body">1142 <div class="stb-col">1143 <h3>Blocs disponibles (${SB.cat.length})</h3>1144 <p class="klabel" style="margin:0 0 10px">Cliquez « + » pour ajouter un bloc au rapport.1145 Un même bloc peut être ajouté sous plusieurs rendus.</p>1146 ${Object.entries(groups).map(([sec, bs]) => `1147 <p class="stb-group">${esc(SB_SECTIONS[sec] || sec)}</p>1148 ${bs.map(b => `<div class="stb-av${selKeys.has(b.key) ? " stb-in" : ""}">1149 <span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"1150 title="${esc(b.title)}">${esc(b.title)}</span>1151 <button type="button" class="btn btn-ghost" data-add="${esc(b.key)}"1152 aria-label="Ajouter ${esc(b.title)}">+</button></div>`).join("")}`).join("")}1153 </div>1154 <div class="stb-col">1155 <h3>Composition du rapport (${SB.sel.length})</h3>1156 <label class="klabel" for="stb-title">Titre du rapport</label>1157 <input class="input" id="stb-title" style="width:100%;margin:4px 0 12px"1158 maxlength="80" placeholder="Ex. : Revue mensuelle" value="${esc(SB.title)}">1159 ${SB.sel.length ? SB.sel.map((s, i) => {1160 const b = SB.cat.find(x => x.key === s.key) || { title: s.key, renders: [s.render] };1161 return `<div class="stb-item">1162 <button type="button" class="mv" data-up="${i}" ${i === 0 ? "disabled" : ""} aria-label="Monter">▲</button>1163 <button type="button" class="mv" data-dn="${i}" ${i === SB.sel.length - 1 ? "disabled" : ""} aria-label="Descendre">▼</button>1164 <span class="t" title="${esc(b.title)}"><b>${i + 1}.</b> ${esc(b.title)}</span>1165 ${b.renders.length > 1 ? `<select data-rd="${i}" aria-label="Rendu">1166 ${b.renders.map(r => `<option value="${r}"${r === s.render ? " selected" : ""}>${SB_RENDERS[r] || r}</option>`).join("")}1167 </select>` : `<span class="klabel">${SB_RENDERS[s.render] || s.render}</span>`}1168 <button type="button" class="mv" data-del="${i}" aria-label="Retirer">✕</button>1169 </div>`;1170 }).join("") : `<div class="stb-empty">Aucun bloc — ajoutez des blocs depuis la colonne de gauche,1171 ou chargez un modèle ci-dessous.</div>`}1172 <p style="display:flex;gap:8px;margin:10px 0 0">1173 <button type="button" class="btn btn-ghost" id="stb-all">Tout ajouter</button>1174 <button type="button" class="btn btn-ghost" id="stb-clear" ${SB.sel.length ? "" : "disabled"}>Vider</button>1175 </p>1176 ${SB.err ? `<p class="stb-err">${esc(SB.err)}</p>` : ""}1177 </div>1178 </div>1179 <div class="stb-foot">1180 <span class="stb-tpl">1181 <select class="input" id="stb-tpl" aria-label="Modèles sauvegardés" style="max-width:210px">1182 <option value="">Modèles (${tpls.length})…</option>1183 ${tpls.map((t, i) => `<option value="${i}">${esc(t.name)}</option>`).join("")}1184 </select>1185 <button type="button" class="btn btn-ghost" id="stb-save" ${SB.sel.length ? "" : "disabled"}>💾 Sauvegarder</button>1186 <button type="button" class="btn btn-ghost" id="stb-tpl-del" ${tpls.length ? "" : "disabled"}>🗑 Supprimer</button>1187 </span>1188 <button type="button" class="btn btn-primary" id="stb-go" ${SB.sel.length && !SB.busy ? "" : "disabled"}>1189 ${SB.busy ? "Génération…" : "⬇ Générer le PDF"}</button>1190 </div>1191 </div>`;11921193 $("stb-close").onclick = sbClose;1194 $("stb-all").onclick = sbAll;1195 $("stb-clear").onclick = () => { SB.sel = []; sbRender(); };1196 $("stb-title").oninput = e => { SB.title = e.target.value; };1197 ov.querySelectorAll("[data-add]").forEach(b => b.onclick = () => sbAdd(b.dataset.add));1198 ov.querySelectorAll("[data-up]").forEach(b => b.onclick = () => sbMove(+b.dataset.up, -1));1199 ov.querySelectorAll("[data-dn]").forEach(b => b.onclick = () => sbMove(+b.dataset.dn, 1));1200 ov.querySelectorAll("[data-del]").forEach(b => b.onclick = () => sbDel(+b.dataset.del));1201 ov.querySelectorAll("[data-rd]").forEach(sl => sl.onchange = () => {1202 SB.sel[+sl.dataset.rd].render = sl.value;1203 });1204 $("stb-save").onclick = () => {1205 const name = prompt("Nom du modèle :", SB.title || "Mon rapport");1206 if(!name) return;1207 const tpl = sbTemplates().filter(t => t.name !== name);1208 tpl.push({ name, title: SB.title, blocks: SB.sel.map(s => ({...s})) });1209 sbSaveTemplates(tpl);1210 sbRender();1211 };1212 $("stb-tpl").onchange = e => {1213 const t = sbTemplates()[+e.target.value];1214 if(!t) return;1215 SB.title = t.title || t.name;1216 SB.sel = (t.blocks || []).filter(s => SB.cat.some(b => b.key === s.key)).map(s => ({...s}));1217 sbRender();1218 };1219 $("stb-tpl-del").onclick = () => {1220 const sel = $("stb-tpl");1221 const t = sbTemplates()[+sel.value];1222 if(!t || !confirm(`Supprimer le modèle « ${t.name} » ?`)) return;1223 sbSaveTemplates(sbTemplates().filter(x => x.name !== t.name));1224 sbRender();1225 };1226 $("stb-go").onclick = sbGenerate;1227}12281229async function sbGenerate(){1230 if(SB.busy || !SB.sel.length) return;1231 SB.busy = true; SB.err = ""; sbRender();1232 try{1233 const body = { title: SB.title, period: state.period, blocks: SB.sel };1234 if(state.from && state.to){ body.from = state.from; body.to = state.to; }1235 const r = await fetch("/api/stats/report/custom", {1236 method: "POST", headers: { "Content-Type": "application/json" },1237 body: JSON.stringify(body) });1238 if(!r.ok) throw new Error(`HTTP ${r.status}`);1239 const blob = await r.blob();1240 const cd = r.headers.get("Content-Disposition") || "";1241 const m = cd.match(/filename="?([^";]+)/);1242 const a = document.createElement("a");1243 a.href = URL.createObjectURL(blob);1244 a.download = m ? m[1] : "rapport-personnalise.pdf";1245 document.body.appendChild(a); a.click(); a.remove();1246 setTimeout(() => URL.revokeObjectURL(a.href), 4000);1247 }catch(e){1248 SB.err = "La génération a échoué — réessayez.";1249 }1250 SB.busy = false; sbRender();1251}12521253$("st-custom-btn").onclick = () => { SB.cat = null; SB.err = ""; sbOpen(); };1254</script>1255<script>window.KA_AGENT={site:"api-ka"}</script>1256<script src="/ka-agent.js" defer></script>1257<script>1258/* KA Nav v2 : ouverture/fermeture du panneau mobile plein ecran (2026-08-25) */1259(function(){1260 var btn=document.getElementById("ka-mnav-btn"),panel=document.getElementById("ka-mnav");1261 if(!btn||!panel)return;1262 function set(o){panel.classList.toggle("open",o);1263 btn.setAttribute("aria-expanded",o?"true":"false");1264 document.documentElement.classList.toggle("ka-menu-locked",o);}1265 btn.addEventListener("click",function(){set(!panel.classList.contains("open"))});1266 panel.addEventListener("click",function(e){1267 if(e.target.closest("a")||e.target.closest("[data-ka-mnav-close]"))set(false)});1268 document.addEventListener("keydown",function(e){if(e.key==="Escape")set(false)});1269})();1270</script>1271</body>1272</html>1273