/stats maître v2 : consolidation enrichie (KPI+sparklines, jauges, volume agrégé aligné sur dates communes, croissance comparée indice 100, volume empilé, calendriers agrégés, spotlights aux accents des marques, table détaillée, records écosystème) + kit kacharts/kapdf/SPEC v2 vendorisés + menu PdfButton 5 rapports branché sur /api/stats/report (alias du proxy ecosystem-report, 5 modes + from/to, tolérant)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 changed file +33 −11
modified
src/lib/ecostats.ts
+33 −11
@@ -330,6 +330,13 @@ function commonDates(seriesList: SeriePoint[][]): string[] { | ||
| 330 | 330 | const byDateMap = (pts: SeriePoint[]): Map<string, number> => |
| 331 | 331 | new Map(pts.map((p) => [p.t, p.v])); |
| 332 | 332 | |
| 333 | +/* 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 somment | |
| 335 | + pas avec des séries quotidiennes. */ | |
| 336 | +const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/; | |
| 337 | +const dailyPts = (pts: SeriePoint[]): SeriePoint[] => | |
| 338 | + pts.filter((p) => ISO_DAY.test(p.t)); | |
| 339 | + | |
| 333 | 340 | export type SiteLite = Pick<Site, "id" | "wordmark" | "domain" | "accent">; |
| 334 | 341 | |
| 335 | 342 | export type PlatformSummary = { |
@@ -417,17 +424,32 @@ export function buildEcoPayload( | ||
| 417 | 424 | ages.length > 0 ? ages.reduce((s, v) => s + v, 0) / ages.length : null; |
| 418 | 425 | const v2count = ok.filter((p) => isV2(p.dash)).length; |
| 419 | 426 | |
| 420 | − // contributeurs aux graphiques temporels : plateformes avec tendance ≥ 2 pts | |
| 427 | + // contributeurs aux graphiques : plateformes avec une tendance ≥ 2 pts, | |
| 428 | + // et sous-ensemble alignable = points datés au jour (ISO) | |
| 421 | 429 | const withSpark = rankedOk |
| 422 | 430 | .map((p) => ({ p, spark: sparkOf(p.dash) })) |
| 423 | 431 | .filter((x) => x.spark.length >= 2); |
| 432 | + const withDaily = withSpark | |
| 433 | + .map((x) => ({ p: x.p, spark: dailyPts(x.spark) })) | |
| 434 | + .filter((x) => x.spark.length >= 2); | |
| 424 | 435 | |
| 425 | − // 1) série écosystème = somme des volumes sur les dates communes | |
| 436 | + // 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êtres | |
| 438 | + // les plus courtes tant que la fenêtre commune reste < 8 jours, pour | |
| 439 | + // garder une courbe lisible sans jamais extrapoler. | |
| 426 | 440 | let volume: EcoPayload["volume"] = null; |
| 427 | − if (withSpark.length > 0) { | |
| 428 | − const dates = commonDates(withSpark.map((x) => x.spark)); | |
| 429 | − if (dates.length >= 2) { | |
| 430 | − const maps = withSpark.map((x) => byDateMap(x.spark)); | |
| 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)); | |
| 431 | 453 | const points = trimPts( |
| 432 | 454 | dates.map((t) => ({ |
| 433 | 455 | t, |
@@ -438,13 +460,13 @@ export function buildEcoPayload( | ||
| 438 | 460 | volume = { |
| 439 | 461 | serie: { |
| 440 | 462 | id: "eco-vol", |
| 441 | − title: `Volume principal agrégé — ${withSpark.length} plateformes`, | |
| 463 | + title: `Volume principal agrégé — ${pool.length} plateformes`, | |
| 442 | 464 | kind: "area", |
| 443 | 465 | points, |
| 444 | 466 | }, |
| 445 | − note: `Somme, jour par jour, des indicateurs principaux publiés par ${withSpark.length} plateformes (${withSpark | |
| 467 | + note: `Somme, jour par jour, des indicateurs principaux publiés par ${pool.length} plateformes (${pool | |
| 446 | 468 | .map((x) => x.p.site.wordmark) |
| 447 | − .join(", ")}), sur les ${dates.length} dates communes à leurs séries.`, | |
| 469 | + .join(", ")}), sur les ${dates.length} dates communes à leurs séries quotidiennes.`, | |
| 448 | 470 | }; |
| 449 | 471 | } |
| 450 | 472 | } |
@@ -452,7 +474,7 @@ export function buildEcoPayload( | ||
| 452 | 474 | // 2) croissance comparée des 4 plus grosses plateformes (indice base 100) |
| 453 | 475 | let growthIndex: EcoPayload["growthIndex"] = null; |
| 454 | 476 | { |
| 455 | − const top4 = withSpark.slice(0, 4); | |
| 477 | + const top4 = withDaily.slice(0, 4); | |
| 456 | 478 | if (top4.length >= 2) { |
| 457 | 479 | const dates = commonDates(top4.map((x) => x.spark)); |
| 458 | 480 | if (dates.length >= 2) { |
@@ -488,7 +510,7 @@ export function buildEcoPayload( | ||
| 488 | 510 | // 3) volume empilé par plateforme dans le temps (top 5) |
| 489 | 511 | let stacked: EcoPayload["stacked"] = null; |
| 490 | 512 | { |
| 491 | − const top5 = withSpark.slice(0, 5); | |
| 513 | + const top5 = withDaily.slice(0, 5); | |
| 492 | 514 | if (top5.length >= 2) { |
| 493 | 515 | const dates = commonDates(top5.map((x) => x.spark)); |
| 494 | 516 | if (dates.length >= 2) { |
| 495 | 517 | |