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%
25.3 KB · 831 lines typescript
Raw Blame History
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