# /backlink/overview 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:** Dashboard netlinking en lecture seule (`/backlink/overview`) — KPIs pool, risques, campagne du site actif, opportunités, activité, actions suggérées — sans migration ni écriture.

**Architecture:** Une fonction d'agrégation server-only `getBacklinkOverview(orgId, siteId)` (réutilise relevance/planning helpers, 3 requêtes SQL + agrégats JS) + logique pure TDD (`overview-logic.ts`) + page RSC avec composants serveur purs (grille de cartes, pattern keywords/overview).

**Tech Stack:** Next 16 App Router (RSC), Drizzle/Postgres, bun:test, shadcn Card, BarList/StatCard existants.

**Spec:** `docs/superpowers/specs/2026-06-10-backlink-overview-design.md` (lis-la en cas de doute).

**Branche:** `feature/backlink-overview` (déjà créée). Conventional Commits. Ne JAMAIS toucher `docs/project/bug-fix.md`.

---

### Task 1: Logique pure `overview-logic.ts` (TDD)

**Files:**
- Create: `src/lib/backlink/overview-logic.ts`
- Test: `src/lib/backlink/overview-logic.test.ts`

- [ ] **Step 1: Écrire les tests qui échouent**

```ts
// src/lib/backlink/overview-logic.test.ts
import { expect, test } from "bun:test";
import {
  buildDrBuckets,
  drBucketLabel,
  suggestedActions,
} from "./overview-logic";

test("drBucketLabel: bornes exactes + null", () => {
  expect(drBucketLabel(null)).toBe("N/A");
  expect(drBucketLabel(0)).toBe("0–20");
  expect(drBucketLabel(20)).toBe("0–20");
  expect(drBucketLabel(21)).toBe("21–40");
  expect(drBucketLabel(40)).toBe("21–40");
  expect(drBucketLabel(41)).toBe("41–60");
  expect(drBucketLabel(60)).toBe("41–60");
  expect(drBucketLabel(61)).toBe("61–80");
  expect(drBucketLabel(80)).toBe("61–80");
  expect(drBucketLabel(81)).toBe("81–100");
  expect(drBucketLabel(100)).toBe("81–100");
});

test("buildDrBuckets: 6 buckets ordonnés, counts à 0 inclus", () => {
  const buckets = buildDrBuckets([null, 10, 20, 35, 90]);
  expect(buckets.map((b) => b.label)).toEqual([
    "0–20",
    "21–40",
    "41–60",
    "61–80",
    "81–100",
    "N/A",
  ]);
  expect(buckets.map((b) => b.count)).toEqual([2, 1, 0, 0, 1, 1]);
});

test("suggestedActions: priorité, max 4, vide", () => {
  expect(
    suggestedActions({ lostSite: 0, toxicSite: 0, prospects: 0, opportunities: 0 }),
  ).toEqual([]);
  const all = suggestedActions({
    lostSite: 2,
    toxicSite: 1,
    prospects: 3,
    opportunities: 5,
  });
  expect(all).toEqual([
    { label: "2 lien(s) perdu(s) à reconquérir", href: "/backlink/planning" },
    { label: "1 toxique(s) à examiner (disavow)", href: "/backlink/planning" },
    { label: "3 prospect(s) à planifier", href: "/backlink/planning" },
    { label: "5 opportunité(s) à prospecter", href: "/backlink/recherche" },
  ]);
  expect(
    suggestedActions({ lostSite: 0, toxicSite: 0, prospects: 2, opportunities: 0 }),
  ).toEqual([{ label: "2 prospect(s) à planifier", href: "/backlink/planning" }]);
});
```

- [ ] **Step 2: Vérifier l'échec**

Run: `bun run test src/lib/backlink/overview-logic.test.ts`
Expected: FAIL (module inexistant)

- [ ] **Step 3: Implémenter**

```ts
// src/lib/backlink/overview-logic.ts — logique pure du dashboard netlinking.

export type DrBucket = { label: string; count: number };

const DR_BUCKET_LABELS = [
  "0–20",
  "21–40",
  "41–60",
  "61–80",
  "81–100",
  "N/A",
] as const;

export function drBucketLabel(value: number | null): string {
  if (value == null) return "N/A";
  if (value <= 20) return "0–20";
  if (value <= 40) return "21–40";
  if (value <= 60) return "41–60";
  if (value <= 80) return "61–80";
  return "81–100";
}

/** Toujours les 6 buckets dans l'ordre, counts à 0 inclus. */
export function buildDrBuckets(values: (number | null)[]): DrBucket[] {
  const counts = new Map<string, number>(DR_BUCKET_LABELS.map((l) => [l, 0]));
  for (const v of values) {
    const label = drBucketLabel(v);
    counts.set(label, (counts.get(label) ?? 0) + 1);
  }
  return DR_BUCKET_LABELS.map((label) => ({
    label,
    count: counts.get(label) ?? 0,
  }));
}

export type SuggestedAction = { label: string; href: string };

/** F6 — dérivation déterministe des actions suggérées, par ordre de priorité. */
export function suggestedActions(input: {
  lostSite: number;
  toxicSite: number;
  prospects: number;
  opportunities: number;
}): SuggestedAction[] {
  const actions: SuggestedAction[] = [];
  if (input.lostSite > 0)
    actions.push({
      label: `${input.lostSite} lien(s) perdu(s) à reconquérir`,
      href: "/backlink/planning",
    });
  if (input.toxicSite > 0)
    actions.push({
      label: `${input.toxicSite} toxique(s) à examiner (disavow)`,
      href: "/backlink/planning",
    });
  if (input.prospects > 0)
    actions.push({
      label: `${input.prospects} prospect(s) à planifier`,
      href: "/backlink/planning",
    });
  if (input.opportunities > 0)
    actions.push({
      label: `${input.opportunities} opportunité(s) à prospecter`,
      href: "/backlink/recherche",
    });
  return actions;
}
```

- [ ] **Step 4: Vérifier le pass**

Run: `bun run test src/lib/backlink/overview-logic.test.ts`
Expected: 3 pass

- [ ] **Step 5: Commit**

```bash
git add src/lib/backlink/overview-logic.ts src/lib/backlink/overview-logic.test.ts
git commit -m "feat(backlink): overview pure logic — DR buckets + actions suggérées"
```

---

### Task 2: Agrégats `getBacklinkOverview` (TDD, DB)

**Files:**
- Create: `src/lib/backlink/overview.ts`
- Test: `src/lib/backlink/overview.test.ts`

**Contexte:** miroir des patterns de `src/lib/backlink/planning.ts` (queries) et `src/lib/backlink/planning.test.ts` (test DB gated `DB_TESTS=1`, org `demo-org`, `assertTestDb`, cleanup en `beforeEach`). `relevance.ts` exporte `relevanceScore`, `FOOTPRINT_THRESHOLD` (=3). `planning.ts` exporte `getCadence`. Le score : langue source ≠ langue site → facteur 0.3 ; valeur = `dr ?? da`.

- [ ] **Step 1: Écrire le test DB qui échoue**

```ts
// src/lib/backlink/overview.test.ts — integration, gated on DB_TESTS=1.
import { beforeEach, describe, expect, mock, test } from "bun:test";

mock.module("server-only", () => ({}));

const RUN_DB = process.env.DB_TESTS === "1";

describe.skipIf(!RUN_DB)("backlink overview aggregates (postgres)", () => {
  let db: typeof import("@/lib/db/client").db;
  let schema: typeof import("@/lib/db/domain-schema");
  const ORG = "demo-org";
  const SITE_A = "wt-ov-a";
  const SITE_B = "wt-ov-b";
  const SITE_C = "wt-ov-c";

  async function insertSource(
    id: string,
    overrides: Record<string, unknown> = {},
  ) {
    await db.insert(schema.linkSources).values({
      id,
      orgId: ORG,
      platform: id,
      url: `https://${id.toLowerCase()}.com`,
      domainNorm: `${id.toLowerCase()}.com`,
      kind: "blog",
      da: 40,
      effort: "low",
      linkType: "dofollow",
      language: "fr",
      ...overrides,
    } as never);
  }

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    schema = await import("@/lib/db/domain-schema");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    const { inArray } = await import("drizzle-orm");
    await assertTestDb();
    await db.delete(schema.backlinkOutreach);
    await db.delete(schema.backlinks);
    await db.delete(schema.linkSources);
    await db.delete(schema.domainRatings);
    await db
      .delete(schema.sites)
      .where(inArray(schema.sites.id, [SITE_A, SITE_B, SITE_C]));
    await db.insert(schema.sites).values(
      [SITE_A, SITE_B, SITE_C].map((id) => ({
        id,
        orgId: ORG,
        ownerUserId: "seed-owner",
        name: id,
        url: `https://${id}.com`,
        primaryLanguage: "fr",
        languages: [],
        targetCountries: ["FR"],
      })),
    );
  });

  test("pool: counts, byKind/byLanguage triés, drBuckets (DR partagé + fallback DA)", async () => {
    await insertSource("S1", { kind: "blog", language: "fr" }); // dr 90 → 81–100
    await insertSource("S2", { kind: "blog", language: "en", da: 10 }); // → 0–20
    await insertSource("S3", { kind: "annuaire", language: "fr", da: null }); // → N/A
    await db
      .insert(schema.domainRatings)
      .values({ domainNorm: "s1.com", dr: 90 });

    const { getBacklinkOverview } = await import("./overview");
    const ov = await getBacklinkOverview(ORG, SITE_A);
    expect(ov.pool.sources).toBe(3);
    expect(ov.pool.byKind[0]).toEqual({ kind: "blog", count: 2 });
    expect(ov.pool.byLanguage[0]).toEqual({ language: "fr", count: 2 });
    const byLabel = Object.fromEntries(
      ov.pool.drBuckets.map((b) => [b.label, b.count]),
    );
    expect(byLabel["81–100"]).toBe(1);
    expect(byLabel["0–20"]).toBe(1);
    expect(byLabel["N/A"]).toBe(1);
  });

  test("risks: footprint ≥3 sites, toxic/lost site vs org, disavow site", async () => {
    await insertSource("S1");
    await insertSource("S2");
    await db.insert(schema.backlinks).values([
      // s1.com référencé par 3 sites → footprint
      { id: "b1", linkSourceId: "S1", siteId: SITE_A, status: "live" },
      { id: "b2", linkSourceId: "S1", siteId: SITE_B, status: "live" },
      { id: "b3", linkSourceId: "S1", siteId: SITE_C, status: "live" },
      // risques site A vs org
      {
        id: "b4",
        linkSourceId: "S2",
        siteId: SITE_A,
        status: "lost",
        toxic: true,
        disavow: true,
      },
      { id: "b5", linkSourceId: "S2", siteId: SITE_B, status: "lost", toxic: true },
    ]);

    const { getBacklinkOverview } = await import("./overview");
    const ov = await getBacklinkOverview(ORG, SITE_A);
    expect(ov.risks.footprintDomains).toEqual([
      { domainNorm: "s1.com", siteCount: 3 },
    ]);
    expect(ov.risks.toxicSite).toBe(1);
    expect(ov.risks.toxicOrg).toBe(2);
    expect(ov.risks.lostSite).toBe(1);
    expect(ov.risks.lostOrg).toBe(2);
    expect(ov.risks.disavowSite).toBe(1);
  });

  test("campaign: compteurs par statut (clés complètes) + cadence", async () => {
    await insertSource("S1");
    await insertSource("S2");
    await db.insert(schema.backlinks).values([
      { id: "b1", linkSourceId: "S1", siteId: SITE_A, status: "prospect" },
      {
        id: "b2",
        linkSourceId: "S2",
        siteId: SITE_A,
        status: "live",
        firstSeen: new Date(),
      },
    ]);

    const { getBacklinkOverview } = await import("./overview");
    const ov = await getBacklinkOverview(ORG, SITE_A);
    expect(ov.campaign.counters).toEqual({
      prospect: 1,
      planned: 0,
      in_progress: 0,
      live: 1,
      lost: 0,
    });
    expect(ov.campaign.cadence.thisMonth).toBe(1);
    expect(ov.campaign.cadence.lastMonth).toBe(0);
  });

  test("opportunities: exclut les sources déjà prospectées par le site, top par score", async () => {
    await insertSource("S1", { language: "fr" }); // prospecté par A → exclu
    await insertSource("S2", { language: "fr", da: 80 }); // score 80
    await insertSource("S3", { language: "en", da: 90 }); // score 27 (0.3×90)
    await db.insert(schema.backlinks).values([
      { id: "b1", linkSourceId: "S1", siteId: SITE_A, status: "prospect" },
      // S2 prospecté par B seulement → reste une opportunité pour A
      { id: "b2", linkSourceId: "S2", siteId: SITE_B, status: "live" },
    ]);

    const { getBacklinkOverview } = await import("./overview");
    const ov = await getBacklinkOverview(ORG, SITE_A);
    expect(ov.opportunities.map((o) => o.id)).toEqual(["S2", "S3"]);
    expect(ov.opportunities[0]?.score).toBe(80);
  });

  test("activity: prospects créés + passages live, 5 plus récents desc", async () => {
    await insertSource("S1");
    await insertSource("S2");
    const old = new Date("2026-01-01T00:00:00Z");
    const mid = new Date("2026-03-01T00:00:00Z");
    const recent = new Date("2026-06-01T00:00:00Z");
    await db.insert(schema.backlinks).values([
      {
        id: "b1",
        linkSourceId: "S1",
        siteId: SITE_A,
        status: "live",
        createdAt: old,
        firstSeen: recent,
      },
      {
        id: "b2",
        linkSourceId: "S2",
        siteId: SITE_A,
        status: "prospect",
        createdAt: mid,
      },
      // autre site → exclu
      { id: "b3", linkSourceId: "S1", siteId: SITE_B, status: "prospect" },
    ]);

    const { getBacklinkOverview } = await import("./overview");
    const ov = await getBacklinkOverview(ORG, SITE_A);
    expect(ov.activity.map((e) => [e.type, e.platform])).toEqual([
      ["live", "S1"],
      ["prospect", "S2"],
      ["prospect", "S1"],
    ]);
    expect(ov.activity[0]?.at).toBe(recent.toISOString());
  });
});
```

- [ ] **Step 2: Vérifier l'échec**

Run: `bun run test src/lib/backlink/overview.test.ts`
Expected: FAIL (`./overview` inexistant)

- [ ] **Step 3: Implémenter**

```ts
// src/lib/backlink/overview.ts — agrégats lecture seule du dashboard netlinking.
import "server-only";
import { eq, inArray } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
  type BacklinkStatus,
  backlinks,
  domainRatings,
  linkSources,
  sites,
} from "@/lib/db/domain-schema";
import { buildDrBuckets, type DrBucket } from "./overview-logic";
import { getCadence } from "./planning";
import { FOOTPRINT_THRESHOLD, relevanceScore } from "./relevance";
import type { SourceKind } from "./types";

export type BacklinkOverview = {
  pool: {
    sources: number;
    byKind: { kind: SourceKind; count: number }[]; // top 5, desc
    byLanguage: { language: string; count: number }[]; // desc
    drBuckets: DrBucket[];
  };
  risks: {
    footprintDomains: { domainNorm: string; siteCount: number }[];
    toxicSite: number;
    toxicOrg: number;
    lostSite: number;
    lostOrg: number;
    disavowSite: number;
  };
  campaign: {
    counters: Record<BacklinkStatus, number>;
    cadence: { thisMonth: number; lastMonth: number };
  };
  opportunities: {
    id: string;
    platform: string;
    domainNorm: string;
    kind: SourceKind;
    dr: number | null;
    da: number | null;
    language: string;
    score: number;
  }[];
  activity: {
    type: "prospect" | "live";
    platform: string;
    domainNorm: string;
    at: string; // ISO
  }[];
};

export async function getBacklinkOverview(
  orgId: string,
  siteId: string,
): Promise<BacklinkOverview> {
  const site = (await db.select().from(sites).where(eq(sites.id, siteId)))[0];
  const siteLanguage = site?.primaryLanguage ?? "fr";

  const sources = await db
    .select()
    .from(linkSources)
    .where(eq(linkSources.orgId, orgId));

  const domains = [...new Set(sources.map((s) => s.domainNorm))];
  const drRows = domains.length
    ? await db
        .select({ domainNorm: domainRatings.domainNorm, dr: domainRatings.dr })
        .from(domainRatings)
        .where(inArray(domainRatings.domainNorm, domains))
    : [];
  const drByDomain = new Map(drRows.map((d) => [d.domainNorm, d.dr]));

  // Tous les backlinks de l'org joints à leur source (risques org + activité site).
  const links = sources.length
    ? await db
        .select({
          siteId: backlinks.siteId,
          linkSourceId: backlinks.linkSourceId,
          status: backlinks.status,
          toxic: backlinks.toxic,
          disavow: backlinks.disavow,
          firstSeen: backlinks.firstSeen,
          createdAt: backlinks.createdAt,
          platform: linkSources.platform,
          domainNorm: linkSources.domainNorm,
        })
        .from(backlinks)
        .innerJoin(linkSources, eq(linkSources.id, backlinks.linkSourceId))
        .where(eq(linkSources.orgId, orgId))
    : [];
  const siteLinks = links.filter((l) => l.siteId === siteId);

  // F1 — pool
  const byKindMap = new Map<SourceKind, number>();
  const byLangMap = new Map<string, number>();
  for (const s of sources) {
    byKindMap.set(s.kind, (byKindMap.get(s.kind) ?? 0) + 1);
    byLangMap.set(s.language, (byLangMap.get(s.language) ?? 0) + 1);
  }
  const byKind = [...byKindMap]
    .map(([kind, count]) => ({ kind, count }))
    .sort((a, b) => b.count - a.count)
    .slice(0, 5);
  const byLanguage = [...byLangMap]
    .map(([language, count]) => ({ language, count }))
    .sort((a, b) => b.count - a.count);
  const drBuckets = buildDrBuckets(
    sources.map((s) => drByDomain.get(s.domainNorm) ?? s.da),
  );

  // F2 — risques
  const sitesByDomain = new Map<string, Set<string>>();
  for (const l of links) {
    const set = sitesByDomain.get(l.domainNorm) ?? new Set<string>();
    set.add(l.siteId);
    sitesByDomain.set(l.domainNorm, set);
  }
  const footprintDomains = [...sitesByDomain]
    .map(([domainNorm, set]) => ({ domainNorm, siteCount: set.size }))
    .filter((d) => d.siteCount >= FOOTPRINT_THRESHOLD)
    .sort((a, b) => b.siteCount - a.siteCount);

  // F3 — campagne du site actif
  const counters: Record<BacklinkStatus, number> = {
    prospect: 0,
    planned: 0,
    in_progress: 0,
    live: 0,
    lost: 0,
  };
  for (const l of siteLinks) counters[l.status] += 1;

  // F4 — top 5 sources non prospectées par le site, par score desc
  const targeted = new Set(siteLinks.map((l) => l.linkSourceId));
  const opportunities = sources
    .filter((s) => !targeted.has(s.id))
    .map((s) => {
      const dr = drByDomain.get(s.domainNorm) ?? null;
      return {
        id: s.id,
        platform: s.platform,
        domainNorm: s.domainNorm,
        kind: s.kind,
        dr,
        da: s.da,
        language: s.language,
        score: relevanceScore(
          { language: s.language, dr, da: s.da },
          siteLanguage,
        ),
      };
    })
    .sort((a, b) => b.score - a.score)
    .slice(0, 5);

  // F5 — activité : prospect créé (createdAt) + passage live (firstSeen)
  const activity = [
    ...siteLinks.map((l) => ({
      type: "prospect" as const,
      platform: l.platform,
      domainNorm: l.domainNorm,
      at: l.createdAt,
    })),
    ...siteLinks
      .filter((l) => l.firstSeen != null)
      .map((l) => ({
        type: "live" as const,
        platform: l.platform,
        domainNorm: l.domainNorm,
        at: l.firstSeen as Date,
      })),
  ]
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 5)
    .map((e) => ({ ...e, at: e.at.toISOString() }));

  return {
    pool: { sources: sources.length, byKind, byLanguage, drBuckets },
    risks: {
      footprintDomains,
      toxicSite: siteLinks.filter((l) => l.toxic).length,
      toxicOrg: links.filter((l) => l.toxic).length,
      lostSite: siteLinks.filter((l) => l.status === "lost").length,
      lostOrg: links.filter((l) => l.status === "lost").length,
      disavowSite: siteLinks.filter((l) => l.disavow).length,
    },
    campaign: { counters, cadence: await getCadence(siteId) },
    opportunities,
    activity,
  };
}
```

Note : si `FOOTPRINT_THRESHOLD` n'est pas exporté par `relevance.ts`, exporte-le (valeur 3, déjà définie là-bas) — ne le redéfinis pas localement.

- [ ] **Step 4: Vérifier le pass**

Run: `bun run test src/lib/backlink/overview.test.ts`
Expected: 5 pass
Run aussi: `bun run test src/lib/backlink` (non-régression du répertoire)

- [ ] **Step 5: Commit**

```bash
git add src/lib/backlink/overview.ts src/lib/backlink/overview.test.ts
git commit -m "feat(backlink): getBacklinkOverview — agrégats pool/risques/campagne/opportunités/activité"
```

---

### Task 3: Page RSC + cartes + sidebar

**Files:**
- Create: `src/app/(app)/backlink/overview/page.tsx`
- Create: `src/components/backlink/overview/overview-view.tsx`
- Create: `src/components/backlink/overview/overview-kpis.tsx`
- Create: `src/components/backlink/overview/actions-card.tsx`
- Create: `src/components/backlink/overview/risks-card.tsx`
- Create: `src/components/backlink/overview/campaign-card.tsx`
- Create: `src/components/backlink/overview/pool-card.tsx`
- Create: `src/components/backlink/overview/opportunities-card.tsx`
- Create: `src/components/backlink/overview/activity-card.tsx`
- Modify: `src/components/app-sidebar.tsx` (section Backlink, ~ligne 69)

**Contexte:** tout est **composant serveur** (lecture seule, zéro `"use client"`, zéro action). Réutilise `StatCard` (`@/components/keywords/overview/stat-card`), `BarList` (`@/components/keywords/overview/bar-list`), `DrBadge` (`@/components/backlink/dr-badge`), `Card` shadcn. Le squelette de page est le miroir de `src/app/(app)/backlink/planning/page.tsx`.

- [ ] **Step 1: Page RSC**

```tsx
// src/app/(app)/backlink/overview/page.tsx
import { BacklinkOverviewView } from "@/components/backlink/overview/overview-view";
import { DashboardHeader } from "@/components/dashboard-header";
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { getActiveOrgId } from "@/lib/active-org";
import { getBacklinkOverview } from "@/lib/backlink/overview";
import { getActiveSiteId } from "@/lib/sites/active-site";
import { getSitesForOrg } from "@/lib/sites/queries";

export default async function Page() {
  const orgId = await getActiveOrgId();
  const sites = await getSitesForOrg(orgId);
  const siteId = await getActiveSiteId(sites.map((s) => s.id));
  const ov = siteId ? await getBacklinkOverview(orgId, siteId) : null;

  return (
    <>
      <DashboardHeader>
        <Breadcrumb>
          <BreadcrumbList>
            <BreadcrumbItem>Backlink</BreadcrumbItem>
            <BreadcrumbSeparator />
            <BreadcrumbItem>
              <BreadcrumbPage>Overview</BreadcrumbPage>
            </BreadcrumbItem>
          </BreadcrumbList>
        </Breadcrumb>
      </DashboardHeader>
      {/* key by site so a switch (router.refresh) remounts with fresh server data */}
      {ov ? (
        <BacklinkOverviewView key={siteId} ov={ov} />
      ) : (
        <div className="flex h-64 items-center justify-center text-muted-foreground">
          Sélectionnez un site pour voir le dashboard netlinking.
        </div>
      )}
    </>
  );
}
```

- [ ] **Step 2: Vue + cartes**

```tsx
// src/components/backlink/overview/overview-view.tsx
import type { BacklinkOverview } from "@/lib/backlink/overview";
import { ActionsCard } from "./actions-card";
import { ActivityCard } from "./activity-card";
import { CampaignCard } from "./campaign-card";
import { OpportunitiesCard } from "./opportunities-card";
import { OverviewKpis } from "./overview-kpis";
import { PoolCard } from "./pool-card";
import { RisksCard } from "./risks-card";

export function BacklinkOverviewView({ ov }: { ov: BacklinkOverview }) {
  return (
    <div className="space-y-4 p-4">
      <OverviewKpis ov={ov} />
      <div className="grid gap-4 lg:grid-cols-3">
        <ActionsCard ov={ov} />
        <RisksCard ov={ov} />
        <CampaignCard ov={ov} />
      </div>
      <div className="grid gap-4 lg:grid-cols-3">
        <PoolCard ov={ov} />
        <OpportunitiesCard ov={ov} />
        <ActivityCard ov={ov} />
      </div>
    </div>
  );
}
```

```tsx
// src/components/backlink/overview/overview-kpis.tsx
import { StatCard } from "@/components/keywords/overview/stat-card";
import type { BacklinkOverview } from "@/lib/backlink/overview";

export function OverviewKpis({ ov }: { ov: BacklinkOverview }) {
  return (
    <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
      <StatCard label="Sources (réservoir)" value={ov.pool.sources} />
      <StatCard label="Liens live" value={ov.campaign.counters.live} />
      <StatCard label="Prospects" value={ov.campaign.counters.prospect} />
      <StatCard
        label="Cadence"
        value={ov.campaign.cadence.thisMonth}
        sub={`ce mois · ${ov.campaign.cadence.lastMonth} le mois dernier`}
      />
    </div>
  );
}
```

```tsx
// src/components/backlink/overview/actions-card.tsx
import Link from "next/link";
import { ArrowRightIcon } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { BacklinkOverview } from "@/lib/backlink/overview";
import { suggestedActions } from "@/lib/backlink/overview-logic";

export function ActionsCard({ ov }: { ov: BacklinkOverview }) {
  const actions = suggestedActions({
    lostSite: ov.risks.lostSite,
    toxicSite: ov.risks.toxicSite,
    prospects: ov.campaign.counters.prospect,
    opportunities: ov.opportunities.length,
  });
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">Actions suggérées</CardTitle>
      </CardHeader>
      <CardContent>
        {actions.length === 0 ? (
          <p className="text-muted-foreground text-sm">Rien à faire ✅</p>
        ) : (
          <ul className="space-y-2">
            {actions.map((a) => (
              <li key={a.label}>
                <Link
                  href={a.href}
                  className="flex items-center justify-between gap-2 text-sm hover:underline"
                >
                  <span>{a.label}</span>
                  <ArrowRightIcon className="size-4 shrink-0" />
                </Link>
              </li>
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}
```

```tsx
// src/components/backlink/overview/risks-card.tsx
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { BacklinkOverview } from "@/lib/backlink/overview";

export function RisksCard({ ov }: { ov: BacklinkOverview }) {
  const r = ov.risks;
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">Risques</CardTitle>
      </CardHeader>
      <CardContent className="space-y-3 text-sm">
        <div className="flex items-center justify-between">
          <Link href="/backlink/planning" className="hover:underline">
            Toxiques
          </Link>
          <span className="tabular-nums">
            {r.toxicSite} <span className="text-muted-foreground">/ {r.toxicOrg} org</span>
          </span>
        </div>
        <div className="flex items-center justify-between">
          <Link href="/backlink/planning" className="hover:underline">
            Liens perdus
          </Link>
          <span className="tabular-nums">
            {r.lostSite} <span className="text-muted-foreground">/ {r.lostOrg} org</span>
          </span>
        </div>
        <div className="flex items-center justify-between">
          <Link href="/backlink/planning" className="hover:underline">
            À disavow
          </Link>
          <span className="tabular-nums">{r.disavowSite}</span>
        </div>
        <div className="space-y-1 border-t pt-2">
          <p className="text-muted-foreground text-xs uppercase tracking-wide">
            Footprint (domaines sur-exploités)
          </p>
          {r.footprintDomains.length === 0 ? (
            <p className="text-muted-foreground">Aucun</p>
          ) : (
            r.footprintDomains.slice(0, 5).map((d) => (
              <div key={d.domainNorm} className="flex items-center justify-between">
                <span className="truncate">{d.domainNorm}</span>
                <Badge variant="destructive">⚠️ {d.siteCount} sites</Badge>
              </div>
            ))
          )}
        </div>
      </CardContent>
    </Card>
  );
}
```

```tsx
// src/components/backlink/overview/campaign-card.tsx
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { BacklinkOverview } from "@/lib/backlink/overview";
import type { BacklinkStatus } from "@/lib/db/domain-schema";

const STATUS_LABELS: { status: BacklinkStatus; label: string }[] = [
  { status: "prospect", label: "Prospect" },
  { status: "planned", label: "Planifié" },
  { status: "in_progress", label: "Outreach" },
  { status: "live", label: "Live" },
  { status: "lost", label: "Perdu" },
];

export function CampaignCard({ ov }: { ov: BacklinkOverview }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">
          <Link href="/backlink/planning" className="hover:underline">
            Campagne du site
          </Link>
        </CardTitle>
      </CardHeader>
      <CardContent className="space-y-2 text-sm">
        {STATUS_LABELS.map((s) => (
          <div key={s.status} className="flex items-center justify-between">
            <span>{s.label}</span>
            <span className="tabular-nums">{ov.campaign.counters[s.status]}</span>
          </div>
        ))}
        <p className="border-t pt-2 text-muted-foreground text-xs">
          {ov.campaign.cadence.thisMonth} lien(s) live ce mois ·{" "}
          {ov.campaign.cadence.lastMonth} le mois dernier
        </p>
      </CardContent>
    </Card>
  );
}
```

```tsx
// src/components/backlink/overview/pool-card.tsx
import { BarList } from "@/components/keywords/overview/bar-list";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { BacklinkOverview } from "@/lib/backlink/overview";

export function PoolCard({ ov }: { ov: BacklinkOverview }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">Pool — distribution DR</CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        <BarList
          items={ov.pool.drBuckets.map((b) => ({
            label: b.label,
            value: b.count,
          }))}
        />
        <div className="flex flex-wrap gap-1.5 border-t pt-3">
          {ov.pool.byKind.map((k) => (
            <Badge key={k.kind} variant="secondary">
              {k.kind} · {k.count}
            </Badge>
          ))}
          {ov.pool.byLanguage.map((l) => (
            <Badge key={l.language} variant="outline" className="uppercase">
              {l.language} · {l.count}
            </Badge>
          ))}
        </div>
      </CardContent>
    </Card>
  );
}
```

```tsx
// src/components/backlink/overview/opportunities-card.tsx
import Link from "next/link";
import { DrBadge } from "@/components/backlink/dr-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { BacklinkOverview } from "@/lib/backlink/overview";

export function OpportunitiesCard({ ov }: { ov: BacklinkOverview }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">
          <Link href="/backlink/recherche" className="hover:underline">
            Top opportunités
          </Link>
        </CardTitle>
      </CardHeader>
      <CardContent>
        {ov.opportunities.length === 0 ? (
          <p className="text-muted-foreground text-sm">
            Tout le réservoir est déjà prospecté 🎉
          </p>
        ) : (
          <ul className="space-y-2">
            {ov.opportunities.map((o) => (
              <li key={o.id} className="flex items-center justify-between gap-2 text-sm">
                <div className="min-w-0">
                  <p className="truncate font-medium">{o.platform}</p>
                  <p className="truncate text-muted-foreground text-xs">
                    {o.domainNorm}
                  </p>
                </div>
                <div className="flex shrink-0 items-center gap-2">
                  <DrBadge value={o.dr ?? o.da} />
                  <span className="tabular-nums text-muted-foreground text-xs">
                    {o.score.toFixed(0)}
                  </span>
                </div>
              </li>
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}
```

```tsx
// src/components/backlink/overview/activity-card.tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { BacklinkOverview } from "@/lib/backlink/overview";

const dateFmt = new Intl.DateTimeFormat("fr-FR", { dateStyle: "medium" });

export function ActivityCard({ ov }: { ov: BacklinkOverview }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">Activité récente</CardTitle>
      </CardHeader>
      <CardContent>
        {ov.activity.length === 0 ? (
          <p className="text-muted-foreground text-sm">Aucune activité</p>
        ) : (
          <ul className="space-y-2 text-sm">
            {ov.activity.map((e, i) => (
              <li
                key={`${e.type}-${e.domainNorm}-${i}`}
                className="flex items-center justify-between gap-2"
              >
                <span className="truncate">
                  {e.platform}{" "}
                  <span className="text-muted-foreground">
                    — {e.type === "live" ? "passé live" : "prospect ajouté"}
                  </span>
                </span>
                <span className="shrink-0 text-muted-foreground text-xs">
                  {dateFmt.format(new Date(e.at))}
                </span>
              </li>
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 3: Sidebar**

Dans `src/components/app-sidebar.tsx`, section Backlink, ajouter Overview **en premier** (miroir de Keywords) :

```tsx
      items: [
        { title: "Overview", url: "/backlink/overview" },
        { title: "Reservoir", url: "/backlink/reservoir" },
        { title: "Recherche", url: "/backlink/recherche" },
        { title: "Planning", url: "/backlink/planning" },
        { title: "Profil", url: "/backlink/profile" },
      ],
```

- [ ] **Step 4: Vérifier**

Run: `bunx tsc --noEmit` → 0 erreur.
Run: `bunx biome check src/components/backlink/overview src/app/\(app\)/backlink/overview src/lib/backlink/overview.ts src/lib/backlink/overview-logic.ts` → clean (sinon `--write`).
Run: `bun run test src/lib/backlink` → pass.

- [ ] **Step 5: Commit**

```bash
git add src/app/\(app\)/backlink/overview src/components/backlink/overview src/components/app-sidebar.tsx
git commit -m "feat(backlink): page /backlink/overview — dashboard lecture seule (KPIs, risques, campagne, opportunités, activité, actions)"
```

---

### Task 4: Vérification live + docs + Linear (contrôleur — pas de subagent)

- [ ] Vérifier sur le dev server (d3k agent-browser, site **Acieb** — vérifier le site actif AVANT toute assertion) : page rendue, compteurs cohérents avec `/backlink/planning`, liens drill-down, entrée sidebar, switch de site.
- [ ] `docs/project/roadmap.md` : marquer `overview` ✅ dans la section backlink (import-export reste ⬜).
- [ ] Linear : ELEVA-64 → re-titrer/fermer pour l'overview livré + créer l'issue dédiée import-export (différée, CdC `export-import.md`).
- [ ] Review finale de branche, puis ff-merge dans `dev`, push, suppression de la branche.
