# Dashboard Contenu — Frame A (Vue d'ensemble) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** `/content/overview` devient la vue « santé SEO des pages » (KPIs + table de pages avec score), réutilisant `site_pages`/crawl/issues.

**Architecture:** Un scorer pur (issues → score /100 + statuts par dimension), une action serveur qui agrège pages + score + KPIs, une nouvelle UI Contenu/Overview. Le pipeline éditorial (ancien overview) est déplacé sur `/content/planning` — rien n'est perdu.

**Tech Stack:** Next.js 16 (RSC + server actions), Drizzle/Postgres, Tailwind/shadcn, bun:test.

**Spec:** `docs/superpowers/specs/2026-07-24-content-dashboard-design.md` (Phase 1 / Frame A).
**Référence visuelle:** `~/Downloads/seo-insight-web/project/Dashboard Contenu - Final.html` (Frame A uniquement — lire pour le layout, PAS copier ses couleurs/fontes).

## Global Constraints

- **`/content/overview` = santé des pages** (Frame A). Le pipeline éditorial (statuts/assignés/échéances, `ContentOverviewContainer`) est **déplacé sur `/content/planning`**, pas supprimé (décision Bastien).
- **WordPress différé** : PAS de segment « WordPress / App », PAS de colonne « Source ». Toutes les pages viennent du crawl sitemap (`site_pages`).
- **Éditeur (Frame B) différé** : l'action « ouvrir » d'une ligne pointe vers le **détail de page existant** (le drawer/détail de `/pages`), pas un nouvel éditeur.
- **Pas de KPI « Métas générées IA »** (feature IA différée) — remplacé par un KPI actionnable non-IA (voir Task 3).
- **Design system** : recréer le rendu du proto (anneaux de score colorés vert/orange/rouge, badges de statut ok/warn/bad, KPI cards) avec les **composants et tokens de l'app** (shadcn, thème, police Geist). NE PAS importer les couleurs (`#5b6ef5`…) ni les fontes (Manrope/Inter) du proto.
- Réutiliser l'existant : `computeIssues` (`src/lib/pages/issues.ts`), `fetchSitePages` (`src/lib/pages/actions-core.ts`), `site_pages` (`domain-schema.ts:369`), mapping keyword↔page (`keywords.targetPageId`).
- Tests : `bun run test <path>` (PAS `bun test`) ; `bunx tsc --noEmit` exit 0 ; biome ; DB-gated pour les tests postgres (pattern du repo).
- Conventional Commits ; trailers : `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` puis `Claude-Session: https://claude.ai/code/session_01TPVZEibkftCoz8dwbRSMkn`.
- Hook local : jamais heredoc + `git commit` ensemble ; AUCUN `git stash`.
- Branche : `feature/content-dashboard` (déjà créée, porte le spec).

---

### Task 1 : Scorer de page (pur)

**Files:**
- Create: `src/lib/pages/score.ts`
- Test: `src/lib/pages/score.test.ts`

**Interfaces:**
- Consomme : les codes d'issue de `computeIssues` (`title-missing`, `title-long`, `title-short`, `description-missing`, `description-long`, `description-short`, `h1-missing`, `h1-multiple`, `noindex`, `canonical-mismatch`, `error`).
- Produit :
  - `type ScoreLevel = "ok" | "warn" | "bad"`
  - `pageScore(issues: string[]): { score: number; level: ScoreLevel }`
  - `type DimStatus = { level: ScoreLevel; label: string }`
  - `titleStatus(issues, len)`, `descStatus(issues, len)`, `hnStatus(issues)` → `DimStatus` (pour les colonnes de la table)

- [ ] **Step 1 : test failing** (`src/lib/pages/score.test.ts`)

```ts
import { expect, test } from "bun:test";
import { descStatus, hnStatus, pageScore, titleStatus } from "./score";

test("page saine (aucune issue) → 100 / ok", () => {
  expect(pageScore([])).toEqual({ score: 100, level: "ok" });
});
test("issue critique (noindex) plombe le score → warn", () => {
  const r = pageScore(["noindex"]);
  expect(r.score).toBe(60); // 100 - 40
  expect(r.level).toBe("warn"); // 60 → warn (bornes ok≥80, warn≥50, bad<50)
});
test("plusieurs issues graves → bad, borné à 0", () => {
  const r = pageScore(["error", "title-missing", "h1-missing", "description-missing"]);
  expect(r.score).toBe(0);
  expect(r.level).toBe("bad");
});
test("issue mineure → ok mais < 100", () => {
  expect(pageScore(["title-long"])).toEqual({ score: 94, level: "ok" });
});
test("titleStatus : missing=bad, long/short=warn, ok sinon avec longueur", () => {
  expect(titleStatus(["title-missing"], 0).level).toBe("bad");
  expect(titleStatus(["title-long"], 72).level).toBe("warn");
  expect(titleStatus([], 48)).toEqual({ level: "ok", label: "48" });
});
test("hnStatus : missing=bad, multiple=warn, sinon ok", () => {
  expect(hnStatus(["h1-missing"]).level).toBe("bad");
  expect(hnStatus(["h1-multiple"]).level).toBe("warn");
  expect(hnStatus([]).level).toBe("ok");
});
```

- [ ] **Step 2 : vérifier l'échec** — `bun run test src/lib/pages/score.test.ts` → FAIL (module absent).

- [ ] **Step 3 : implémenter `src/lib/pages/score.ts`**

```ts
// src/lib/pages/score.ts — score SEO d'une page à partir de ses issues on-page
// (cf. computeIssues, issues.ts). Pur, sans I/O. Score /100, pénalité par issue
// pondérée par gravité ; borné [0,100]. Sert la table Contenu/Overview.

export type ScoreLevel = "ok" | "warn" | "bad";
export type DimStatus = { level: ScoreLevel; label: string };

/** Pénalité (points retranchés) par code d'issue. Les issues qui rendent la page
 *  invisible/cassée pèsent le plus. */
const PENALTY: Record<string, number> = {
  error: 40,
  noindex: 40,
  "title-missing": 20,
  "h1-missing": 15,
  "description-missing": 12,
  "canonical-mismatch": 10,
  "h1-multiple": 8,
  "title-long": 6,
  "title-short": 6,
  "description-long": 6,
  "description-short": 6,
};

function levelForScore(score: number): ScoreLevel {
  if (score >= 80) return "ok";
  if (score >= 50) return "warn";
  return "bad";
}

export function pageScore(issues: string[]): { score: number; level: ScoreLevel } {
  const penalty = issues.reduce((sum, i) => sum + (PENALTY[i] ?? 0), 0);
  const score = Math.max(0, 100 - penalty);
  return { score, level: levelForScore(score) };
}

export function titleStatus(issues: string[], len: number): DimStatus {
  if (issues.includes("title-missing")) return { level: "bad", label: "Absent" };
  if (issues.includes("title-long") || issues.includes("title-short"))
    return { level: "warn", label: String(len) };
  return { level: "ok", label: String(len) };
}

export function descStatus(issues: string[], len: number): DimStatus {
  if (issues.includes("description-missing"))
    return { level: "bad", label: "Absente" };
  if (issues.includes("description-long") || issues.includes("description-short"))
    return { level: "warn", label: String(len) };
  return { level: "ok", label: String(len) };
}

export function hnStatus(issues: string[]): DimStatus {
  if (issues.includes("h1-missing")) return { level: "bad", label: "H1 absent" };
  if (issues.includes("h1-multiple")) return { level: "warn", label: "H1 ×2+" };
  return { level: "ok", label: "OK" };
}
```

- [ ] **Step 4 : vérifier le pass** — `bun run test src/lib/pages/score.test.ts` (0 fail) ; `bunx tsc --noEmit` (0) ; biome.

- [ ] **Step 5 : commit**

```bash
git add src/lib/pages/score.ts src/lib/pages/score.test.ts
git commit -m "feat(pages): scorer SEO de page (issues -> score/100 + statuts dimension)"
```

---

### Task 2 : Action overview « santé des pages » + KPIs

**Files:**
- Create: `src/lib/content/pages-overview.ts` (query + action)
- Test: `src/lib/content/pages-overview.test.ts`

**Interfaces:**
- Consomme : `pageScore`, `titleStatus`, `descStatus`, `hnStatus` (Task 1) ; `sitePages`, `keywords` (Drizzle) ; `assertSiteInActiveOrg` (pattern `actions-core.ts`).
- Produit :
  - `type ContentPageRow = { id, url, title, metaDescription, h1, h1Count, wordCount, targetKeyword: string | null, issues: string[], crawledAt: Date | null, score: number, level: ScoreLevel }`
  - `type ContentPagesOverview = { pages: ContentPageRow[]; kpis: { tracked: number; avgScore: number; toFix: number; withoutKeyword: number } }`
  - `getContentPagesOverviewAction(siteId: string): Promise<ContentPagesOverview>`

- [ ] **Step 1 : comprendre l'existant** — lire `src/lib/pages/actions-core.ts` (`fetchSitePages`, `toRow`, `SitePageRow`), `src/lib/db/domain-schema.ts:369` (colonnes `site_pages` : dont `wordCount`, `h1Count`, `metaDescription`, `issues`), et le join `keywords.targetPageId` (mot-clé cible d'une page). Le mot-clé « cible » = le keyword dont `targetPageId = page.id` (prendre le premier / le principal — vérifier s'il y a une notion de primaire ; sinon `min(term)` déterministe).

- [ ] **Step 2 : test failing** (`src/lib/content/pages-overview.test.ts`) — pattern DB-gated du repo (regarde `src/lib/pages/actions-core.test.ts` pour le setup : insert site + site_pages + keywords, mock `assertSite`). Cas : une page saine (score 100), une page avec issues (score < 100, statuts corrects), mot-clé cible résolu vs null, et les 4 KPIs (`tracked` = count, `avgScore` = moyenne arrondie, `toFix` = pages level ≠ ok, `withoutKeyword` = pages sans targetKeyword). Reprends les valeurs exactes du scorer de Task 1.

- [ ] **Step 3 : implémenter** `getContentPagesOverviewAction` — s'inspirer de `fetchSitePages` mais sélectionner AUSSI `wordCount`, `h1Count`, `metaDescription`, et le terme du mot-clé cible via `leftjoin keywords on keywords.targetPageId = site_pages.id` (agrégé : `min(keywords.term)` ou le premier). Pour chaque row : calculer `score`/`level` (`pageScore(issues)`) et exposer les longueurs (title/desc) pour les statuts côté UI. Agréger les KPIs. `assertSiteInActiveOrg(siteId)` en tête (tenancy). Tri par score croissant (les pires en premier — « priorité »).

- [ ] **Step 4 : vérifs** — `bun run test src/lib/content/pages-overview.test.ts` (0 fail, DB-gated) ; `bunx tsc --noEmit` (0) ; biome.

- [ ] **Step 5 : commit**

```bash
git add src/lib/content/pages-overview.ts src/lib/content/pages-overview.test.ts
git commit -m "feat(content): action overview sante des pages (pages + score + KPIs)"
```

---

### Task 3 : UI Frame A — `/content/overview` = santé des pages

**Files:**
- Create: `src/components/content/pages-health/pages-health-container.tsx` (+ sous-composants : `kpi-strip.tsx`, `pages-health-table.tsx`, `score-ring.tsx`, `dim-badge.tsx`)
- Modify: `src/app/(app)/content/overview/page.tsx` (monter la nouvelle vue, fetch `getContentPagesOverviewAction`)

**Interfaces:**
- Consomme : `ContentPagesOverview`, `getContentPagesOverviewAction` (Task 2) ; `ScoreLevel`, `titleStatus`, `descStatus`, `hnStatus` (Task 1) ; composants ui app (`Card`, `Table`/`DataTable`, `Badge`, `Input`), `useActiveSite`.

- [ ] **Step 1 : lire la référence + l'existant** — lire le Frame A du proto (`~/Downloads/seo-insight-web/project/Dashboard Contenu - Final.html`, section FRAME A uniquement) pour le layout/hiérarchie, et `src/components/pages/pages-view.tsx` (table pages existante) + `src/components/keywords/overview/*` (patterns de cards/KPIs de l'app) pour le style app à répliquer.

- [ ] **Step 2 : `score-ring.tsx`** — anneau de score (donut SVG) : prop `score: number`, `level: ScoreLevel`. Couleur de l'arc selon le niveau via tokens app (`text-emerald-500`/`text-amber-500`/`text-red-500` ou les tokens `--ok/--warn/--bad` du thème s'ils existent — vérifier `globals.css`). Recréer l'anneau du proto (rayon, épaisseur) mais avec les couleurs du thème.

- [ ] **Step 3 : `dim-badge.tsx`** — badge de statut d'une dimension : prop `status: DimStatus`. Point coloré + label, styles ok/warn/bad via tokens app. Rend `titleStatus`/`descStatus`/`hnStatus`.

- [ ] **Step 4 : `kpi-strip.tsx`** — 4 KPI cards depuis `kpis` : **Pages suivies** (`tracked`), **Score moyen** (`avgScore`/100), **Pages à corriger** (`toFix`), **Sans mot-clé cible** (`withoutKeyword`, remplace « Métas IA » — actionnable, non-IA). Icônes lucide, cards `Card` app.

- [ ] **Step 5 : `pages-health-table.tsx`** — table (composant table app) : colonnes **Page** (title + slug dérivé de l'url), **Score** (`ScoreRing`), **Title** (`DimBadge` via `titleStatus`), **Méta desc.** (`descStatus`), **Hn** (`hnStatus`), **Mot-clé cible** (chip ou « — non défini »), **Mots** (`wordCount`), **MàJ** (`crawledAt` relatif), action « ouvrir » → **le détail de page existant** (drawer/route de `/pages`, Frame B différé). Toolbar : champ de recherche + tri (par score/MàJ) ; **PAS de segment WordPress/App**. Filtrage/tri côté client sur les rows, ou re-fetch — choisir le plus simple.

- [ ] **Step 6 : `pages-health-container.tsx`** — assemble KPIs + toolbar + table ; client component, reçoit `ContentPagesOverview` initial, refetch sur changement de site (`useActiveSite`, pattern des autres containers keywords).

- [ ] **Step 7 : monter dans `content/overview/page.tsx`** — remplacer `ContentOverviewContainer` par `PagesHealthContainer`, fetch `getContentPagesOverviewAction(siteId)`. Garder le `DashboardHeader`/breadcrumb (« Contenu › Overview »). Gérer l'état 0-page (site jamais crawlé → message + lien crawl vers `/pages`).

- [ ] **Step 8 : vérifs** — `bunx tsc --noEmit` (0) ; biome ; `bun run test src/lib/content/ src/lib/pages/` (0 nouvel échec). Vérif visuelle d3k en fin de branche.

- [ ] **Step 9 : commit**

```bash
git add "src/app/(app)/content/overview/page.tsx" src/components/content/pages-health/
git commit -m "feat(content): vue sante des pages sur /content/overview (KPIs + table + score)"
```

---

### Task 4 : Préserver le pipeline éditorial (déplacer l'ancien overview sur Planning)

**Files:**
- Modify: `src/app/(app)/content/planning/page.tsx` (monter les KPIs pipeline au-dessus du kanban)
- Verify: les composants `src/components/content/overview/*` (`ContentOverviewKpis`, `ContentStatusDistribution`, `ContentByAssignee`, `ContentUpcoming`) restent utilisés (pas de code mort).

**Interfaces:**
- Consomme : `getContentOverviewAction`, `ContentOverviewContainer`/ses sous-composants (existants).

- [ ] **Step 1 : lire** `src/app/(app)/content/planning/page.tsx` + `ContentBoard` (le kanban) + `content-overview-container.tsx` (ce qu'il montre : KPIs, distribution statut, par assigné, échéances).

- [ ] **Step 2 : monter le résumé pipeline sur Planning** — dans `content/planning/page.tsx`, fetch aussi `getContentOverviewAction(siteId)` et rendre un résumé pipeline condensé (les KPIs + distribution statut/échéances) AU-DESSUS du kanban `ContentBoard`. Réutiliser les composants existants (`ContentOverviewKpis`, `ContentStatusDistribution`, `ContentUpcoming`) — ne rien réécrire. But : aucune info de l'ancien overview n'est perdue.

- [ ] **Step 3 : nettoyer les orphelins** — vérifier qu'aucun import ne pointe plus vers l'ancien `ContentOverviewContainer` sur `/content/overview` (remplacé en Task 3). Si `content-overview-container.tsx` n'est plus monté nulle part, soit le monter sur planning (Step 2), soit le supprimer proprement (`git rm`) — décider selon ce qui est le plus propre (préférer réutiliser ses sous-composants sur planning). `grep -rn ContentOverviewContainer src/` doit être cohérent.

- [ ] **Step 4 : vérifs** — `bunx tsc --noEmit` (0) ; biome ; `bun run test src/lib/content/` (0 nouvel échec). `knip` si dispo pour détecter le code mort éventuel.

- [ ] **Step 5 : commit**

```bash
git add "src/app/(app)/content/planning/page.tsx" src/components/content/
git commit -m "refactor(content): resume pipeline editorial deplace sur /content/planning"
```

---

## Fin de branche

Review finale (diff `dev..HEAD`), puis merge ff-only dans `dev`. **Vérification visuelle d3k** : `/content/overview` (KPIs + table pages avec anneaux de score colorés, statuts par dimension, mot-clé cible) et `/content/planning` (résumé pipeline + kanban). Note : `/content` est gaté under-construction en prod — la vérif se fait en dev (`SHOW_WIP_FEATURES=1` ou `constructionBlockActive()` false en dev). Ne PAS retirer le gate under-construction dans ce chantier (décision produit séparée : quand ouvrir /content en prod).
