SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
20 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%
66.7 KB · 1,273 lines html
Raw Blame History
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 &amp; évolution<small>Toutes les séries temporelles + statistiques</small></button>358        <button type="button" data-mode="repartitions" role="menuitem">Répartitions &amp; 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 &amp; 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 &amp; 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,"&amp;").replace(/</g,"&lt;").replace(/"/g,"&quot;");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>&nbsp;${f(sv[0])}</b></span><span class="chip">max <b>&nbsp;${f(sv[sv.length-1])}</b></span>559    <span class="chip">moyenne <b>&nbsp;${f(mean)}</b></span><span class="chip">médiane <b>&nbsp;${f(med)}</b></span>560    <span class="chip">écart-type <b>&nbsp;${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>&nbsp;${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>&nbsp;${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