# `/content/stats` — performance des pages dans le temps

**Date:** 2026-06-10 · **Linear:** [ELEVA-60](https://linear.app/elevaseo/issue/ELEVA-60) · **CdC:** `docs/specs/contenu/stats.md`
**Débloqué par** ELEVA-40 (worker snapshots GSC). **D1 tranché de facto** : source de perf = **GSC** (`gsc_page_snapshots` : clics, impressions, CTR, position). Conversions hors scope (pas de GA4). L'état « en souffrance » du CdC est levé.

**Décisions (brainstorm):** périmètre = **toutes les pages GSC** du site (badge + lien vers le `content_item` quand l'URL matche) ; agrégation **SQL côté serveur** ; périodes 7/28/90 j avec **comparaison vs période précédente** ; F4 v1 = détection + badge + filtre (action refonte / intégration `online` = itération suivante).

---

## A. Données — `src/lib/content/stats.ts`

### Types
```ts
export interface PageStatRow {
  page: string;                    // URL GSC complète
  clicks: number;                  // somme période courante
  impressions: number;
  ctr: number | null;              // clicks/impressions (null si 0 impr)
  position: number | null;         // moyenne pondérée par impressions
  prevClicks: number | null;       // période précédente (null si aucune donnée)
  prevImpressions: number | null;
  prevPosition: number | null;
  spark: number[];                 // clics par jour (période courante, jours manquants = 0)
  contentItemId: string | null;    // content_item matché par URL (sinon null)
  contentTitle: string | null;
  dropping: boolean;               // signal décrochage F4
}

export interface StatsKpis {
  clicks: number; impressions: number; ctr: number | null; position: number | null;
  prevClicks: number | null; prevImpressions: number | null;
  prevCtr: number | null; prevPosition: number | null;
}
```

### Requêtes (Drizzle, agrégation en SQL)
- `getPageStats(siteId, periodDays: 7|28|90): Promise<{ rows: PageStatRow[]; kpis: StatsKpis }>` :
  1. **Période courante** = `[today - periodDays, today)` ; **précédente** = `[today - 2*periodDays, today - periodDays)`. (Dates UTC, cohérent avec le worker.)
  2. Une requête groupée par `page` sur la fenêtre 2P : `sum(clicks) FILTER (WHERE date >= cur_start)`, idem impressions, `sum(position*impressions) FILTER (...)` (pour la moyenne pondérée), et les mêmes FILTER pour la période précédente. Position pondérée = `sum(pos*impr)/sum(impr)` (null si 0 impr).
  3. **Séries sparkline** : une requête `(page, date, clicks)` sur la période courante pour les pages retournées → densifiées en JS (jours manquants = 0, ordre chronologique).
  4. **Matching articles** : `content_items` du site avec `url` non null ; normalisation des deux côtés (lowercase host, strip trailing slash) ; map URL→{id,title}.
  5. KPIs = mêmes agrégats au niveau site (somme des pages).
  6. Tri par défaut : clics période courante desc. Pas de limite serveur (volumes faibles : ~100 pages/site) — la pagination est côté table.

### Logique pure (TDD)
- `weightedPosition(rows: {position, impressions}[]): number | null`
- `normalizeUrl(u: string): string` — lowercase protocole+host, strip trailing slash (path seul préservé en casse).
- `detectDrop(cur: {clicks, impressions, position}, prev: {clicks, impressions, position}): boolean` — **vrai** si les deux périodes ont `impressions > 0` ET (`position` se dégrade de **≥ 3** OU `clicks` chute de **≥ 50 %** avec `prev.clicks ≥ 5` — plancher anti-bruit). Sinon faux (données jeunes → jamais de faux signal).
- `densifySeries(points: {date, clicks}[], start, end): number[]`

## B. Page & navigation

- RSC `src/app/(app)/content/stats/page.tsx` (pattern de `online/page.tsx`) : `searchParams` (Promise, Next 16) → `p` ∈ {7,28,90} défaut **28** → `getPageStats(siteId, p)` → `<StatsContainer initialRows initialKpis period siteId />`. Breadcrumb Contenu → Stats.
- **Sidebar** (`src/components/app-sidebar.tsx`) : entrée « Stats » dans la section CONTENU (après Online).
- Switch de période **par l'URL** (`?p=`) : le container utilise nuqs (`parseAsStringEnum(["7","28","90"])` + `shallow: false`) pour déclencher le refetch RSC — pas de fetch on mount (gotcha route-deadlock).

## C. UI — `src/components/content/stats/`

- `stats-container.tsx` (client) : seedé par props RSC ; toolbar = switch de période (Tabs 7j/28j/90j) + recherche par path + toggle « En décrochage » ; KPI cards ; DataTable ; Sheet détail.
- `stats-kpis.tsx` : 4 cards (Clics, Impressions, CTR, Position) avec **delta vs période précédente** (↗ vert / ↘ rouge ; **inversé pour la position** : baisse = vert). Delta « — » si prev null.
- `stats-columns.tsx` : Page (path tronqué, title = URL complète) · Clics + Δ% · Impr. + Δ% · CTR % · Position + Δ (inversé) · Sparkline clics (réutilise `@/components/keywords/sparkline`) · 📉 badge décrochage · badge Article (lien ouvre la carte ? v1 : lien vers `/content/planning` — la carte s'ouvre depuis là ; pas de deep-link carte v1).
- `stats-detail-sheet.tsx` : au clic ligne — courbes **clics** et **position** par jour sur la période (SVG simple style sparkline en plus grand, ou 2 sparklines étiquetées ; pas de lib chart nouvelle), métriques de la ligne, lien article si matché.
- Densité table conforme aux autres pages (DataTable existant + pagination shadcn).

## D. F4 — périmètre v1

`dropping` est calculé serveur, affiché (badge + filtre). **Hors v1** : action « envoyer en refonte », flag « à mettre à jour » dans `online`, retour vers `keywords/planning` — itération dédiée (couplage avec online à designer).

## E. Tests

- **Purs (TDD)** : `weightedPosition` (pondération, 0 impr → null) · `normalizeUrl` (trailing slash, casse host, path préservé) · `detectDrop` (dégradation position ≥3, chute clics ≥50 % avec plancher 5, garde impressions des deux côtés, prev vide → false) · `densifySeries` (trous comblés, ordre).
- **DB** : seed `gsc_page_snapshots` 2 périodes + `content_items` avec URL → `getPageStats` : agrégats corrects, position pondérée, prev null quand pas de données, matching URL normalisé, KPIs site.
- **Live (d3k)** : `/content/stats` sur Acieb — KPIs remplis avec les 3 jours réels, deltas « — » (période précédente vide), aucune ligne en décrochage (garde), sparklines rendues, switch 7/28/90, Sheet détail.

## Hors scope

Conversions/GA4 · action refonte + intégration online · dimension pays (ELEVA-57) · exports CSV · rétention/agrégats matérialisés.
