Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.
TypeScript 70.4%
HTML 18.4%
JavaScript 4%
Python 3.8%
CSS 3.4%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Statistiques de l'écosystème — fetch serveur parallèle des tableaux de3// bord /api/stats/dashboard des plateformes de données (contrat commun4// src/ka/stats/SPEC.md v2), cache ISR 10 min, délai 8 s, null si échec.5// buildEcoPayload() consolide le tout en un objet sérialisable pour la page6// /stats maître : KPI écosystème (+ sparkline sommée sur les dates communes),7// classement, croissance comparée (indice 100), volume empilé par plateforme,8// calendrier/heatmap horaire agrégés, résumé par plateforme, table détaillée9// et records. AUCUNE donnée inventée : une plateforme injoignable reste null10// (bloc « indisponible »), un dashboard v1 est rendu tel quel (dégradé11// propre) ; les seuls calculs sont des sommes/alignements/indices sur les12// chiffres publiés par les plateformes elles-mêmes.1314import eco from "@/ka/ecosystem.json";15import type {16 Kpi as ChartKpi,17 Point as SeriePoint,18 Serie as ChartSerie,19 MultiSerie as ChartMultiSerie,20 StackedSerie as ChartStacked,21 BreakItem,22 Gauge as ChartGauge,23 TableSpec as ChartTable,24 RecordFact,25 HourCell,26} from "@/ka/stats/kacharts";2728/* ---------- types du contrat SPEC v2 (tous champs optionnels) ---------- */2930export type Kpi = {31 id?: string;32 label: string;33 value: number | string;34 unit?: string;35 delta_pct?: number | null;36 direction?: "up" | "down";37 spark?: SeriePoint[];38};3940export type KaRecord = RecordFact;4142export type BreakdownItem = {43 label: string;44 value: number;45 delta_pct?: number | null;46};4748export type Breakdown = {49 id?: string;50 title?: string;51 label?: string;52 kind?: string;53 items?: BreakdownItem[];54};5556export type DashSerie = {57 id?: string;58 title?: string;59 unit?: string;60 kind?: string;61 points?: SeriePoint[];62 compare?: SeriePoint[];63};6465export type Dashboard = {66 updated?: string;67 period?: { from?: string; to?: string; label?: string };68 kpis?: Kpi[];69 gauges?: { id?: string; label?: string; value?: number; max?: number; unit?: string }[];70 series?: DashSerie[];71 multiseries?: unknown[];72 stacked?: unknown[];73 breakdowns?: Breakdown[];74 distributions?: unknown[];75 geo?: Breakdown | null;76 heatmap?: { title?: string; cells?: { date?: string; value?: number }[] } | null;77 hourly?: { title?: string; cells?: { dow?: number; hour?: number; value?: number }[] } | null;78 tables?: unknown[];79 records?: KaRecord[];80};8182export type Site = {83 id: string;84 wordmark: string;85 domain: string;86 accent: string;87 accentSoft: string;88 tagline: string;89};9091export type PlatformStats = { site: Site; dash: Dashboard | null };9293export const PERIODS = [94 ["auj", "Aujourd'hui"],95 ["7j", "7 jours"],96 ["30j", "30 jours"],97 ["3m", "3 mois"],98 ["6m", "6 mois"],99 ["12m", "12 mois"],100 ["annee", "Année en cours"],101 ["tout", "Tout"],102] as const;103104export type PeriodKey = (typeof PERIODS)[number][0];105106export const isPeriod = (p: string | undefined): p is PeriodKey =>107 PERIODS.some(([k]) => k === p);108109/* ---------- lecture des dashboards ---------- */110111// Identifiants de KPI « nouveautés de la période » connus des plateformes.112const NEW_KPI_IDS = [113 "nouvelles",114 "nouveaux",115 "nouveautes",116 "new",117 "ajouts",118 "added",119 "indexees",120 "releves",121 "records",122];123124const isNum = (v: unknown): v is number =>125 typeof v === "number" && Number.isFinite(v);126127/** Premier KPI numérique = volume principal (convention du contrat SPEC). */128export function mainKpi(dash: Dashboard | null): Kpi | null {129 for (const k of dash?.kpis ?? []) if (isNum(k.value)) return k;130 return null;131}132133/** KPI « nouveautés de la période », si la plateforme le mesure. */134export function newKpi(dash: Dashboard | null): Kpi | null {135 const kpis = dash?.kpis ?? [];136 for (const id of NEW_KPI_IDS) {137 const k = kpis.find((k) => k.id === id && isNum(k.value));138 if (k) return k;139 }140 return null;141}142143/** Les 3 premiers KPI numériques d'une plateforme (pour son bloc résumé). */144export function topKpis(dash: Dashboard | null, n = 3): Kpi[] {145 return (dash?.kpis ?? []).filter((k) => isNum(k.value)).slice(0, n);146}147148/** Items valides d'un bloc de répartition (breakdowns[] / geo du SPEC). */149export function breakdownItems(150 b: Breakdown | null | undefined,151 n = 6,152): BreakdownItem[] {153 if (!b || !Array.isArray(b.items)) return [];154 return b.items155 .filter(156 (it): it is BreakdownItem =>157 !!it &&158 typeof it === "object" &&159 typeof it.label === "string" &&160 isNum(it.value),161 )162 .slice(0, n);163}164165/** Première répartition exploitable d'un tableau de bord SPEC. */166export function firstBreakdown(dash: Dashboard | null): Breakdown | null {167 for (const b of dash?.breakdowns ?? [])168 if (breakdownItems(b, 1).length > 0) return b;169 return null;170}171172/** Bloc géographique du SPEC (top villes / régions), s'il est publié. */173export function geoBreakdown(dash: Dashboard | null): Breakdown | null {174 const g = dash?.geo;175 return g && breakdownItems(g, 1).length > 0 ? g : null;176}177178const numPts = (pts?: SeriePoint[]): SeriePoint[] =>179 (pts ?? []).filter(180 (p): p is SeriePoint => !!p && typeof p.t === "string" && isNum(p.v),181 );182183const trimPts = (pts: SeriePoint[], n: number): SeriePoint[] =>184 pts.length > n ? pts.slice(pts.length - n) : pts;185186/** Première série temporelle exploitable (convention : la 1re = volume). */187export function mainSeries(dash: Dashboard | null): DashSerie | null {188 for (const s of dash?.series ?? [])189 if (numPts(s.points).length >= 2) return s;190 return null;191}192193/** Tendance du volume principal : spark v2 du KPI, sinon 1re série publiée. */194export function sparkOf(dash: Dashboard | null): SeriePoint[] {195 const ks = numPts(mainKpi(dash)?.spark);196 if (ks.length >= 2) return ks;197 return numPts(mainSeries(dash)?.points);198}199200/** Croissance du volume principal : delta_pct publié (v2), sinon dérivée de201 * la série publiée (dernier vs premier point — marquée `derived`). */202export function growthOf(203 dash: Dashboard | null,204): { pct: number; derived: boolean } | null {205 const mk = mainKpi(dash);206 if (mk && isNum(mk.delta_pct)) return { pct: mk.delta_pct, derived: false };207 const sp = sparkOf(dash);208 if (sp.length >= 2 && sp[0].v > 0) {209 const pct =210 Math.round(((sp[sp.length - 1].v - sp[0].v) / sp[0].v) * 1000) / 10;211 return { pct, derived: true };212 }213 return null;214}215216/** Dashboard v2 : publie au moins un des enrichissements du SPEC v2. */217export function isV2(dash: Dashboard | null): boolean {218 if (!dash) return false;219 return Boolean(220 dash.gauges?.length ||221 dash.multiseries?.length ||222 dash.stacked?.length ||223 dash.distributions?.length ||224 dash.hourly?.cells?.length ||225 (dash.kpis ?? []).some(226 (k) => (k.spark?.length ?? 0) > 1 || isNum(k.delta_pct),227 ),228 );229}230231export const dataSites: Site[] = (eco.sites as Site[]).filter(232 (s) => s.id !== "groupe-ka",233);234235async function fetchDashboard(236 site: Site,237 period: PeriodKey,238): Promise<Dashboard | null> {239 try {240 const res = await fetch(241 `https://${site.domain}/api/stats/dashboard?period=${period}`,242 {243 next: { revalidate: 600 },244 signal: AbortSignal.timeout(8000),245 headers: { accept: "application/json" },246 },247 );248 if (!res.ok) return null;249 let json: unknown = await res.json();250 // certaines plateformes enveloppent la réponse ({success, data, meta})251 if (252 json &&253 typeof json === "object" &&254 !("kpis" in json) &&255 typeof (json as { data?: unknown }).data === "object"256 ) {257 json = (json as { data: unknown }).data;258 }259 const dash = json as Dashboard;260 if (!dash || !Array.isArray(dash.kpis) || dash.kpis.length === 0)261 return null;262 return dash;263 } catch {264 return null;265 }266}267268/** Dashboards des plateformes de données, en parallèle (ordre d'ecosystem.json). */269export async function getEcosystemStats(270 period: PeriodKey,271): Promise<PlatformStats[]> {272 return Promise.all(273 dataSites.map(async (site) => ({274 site,275 dash: await fetchDashboard(site, period),276 })),277 );278}279280/* ---------- consolidation v2 → payload sérialisable pour la page ---------- */281282const fmtInt = (n: number): string => n.toLocaleString("fr-CA");283284/** Format compact honnête pour les très grands nombres (G / M). */285export function fmtCompact(v: number, unit?: string): string {286 let txt: string;287 if (Math.abs(v) >= 1_000_000_000) {288 txt = `${(v / 1_000_000_000).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} G`;289 } else if (Math.abs(v) >= 100_000_000) {290 txt = `${(v / 1_000_000).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M`;291 } else if (Number.isInteger(v)) {292 txt = fmtInt(v);293 } else {294 txt = v.toLocaleString("fr-CA", { maximumFractionDigits: 2 });295 }296 return unit ? `${txt} ${unit}` : txt;297}298299const fmtPctSigned = (pct: number): string =>300 `${pct >= 0 ? "+" : ""}${pct.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`;301302function fmtAgo(ms: number): string {303 const min = Math.round(ms / 60_000);304 if (min < 1) return "< 1 min";305 if (min < 90) return `${min} min`;306 const h = ms / 3_600_000;307 if (h < 48)308 return `${h.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} h`;309 return `${Math.round(h / 24)} j`;310}311312const fmtDateTime = (iso: string): string =>313 new Date(iso).toLocaleString("fr-CA", {314 timeZone: "America/Toronto",315 dateStyle: "medium",316 timeStyle: "short",317 });318319/** Dates communes (intersection triée) à plusieurs séries alignables. */320function commonDates(seriesList: SeriePoint[][]): string[] {321 if (!seriesList.length) return [];322 let set = new Set(seriesList[0].map((p) => p.t));323 for (const s of seriesList.slice(1)) {324 const other = new Set(s.map((p) => p.t));325 set = new Set([...set].filter((d) => other.has(d)));326 }327 return [...set].sort();328}329330const byDateMap = (pts: SeriePoint[]): Map<string, number> =>331 new Map(pts.map((p) => [p.t, p.v]));332333/* Seuls les points datés au jour (ISO) sont alignables entre plateformes —334 certaines publient des séries annuelles (ex. vrai-prix) qui ne se somment335 pas avec des séries quotidiennes. */336const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;337const dailyPts = (pts: SeriePoint[]): SeriePoint[] =>338 pts.filter((p) => ISO_DAY.test(p.t));339340export type SiteLite = Pick<Site, "id" | "wordmark" | "domain" | "accent">;341342export type PlatformSummary = {343 site: SiteLite;344 ok: boolean;345 v2: boolean;346 kpis: ChartKpi[]; // ≤ 3, le 1er porte la sparkline du volume principal347 record: RecordFact | null;348 breakdown: { title: string; kind: string; items: BreakItem[] } | null;349 geo: { title: string; items: BreakItem[] } | null;350 volume: number | null;351 growth: { pct: number; derived: boolean } | null;352 updatedFmt: string | null;353};354355export type EcoPayload = {356 period: PeriodKey;357 periodLabel: string;358 banner: ChartKpi[];359 gauges: ChartGauge[];360 volume: { serie: ChartSerie; note: string } | null;361 growthIndex: { ms: ChartMultiSerie; note: string } | null;362 stacked: { st: ChartStacked; note: string } | null;363 ranking: BreakItem[];364 heat: { title: string; cells: { date: string; value: number }[]; note: string } | null;365 hourly: { title: string; cells: HourCell[]; note: string } | null;366 platforms: PlatformSummary[];367 table: ChartTable;368 records: RecordFact[];369 freshness: string | null;370 reachable: number;371 total: number;372 v2count: number;373 growthDerived: boolean;374};375376export function buildEcoPayload(377 platforms: PlatformStats[],378 opts: { period: PeriodKey; periodLabel: string; connectors: number },379): EcoPayload {380 const now = Date.now();381 const ok = platforms.filter((p) => p.dash);382383 // classement par volume principal (injoignables à la fin)384 const ranked = [...platforms].sort((a, b) => {385 const va = mainKpi(a.dash)?.value;386 const vb = mainKpi(b.dash)?.value;387 return (isNum(vb) ? vb : -1) - (isNum(va) ? va : -1);388 });389 const rankedOk = ranked.filter((p) => p.dash);390391 // agrégats de base392 let totalVolume = 0;393 let totalNew = 0;394 let newCount = 0;395 let latestUpdate: { iso: string; site: Site } | null = null;396 const ages: number[] = [];397 let gNum = 0;398 let gDen = 0;399 let growthDerived = false;400 for (const p of ok) {401 const mk = mainKpi(p.dash);402 if (mk && isNum(mk.value)) totalVolume += mk.value;403 const nk = newKpi(p.dash);404 if (nk && isNum(nk.value)) {405 totalNew += nk.value;406 newCount += 1;407 }408 const upd = p.dash?.updated;409 if (upd) {410 const t = Date.parse(upd);411 if (Number.isFinite(t)) ages.push(Math.max(0, now - t));412 if (!latestUpdate || upd > latestUpdate.iso)413 latestUpdate = { iso: upd, site: p.site };414 }415 const g = growthOf(p.dash);416 if (g && mk && isNum(mk.value) && mk.value > 0) {417 gNum += mk.value * g.pct;418 gDen += mk.value;419 if (g.derived) growthDerived = true;420 }421 }422 const wGrowth = gDen > 0 ? Math.round((gNum / gDen) * 10) / 10 : null;423 const avgAge =424 ages.length > 0 ? ages.reduce((s, v) => s + v, 0) / ages.length : null;425 const v2count = ok.filter((p) => isV2(p.dash)).length;426427 // contributeurs aux graphiques : plateformes avec une tendance ≥ 2 pts,428 // et sous-ensemble alignable = points datés au jour (ISO)429 const withSpark = rankedOk430 .map((p) => ({ p, spark: sparkOf(p.dash) }))431 .filter((x) => x.spark.length >= 2);432 const withDaily = withSpark433 .map((x) => ({ p: x.p, spark: dailyPts(x.spark) }))434 .filter((x) => x.spark.length >= 2);435436 // 1) série écosystème = somme des volumes sur les dates communes.437 // Les fenêtres publiées varient (3 à 31 jours) : on écarte les fenêtres438 // les plus courtes tant que la fenêtre commune reste < 8 jours, pour439 // garder une courbe lisible sans jamais extrapoler.440 let volume: EcoPayload["volume"] = null;441 {442 const pool = [...withDaily];443 let dates = commonDates(pool.map((x) => x.spark));444 while (pool.length > 2 && dates.length < 8) {445 let idx = 0;446 for (let i = 1; i < pool.length; i++)447 if (pool[i].spark.length < pool[idx].spark.length) idx = i;448 pool.splice(idx, 1);449 dates = commonDates(pool.map((x) => x.spark));450 }451 if (pool.length >= 2 && dates.length >= 2) {452 const maps = pool.map((x) => byDateMap(x.spark));453 const points = trimPts(454 dates.map((t) => ({455 t,456 v: maps.reduce((s, m) => s + (m.get(t) ?? 0), 0),457 })),458 90,459 );460 volume = {461 serie: {462 id: "eco-vol",463 title: `Volume principal agrégé — ${pool.length} plateformes`,464 kind: "area",465 points,466 },467 note: `Somme, jour par jour, des indicateurs principaux publiés par ${pool.length} plateformes (${pool468 .map((x) => x.p.site.wordmark)469 .join(", ")}), sur les ${dates.length} dates communes à leurs séries quotidiennes.`,470 };471 }472 }473474 // 2) croissance comparée des 4 plus grosses plateformes (indice base 100)475 let growthIndex: EcoPayload["growthIndex"] = null;476 {477 const top4 = withDaily.slice(0, 4);478 if (top4.length >= 2) {479 const dates = commonDates(top4.map((x) => x.spark));480 if (dates.length >= 2) {481 const series = top4482 .map((x) => {483 const m = byDateMap(x.spark);484 const base = m.get(dates[0]) ?? 0;485 if (base <= 0) return null;486 return {487 label: x.p.site.wordmark,488 points: dates.map((t) => ({489 t,490 v: Math.round(((m.get(t) ?? 0) / base) * 1000) / 10,491 })),492 };493 })494 .filter((s): s is NonNullable<typeof s> => s !== null);495 if (series.length >= 2) {496 growthIndex = {497 ms: {498 id: "eco-growth",499 title: "Croissance comparée des plus grosses plateformes",500 unit: "indice",501 series,502 },503 note: `Indice 100 = volume principal de chaque plateforme au ${dates[0]} (début de la fenêtre commune aux séries publiées) — permet de comparer les rythmes de croissance malgré des volumes de natures différentes.`,504 };505 }506 }507 }508 }509510 // 3) volume empilé par plateforme dans le temps (top 5)511 let stacked: EcoPayload["stacked"] = null;512 {513 const top5 = withDaily.slice(0, 5);514 if (top5.length >= 2) {515 const dates = commonDates(top5.map((x) => x.spark));516 if (dates.length >= 2) {517 const maps = top5.map((x) => byDateMap(x.spark));518 stacked = {519 st: {520 id: "eco-stack",521 title: "Volume par plateforme dans le temps",522 keys: top5.map((x) => x.p.site.wordmark),523 points: dates.map((t) => ({524 t,525 values: maps.map((m) => m.get(t) ?? 0),526 })),527 },528 note: `Composition du volume agrégé sur les dates communes aux séries des ${top5.length} plus grosses plateformes. Les volumes ne sont pas de même nature (annonces, produits, pages…) : lire comme un ordre de grandeur.`,529 };530 }531 }532 }533534 // 4) classement (barres + anneau)535 const ranking: BreakItem[] = rankedOk536 .map((p) => {537 const mk = mainKpi(p.dash);538 const g = growthOf(p.dash);539 return {540 label: p.site.wordmark,541 value: mk && isNum(mk.value) ? mk.value : 0,542 delta_pct: g ? g.pct : undefined,543 };544 })545 .filter((r) => r.value > 0);546547 // 5) calendriers agrégés (somme des plateformes qui les publient)548 let heat: EcoPayload["heat"] = null;549 {550 const contrib: Site[] = [];551 const sum = new Map<string, number>();552 for (const p of ok) {553 const cells = p.dash?.heatmap?.cells ?? [];554 const valid = cells.filter(555 (c) => typeof c?.date === "string" && isNum(c?.value),556 );557 if (!valid.length) continue;558 contrib.push(p.site);559 for (const c of valid)560 sum.set(c.date as string, (sum.get(c.date as string) ?? 0) + (c.value as number));561 }562 if (sum.size > 0) {563 heat = {564 title: "Activité quotidienne de l'écosystème",565 cells: [...sum.entries()].map(([date, value]) => ({ date, value })),566 note: `Somme des calendriers d'activité publiés par ${contrib.length} plateformes (${contrib.map((s) => s.wordmark).join(", ")}).`,567 };568 }569 }570 let hourly: EcoPayload["hourly"] = null;571 {572 const contrib: Site[] = [];573 const sum = new Map<string, number>();574 for (const p of ok) {575 const cells = p.dash?.hourly?.cells ?? [];576 const valid = cells.filter(577 (c) => isNum(c?.dow) && isNum(c?.hour) && isNum(c?.value),578 );579 if (!valid.length) continue;580 contrib.push(p.site);581 for (const c of valid) {582 const key = `${c.dow}-${c.hour}`;583 sum.set(key, (sum.get(key) ?? 0) + (c.value as number));584 }585 }586 if (sum.size > 0) {587 hourly = {588 title: "Activité horaire de l'écosystème",589 cells: [...sum.entries()].map(([k, value]) => {590 const [dow, hour] = k.split("-").map(Number);591 return { dow, hour, value };592 }),593 note: `Somme de l'activité horaire journalisée par ${contrib.length} plateformes (${contrib.map((s) => s.wordmark).join(", ")}).`,594 };595 }596 }597598 // 6) résumé par plateforme599 const summaries: PlatformSummary[] = ranked.map((p) => {600 const { site, dash } = p;601 const lite: SiteLite = {602 id: site.id,603 wordmark: site.wordmark,604 domain: site.domain,605 accent: site.accent,606 };607 if (!dash)608 return {609 site: lite,610 ok: false,611 v2: false,612 kpis: [],613 record: null,614 breakdown: null,615 geo: null,616 volume: null,617 growth: null,618 updatedFmt: null,619 };620 const spark = trimPts(sparkOf(dash), 40);621 const kpis: ChartKpi[] = topKpis(dash, 3).map((k, i) => ({622 id: k.id ?? k.label,623 label: k.label,624 value: k.value,625 unit: k.unit,626 delta_pct: isNum(k.delta_pct) ? k.delta_pct : undefined,627 direction: k.direction,628 spark: i === 0 && spark.length >= 2 ? spark : undefined,629 }));630 const bd = firstBreakdown(dash);631 const g = geoBreakdown(dash);632 const mk = mainKpi(dash);633 return {634 site: lite,635 ok: true,636 v2: isV2(dash),637 kpis,638 record: dash.records?.[0] ?? null,639 breakdown: bd640 ? {641 title: bd.title ?? bd.label ?? bd.id ?? "Répartition",642 kind: bd.kind === "donut" ? "donut" : "bars",643 items: breakdownItems(bd, 8),644 }645 : null,646 geo: g647 ? {648 title: g.title ?? g.label ?? "Par région",649 items: breakdownItems(g, 6),650 }651 : null,652 volume: mk && isNum(mk.value) ? mk.value : null,653 growth: growthOf(dash),654 updatedFmt: dash.updated ? fmtDateTime(dash.updated) : null,655 };656 });657658 // 7) bandeau KPI écosystème659 const banner: ChartKpi[] = [660 ...(ok.length > 0661 ? [662 {663 id: "vol",664 label: "Volume principal agrégé",665 value: totalVolume,666 delta_pct: wGrowth,667 spark: volume ? trimPts(volume.serie.points, 40) : undefined,668 help: `Somme des indicateurs principaux des ${ok.length} plateformes jointes${669 wGrowth !== null670 ? ` · croissance pondérée par le volume${growthDerived ? ", en partie dérivée des séries publiées" : ""}`671 : ""672 }`,673 } satisfies ChartKpi,674 ]675 : []),676 ...(newCount > 0677 ? [678 {679 id: "new",680 label: `Nouveautés de la période (${newCount} plateformes)`,681 value: totalNew,682 } satisfies ChartKpi,683 ]684 : []),685 {686 id: "online",687 label: "Plateformes de données en ligne",688 value: `${ok.length}/${platforms.length}`,689 },690 {691 id: "err",692 label: "Plateformes injoignables",693 value: platforms.length - ok.length,694 },695 ...(avgAge !== null696 ? [697 {698 id: "fresh",699 label: "Fraîcheur moyenne des données",700 value: fmtAgo(avgAge),701 help: "Âge moyen de la dernière mise à jour des tableaux de bord joints",702 } satisfies ChartKpi,703 ]704 : []),705 {706 id: "conn",707 label: "Connecteurs sources actifs",708 value: `${fmtInt(opts.connectors)}+`,709 },710 ];711712 // 8) jauges de couverture du hub713 const gauges: ChartGauge[] = [714 {715 id: "jointes",716 label: "Plateformes jointes",717 value: ok.length,718 max: platforms.length,719 },720 {721 id: "series",722 label: "Publient des séries de tendance",723 value: withSpark.length,724 max: platforms.length,725 },726 {727 id: "v2",728 label: "Tableaux de bord enrichis (v2)",729 value: v2count,730 max: platforms.length,731 },732 ];733734 // 9) table détaillée du classement735 const table: ChartTable = {736 id: "classement",737 title: "Classement détaillé des plateformes",738 columns: [739 "#",740 "Plateforme",741 "Indicateur principal",742 "Volume",743 "Croissance",744 "Nouveautés",745 "Mis à jour",746 "Statut",747 ],748 rows: ranked.map((p, i) => {749 const mk = mainKpi(p.dash);750 const nk = newKpi(p.dash);751 const g = growthOf(p.dash);752 const s = summaries.find((x) => x.site.id === p.site.id);753 return [754 i + 1,755 p.site.wordmark,756 p.dash ? (mk?.label ?? "—") : "—",757 mk && isNum(mk.value) ? mk.value : "—",758 g ? `${fmtPctSigned(g.pct)}${g.derived ? " *" : ""}` : "—",759 nk && isNum(nk.value) ? nk.value : "—",760 s?.updatedFmt ?? "—",761 p.dash ? (isV2(p.dash) ? "en ligne · v2" : "en ligne") : "injoignable",762 ];763 }),764 };765766 // 10) records écosystème (calculés depuis les données publiées)767 const records: RecordFact[] = [];768 const top = summaries.find((s) => s.ok && s.volume !== null);769 if (top && top.volume !== null)770 records.push({771 label: "Plus gros inventaire",772 value: `${top.site.wordmark} · ${fmtCompact(top.volume)}`,773 });774 const gBest = summaries775 .filter((s) => s.ok && s.growth)776 .sort((a, b) => (b.growth?.pct ?? 0) - (a.growth?.pct ?? 0))[0];777 if (gBest?.growth)778 records.push({779 label: "Plus forte croissance de la période",780 value: `${gBest.site.wordmark} · ${fmtPctSigned(gBest.growth.pct)}`,781 });782 let nBest: { site: Site; v: number } | null = null;783 for (const p of ok) {784 const nk = newKpi(p.dash);785 if (nk && isNum(nk.value) && (!nBest || nk.value > nBest.v))786 nBest = { site: p.site, v: nk.value };787 }788 if (nBest)789 records.push({790 label: "Le plus de nouveautés (période)",791 value: `${nBest.site.wordmark} · ${fmtCompact(nBest.v)}`,792 });793 if (latestUpdate)794 records.push({795 label: "Mise à jour la plus récente",796 value: latestUpdate.site.wordmark,797 date: fmtDateTime(latestUpdate.iso),798 });799 for (const p of rankedOk) {800 if (records.length >= 10) break;801 const r = p.dash?.records?.[0];802 if (r && typeof r.label === "string" && typeof r.value === "string")803 records.push({804 label: `${p.site.wordmark} — ${r.label}`,805 value: r.value,806 date: r.date,807 });808 }809810 return {811 period: opts.period,812 periodLabel: opts.periodLabel,813 banner,814 gauges,815 volume,816 growthIndex,817 stacked,818 ranking,819 heat,820 hourly,821 platforms: summaries,822 table,823 records,824 freshness: latestUpdate ? fmtDateTime(latestUpdate.iso) : null,825 reachable: ok.length,826 total: platforms.length,827 v2count,828 growthDerived,829 };830}831