rapports quotidiens : onglet Rapports — rédaction Claude + PDF, automatisé chaque matin
Chaque matin à 06:00 (PM2 cron groupe-ka-rapport-quotidien), le script scripts/rapport-quotidien.mjs lit la collecte API·Ka de 02:00 (9 services, runs, connecteurs, stats d'appels), fait rédiger le rapport de la nuit par claude-sonnet-4-6 (JSON structuré : édito, faits saillants, analyse, vigilance, chiffre du jour, météo) puis produit le PDF en imprimant la page /rapports/[date] via le Chromium headless de Playwright (@media print). Pages /rapports (archives) + /rapports/[date] (tableau « la nuit en chiffres » rendu depuis le snapshot, sans passer par le modèle) + route /api/rapports/[date]/pdf. Le bloc print existant (carte de membre) est scopé à body > header/footer pour ne plus cacher la manchette des pages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
9 changed files +1,020 −2
added
scripts/rapport-quotidien.mjs
+279 −0
@@ -0,0 +1,279 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Rapport quotidien du Groupe KA — généré chaque matin à 06:00 (PM2 cron). | |
| 3 | +// Pipeline : données API·Ka (collecte de 02:00) → rédaction par Claude | |
| 4 | +// (JSON structuré) → data/rapports/YYYY-MM-DD.json → PDF via le Chromium | |
| 5 | +// headless de Playwright qui « imprime » la page /rapports/[date] du site | |
| 6 | +// (une seule source de rendu, @media print dans globals.css). | |
| 7 | +// Usage : node scripts/rapport-quotidien.mjs [--date YYYY-MM-DD] [--force] [--pdf-only] | |
| 8 | +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; | |
| 9 | +import { execFileSync } from "node:child_process"; | |
| 10 | +import path from "node:path"; | |
| 11 | +import os from "node:os"; | |
| 12 | +import { fileURLToPath } from "node:url"; | |
| 13 | + | |
| 14 | +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); | |
| 15 | +const RAPPORTS_DIR = path.join(ROOT, "data", "rapports"); | |
| 16 | +const API_KA = "http://127.0.0.1:8000"; | |
| 17 | +const SITE = "http://127.0.0.1:8110"; | |
| 18 | +const MODEL = "claude-sonnet-4-6"; | |
| 19 | +const HEADLESS_SHELL = path.join( | |
| 20 | + os.homedir(), | |
| 21 | + "Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell", | |
| 22 | +); | |
| 23 | + | |
| 24 | +const SERVICES = [ | |
| 25 | + ["louka", "Lou·Ka", "logements à louer"], | |
| 26 | + ["immoka", "Immo·Ka", "propriétés à vendre"], | |
| 27 | + ["autoka", "Auto·Ka", "voitures usagées"], | |
| 28 | + ["fabrika", "Fabri·Ka", "produits québécois"], | |
| 29 | + ["foodka", "Food·Ka", "prix d'épicerie"], | |
| 30 | + ["restoka", "Resto·Ka", "restos, menus et prix"], | |
| 31 | + ["sortika", "Sorti·Ka", "sorties et événements"], | |
| 32 | + ["creaka", "Créa·Ka", "créateurs d'ici"], | |
| 33 | + ["jobka", "Job·Ka", "offres d'emploi"], | |
| 34 | +]; | |
| 35 | + | |
| 36 | +// ---------- utilitaires ---------- | |
| 37 | + | |
| 38 | +function loadEnv() { | |
| 39 | + const file = path.join(ROOT, ".env.local"); | |
| 40 | + if (!existsSync(file)) return; | |
| 41 | + for (const line of readFileSync(file, "utf8").split("\n")) { | |
| 42 | + const m = line.match(/^([A-Z0-9_]+)=(.*)$/); | |
| 43 | + if (m && !process.env[m[1]]) process.env[m[1]] = m[2]; | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +function todayLocal() { | |
| 48 | + return new Intl.DateTimeFormat("en-CA", { | |
| 49 | + timeZone: "America/Toronto", | |
| 50 | + }).format(new Date()); | |
| 51 | +} | |
| 52 | + | |
| 53 | +async function getJson(url, timeoutMs = 20000) { | |
| 54 | + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); | |
| 55 | + if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`); | |
| 56 | + return res.json(); | |
| 57 | +} | |
| 58 | + | |
| 59 | +async function tryJson(url, timeoutMs) { | |
| 60 | + try { | |
| 61 | + return await getJson(url, timeoutMs); | |
| 62 | + } catch (e) { | |
| 63 | + console.warn(`⚠️ ${url} : ${e.message}`); | |
| 64 | + return null; | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +// ---------- 1. collecte des données API·Ka ---------- | |
| 69 | + | |
| 70 | +async function collecter(dateRapport) { | |
| 71 | + const [health, dash7j, runsRes, ...statsParService] = await Promise.all([ | |
| 72 | + tryJson(`${API_KA}/health`), | |
| 73 | + tryJson(`${API_KA}/api/stats/dashboard?period=7j`), | |
| 74 | + tryJson(`${API_KA}/api/v1/runs?limit=60`), | |
| 75 | + ...SERVICES.map(([s]) => tryJson(`${API_KA}/api/v1/${s}/stats`)), | |
| 76 | + ]); | |
| 77 | + | |
| 78 | + const services = SERVICES.map(([service, nom, description], i) => { | |
| 79 | + const d = statsParService[i]?.data; | |
| 80 | + const days = (d?.days ?? []).slice(0, 15); // du plus récent au plus ancien | |
| 81 | + const jour = days[0] ?? null; | |
| 82 | + const veille = days[1] ?? null; | |
| 83 | + const delta = | |
| 84 | + jour && veille && veille.records > 0 | |
| 85 | + ? ((jour.records - veille.records) / veille.records) * 100 | |
| 86 | + : null; | |
| 87 | + return { | |
| 88 | + service, | |
| 89 | + nom, | |
| 90 | + description, | |
| 91 | + total: d?.total_records ?? null, | |
| 92 | + date_jour: jour?.date_key ?? null, | |
| 93 | + jour: jour?.records ?? null, | |
| 94 | + veille: veille?.records ?? null, | |
| 95 | + delta_pct: delta === null ? null : Math.round(delta * 100) / 100, | |
| 96 | + statut: d?.last_success?.status ?? "inconnu", | |
| 97 | + serie_14j: days | |
| 98 | + .slice(0, 14) | |
| 99 | + .map((x) => ({ d: x.date_key, n: x.records })) | |
| 100 | + .reverse(), | |
| 101 | + }; | |
| 102 | + }); | |
| 103 | + | |
| 104 | + const dateDonnees = | |
| 105 | + services.map((s) => s.date_jour).filter(Boolean).sort().at(-1) ?? dateRapport; | |
| 106 | + | |
| 107 | + const runsJour = (runsRes?.data ?? []).filter( | |
| 108 | + (r) => r.date_key === dateDonnees, | |
| 109 | + ); | |
| 110 | + | |
| 111 | + const kpi = (id) => | |
| 112 | + dash7j?.data?.kpis?.find((k) => k.id === id)?.value ?? null; | |
| 113 | + | |
| 114 | + const snapshot = { | |
| 115 | + date_donnees: dateDonnees, | |
| 116 | + services, | |
| 117 | + totaux: { | |
| 118 | + records_jour: services.reduce((a, s) => a + (s.jour ?? 0), 0), | |
| 119 | + records_total: services.reduce((a, s) => a + (s.total ?? 0), 0), | |
| 120 | + }, | |
| 121 | + connecteurs: health?.data?.connectors ?? null, | |
| 122 | + api_7j: { | |
| 123 | + appels: kpi("calls"), | |
| 124 | + latence_ms: kpi("latency"), | |
| 125 | + erreurs_pct: kpi("errors"), | |
| 126 | + }, | |
| 127 | + runs_du_jour: runsJour.map((r) => ({ | |
| 128 | + service: r.service, | |
| 129 | + statut: r.status, | |
| 130 | + records: r.records_count, | |
| 131 | + duree_s: r.duration_seconds | |
| 132 | + ? Math.round(r.duration_seconds * 10) / 10 | |
| 133 | + : null, | |
| 134 | + })), | |
| 135 | + }; | |
| 136 | + return snapshot; | |
| 137 | +} | |
| 138 | + | |
| 139 | +// ---------- 2. rédaction par Claude ---------- | |
| 140 | + | |
| 141 | +const SYSTEM = `Tu es le rédacteur en chef du rapport quotidien du Groupe KA, un holding québécois d'agrégateurs de produits et services entièrement automatisés (Lou·Ka : location ; Immo·Ka : propriétés ; Auto·Ka : autos usagées ; Fabri·Ka : produits d'ici ; Food·Ka : épicerie ; Resto·Ka : restos ; Sorti·Ka : sorties ; Créa·Ka : créateurs ; Job·Ka : emplois). Sa devise : zéro saisie manuelle, zéro boîte noire — rien d'inventé, tout est traçable. | |
| 142 | + | |
| 143 | +Chaque matin, tu écris LE rapport de la nuit : la collecte automatisée de 02:00 vient de passer sur les 9 plateformes et tu racontes ce qu'elle a trouvé. Ton style : français québécois soigné, précis, vivant, avec de l'aplomb — un rapport qu'on a du plaisir à lire, jamais un procès-verbal plate. Tu compares au jour précédent, tu repères les tendances sur 14 jours, les records, les séries en progression ou en recul, et tu le dis franchement quand une collecte a mal été. | |
| 144 | + | |
| 145 | +Règles absolues : | |
| 146 | +- Tu n'inventes AUCUN chiffre : tout vient des données fournies. Si une donnée est nulle/absente, tu le dis ou tu n'en parles pas. | |
| 147 | +- Nombres en format canadien-français (espace pour les milliers, virgule décimale) : « 45 073 », « 2,3 % ». | |
| 148 | +- Tu réponds UNIQUEMENT avec un objet JSON valide (aucun texte avant/après, aucune clôture markdown), au schéma exact : | |
| 149 | +{ | |
| 150 | + "titre": "titre du jour, accrocheur, max 70 caractères, sans le mot 'rapport'", | |
| 151 | + "sous_titre": "une phrase qui résume la nuit", | |
| 152 | + "meteo": { "etat": "beau" | "variable" | "orage", "resume": "l'état de l'écosystème en une phrase" }, | |
| 153 | + "chiffre_du_jour": { "valeur": "le nombre formaté", "label": "ce que c'est", "contexte": "pourquoi c'est LE chiffre du jour" }, | |
| 154 | + "edito": ["2 à 3 paragraphes d'éditorial sur la nuit de collecte, le mouvement d'ensemble, ce qui mérite attention"], | |
| 155 | + "faits_saillants": [{ "plateforme": "Nom·Ka", "texte": "1-2 phrases : le fait marquant du jour pour cette plateforme, avec chiffres" }], | |
| 156 | + "analyse": [{ "titre": "titre de section", "contenu": ["1-2 paragraphes d'analyse : tendances 14 jours, comparaisons entre plateformes, santé technique (runs, durées, connecteurs, API)"] }], | |
| 157 | + "vigilance": ["points à surveiller (collectes échouées, baisses inhabituelles, connecteurs dégradés) — liste vide si tout va bien"], | |
| 158 | + "mot_de_la_fin": "une phrase de clôture avec du panache" | |
| 159 | +} | |
| 160 | +- "faits_saillants" : une entrée par plateforme qui a quelque chose à dire (6 à 9 entrées). | |
| 161 | +- "analyse" : 2 ou 3 sections. | |
| 162 | +- Utilise les vrais noms : Lou·Ka, Immo·Ka, Auto·Ka, Fabri·Ka, Food·Ka, Resto·Ka, Sorti·Ka, Créa·Ka, Job·Ka, API·Ka.`; | |
| 163 | + | |
| 164 | +async function rediger(snapshot, dateRapport) { | |
| 165 | + const key = process.env.ANTHROPIC_API_KEY; | |
| 166 | + if (!key) throw new Error("ANTHROPIC_API_KEY manquante (.env.local)"); | |
| 167 | + | |
| 168 | + const user = `Date du rapport : ${dateRapport}. Données de la collecte (date_donnees = ${snapshot.date_donnees}) :\n${JSON.stringify(snapshot)}`; | |
| 169 | + | |
| 170 | + const res = await fetch("https://api.anthropic.com/v1/messages", { | |
| 171 | + method: "POST", | |
| 172 | + headers: { | |
| 173 | + "x-api-key": key, | |
| 174 | + "anthropic-version": "2023-06-01", | |
| 175 | + "content-type": "application/json", | |
| 176 | + }, | |
| 177 | + body: JSON.stringify({ | |
| 178 | + model: MODEL, | |
| 179 | + max_tokens: 8000, | |
| 180 | + system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }], | |
| 181 | + messages: [{ role: "user", content: user }], | |
| 182 | + }), | |
| 183 | + signal: AbortSignal.timeout(300000), | |
| 184 | + }); | |
| 185 | + if (!res.ok) { | |
| 186 | + throw new Error(`API Anthropic → HTTP ${res.status} : ${await res.text()}`); | |
| 187 | + } | |
| 188 | + const data = await res.json(); | |
| 189 | + const texte = (data.content ?? []) | |
| 190 | + .filter((b) => b.type === "text") | |
| 191 | + .map((b) => b.text) | |
| 192 | + .join(""); | |
| 193 | + const brut = texte.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, ""); | |
| 194 | + const debut = brut.indexOf("{"); | |
| 195 | + const fin = brut.lastIndexOf("}"); | |
| 196 | + if (debut === -1 || fin === -1) throw new Error(`Réponse sans JSON : ${texte.slice(0, 200)}`); | |
| 197 | + const contenu = JSON.parse(brut.slice(debut, fin + 1)); | |
| 198 | + for (const champ of ["titre", "edito", "faits_saillants", "analyse"]) { | |
| 199 | + if (!contenu[champ]) throw new Error(`Champ manquant dans la réponse : ${champ}`); | |
| 200 | + } | |
| 201 | + return { contenu, usage: data.usage }; | |
| 202 | +} | |
| 203 | + | |
| 204 | +// ---------- 3. rendu PDF (imprime la page du site) ---------- | |
| 205 | + | |
| 206 | +function genererPdf(dateRapport) { | |
| 207 | + if (!existsSync(HEADLESS_SHELL)) { | |
| 208 | + console.warn(`⚠️ Chromium headless introuvable (${HEADLESS_SHELL}) — pas de PDF.`); | |
| 209 | + return false; | |
| 210 | + } | |
| 211 | + const pdfPath = path.join(RAPPORTS_DIR, `${dateRapport}.pdf`); | |
| 212 | + execFileSync( | |
| 213 | + HEADLESS_SHELL, | |
| 214 | + [ | |
| 215 | + "--headless", | |
| 216 | + "--disable-gpu", | |
| 217 | + "--no-margins", | |
| 218 | + "--run-all-compositor-stages-before-draw", | |
| 219 | + "--virtual-time-budget=20000", | |
| 220 | + "--timeout=60000", | |
| 221 | + `--print-to-pdf=${pdfPath}`, | |
| 222 | + "--no-pdf-header-footer", | |
| 223 | + `${SITE}/rapports/${dateRapport}`, | |
| 224 | + ], | |
| 225 | + { stdio: "pipe", timeout: 120000 }, | |
| 226 | + ); | |
| 227 | + if (!existsSync(pdfPath)) throw new Error("le PDF n'a pas été produit"); | |
| 228 | + console.log(`✅ PDF : ${pdfPath}`); | |
| 229 | + return true; | |
| 230 | +} | |
| 231 | + | |
| 232 | +// ---------- main ---------- | |
| 233 | + | |
| 234 | +async function main() { | |
| 235 | + loadEnv(); | |
| 236 | + const args = process.argv.slice(2); | |
| 237 | + const force = args.includes("--force"); | |
| 238 | + const pdfOnly = args.includes("--pdf-only"); | |
| 239 | + const dateArg = args[args.indexOf("--date") + 1]; | |
| 240 | + const dateRapport = | |
| 241 | + args.includes("--date") && /^\d{4}-\d{2}-\d{2}$/.test(dateArg ?? "") | |
| 242 | + ? dateArg | |
| 243 | + : todayLocal(); | |
| 244 | + const jsonPath = path.join(RAPPORTS_DIR, `${dateRapport}.json`); | |
| 245 | + mkdirSync(RAPPORTS_DIR, { recursive: true }); | |
| 246 | + | |
| 247 | + if (!pdfOnly) { | |
| 248 | + if (existsSync(jsonPath) && !force) { | |
| 249 | + console.log(`Rapport ${dateRapport} déjà généré (--force pour refaire).`); | |
| 250 | + } else { | |
| 251 | + console.log(`📡 Collecte des données API·Ka…`); | |
| 252 | + const snapshot = await collecter(dateRapport); | |
| 253 | + if (!snapshot.services.some((s) => s.total !== null)) { | |
| 254 | + throw new Error("API·Ka injoignable — aucune donnée, rapport annulé."); | |
| 255 | + } | |
| 256 | + console.log( | |
| 257 | + `✍️ Rédaction par ${MODEL} (${snapshot.totaux.records_jour} enregistrements le ${snapshot.date_donnees})…`, | |
| 258 | + ); | |
| 259 | + const { contenu, usage } = await rediger(snapshot, dateRapport); | |
| 260 | + const rapport = { | |
| 261 | + date: dateRapport, | |
| 262 | + genere_le: new Date().toISOString(), | |
| 263 | + modele: MODEL, | |
| 264 | + usage, | |
| 265 | + contenu, | |
| 266 | + snapshot, | |
| 267 | + }; | |
| 268 | + writeFileSync(jsonPath, JSON.stringify(rapport, null, 2)); | |
| 269 | + console.log(`✅ Rapport : ${jsonPath} (« ${contenu.titre} »)`); | |
| 270 | + } | |
| 271 | + } | |
| 272 | + | |
| 273 | + genererPdf(dateRapport); | |
| 274 | +} | |
| 275 | + | |
| 276 | +main().catch((e) => { | |
| 277 | + console.error(`❌ ${e.message}`); | |
| 278 | + process.exit(1); | |
| 279 | +}); | |
added
scripts/rapport-quotidien.sh
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +# Auteur : Simon-Pierre Boucher — rapport quotidien Groupe KA (pm2 cron 06:00) | |
| 3 | +cd "$(dirname "$0")/.." || exit 1 | |
| 4 | +exec /opt/homebrew/bin/node scripts/rapport-quotidien.mjs | |
added
src/app/api/rapports/[date]/pdf/route.ts
+31 −0
@@ -0,0 +1,31 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Sert le PDF d'un rapport quotidien (généré par scripts/rapport-quotidien.mjs | |
| 3 | +// dans data/rapports/YYYY-MM-DD.pdf). | |
| 4 | +import { promises as fs } from "node:fs"; | |
| 5 | +import path from "node:path"; | |
| 6 | +import { NextResponse } from "next/server"; | |
| 7 | +import { RAPPORTS_DIR, isDateValide } from "@/lib/rapports"; | |
| 8 | + | |
| 9 | +export const dynamic = "force-dynamic"; | |
| 10 | + | |
| 11 | +export async function GET( | |
| 12 | + _req: Request, | |
| 13 | + { params }: { params: Promise<{ date: string }> }, | |
| 14 | +) { | |
| 15 | + const { date } = await params; | |
| 16 | + if (!isDateValide(date)) { | |
| 17 | + return NextResponse.json({ error: "Date invalide" }, { status: 400 }); | |
| 18 | + } | |
| 19 | + try { | |
| 20 | + const pdf = await fs.readFile(path.join(RAPPORTS_DIR, `${date}.pdf`)); | |
| 21 | + return new NextResponse(new Uint8Array(pdf), { | |
| 22 | + headers: { | |
| 23 | + "Content-Type": "application/pdf", | |
| 24 | + "Content-Disposition": `inline; filename="rapport-groupe-ka-${date}.pdf"`, | |
| 25 | + "Cache-Control": "public, max-age=3600", | |
| 26 | + }, | |
| 27 | + }); | |
| 28 | + } catch { | |
| 29 | + return NextResponse.json({ error: "PDF introuvable" }, { status: 404 }); | |
| 30 | + } | |
| 31 | +} | |
modified
src/app/globals.css
+39 −2
@@ -656,10 +656,13 @@ h4 { | ||
| 656 | 656 | } |
| 657 | 657 | |
| 658 | 658 | @media print { |
| 659 | + /* header/footer scopés au chrome du site (enfants directs de body) : les | |
| 660 | + <header> internes aux pages — ex. manchette d'un rapport — restent | |
| 661 | + imprimés. */ | |
| 659 | 662 | .no-print, |
| 660 | − header, | |
| 663 | + body > header, | |
| 661 | 664 | .ticker, |
| 662 | − footer, | |
| 665 | + body > footer, | |
| 663 | 666 | body::before { |
| 664 | 667 | display: none !important; |
| 665 | 668 | } |
@@ -877,3 +880,37 @@ h4 { | ||
| 877 | 880 | scroll-behavior: auto; |
| 878 | 881 | } |
| 879 | 882 | } |
| 883 | + | |
| 884 | +/* ---------- Impression (PDF des rapports quotidiens) ---------- | |
| 885 | + Le PDF d'un rapport est produit par Chromium headless qui imprime la page | |
| 886 | + /rapports/[date] (scripts/rapport-quotidien.mjs) : on ne garde que le | |
| 887 | + contenu, avec les couleurs exactes du design KA. */ | |
| 888 | +@media print { | |
| 889 | + body > header, | |
| 890 | + body > footer, | |
| 891 | + .ticker, | |
| 892 | + .gk-mobile-overlay, | |
| 893 | + .no-print, | |
| 894 | + [class*="kaa-"] { | |
| 895 | + display: none !important; | |
| 896 | + } | |
| 897 | + body::before { | |
| 898 | + display: none !important; /* grain de film */ | |
| 899 | + } | |
| 900 | + body { | |
| 901 | + -webkit-print-color-adjust: exact; | |
| 902 | + print-color-adjust: exact; | |
| 903 | + } | |
| 904 | + main { | |
| 905 | + padding-top: 24px !important; | |
| 906 | + padding-bottom: 24px !important; | |
| 907 | + } | |
| 908 | + section, | |
| 909 | + .gk-card, | |
| 910 | + blockquote { | |
| 911 | + break-inside: avoid; | |
| 912 | + } | |
| 913 | + @page { | |
| 914 | + margin: 14mm 12mm; | |
| 915 | + } | |
| 916 | +} | |
modified
src/app/layout.tsx
+5 −0
@@ -119,6 +119,7 @@ const NAV_ITEMS = [ | ||
| 119 | 119 | { href: "/#socle", label: "Socle" }, |
| 120 | 120 | { href: "/#bots", label: "Bots" }, |
| 121 | 121 | { href: "/stats", label: "Stats" }, |
| 122 | + { href: "/rapports", label: "Rapports" }, | |
| 122 | 123 | { href: "/telecharger", label: "Télécharger" }, |
| 123 | 124 | { href: "/status", label: "Statut" }, |
| 124 | 125 | { href: "/#contact", label: "Contact" }, |
@@ -399,6 +400,10 @@ export default async function RootLayout({ | ||
| 399 | 400 | Statistiques de l'écosystème |
| 400 | 401 | </a>{" "} |
| 401 | 402 | ·{" "} |
| 403 | + <a href="/rapports" className="underline-offset-4 hover:text-lime hover:underline"> | |
| 404 | + Rapports quotidiens | |
| 405 | + </a>{" "} | |
| 406 | + ·{" "} | |
| 402 | 407 | <a href="/status" className="underline-offset-4 hover:text-lime hover:underline"> |
| 403 | 408 | État des services |
| 404 | 409 | </a>{" "} |
added
src/app/rapports/[date]/page.tsx
+363 −0
@@ -0,0 +1,363 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /rapports/[date] — une édition du rapport quotidien : contenu rédigé par | |
| 3 | +// Claude (édito, faits saillants, analyse, vigilance) + tableau de bord | |
| 4 | +// chiffré rendu côté serveur depuis le snapshot API·Ka figé au moment de la | |
| 5 | +// génération (data/rapports/YYYY-MM-DD.json). Le PDF est l'impression de | |
| 6 | +// CETTE page par Chromium headless (@media print dans globals.css) — une | |
| 7 | +// seule source de rendu, aucun template dupliqué. | |
| 8 | +import type { Metadata } from "next"; | |
| 9 | +import Link from "next/link"; | |
| 10 | +import { notFound } from "next/navigation"; | |
| 11 | +import { | |
| 12 | + getRapport, | |
| 13 | + hasPdf, | |
| 14 | + fmtDateLongue, | |
| 15 | + fmtNombre, | |
| 16 | + METEO_INFO, | |
| 17 | + type ServiceSnap, | |
| 18 | +} from "@/lib/rapports"; | |
| 19 | + | |
| 20 | +export const dynamic = "force-dynamic"; | |
| 21 | + | |
| 22 | +type Props = { params: Promise<{ date: string }> }; | |
| 23 | + | |
| 24 | +export async function generateMetadata({ params }: Props): Promise<Metadata> { | |
| 25 | + const { date } = await params; | |
| 26 | + const rapport = await getRapport(date); | |
| 27 | + if (!rapport) return { title: "Rapport introuvable" }; | |
| 28 | + return { | |
| 29 | + title: `${rapport.contenu.titre} — rapport du ${fmtDateLongue(date)}`, | |
| 30 | + description: | |
| 31 | + rapport.contenu.sous_titre ?? | |
| 32 | + `Rapport quotidien du Groupe KA — ${fmtDateLongue(date)}.`, | |
| 33 | + }; | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** Rend un paragraphe en interprétant uniquement **gras** (aucun autre markdown). */ | |
| 37 | +function Para({ texte, className }: { texte: string; className?: string }) { | |
| 38 | + const morceaux = texte.split(/\*\*(.+?)\*\*/g); | |
| 39 | + return ( | |
| 40 | + <p className={className}> | |
| 41 | + {morceaux.map((m, i) => | |
| 42 | + i % 2 === 1 ? <strong key={i}>{m}</strong> : m, | |
| 43 | + )} | |
| 44 | + </p> | |
| 45 | + ); | |
| 46 | +} | |
| 47 | + | |
| 48 | +function Sparkline({ serie }: { serie: { d: string; n: number }[] }) { | |
| 49 | + if (serie.length < 2) return null; | |
| 50 | + const w = 120; | |
| 51 | + const h = 34; | |
| 52 | + const vals = serie.map((p) => p.n); | |
| 53 | + const min = Math.min(...vals); | |
| 54 | + const max = Math.max(...vals); | |
| 55 | + const span = max - min || 1; | |
| 56 | + const pts = serie | |
| 57 | + .map( | |
| 58 | + (p, i) => | |
| 59 | + `${((i / (serie.length - 1)) * (w - 4) + 2).toFixed(1)},${(h - 4 - ((p.n - min) / span) * (h - 8)).toFixed(1)}`, | |
| 60 | + ) | |
| 61 | + .join(" "); | |
| 62 | + return ( | |
| 63 | + <svg | |
| 64 | + viewBox={`0 0 ${w} ${h}`} | |
| 65 | + className="h-[34px] w-[120px]" | |
| 66 | + aria-hidden="true" | |
| 67 | + > | |
| 68 | + <polyline | |
| 69 | + points={pts} | |
| 70 | + fill="none" | |
| 71 | + stroke="var(--green)" | |
| 72 | + strokeWidth="2" | |
| 73 | + strokeLinejoin="round" | |
| 74 | + strokeLinecap="round" | |
| 75 | + /> | |
| 76 | + </svg> | |
| 77 | + ); | |
| 78 | +} | |
| 79 | + | |
| 80 | +function Delta({ pct }: { pct: number | null }) { | |
| 81 | + if (pct === null) return <span className="klabel">—</span>; | |
| 82 | + const up = pct >= 0; | |
| 83 | + return ( | |
| 84 | + <span | |
| 85 | + className={`gk-mono text-[12px] font-bold ${up ? "text-green" : "text-[var(--danger,#b3423a)]"}`} | |
| 86 | + > | |
| 87 | + {up ? "▲" : "▼"} {Math.abs(pct).toLocaleString("fr-CA")} % | |
| 88 | + </span> | |
| 89 | + ); | |
| 90 | +} | |
| 91 | + | |
| 92 | +function LigneService({ s, maxJour }: { s: ServiceSnap; maxJour: number }) { | |
| 93 | + const largeur = | |
| 94 | + s.jour !== null && maxJour > 0 ? Math.max((s.jour / maxJour) * 100, 1.5) : 0; | |
| 95 | + return ( | |
| 96 | + <li className="grid grid-cols-[110px_1fr_auto] items-center gap-x-3 gap-y-1 border-b border-dashed border-[rgba(20,24,20,0.18)] py-3 last:border-0 sm:grid-cols-[130px_1fr_130px_150px]"> | |
| 97 | + <p className="gk-display text-[14px] font-bold">{s.nom}</p> | |
| 98 | + <div> | |
| 99 | + <div className="h-[14px] overflow-hidden rounded-[4px] border border-ink/40 bg-[rgba(20,24,20,0.05)]"> | |
| 100 | + <div | |
| 101 | + className="h-full bg-lime" | |
| 102 | + style={{ width: `${largeur}%` }} | |
| 103 | + aria-hidden="true" | |
| 104 | + /> | |
| 105 | + </div> | |
| 106 | + </div> | |
| 107 | + <p className="hidden justify-self-end sm:block"> | |
| 108 | + <Sparkline serie={s.serie_14j} /> | |
| 109 | + </p> | |
| 110 | + <p className="col-span-2 text-right sm:col-span-1"> | |
| 111 | + <span className="gk-mono text-[13px] font-bold"> | |
| 112 | + {fmtNombre(s.jour)} | |
| 113 | + </span>{" "} | |
| 114 | + <Delta pct={s.delta_pct} /> | |
| 115 | + </p> | |
| 116 | + </li> | |
| 117 | + ); | |
| 118 | +} | |
| 119 | + | |
| 120 | +export default async function RapportPage({ params }: Props) { | |
| 121 | + const { date } = await params; | |
| 122 | + const rapport = await getRapport(date); | |
| 123 | + if (!rapport) notFound(); | |
| 124 | + const { contenu, snapshot } = rapport; | |
| 125 | + const pdf = await hasPdf(date); | |
| 126 | + const meteo = contenu.meteo ? METEO_INFO[contenu.meteo.etat] : null; | |
| 127 | + const maxJour = Math.max(...snapshot.services.map((s) => s.jour ?? 0), 0); | |
| 128 | + const genereA = new Intl.DateTimeFormat("fr-CA", { | |
| 129 | + hour: "2-digit", | |
| 130 | + minute: "2-digit", | |
| 131 | + timeZone: "America/Toronto", | |
| 132 | + }).format(new Date(rapport.genere_le)); | |
| 133 | + | |
| 134 | + return ( | |
| 135 | + <main className="mx-auto max-w-4xl px-4 py-12 sm:px-6"> | |
| 136 | + {/* fil d'Ariane + actions */} | |
| 137 | + <div className="no-print flex flex-wrap items-center justify-between gap-3"> | |
| 138 | + <Link | |
| 139 | + href="/rapports" | |
| 140 | + className="gk-mono text-[12px] font-bold tracking-[0.08em] uppercase underline-offset-4 hover:underline" | |
| 141 | + > | |
| 142 | + ← Tous les rapports | |
| 143 | + </Link> | |
| 144 | + {pdf && ( | |
| 145 | + <a | |
| 146 | + href={`/api/rapports/${date}/pdf`} | |
| 147 | + className="btn btn-primary !min-h-[38px] !px-4 !text-[13px]" | |
| 148 | + > | |
| 149 | + Télécharger le PDF ↓ | |
| 150 | + </a> | |
| 151 | + )} | |
| 152 | + </div> | |
| 153 | + | |
| 154 | + {/* manchette */} | |
| 155 | + <header className="mt-8"> | |
| 156 | + <p className="kicker">Rapport quotidien · Groupe KA × Claude</p> | |
| 157 | + <p className="gk-mono mt-3 text-[12.5px] font-bold tracking-[0.08em] text-ink-3 uppercase"> | |
| 158 | + {fmtDateLongue(date)} · édition de {genereA} | |
| 159 | + </p> | |
| 160 | + <h1 className="gk-display mt-3 text-[clamp(26px,4.5vw,42px)] leading-[1.04] font-bold tracking-[-0.035em]"> | |
| 161 | + {contenu.titre} | |
| 162 | + </h1> | |
| 163 | + {contenu.sous_titre && ( | |
| 164 | + <p className="mt-4 max-w-2xl text-[15.5px] leading-relaxed text-ink-2"> | |
| 165 | + {contenu.sous_titre} | |
| 166 | + </p> | |
| 167 | + )} | |
| 168 | + {meteo && contenu.meteo && ( | |
| 169 | + <p className="gk-card mt-5 inline-flex flex-wrap items-center gap-2 px-4 py-2.5 text-[13.5px]"> | |
| 170 | + <span aria-hidden="true">{meteo.icone}</span> | |
| 171 | + <strong className="gk-display">{meteo.label}</strong> | |
| 172 | + <span className="text-ink-2">— {contenu.meteo.resume}</span> | |
| 173 | + </p> | |
| 174 | + )} | |
| 175 | + </header> | |
| 176 | + | |
| 177 | + {/* chiffre du jour + repères */} | |
| 178 | + <section className="mt-8 grid gap-4 sm:grid-cols-3"> | |
| 179 | + {contenu.chiffre_du_jour && ( | |
| 180 | + <div className="gk-card bg-lime p-5 sm:col-span-1"> | |
| 181 | + <p className="klabel !text-ink/70">Le chiffre du jour</p> | |
| 182 | + <p className="gk-display mt-1 text-[34px] leading-none font-bold tracking-[-0.03em]"> | |
| 183 | + {contenu.chiffre_du_jour.valeur} | |
| 184 | + </p> | |
| 185 | + <p className="gk-display mt-2 text-[13.5px] font-bold"> | |
| 186 | + {contenu.chiffre_du_jour.label} | |
| 187 | + </p> | |
| 188 | + {contenu.chiffre_du_jour.contexte && ( | |
| 189 | + <p className="mt-2 text-[12.5px] leading-snug text-ink/80"> | |
| 190 | + {contenu.chiffre_du_jour.contexte} | |
| 191 | + </p> | |
| 192 | + )} | |
| 193 | + </div> | |
| 194 | + )} | |
| 195 | + <div className="grid grid-cols-2 gap-4 sm:col-span-2"> | |
| 196 | + {[ | |
| 197 | + { | |
| 198 | + label: "Enregistrements collectés", | |
| 199 | + valeur: fmtNombre(snapshot.totaux.records_jour), | |
| 200 | + note: `collecte du ${snapshot.date_donnees}`, | |
| 201 | + }, | |
| 202 | + { | |
| 203 | + label: "Total en base", | |
| 204 | + valeur: fmtNombre(snapshot.totaux.records_total), | |
| 205 | + note: "9 plateformes de données", | |
| 206 | + }, | |
| 207 | + { | |
| 208 | + label: "Connecteurs en santé", | |
| 209 | + valeur: snapshot.connecteurs | |
| 210 | + ? `${snapshot.connecteurs.ok}` | |
| 211 | + : "—", | |
| 212 | + note: snapshot.connecteurs | |
| 213 | + ? `${snapshot.connecteurs.degraded} dégradé(s) · ${snapshot.connecteurs.broken} brisé(s)` | |
| 214 | + : "santé indisponible", | |
| 215 | + }, | |
| 216 | + { | |
| 217 | + label: "API·Ka (7 jours)", | |
| 218 | + valeur: | |
| 219 | + snapshot.api_7j.appels !== null | |
| 220 | + ? `${fmtNombre(snapshot.api_7j.appels)} appels` | |
| 221 | + : "—", | |
| 222 | + note: | |
| 223 | + snapshot.api_7j.latence_ms !== null | |
| 224 | + ? `${Math.round(snapshot.api_7j.latence_ms)} ms de latence moyenne` | |
| 225 | + : "", | |
| 226 | + }, | |
| 227 | + ].map((k) => ( | |
| 228 | + <div key={k.label} className="gk-card p-4"> | |
| 229 | + <p className="klabel">{k.label}</p> | |
| 230 | + <p className="gk-display mt-1 text-[22px] leading-none font-bold"> | |
| 231 | + {k.valeur} | |
| 232 | + </p> | |
| 233 | + {k.note && ( | |
| 234 | + <p className="gk-mono mt-2 text-[10.5px] text-ink-3"> | |
| 235 | + {k.note} | |
| 236 | + </p> | |
| 237 | + )} | |
| 238 | + </div> | |
| 239 | + ))} | |
| 240 | + </div> | |
| 241 | + </section> | |
| 242 | + | |
| 243 | + {/* éditorial */} | |
| 244 | + <section className="mt-10"> | |
| 245 | + <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase"> | |
| 246 | + L'éditorial de la nuit | |
| 247 | + </h2> | |
| 248 | + <div className="mt-4 space-y-4 border-l-2 border-lime pl-5"> | |
| 249 | + {contenu.edito.map((p, i) => ( | |
| 250 | + <Para | |
| 251 | + key={i} | |
| 252 | + texte={p} | |
| 253 | + className="max-w-3xl text-[15px] leading-[1.75] text-ink-2" | |
| 254 | + /> | |
| 255 | + ))} | |
| 256 | + </div> | |
| 257 | + </section> | |
| 258 | + | |
| 259 | + {/* la nuit en chiffres — barres + sparklines par plateforme */} | |
| 260 | + <section className="mt-10"> | |
| 261 | + <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase"> | |
| 262 | + La nuit en chiffres | |
| 263 | + </h2> | |
| 264 | + <p className="gk-mono mt-1 text-[11px] text-ink-3"> | |
| 265 | + Enregistrements par plateforme (collecte du {snapshot.date_donnees}) | |
| 266 | + · tendance 14 jours · variation vs la veille | |
| 267 | + </p> | |
| 268 | + <ul className="gk-card mt-4 px-5 py-2"> | |
| 269 | + {[...snapshot.services] | |
| 270 | + .sort((a, b) => (b.jour ?? 0) - (a.jour ?? 0)) | |
| 271 | + .map((s) => ( | |
| 272 | + <LigneService key={s.service} s={s} maxJour={maxJour} /> | |
| 273 | + ))} | |
| 274 | + </ul> | |
| 275 | + </section> | |
| 276 | + | |
| 277 | + {/* faits saillants */} | |
| 278 | + <section className="mt-10"> | |
| 279 | + <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase"> | |
| 280 | + Faits saillants | |
| 281 | + </h2> | |
| 282 | + <ul className="mt-4 grid gap-4 sm:grid-cols-2"> | |
| 283 | + {contenu.faits_saillants.map((f) => ( | |
| 284 | + <li key={f.plateforme} className="gk-card p-4"> | |
| 285 | + <p className="gk-mono text-[11px] font-bold tracking-[0.1em] text-green uppercase"> | |
| 286 | + {f.plateforme} | |
| 287 | + </p> | |
| 288 | + <Para | |
| 289 | + texte={f.texte} | |
| 290 | + className="mt-2 text-[13.5px] leading-relaxed text-ink-2" | |
| 291 | + /> | |
| 292 | + </li> | |
| 293 | + ))} | |
| 294 | + </ul> | |
| 295 | + </section> | |
| 296 | + | |
| 297 | + {/* analyse */} | |
| 298 | + {contenu.analyse.map((sec) => ( | |
| 299 | + <section key={sec.titre} className="mt-10"> | |
| 300 | + <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase"> | |
| 301 | + {sec.titre} | |
| 302 | + </h2> | |
| 303 | + <div className="mt-4 space-y-4"> | |
| 304 | + {sec.contenu.map((p, i) => ( | |
| 305 | + <Para | |
| 306 | + key={i} | |
| 307 | + texte={p} | |
| 308 | + className="max-w-3xl text-[14.5px] leading-[1.75] text-ink-2" | |
| 309 | + /> | |
| 310 | + ))} | |
| 311 | + </div> | |
| 312 | + </section> | |
| 313 | + ))} | |
| 314 | + | |
| 315 | + {/* vigilance */} | |
| 316 | + {(contenu.vigilance?.length ?? 0) > 0 && ( | |
| 317 | + <section className="mt-10"> | |
| 318 | + <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase"> | |
| 319 | + À surveiller | |
| 320 | + </h2> | |
| 321 | + <ul className="gk-card mt-4 space-y-3 border-[var(--amber,#e8a33d)] p-5"> | |
| 322 | + {contenu.vigilance!.map((v, i) => ( | |
| 323 | + <li key={i} className="flex gap-3 text-[13.5px] text-ink-2"> | |
| 324 | + <span aria-hidden="true">⚠️</span> | |
| 325 | + <Para texte={v} /> | |
| 326 | + </li> | |
| 327 | + ))} | |
| 328 | + </ul> | |
| 329 | + </section> | |
| 330 | + )} | |
| 331 | + | |
| 332 | + {/* mot de la fin */} | |
| 333 | + {contenu.mot_de_la_fin && ( | |
| 334 | + <blockquote className="gk-display mt-12 border-l-4 border-ink pl-5 text-[19px] leading-snug font-bold tracking-[-0.02em]"> | |
| 335 | + « {contenu.mot_de_la_fin} » | |
| 336 | + </blockquote> | |
| 337 | + )} | |
| 338 | + | |
| 339 | + {/* méthode */} | |
| 340 | + <p className="gk-mono mt-12 text-[11px] leading-relaxed text-ink-3"> | |
| 341 | + Méthode : rapport rédigé automatiquement le {fmtDateLongue(date)} à{" "} | |
| 342 | + {genereA} par le modèle {rapport.modele} (Anthropic) à partir des | |
| 343 | + données mesurées par API·Ka — collectes quotidiennes de 02:00 sur les 9 | |
| 344 | + plateformes, historique des runs, santé des connecteurs et | |
| 345 | + statistiques d'appels. Le tableau « la nuit en chiffres » est rendu | |
| 346 | + directement depuis les données, sans passer par le modèle. Aucun | |
| 347 | + chiffre inventé ni extrapolé. Le PDF est l'impression fidèle de | |
| 348 | + cette page. | |
| 349 | + </p> | |
| 350 | + | |
| 351 | + <div className="no-print mt-8 flex flex-wrap gap-3"> | |
| 352 | + <Link href="/rapports" className="btn btn-ghost"> | |
| 353 | + ← Tous les rapports | |
| 354 | + </Link> | |
| 355 | + {pdf && ( | |
| 356 | + <a href={`/api/rapports/${date}/pdf`} className="btn btn-primary"> | |
| 357 | + Télécharger le PDF ↓ | |
| 358 | + </a> | |
| 359 | + )} | |
| 360 | + </div> | |
| 361 | + </main> | |
| 362 | + ); | |
| 363 | +} | |
added
src/app/rapports/page.tsx
+143 −0
@@ -0,0 +1,143 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /rapports — archives des rapports quotidiens du Groupe KA : chaque matin à | |
| 3 | +// 06:00, Claude (Anthropic) lit les données de la collecte API·Ka de 02:00 et | |
| 4 | +// rédige le rapport de la nuit (HTML + PDF). Cette page liste les éditions ; | |
| 5 | +// le rendu vient de data/rapports/ (src/lib/rapports.ts). Rien d'inventé : | |
| 6 | +// le modèle commente uniquement les chiffres mesurés par API·Ka. | |
| 7 | +import type { Metadata } from "next"; | |
| 8 | +import Link from "next/link"; | |
| 9 | +import { | |
| 10 | + listRapports, | |
| 11 | + fmtDateLongue, | |
| 12 | + fmtNombre, | |
| 13 | + METEO_INFO, | |
| 14 | +} from "@/lib/rapports"; | |
| 15 | + | |
| 16 | +export const dynamic = "force-dynamic"; | |
| 17 | + | |
| 18 | +export const metadata: Metadata = { | |
| 19 | + title: "Rapports quotidiens", | |
| 20 | + description: | |
| 21 | + "Chaque matin, l'IA du Groupe KA lit la collecte de la nuit sur les 9 plateformes de données ·Ka et rédige le rapport du jour — HTML et PDF, archivés ici.", | |
| 22 | +}; | |
| 23 | + | |
| 24 | +export default async function RapportsPage() { | |
| 25 | + const rapports = await listRapports(); | |
| 26 | + const [dernier, ...archives] = rapports; | |
| 27 | + | |
| 28 | + return ( | |
| 29 | + <main className="mx-auto max-w-6xl px-4 py-12 sm:px-6"> | |
| 30 | + <p className="kicker">Le journal de l'écosystème · un rapport par jour</p> | |
| 31 | + <h1 className="gk-display mt-3 text-[clamp(28px,4.5vw,44px)] leading-[1.02] font-bold tracking-[-0.035em] uppercase"> | |
| 32 | + Rapports <span className="hl">quotidiens</span> | |
| 33 | + </h1> | |
| 34 | + <p className="mt-4 max-w-2xl text-[14.5px] text-ink-2"> | |
| 35 | + Chaque nuit à 02:00, API·Ka collecte les 9 plateformes de données de | |
| 36 | + l'écosystème. Chaque matin à 06:00, notre intelligence | |
| 37 | + artificielle lit ces chiffres et rédige le rapport de la nuit — | |
| 38 | + tendances, records, points de vigilance. Rédigé par une IA, oui ; mais | |
| 39 | + chaque chiffre vient d'API·Ka. Rien d'inventé. | |
| 40 | + </p> | |
| 41 | + | |
| 42 | + {rapports.length === 0 && ( | |
| 43 | + <div className="gk-card mt-10 p-8"> | |
| 44 | + <p className="gk-display text-[20px] font-bold"> | |
| 45 | + Le premier rapport s'écrit cette nuit. | |
| 46 | + </p> | |
| 47 | + <p className="mt-2 text-[14px] text-ink-2"> | |
| 48 | + Repassez demain matin après 06:00 — l'édition inaugurale sera | |
| 49 | + ici. | |
| 50 | + </p> | |
| 51 | + </div> | |
| 52 | + )} | |
| 53 | + | |
| 54 | + {dernier && ( | |
| 55 | + <Link | |
| 56 | + href={`/rapports/${dernier.date}`} | |
| 57 | + className="gk-card gk-card-hover mt-10 block p-6 sm:p-8" | |
| 58 | + > | |
| 59 | + <div className="flex flex-wrap items-center gap-3"> | |
| 60 | + <span className="gk-mono rounded-full border-[1.5px] border-ink bg-lime px-3 py-1 text-[11px] font-bold tracking-[0.1em] uppercase"> | |
| 61 | + Dernière édition | |
| 62 | + </span> | |
| 63 | + <span className="gk-mono text-[12px] font-bold text-ink-3 uppercase"> | |
| 64 | + {fmtDateLongue(dernier.date)} | |
| 65 | + </span> | |
| 66 | + {dernier.meteo && ( | |
| 67 | + <span className="gk-mono text-[12px] text-ink-2"> | |
| 68 | + {METEO_INFO[dernier.meteo.etat]?.icone}{" "} | |
| 69 | + {METEO_INFO[dernier.meteo.etat]?.label} | |
| 70 | + </span> | |
| 71 | + )} | |
| 72 | + </div> | |
| 73 | + <h2 className="gk-display mt-4 text-[clamp(22px,3.4vw,34px)] leading-[1.05] font-bold tracking-[-0.03em]"> | |
| 74 | + {dernier.titre} | |
| 75 | + </h2> | |
| 76 | + {dernier.sous_titre && ( | |
| 77 | + <p className="mt-3 max-w-2xl text-[14.5px] text-ink-2"> | |
| 78 | + {dernier.sous_titre} | |
| 79 | + </p> | |
| 80 | + )} | |
| 81 | + <div className="mt-5 flex flex-wrap items-center gap-x-8 gap-y-2"> | |
| 82 | + {dernier.chiffre && ( | |
| 83 | + <p> | |
| 84 | + <span className="gk-display text-[26px] font-bold"> | |
| 85 | + {dernier.chiffre.valeur} | |
| 86 | + </span>{" "} | |
| 87 | + <span className="klabel">{dernier.chiffre.label}</span> | |
| 88 | + </p> | |
| 89 | + )} | |
| 90 | + <p> | |
| 91 | + <span className="gk-display text-[26px] font-bold"> | |
| 92 | + {fmtNombre(dernier.records_jour)} | |
| 93 | + </span>{" "} | |
| 94 | + <span className="klabel">enregistrements collectés</span> | |
| 95 | + </p> | |
| 96 | + <span className="gk-display ml-auto text-[14px] font-bold underline underline-offset-4"> | |
| 97 | + Lire le rapport → | |
| 98 | + </span> | |
| 99 | + </div> | |
| 100 | + </Link> | |
| 101 | + )} | |
| 102 | + | |
| 103 | + {archives.length > 0 && ( | |
| 104 | + <> | |
| 105 | + <h2 className="gk-display mt-12 text-[20px] font-bold tracking-[-0.02em] uppercase"> | |
| 106 | + Éditions précédentes | |
| 107 | + </h2> | |
| 108 | + <ul className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> | |
| 109 | + {archives.map((r) => ( | |
| 110 | + <li key={r.date}> | |
| 111 | + <Link | |
| 112 | + href={`/rapports/${r.date}`} | |
| 113 | + className="gk-card gk-card-hover flex h-full flex-col p-5" | |
| 114 | + > | |
| 115 | + <p className="gk-mono flex items-center justify-between text-[11px] font-bold tracking-[0.08em] text-ink-3 uppercase"> | |
| 116 | + {fmtDateLongue(r.date)} | |
| 117 | + {r.meteo && <span>{METEO_INFO[r.meteo.etat]?.icone}</span>} | |
| 118 | + </p> | |
| 119 | + <p className="gk-display mt-2 text-[17px] leading-snug font-bold"> | |
| 120 | + {r.titre} | |
| 121 | + </p> | |
| 122 | + <p className="mt-auto pt-4 text-[12.5px] text-ink-2"> | |
| 123 | + {fmtNombre(r.records_jour)} enregistrements ·{" "} | |
| 124 | + <span className="underline underline-offset-4">Lire →</span> | |
| 125 | + </p> | |
| 126 | + </Link> | |
| 127 | + </li> | |
| 128 | + ))} | |
| 129 | + </ul> | |
| 130 | + </> | |
| 131 | + )} | |
| 132 | + | |
| 133 | + <p className="gk-mono mt-12 text-[11px] leading-relaxed text-ink-3"> | |
| 134 | + Méthode : le rapport est rédigé automatiquement chaque matin par un | |
| 135 | + modèle Claude (Anthropic) à partir des seules données mesurées par | |
| 136 | + API·Ka — collectes de 02:00, historique des runs, santé des connecteurs | |
| 137 | + et statistiques d'appels (api-ka.com). Le PDF est le rendu imprimé | |
| 138 | + de la page HTML. Aucun chiffre n'est inventé ni extrapolé ; une | |
| 139 | + donnée absente est signalée comme telle. | |
| 140 | + </p> | |
| 141 | + </main> | |
| 142 | + ); | |
| 143 | +} | |
modified
src/app/sitemap.ts
+1 −0
@@ -15,6 +15,7 @@ export default function sitemap(): MetadataRoute.Sitemap { | ||
| 15 | 15 | }[] = [ |
| 16 | 16 | { path: "/", changeFrequency: "daily", priority: 1 }, |
| 17 | 17 | { path: "/stats", changeFrequency: "daily", priority: 0.8 }, |
| 18 | + { path: "/rapports", changeFrequency: "daily", priority: 0.7 }, | |
| 18 | 19 | { path: "/status", changeFrequency: "daily", priority: 0.6 }, |
| 19 | 20 | { path: "/telecharger", changeFrequency: "weekly", priority: 0.7 }, |
| 20 | 21 | { path: "/avis", changeFrequency: "monthly", priority: 0.5 }, |
added
src/lib/rapports.ts
+155 −0
@@ -0,0 +1,155 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Lecture des rapports quotidiens générés par scripts/rapport-quotidien.mjs | |
| 3 | +// (data/rapports/YYYY-MM-DD.json + .pdf). Aucune génération ici : le job PM2 | |
| 4 | +// groupe-ka-rapport-quotidien écrit, le site lit. | |
| 5 | +import { promises as fs } from "node:fs"; | |
| 6 | +import path from "node:path"; | |
| 7 | + | |
| 8 | +export const RAPPORTS_DIR = path.join(process.cwd(), "data", "rapports"); | |
| 9 | +const RE_DATE = /^\d{4}-\d{2}-\d{2}$/; | |
| 10 | + | |
| 11 | +export type MeteoEtat = "beau" | "variable" | "orage"; | |
| 12 | + | |
| 13 | +export type ServiceSnap = { | |
| 14 | + service: string; | |
| 15 | + nom: string; | |
| 16 | + description: string; | |
| 17 | + total: number | null; | |
| 18 | + date_jour: string | null; | |
| 19 | + jour: number | null; | |
| 20 | + veille: number | null; | |
| 21 | + delta_pct: number | null; | |
| 22 | + statut: string; | |
| 23 | + serie_14j: { d: string; n: number }[]; | |
| 24 | +}; | |
| 25 | + | |
| 26 | +export type Snapshot = { | |
| 27 | + date_donnees: string; | |
| 28 | + services: ServiceSnap[]; | |
| 29 | + totaux: { records_jour: number; records_total: number }; | |
| 30 | + connecteurs: { | |
| 31 | + ok: number; | |
| 32 | + degraded: number; | |
| 33 | + broken: number; | |
| 34 | + stale: number; | |
| 35 | + } | null; | |
| 36 | + api_7j: { | |
| 37 | + appels: number | null; | |
| 38 | + latence_ms: number | null; | |
| 39 | + erreurs_pct: number | null; | |
| 40 | + }; | |
| 41 | + runs_du_jour: { | |
| 42 | + service: string; | |
| 43 | + statut: string; | |
| 44 | + records: number | null; | |
| 45 | + duree_s: number | null; | |
| 46 | + }[]; | |
| 47 | +}; | |
| 48 | + | |
| 49 | +export type RapportContenu = { | |
| 50 | + titre: string; | |
| 51 | + sous_titre?: string; | |
| 52 | + meteo?: { etat: MeteoEtat; resume: string }; | |
| 53 | + chiffre_du_jour?: { valeur: string; label: string; contexte?: string }; | |
| 54 | + edito: string[]; | |
| 55 | + faits_saillants: { plateforme: string; texte: string }[]; | |
| 56 | + analyse: { titre: string; contenu: string[] }[]; | |
| 57 | + vigilance?: string[]; | |
| 58 | + mot_de_la_fin?: string; | |
| 59 | +}; | |
| 60 | + | |
| 61 | +export type Rapport = { | |
| 62 | + date: string; | |
| 63 | + genere_le: string; | |
| 64 | + modele: string; | |
| 65 | + contenu: RapportContenu; | |
| 66 | + snapshot: Snapshot; | |
| 67 | +}; | |
| 68 | + | |
| 69 | +export type RapportResume = { | |
| 70 | + date: string; | |
| 71 | + titre: string; | |
| 72 | + sous_titre?: string; | |
| 73 | + meteo?: { etat: MeteoEtat; resume: string }; | |
| 74 | + chiffre?: { valeur: string; label: string }; | |
| 75 | + records_jour: number; | |
| 76 | +}; | |
| 77 | + | |
| 78 | +export function isDateValide(date: string): boolean { | |
| 79 | + return RE_DATE.test(date); | |
| 80 | +} | |
| 81 | + | |
| 82 | +export async function getRapport(date: string): Promise<Rapport | null> { | |
| 83 | + if (!isDateValide(date)) return null; | |
| 84 | + try { | |
| 85 | + const brut = await fs.readFile( | |
| 86 | + path.join(RAPPORTS_DIR, `${date}.json`), | |
| 87 | + "utf8", | |
| 88 | + ); | |
| 89 | + return JSON.parse(brut) as Rapport; | |
| 90 | + } catch { | |
| 91 | + return null; | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +export async function hasPdf(date: string): Promise<boolean> { | |
| 96 | + if (!isDateValide(date)) return false; | |
| 97 | + try { | |
| 98 | + await fs.access(path.join(RAPPORTS_DIR, `${date}.pdf`)); | |
| 99 | + return true; | |
| 100 | + } catch { | |
| 101 | + return false; | |
| 102 | + } | |
| 103 | +} | |
| 104 | + | |
| 105 | +/** Liste des rapports disponibles, du plus récent au plus ancien. */ | |
| 106 | +export async function listRapports(): Promise<RapportResume[]> { | |
| 107 | + let fichiers: string[] = []; | |
| 108 | + try { | |
| 109 | + fichiers = await fs.readdir(RAPPORTS_DIR); | |
| 110 | + } catch { | |
| 111 | + return []; | |
| 112 | + } | |
| 113 | + const dates = fichiers | |
| 114 | + .filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)) | |
| 115 | + .map((f) => f.slice(0, 10)) | |
| 116 | + .sort() | |
| 117 | + .reverse(); | |
| 118 | + const resumes = await Promise.all( | |
| 119 | + dates.map(async (date): Promise<RapportResume | null> => { | |
| 120 | + const r = await getRapport(date); | |
| 121 | + if (!r) return null; | |
| 122 | + return { | |
| 123 | + date, | |
| 124 | + titre: r.contenu.titre, | |
| 125 | + sous_titre: r.contenu.sous_titre, | |
| 126 | + meteo: r.contenu.meteo, | |
| 127 | + chiffre: r.contenu.chiffre_du_jour, | |
| 128 | + records_jour: r.snapshot?.totaux?.records_jour ?? 0, | |
| 129 | + }; | |
| 130 | + }), | |
| 131 | + ); | |
| 132 | + return resumes.filter((r): r is RapportResume => r !== null); | |
| 133 | +} | |
| 134 | + | |
| 135 | +export function fmtDateLongue(date: string): string { | |
| 136 | + const [y, m, d] = date.split("-").map(Number); | |
| 137 | + return new Intl.DateTimeFormat("fr-CA", { | |
| 138 | + weekday: "long", | |
| 139 | + day: "numeric", | |
| 140 | + month: "long", | |
| 141 | + year: "numeric", | |
| 142 | + timeZone: "America/Toronto", | |
| 143 | + }).format(new Date(Date.UTC(y, m - 1, d, 12))); | |
| 144 | +} | |
| 145 | + | |
| 146 | +export function fmtNombre(n: number | null): string { | |
| 147 | + if (n === null || n === undefined) return "—"; | |
| 148 | + return new Intl.NumberFormat("fr-CA").format(n); | |
| 149 | +} | |
| 150 | + | |
| 151 | +export const METEO_INFO: Record<MeteoEtat, { icone: string; label: string }> = { | |
| 152 | + beau: { icone: "☀️", label: "Beau fixe" }, | |
| 153 | + variable: { icone: "⛅", label: "Variable" }, | |
| 154 | + orage: { icone: "⛈️", label: "Orageux" }, | |
| 155 | +}; | |
| 156 | ||