# Design — keywords/recherche (lot 1 : liste + matrice + ciblage)

**Date** : 2026-06-03
**Page** : `keywords/recherche` · route `/keywords/recherche` (route group `(app)`)
**Source de vérité** : CdC `keywords/recherche` (mémoire specs, hors repo)
**Build brief** : `docs/project/pages/keywords/recherche.md`

## Contexte

`recherche` est un plan de **triage/ciblage**, pas de découverte. On part du **Reservoir**
déjà rempli (l'alimentation en nouveaux keywords passe par `export-import`). Ici on
sélectionne des keywords, on leur assigne des **pays cibles** (multi), et on visualise la
**couverture `keyword × pays`**. La découverte/scraping, le rank-tracking et le calendrier
éditorial sont hors périmètre.

L'utilisateur démarre la page en **réutilisant le template table + couleurs du Reservoir**
construit précédemment (DataTable, badges colorés KD/CPC, sparkline, status-badge,
KeywordSheet, FiltersBar).

## Périmètre

### Lot 1 (ce design)

- **Vue liste** : reskin du template Reservoir (colonnes Reservoir + colonne « Pays ciblés »).
- **Vue matrice** : `keyword × pays`, cellules à badge de statut, clic → menu.
- **Toggle** liste ↔ matrice.
- **Ciblage multi-pays** : bulk depuis la liste (lignes sélectionnées × pays) + clic-cellule
  dans la matrice.

### Reporté (lots suivants — non implémenté ici)

Métriques par pays (F5 / panneau détail cellule) · gaps + expansion inter-pays (F6) ·
suggestions saisonnières (F7) · shortlist/panier (F8) · push-planning réel (F9) ·
analytics d'avancement (F10) · virtualisation de la matrice.

## Décisions

| Sujet | Décision |
|---|---|
| Pays cibles | Jeu **fixe** `US · GB · CA · AU` (proto). « Cadré par `site.locales` vs libre » = question API réelle, reportée. |
| Statuts cellule | `candidate · planned · live` ; **absence = non ciblé**. |
| Ciblage | **Bulk** (liste : lignes + multi-select pays → `candidate`) **+ clic-cellule** (matrice : menu `— / candidat / planifié / en ligne`). |
| State | **Client mocké**, seed déterministe, **reset au switch de site** (cohérent Reservoir). |
| Filtrage | Recherche texte → **les deux vues** ; filtres riches (thème/intent/plages) → **liste seule** pour ce lot. |
| Archi | Cœur de table partagé + container recherche dédié (Approche 1). Matrice = composant propre, pas dans TanStack. |

## Architecture

### Modèle de données — `src/lib/keywords/targets.ts`

```ts
export type TargetStatus = "candidate" | "planned" | "live"; // absence = "non ciblé"

export interface TargetCountry { code: string; label: string; flag: string }
export const TARGET_COUNTRIES: TargetCountry[]; // US, GB, CA, AU

// keywordId -> (country -> status). Pas d'entrée = non ciblé.
export type TargetsState = Record<string, Partial<Record<string, TargetStatus>>>;

// helpers purs (testables)
seedTargets(keywords: Keyword[]): TargetsState;           // seed déterministe (par index)
setCell(s, kwId, country, status: TargetStatus | null): TargetsState;
setForSelected(s, kwIds, countries, status = "candidate"): TargetsState;
coverageOf(s, kwId): { country: string; status: TargetStatus }[]; // résumé trié
```

`seedTargets` est déterministe (dérivé de l'index du keyword, pas de `Math.random`) pour que
la matrice ne soit pas vide au chargement et que les tests soient stables.

### Composants — `src/components/keywords/research/`

- **`ResearchContainer`** — orchestration. État : `targets`, `view: "list" | "matrix"`,
  `selectedCountries: string[]`, + réutilisés du Reservoir (`search`, `columnFilters`,
  `columnVisibility`, `rowSelection`, `openKeyword`). Loading mock 200 ms + skeleton réutilisés.
  Re-seed `targets` au changement de site. Actions : `setForSelected`, `setCell`.
- **`research-columns.tsx`** — colonnes liste = colonnes Reservoir importées + colonne
  **« Pays ciblés »** (lit `targets` via `table.options.meta`, rend flags + `TargetStatusBadge`
  ou « — »).
- **`TargetingBar`** — visible à la sélection : multi-select des `TARGET_COUNTRIES` + bouton
  **Cibler** → `setForSelected(selectedKwIds, selectedCountries, "candidate")`. Bouton
  « Annuler » (clear sélection). Distinct du `BulkActionsBar` du Reservoir.
- **`CoverageMatrix`** — vrai `<table>`. `<thead>` : 1ʳᵉ cellule vide + 1 `<th scope="col">`
  par pays (flag + label). `<tbody>` : 1 ligne/keyword, `<th scope="row">` = terme, puis 1
  `<td>` par pays contenant un `TargetStatusBadge`/« — » dans un `DropdownMenu` (clic → menu
  statut → `setCell`). Filtrée par la recherche texte. ~30×4 → pas de virtualisation.
- **`TargetStatusBadge`** — petit badge par statut : `candidate` (outline), `planned`
  (default), `live` (emerald/success). Labels FR.
- **Toggle** — shadcn `Tabs` (Liste | Matrice) dans la barre d'en-tête.

### Câblage

`src/app/(app)/keywords/recherche/page.tsx` rend `<ResearchContainer />` sous le
`DashboardHeader`/breadcrumb existant (remplace le stub « en construction »).

## Flux de données

1. `ResearchContainer` charge `getKeywordsForSite(activeSiteId)` (mock 200 ms) → `rows`.
2. `seedTargets(rows)` initialise `targets` (re-seed au switch de site).
3. **Liste** : `DataTable` + `research-columns`, `targets` passé via `meta`. Sélection de
   lignes → `TargetingBar` → `setForSelected`.
4. **Matrice** : `CoverageMatrix` lit `rows` (filtrés par `search`) + `targets`. Clic cellule
   → `setCell`.
5. Le **toggle** ne change que `view` ; `targets` et la sélection persistent entre les vues.

## États & a11y

- **Loading** : skeleton réutilisé du Reservoir.
- **Vide** : « Aucun keyword pour ce site. »
- **Erreur** : n/a (mock).
- **a11y** : matrice = tableau sémantique (`scope="row"` + `scope="col"`) ; menus cellule
  (`DropdownMenu`) et multi-select pays clavier-accessibles ; focus visibles.

## Tests

`src/lib/keywords/targets.test.ts` (bun:test) sur les helpers purs :
- `seedTargets` déterministe (même entrée → même sortie ; isolation par `siteId`).
- `setCell` ajoute, met à jour, et **retire** (status `null` = non ciblé).
- `setForSelected` crée des `candidate` pour chaque (keyword × pays), sans écraser un statut
  plus avancé existant (à trancher : écrase ou préserve — **décision : ne pas rétrograder**).
- `coverageOf` renvoie le résumé trié attendu.

## Risques / questions ouvertes (reportées)

- Liste des pays = `site.locales` (cadrée) vs libre — tranché côté API réelle.
- Statut `live` renseigné en retour par `contenu/planning` vs manuel — hors lot.
- Priorité d'une cible (manuelle vs score par pays) — hors lot.
