SPB Git forge

spb/groupe-ka

Public

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

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent b744e69

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&apos;é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&apos;é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&apos;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&apos;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&apos;é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&apos;é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&apos;API·Ka. Rien d&apos;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&apos;écrit cette nuit.
46 + </p>
47 + <p className="mt-2 text-[14px] text-ink-2">
48 + Repassez demain matin après 06:00 — l&apos;é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&apos;appels (api-ka.com). Le PDF est le rendu imprimé
138 + de la page HTML. Aucun chiffre n&apos;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