---
page: backlink
sous-page: reservoir
route: /backlink/reservoir
statut: design-approved
version: 1.0
date: 2026-06-04
source-cdc: ~/Documents/seo insight/docs/specs/backlink/reservoir.md
seed-source: ~/Downloads/outrank_400+ directories.xlsx - Directories.tsv
---

# Design — `backlink/reservoir` (premier slice)

> **Reservoir GLOBAL** des sources de liens, calqué sur le template table + couleurs
> du reservoir keywords. Seedé depuis le TSV "Startup Directory Master List"
> (~400 répertoires/communautés). Le site n'intervient **pas** ici (CdC) ;
> le pont vers le travail par site = action **"Add to project"** (projets nommés,
> reliés à un site plus tard dans `backlink/planning`).

## 0. Périmètre & non-périmètre

**Dans ce slice :**
- Seed dataset nettoyé depuis le TSV (sources GLOBALES).
- Table dense réutilisant `DataTable` (tri, colonnes réordonnables/masquables) + badges/dégradés du reservoir keywords.
- Colonne **DA** en dégradé couleur **inversé** (haut=vert, bas=rouge — l'inverse de KD).
- Couche **projets nommés (multi)** + action **"Add to project"** (bulk + ligne).
- Colonne **Footprint** présente dans le modèle (lecture seule), **masquée par défaut** tant que vide.

**Hors slice (différé) :**
- Triage par site / matrice `source × site` / targeting bar par site → `backlink/recherche` & `backlink/planning`.
- Remplissage du footprint (nécessite des liens placés / import de profil) → `planning` / `export-import`.
- Métriques multi-fournisseurs (TF/CF, DR, referring domains, trafic) → différé ; seul **DA** est seedé.
- Flux UI toxic→disavow, dédup/merge interactif, vues sauvegardées → différé (le **modèle** porte déjà `flags`, la dédup est faite **au seed**).
- Édition inline des champs source (kind/langue/thèmes/tags) → différé sauf "Add to project".

## 1. Modèle de données

### 1.1 Source globale — `src/lib/backlink/types.ts` (pur)

```ts
export type LinkType = "dofollow" | "nofollow" | "mixed";
export type Effort = "low" | "medium" | "high";
export type SourceKind =
  | "directory" | "ai-directory" | "media" | "community" | "subreddit"
  | "review-site" | "launch-platform" | "blog" | "forum" | "marketplace"
  | "newsletter" | "design" | "platform" | "jobs" | "tools" | "other";

export interface LinkSource {        // GLOBAL — pas de site_id
  id: string;                        // slug déterministe dérivé de domainNorm
  platform: string;                  // "Product Hunt"
  url: string;                       // normalisée (https, sans slash final)
  domainNorm: string;                // "producthunt.com" (sans www) — clé de dédup
  kind: SourceKind;                  // ex-"Category" du TSV
  da: number | null;                 // 0-100 ; null = inconnu ("—")
  effort: Effort;
  linkType: LinkType;
  language: string;                  // "en" par défaut (dataset US)
  themes: string[];                  // topical — vide au seed
  tags: string[];                    // libres — vides au seed
  flags: { toxic: boolean; disavow: boolean };
  note?: string;
}
```

`kind` = **format** de la source (≠ thème topical). `themes` reste séparé et vide,
à remplir plus tard.

### 1.2 Projets — `src/lib/backlink/projects.ts` (pur, testé)

```ts
export interface Project { id: string; name: string; } // siteId assigné plus tard (planning)
export type Membership = Record<string /*sourceId*/, string[] /*projectIds*/>;

export const SEED_PROJECTS: Project[];                  // 2 projets d'exemple
listProjects(projects): Project[];
createProject(projects, name): { projects: Project[]; project: Project };  // id slug déterministe
addToProject(m: Membership, sourceIds: string[], projectId): Membership;   // immutable, dédup
removeFromProject(m: Membership, sourceId: string, projectId): Membership;  // drop entrées vides
projectsOf(m: Membership, projects: Project[], sourceId: string): Project[];// ordre SEED_PROJECTS
```

- Déterministe — **pas de `Math.random`** (id = slug du nom ; collision → suffixe `-2`, `-3`…).
- `Membership` démarre **vide** (le TSV n'a aucun projet assigné).

### 1.3 Relation par site (différée)

Le pont `LinkSource ↔ site` (statut `prospect/planned/live/lost`, footprint) vit dans
`backlink/planning`. Non implémenté dans ce slice ; documenté pour cohérence avec le CdC.

## 2. Seed dataset (nettoyage TSV → `fixtures.ts`)

Script de transformation **one-shot** (lancé hors app, non livré) : TSV →
`src/lib/backlink/fixtures.ts` exportant `BACKLINK_SOURCES: LinkSource[]`.

Règles :
1. **Drop** : 4 lignes d'en-tête vides, la cellule pub "→ outrank.so automates all of this", les lignes totalement vides, la catégorie parasite `→ outrank.so`.
2. **URL** : forcer `https://`, retirer slash final, host en minuscules ; garder le path s'il existe.
3. **`domainNorm`** : host sans `www.` → clé de dédup.
4. **Dédup** : clé = `domainNorm` pour les hôtes normaux, mais **path-aware** pour les plateformes multi-tenant (`reddit.com`, `discord.com`, `discord.gg`, `linkedin.com`, `t.me`, `facebook.com`) où chaque subreddit/serveur/groupe reste une source distincte (clé = domaine + chemin minuscule). Collision de clé → **merge auto** (DA max, union des champs) + **log** des merges (rapporté à l'utilisateur). `domainNorm` n'est donc volontairement **pas unique** (plusieurs `reddit.com`) ; l'unicité porte sur `id`/`url`.
5. **Mapping** : `Category`→`kind` (slug) ; `DA` `"—"`/vide → **`null`** ; `Effort`→`low/medium/high` ; `Link Type`→`dofollow/nofollow/mixed` ; `language="en"` ; `themes=[]` ; `tags=[]` ; `flags={toxic:false,disavow:false}`.
6. **`id`** = slug déterministe du `domainNorm`.

## 3. Table reservoir (UI)

`src/app/(app)/backlink/reservoir/page.tsx` → `<BacklinkReservoirContainer />`
(`src/components/backlink/reservoir-container.tsx`). Réutilise le `DataTable`
générique (tri, `aria-sort`, réordonnable drag + Alt+←/→, masquable) et le pattern
badges/dégradés du reservoir keywords.

Colonnes — `src/components/backlink/columns.tsx` :

| Col | Rendu | Tri | Défaut |
|---|---|---|---|
| ☑ select | checkbox (pinned) | — | visible |
| Platform | nom + host en sous-ligne, lien `↗` vers l'URL | alpha | visible |
| Kind | badge | alpha | visible |
| DA | `DaBadge` — dégradé **inversé** (100=vert→50=jaune→0=rouge) ; `null`=gris "—" trié à part | num | visible |
| Link Type | badge dofollow(vert)/nofollow(gris)/mixed(ambre) | alpha | visible |
| Effort | badge low(vert)/medium(ambre)/high(rouge) | low<med<high | visible |
| Projects | badges projets (ou `n` si nombreux), `—` si aucun | — | visible |
| Footprint | `n/3` sites + pastilles statut | num | **masqué** (vide pour l'instant) |
| Langue | badge | alpha | **masqué** |
| Thèmes | tags (vide → "—") | — | **masqué** |
| Tags | tags | — | **masqué** |
| Flags | icônes toxic/disavow si actifs | — | visible |

- **`DaBadge`** (`src/components/backlink/da-badge.tsx`) = clone de `KdBadge` avec **hue inversé** (`hue = 120 * clamped` au lieu de `120 * (1-clamped)`), même style `font-bold text-white`. `da === null` → `bg-muted text-muted-foreground` "—".
- **`Projects`** lit `Membership` via `table.options.meta` (même mécanisme que `targets`/`cpcRange` sur keywords ; augmentation de `TableMeta`).
- **Toolbar** : recherche plein-texte (platform/domainNorm), filtres (kind, linkType, effort, plage DA, langue, état flags), bouton colonnes — repris du reservoir keywords.

## 4. Couche projets (UI)

- **Action "Add to project"** : `DropdownMenu` avec `DropdownMenuCheckboxItem` par projet (multi-sélection) + champ **"New project…"** en bas.
  - **Bulk** : barre de sélection quand ≥1 ligne cochée.
  - **Ligne** : menu `⋯`.
  - Composant `src/components/backlink/add-to-project-menu.tsx`.
- État `Membership` + `projects` détenus par `reservoir-container.tsx` (`useState`), passés en `meta`.
- Seed : `SEED_PROJECTS` = 2 projets d'exemple pour que l'UI ne soit pas vide.

## 5. Fichiers

```
src/lib/backlink/
  types.ts            # LinkSource, enums (pur)
  fixtures.ts         # BACKLINK_SOURCES (~400, généré du TSV)
  projects.ts         # Project, Membership + helpers purs
  projects.test.ts    # bun:test
  normalize.ts        # normalizeUrl / domainNorm / dédup-merge (pur, réutilisé par le script de seed)
  normalize.test.ts   # bun:test
src/components/backlink/
  columns.tsx
  da-badge.tsx
  kind-badge.tsx      # (+ effort/link-type badges — réutilise metric-badge si possible)
  add-to-project-menu.tsx
  reservoir-container.tsx
src/app/(app)/backlink/reservoir/page.tsx
docs/project/pages/backlink/reservoir.md   # build brief
```

## 6. Tests

- **`bun:test` sur fonctions pures uniquement** (pas de runner React) :
  - `projects.ts` : addToProject (dédup/multi), removeFromProject (drop vide), createProject (slug déterministe + collision), projectsOf (ordre).
  - `normalize.ts` : normalizeUrl (https/slash/casse/path), domainNorm (www), dédup-merge (DA max, union).
- Déterminisme strict — **aucun `Math.random` / `Date.now`**.
- tsc clean + biome clean.

## 7. Réutilisations

`DataTable`, `cn`, `metric-badge`/`scoreStyle` (base du `DaBadge`), tokens design,
mécanisme `TableMeta` (augmentation), pattern `targets.ts` (immutabilité/dédup) repris
pour `projects.ts`.

## 8. Critères d'acceptation

- [ ] Le seed charge ~400 sources globales nettoyées (URLs normalisées, `domainNorm` dédupliqué + log des merges).
- [ ] DA s'affiche en dégradé inversé (DA élevé = vert) ; DA `null` = badge gris "—" trié à part.
- [ ] La table trie/réordonne/masque les colonnes (template keywords).
- [ ] "Add to project" (bulk + ligne) ajoute des sources à un projet existant ou nouveau (multi-projets), reflété dans la colonne Projects.
- [ ] La colonne Footprint existe dans le modèle, masquée par défaut (vide pour l'instant).
- [ ] tsc + biome + `bun test` verts.

## 9. Conformité CdC

Aligné avec `~/Documents/seo insight/docs/specs/backlink/reservoir.md` : reservoir
**global** sans isolation site (§1, §F1), `link_source` global clé `domain_norm` (§7),
dédup `www`/non-`www` (§F7, faite au seed), modèle portant `flags toxic/disavow` (§F8).
Différé explicitement : multi-fournisseurs (§F4), footprint peuplé (§F5), pertinence
thème/langue (§F6 — dimensions présentes mais non exploitées), relations par site (§7
`backlink` → `recherche`/`planning`).
