# Keyword 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:** A per-site keyword overview dashboard (GSC performance hero + position distribution, 6 KPIs, quick-win opportunities, intent/theme breakdown, KD histogram, top-by-volume, stages funnel, backlink reservoir card) built on real owned data.

**Architecture:** A single pure aggregator `buildKeywordOverview(keywords, backlinks)` (unit-tested) feeds a typed `KeywordOverview`. A server action `getKeywordOverviewAction(siteId)` fetches keywords + backlink statuses and returns the aggregate. A thin client `OverviewContainer` (active site) renders focused presentational modules using CSS/flex bars (no chart lib).

**Tech Stack:** Next 16 server actions, Drizzle/Postgres, shadcn (Card), bun + biome. Reuses `getKeywordsForSite`, `opportunityScore`, `KdBadge`, `useThemeLabels`, `countryFlag`.

Spec: `docs/superpowers/specs/2026-06-06-keywords-overview-design.md`

---

## File structure

- Modify `src/lib/keywords/gsc-positioning.ts` — add `positionBucket` (+ test).
- Create `src/lib/keywords/overview.ts` — `KeywordOverview` types + `buildKeywordOverview`.
- Create `src/lib/keywords/overview.test.ts` — pure aggregation tests.
- Modify `src/lib/keywords/actions.ts` — `getKeywordOverviewAction(siteId)`.
- Create `src/components/keywords/overview/stat-card.tsx` — KPI primitive.
- Create `src/components/keywords/overview/bar-list.tsx` — horizontal-bar list primitive.
- Create `src/components/keywords/overview/position-distribution.tsx`.
- Create `src/components/keywords/overview/gsc-performance-hero.tsx`.
- Create `src/components/keywords/overview/overview-kpis.tsx`.
- Create `src/components/keywords/overview/opportunities-card.tsx`.
- Create `src/components/keywords/overview/breakdown-card.tsx`.
- Create `src/components/keywords/overview/kd-histogram.tsx`.
- Create `src/components/keywords/overview/top-volume-card.tsx`.
- Create `src/components/keywords/overview/stages-funnel.tsx`.
- Create `src/components/keywords/overview/backlinks-card.tsx`.
- Create `src/components/keywords/overview/overview-container.tsx` — client loader + grid.
- Modify `src/app/(app)/keywords/overview/page.tsx` — header + container.

Tooling: tsc `bunx tsc --noEmit`; pure tests `bun run test <file>`; lint `bunx biome check --write <files>`. Dev server runs under d3k — do NOT start/restart it.

---

## Task 1: `positionBucket` helper

**Files:**
- Modify: `src/lib/keywords/gsc-positioning.ts`
- Test: `src/lib/keywords/gsc-positioning.test.ts` (append)

- [ ] **Step 1: Append the failing test**

Add to `src/lib/keywords/gsc-positioning.test.ts`:
```ts
import { positionBucket } from "./gsc-positioning";

test("positionBucket: top3 ≤3, top10 4-10, quickwin 11-20, deep 21-50, far >50, none null", () => {
  expect(positionBucket(null)).toBe("none");
  expect(positionBucket(g(3))).toBe("top3");
  expect(positionBucket(g(3.4))).toBe("top10");
  expect(positionBucket(g(10))).toBe("top10");
  expect(positionBucket(g(10.4))).toBe("quickwin");
  expect(positionBucket(g(20))).toBe("quickwin");
  expect(positionBucket(g(20.4))).toBe("deep");
  expect(positionBucket(g(50))).toBe("deep");
  expect(positionBucket(g(50.4))).toBe("far");
});
```
(The `g(position)` helper already exists at the top of this test file.)

- [ ] **Step 2: Run — verify fail**

Run: `bun run test src/lib/keywords/gsc-positioning.test.ts`
Expected: FAIL (`positionBucket` not exported).

- [ ] **Step 3: Implement**

Append to `src/lib/keywords/gsc-positioning.ts`:
```ts
export type PositionBucket =
  | "top3"
  | "top10"
  | "quickwin"
  | "deep"
  | "far"
  | "none";

/** Finer position bucketing for the overview distribution. `quickwin` (11–20)
 *  matches gscBand's quick-win band, so counts stay consistent. */
export function positionBucket(
  gsc: GscTracking | null | undefined,
): PositionBucket {
  if (!gsc) return "none";
  if (gsc.position <= 3) return "top3";
  if (gsc.position <= 10) return "top10";
  if (gsc.position <= 20) return "quickwin";
  if (gsc.position <= 50) return "deep";
  return "far";
}
```
(`GscTracking` is already imported at the top of this file.)

- [ ] **Step 4: Run — verify pass**

Run: `bun run test src/lib/keywords/gsc-positioning.test.ts`
Expected: PASS.

- [ ] **Step 5: Lint + commit**

```bash
bunx biome check --write src/lib/keywords/gsc-positioning.ts src/lib/keywords/gsc-positioning.test.ts
bunx tsc --noEmit
git add src/lib/keywords/gsc-positioning.ts src/lib/keywords/gsc-positioning.test.ts
git commit -m "feat(keywords): positionBucket helper for the overview distribution"
```

---

## Task 2: `buildKeywordOverview` aggregator + types

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

- [ ] **Step 1: Implement `overview.ts`**

`src/lib/keywords/overview.ts`:
```ts
// src/lib/keywords/overview.ts — pure aggregation for the keyword overview.
import type { BacklinkStatus } from "@/lib/db/domain-schema";
import { opportunityScore, positionBucket } from "./gsc-positioning";
import type { Keyword, SearchIntent } from "./types";

export interface CountBy<K> {
  key: K;
  count: number;
}
export interface OpportunityRow {
  id: string;
  term: string;
  volume: number;
  position: number;
  kd: number;
  impressions: number;
  score: number;
}
export interface TopVolumeRow {
  id: string;
  term: string;
  volume: number;
  position: number | null;
}
export interface KeywordOverview {
  totalKeywords: number;
  positioned: number;
  positionedPct: number;
  avgPosition: number | null;
  totalClicks: number;
  totalImpressions: number;
  avgCtr: number | null;
  quickWins: number;
  totalVolume: number;
  avgKd: number | null;
  positionDistribution: CountBy<
    "top3" | "top10" | "quickwin" | "deep" | "far" | "none"
  >[];
  kdDistribution: CountBy<string>[];
  byIntent: CountBy<SearchIntent>[];
  byTheme: CountBy<string>[];
  topByVolume: TopVolumeRow[];
  opportunities: OpportunityRow[];
  stages: { reservoir: number; research: number; planning: number };
  backlinks: { total: number; byStatus: CountBy<BacklinkStatus>[] } | null;
}

const POSITION_BUCKETS = [
  "top3",
  "top10",
  "quickwin",
  "deep",
  "far",
  "none",
] as const;
const KD_BUCKETS = ["0-20", "21-40", "41-60", "61-80", "81-100"] as const;
const INTENTS: SearchIntent[] = [
  "informational",
  "commercial",
  "transactional",
  "navigational",
];

function kdBucket(kd: number): string {
  if (kd <= 20) return "0-20";
  if (kd <= 40) return "21-40";
  if (kd <= 60) return "41-60";
  if (kd <= 80) return "61-80";
  return "81-100";
}

const round1 = (n: number) => Math.round(n * 10) / 10;

/** Build the overview aggregate for a site's keywords (+ optional backlink rows). */
export function buildKeywordOverview(
  keywords: Keyword[],
  backlinks: { status: BacklinkStatus }[] | null,
): KeywordOverview {
  const total = keywords.length;
  const positionedKw = keywords.filter((k) => k.gsc != null);
  const totalClicks = positionedKw.reduce((s, k) => s + (k.gsc?.clicks ?? 0), 0);
  const totalImpressions = positionedKw.reduce(
    (s, k) => s + (k.gsc?.impressions ?? 0),
    0,
  );
  const totalVolume = keywords.reduce(
    (s, k) => s + (k.metrics[k.primarySource]?.volume ?? 0),
    0,
  );
  const kds = keywords
    .map((k) => k.metrics[k.primarySource]?.kd)
    .filter((v): v is number => typeof v === "number");

  const posCounts = new Map<string, number>(
    POSITION_BUCKETS.map((b) => [b, 0]),
  );
  for (const k of keywords) {
    const b = positionBucket(k.gsc);
    posCounts.set(b, (posCounts.get(b) ?? 0) + 1);
  }
  const kdCounts = new Map<string, number>(KD_BUCKETS.map((b) => [b, 0]));
  for (const kd of kds) kdCounts.set(kdBucket(kd), (kdCounts.get(kdBucket(kd)) ?? 0) + 1);

  const intentCounts = new Map<SearchIntent, number>(
    INTENTS.map((i) => [i, 0]),
  );
  for (const k of keywords)
    for (const i of k.intent)
      intentCounts.set(i, (intentCounts.get(i) ?? 0) + 1);

  const themeCounts = new Map<string, number>();
  for (const k of keywords) {
    const key = k.theme ?? "—";
    themeCounts.set(key, (themeCounts.get(key) ?? 0) + 1);
  }

  const opportunities: OpportunityRow[] = keywords
    .map((k) => ({ k, score: opportunityScore(k.gsc) }))
    .filter((x) => x.score > 0 && x.k.gsc != null)
    .sort((a, b) => b.score - a.score)
    .slice(0, 8)
    .map(({ k, score }) => ({
      id: k.id,
      term: k.term,
      volume: k.metrics[k.primarySource]?.volume ?? 0,
      position: k.gsc?.position ?? 0,
      kd: k.metrics[k.primarySource]?.kd ?? 0,
      impressions: k.gsc?.impressions ?? 0,
      score,
    }));

  const topByVolume: TopVolumeRow[] = [...keywords]
    .sort(
      (a, b) =>
        (b.metrics[b.primarySource]?.volume ?? 0) -
        (a.metrics[a.primarySource]?.volume ?? 0),
    )
    .slice(0, 10)
    .map((k) => ({
      id: k.id,
      term: k.term,
      volume: k.metrics[k.primarySource]?.volume ?? 0,
      position: k.gsc?.position ?? null,
    }));

  const blCounts = new Map<BacklinkStatus, number>();
  if (backlinks)
    for (const b of backlinks)
      blCounts.set(b.status, (blCounts.get(b.status) ?? 0) + 1);

  return {
    totalKeywords: total,
    positioned: positionedKw.length,
    positionedPct: total ? Math.round((positionedKw.length / total) * 100) : 0,
    avgPosition: positionedKw.length
      ? round1(
          positionedKw.reduce((s, k) => s + (k.gsc?.position ?? 0), 0) /
            positionedKw.length,
        )
      : null,
    totalClicks,
    totalImpressions,
    avgCtr: totalImpressions ? totalClicks / totalImpressions : null,
    quickWins: posCounts.get("quickwin") ?? 0,
    totalVolume,
    avgKd: kds.length
      ? Math.round(kds.reduce((s, v) => s + v, 0) / kds.length)
      : null,
    positionDistribution: POSITION_BUCKETS.map((key) => ({
      key,
      count: posCounts.get(key) ?? 0,
    })),
    kdDistribution: KD_BUCKETS.map((key) => ({
      key,
      count: kdCounts.get(key) ?? 0,
    })),
    byIntent: INTENTS.map((key) => ({ key, count: intentCounts.get(key) ?? 0 }))
      .filter((x) => x.count > 0)
      .sort((a, b) => b.count - a.count),
    byTheme: [...themeCounts.entries()]
      .map(([key, count]) => ({ key, count }))
      .sort((a, b) => b.count - a.count)
      .slice(0, 6),
    topByVolume,
    opportunities,
    stages: {
      reservoir: total,
      research: keywords.filter((k) => k.stages.includes("research")).length,
      planning: keywords.filter((k) => k.stages.includes("planning")).length,
    },
    backlinks: backlinks
      ? {
          total: backlinks.length,
          byStatus: [...blCounts.entries()]
            .map(([key, count]) => ({ key, count }))
            .sort((a, b) => b.count - a.count),
        }
      : null,
  };
}
```

- [ ] **Step 2: Write the test**

`src/lib/keywords/overview.test.ts`:
```ts
import { expect, test } from "bun:test";
import { buildKeywordOverview } from "./overview";
import type { GscTracking, Keyword, SearchIntent } from "./types";

const gsc = (position: number, clicks = 0, impressions = 0): GscTracking => ({
  clicks,
  impressions,
  ctr: 0,
  position,
  updatedAt: "2026-06-06",
});

let n = 0;
function kw(over: Partial<Keyword> = {}): Keyword {
  n += 1;
  return {
    id: `k${n}`,
    term: `kw ${n}`,
    locale: { lang: "fr", country: "FR" },
    theme: null,
    status: "idea",
    intent: ["informational"] as SearchIntent[],
    stages: [],
    primarySource: "mangools",
    sources: ["mangools"],
    metrics: { mangools: { volume: 100, kd: 30, cpc: 1, updatedAt: "" } },
    gsc: null,
    trend: [],
    ...over,
  } as Keyword;
}

test("aggregates counts, distributions, opportunities and stages", () => {
  const ov = buildKeywordOverview(
    [
      kw({ gsc: gsc(2), metrics: { mangools: { volume: 500, kd: 10, cpc: 1, updatedAt: "" } } }),
      kw({ gsc: gsc(15, 5, 900), intent: ["commercial"], stages: ["research"], theme: "energie" }),
      kw({ gsc: gsc(40), theme: "energie" }),
      kw({ gsc: null, stages: ["research", "planning"] }),
    ],
    [{ status: "live" }, { status: "live" }, { status: "prospect" }],
  );
  expect(ov.totalKeywords).toBe(4);
  expect(ov.positioned).toBe(3);
  expect(ov.positionedPct).toBe(75);
  expect(ov.quickWins).toBe(1); // position 15
  expect(ov.totalClicks).toBe(5);
  expect(ov.totalImpressions).toBe(900);
  expect(ov.positionDistribution.find((b) => b.key === "top3")?.count).toBe(1);
  expect(ov.positionDistribution.find((b) => b.key === "none")?.count).toBe(1);
  expect(ov.opportunities[0]?.term).toBe("kw 2"); // only the quickwin scores > 0
  expect(ov.opportunities.length).toBe(1);
  expect(ov.topByVolume[0]?.volume).toBe(500);
  expect(ov.stages).toEqual({ reservoir: 4, research: 2, planning: 1 });
  expect(ov.backlinks?.total).toBe(3);
  expect(ov.backlinks?.byStatus.find((s) => s.key === "live")?.count).toBe(2);
  expect(ov.byTheme.find((t) => t.key === "energie")?.count).toBe(2);
});

test("empty + no-backlinks", () => {
  const ov = buildKeywordOverview([], null);
  expect(ov.totalKeywords).toBe(0);
  expect(ov.positionedPct).toBe(0);
  expect(ov.avgPosition).toBeNull();
  expect(ov.avgCtr).toBeNull();
  expect(ov.backlinks).toBeNull();
  expect(ov.positionDistribution.length).toBe(6);
});
```

- [ ] **Step 3: Run — verify pass**

Run: `bun run test src/lib/keywords/overview.test.ts`
Expected: PASS (2 tests). If `Keyword`'s shape differs (e.g. `locale` vs flat `lang`), match the real `Keyword` type from `src/lib/keywords/types.ts` in the `kw()` factory — keep the assertions.

- [ ] **Step 4: Lint + typecheck + commit**

```bash
bunx biome check --write src/lib/keywords/overview.ts src/lib/keywords/overview.test.ts
bunx tsc --noEmit
git add src/lib/keywords/overview.ts src/lib/keywords/overview.test.ts
git commit -m "feat(keywords): buildKeywordOverview pure aggregator + types"
```

---

## Task 3: `getKeywordOverviewAction`

**Files:**
- Modify: `src/lib/keywords/actions.ts`

- [ ] **Step 1: Add the action**

In `src/lib/keywords/actions.ts`, add imports (merge with existing ones; do not duplicate):
```ts
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { backlinks as backlinksTable } from "@/lib/db/domain-schema";
import { getKeywordsForSite } from "./queries";
import { buildKeywordOverview, type KeywordOverview } from "./overview";
```
Add the action:
```ts
/** Aggregated keyword overview for a site (active-site dashboard). */
export async function getKeywordOverviewAction(
  siteId: string,
): Promise<KeywordOverview> {
  const keywords = await getKeywordsForSite(siteId);
  const bl = await db
    .select({ status: backlinksTable.status })
    .from(backlinksTable)
    .where(eq(backlinksTable.siteId, siteId));
  return buildKeywordOverview(keywords, bl.length ? bl : null);
}
```

- [ ] **Step 2: Typecheck + lint + commit**

```bash
bunx tsc --noEmit
bunx biome check --write src/lib/keywords/actions.ts
git add src/lib/keywords/actions.ts
git commit -m "feat(keywords): getKeywordOverviewAction (keywords + backlink statuses)"
```

---

## Task 4: Shared primitives — `stat-card` + `bar-list`

**Files:**
- Create: `src/components/keywords/overview/stat-card.tsx`
- Create: `src/components/keywords/overview/bar-list.tsx`

- [ ] **Step 1: `stat-card.tsx`**

```tsx
// src/components/keywords/overview/stat-card.tsx
import type { ReactNode } from "react";

/** A compact KPI: label + big value + optional sublabel. */
export function StatCard({
  label,
  value,
  sub,
}: {
  label: string;
  value: ReactNode;
  sub?: ReactNode;
}) {
  return (
    <div className="rounded-lg border bg-card p-4">
      <div className="text-muted-foreground text-xs uppercase tracking-wide">
        {label}
      </div>
      <div className="mt-1 font-semibold text-2xl tabular-nums">{value}</div>
      {sub ? (
        <div className="mt-0.5 text-muted-foreground text-xs">{sub}</div>
      ) : null}
    </div>
  );
}
```

- [ ] **Step 2: `bar-list.tsx`**

```tsx
// src/components/keywords/overview/bar-list.tsx
/** A list of labelled horizontal bars, scaled to the largest value. */
export function BarList({
  items,
  emptyLabel = "Aucune donnée",
}: {
  items: { label: string; value: number; hint?: string }[];
  emptyLabel?: string;
}) {
  const max = Math.max(1, ...items.map((i) => i.value));
  if (items.length === 0)
    return <p className="text-muted-foreground text-sm">{emptyLabel}</p>;
  return (
    <div className="space-y-2">
      {items.map((i) => (
        <div key={i.label} className="space-y-1">
          <div className="flex items-baseline justify-between text-sm">
            <span className="truncate">{i.label}</span>
            <span className="text-muted-foreground tabular-nums">
              {i.hint ?? i.value.toLocaleString("fr-FR")}
            </span>
          </div>
          <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
            <div
              className="h-full rounded-full bg-primary"
              style={{ width: `${(i.value / max) * 100}%` }}
            />
          </div>
        </div>
      ))}
    </div>
  );
}
```

- [ ] **Step 3: Typecheck + lint + commit**

```bash
bunx tsc --noEmit
bunx biome check --write src/components/keywords/overview/stat-card.tsx src/components/keywords/overview/bar-list.tsx
git add src/components/keywords/overview/stat-card.tsx src/components/keywords/overview/bar-list.tsx
git commit -m "feat(overview): shared StatCard + BarList primitives"
```

---

## Task 5: Hero + position distribution + KPIs

**Files:**
- Create: `src/components/keywords/overview/position-distribution.tsx`
- Create: `src/components/keywords/overview/gsc-performance-hero.tsx`
- Create: `src/components/keywords/overview/overview-kpis.tsx`

- [ ] **Step 1: `position-distribution.tsx`**

```tsx
// src/components/keywords/overview/position-distribution.tsx
import type { KeywordOverview } from "@/lib/keywords/overview";

const META: Record<string, { label: string; className: string }> = {
  top3: { label: "Top 3", className: "bg-emerald-500" },
  top10: { label: "4–10", className: "bg-emerald-400" },
  quickwin: { label: "11–20 ⚡", className: "bg-amber-400" },
  deep: { label: "21–50", className: "bg-orange-400" },
  far: { label: "51+", className: "bg-rose-400" },
  none: { label: "Non positionné", className: "bg-muted-foreground/30" },
};

export function PositionDistribution({
  dist,
}: {
  dist: KeywordOverview["positionDistribution"];
}) {
  const total = Math.max(1, dist.reduce((s, b) => s + b.count, 0));
  return (
    <div className="space-y-2">
      <div className="flex h-3 w-full overflow-hidden rounded-full">
        {dist.map((b) =>
          b.count > 0 ? (
            <div
              key={b.key}
              className={META[b.key].className}
              style={{ width: `${(b.count / total) * 100}%` }}
              title={`${META[b.key].label}: ${b.count}`}
            />
          ) : null,
        )}
      </div>
      <div className="flex flex-wrap gap-x-4 gap-y-1 text-muted-foreground text-xs">
        {dist.map((b) => (
          <span key={b.key} className="inline-flex items-center gap-1">
            <span className={`size-2 rounded-full ${META[b.key].className}`} />
            {META[b.key].label}
            <span className="tabular-nums text-foreground">{b.count}</span>
          </span>
        ))}
      </div>
    </div>
  );
}
```

- [ ] **Step 2: `gsc-performance-hero.tsx`**

```tsx
// src/components/keywords/overview/gsc-performance-hero.tsx
import { Card, CardContent } from "@/components/ui/card";
import type { KeywordOverview } from "@/lib/keywords/overview";
import { PositionDistribution } from "./position-distribution";

function Stat({ value, label }: { value: string; label: string }) {
  return (
    <div>
      <div className="font-semibold text-xl tabular-nums">{value}</div>
      <div className="text-muted-foreground text-xs">{label}</div>
    </div>
  );
}

export function GscPerformanceHero({ ov }: { ov: KeywordOverview }) {
  return (
    <Card>
      <CardContent className="space-y-4 p-5">
        <div className="flex items-center justify-between">
          <h2 className="font-semibold text-sm">Performance GSC · 28 jours</h2>
          <span className="text-muted-foreground text-xs">
            {ov.totalKeywords} mots-clés
          </span>
        </div>
        {ov.positioned === 0 ? (
          <p className="text-muted-foreground text-sm">
            Aucune donnée GSC. Synchronise Google Search Console depuis Recherche
            ou « Importer depuis GSC » pour voir ta performance.
          </p>
        ) : (
          <>
            <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
              <Stat
                value={`${ov.positioned} (${ov.positionedPct}%)`}
                label="Positionnés"
              />
              <Stat
                value={ov.avgPosition?.toFixed(1) ?? "—"}
                label="Position moyenne"
              />
              <Stat
                value={ov.totalClicks.toLocaleString("fr-FR")}
                label="Clics"
              />
              <Stat
                value={
                  ov.avgCtr != null ? `${(ov.avgCtr * 100).toFixed(1)}%` : "—"
                }
                label="CTR moyen"
              />
            </div>
            <PositionDistribution dist={ov.positionDistribution} />
          </>
        )}
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 3: `overview-kpis.tsx`**

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

export function OverviewKpis({ ov }: { ov: KeywordOverview }) {
  return (
    <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
      <StatCard label="Mots-clés" value={ov.totalKeywords.toLocaleString("fr-FR")} />
      <StatCard
        label="Positionnés"
        value={ov.positioned.toLocaleString("fr-FR")}
        sub={`${ov.positionedPct}%`}
      />
      <StatCard label="Quick-wins" value={ov.quickWins.toLocaleString("fr-FR")} sub="pos. 11–20" />
      <StatCard label="Volume cumulé" value={ov.totalVolume.toLocaleString("fr-FR")} />
      <StatCard label="Clics GSC" value={ov.totalClicks.toLocaleString("fr-FR")} sub="28 jours" />
      <StatCard label="KD moyen" value={ov.avgKd ?? "—"} />
    </div>
  );
}
```

- [ ] **Step 4: Typecheck + lint + commit**

```bash
bunx tsc --noEmit
bunx biome check --write src/components/keywords/overview/position-distribution.tsx src/components/keywords/overview/gsc-performance-hero.tsx src/components/keywords/overview/overview-kpis.tsx
git add src/components/keywords/overview/position-distribution.tsx src/components/keywords/overview/gsc-performance-hero.tsx src/components/keywords/overview/overview-kpis.tsx
git commit -m "feat(overview): GSC performance hero + position distribution + KPI row"
```

---

## Task 6: Opportunities, breakdown, KD histogram, top-volume, funnel, backlinks

**Files:**
- Create the six module components below.

- [ ] **Step 1: `opportunities-card.tsx`**

```tsx
// src/components/keywords/overview/opportunities-card.tsx
import Link from "next/link";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { KdBadge } from "@/components/keywords/metric-badge";
import type { KeywordOverview } from "@/lib/keywords/overview";

export function OpportunitiesCard({ ov }: { ov: KeywordOverview }) {
  return (
    <Card>
      <CardHeader className="flex-row items-center justify-between">
        <CardTitle className="text-sm">Opportunités (quick-wins)</CardTitle>
        <Link
          href="/keywords/recherche"
          className="text-muted-foreground text-xs hover:text-foreground"
        >
          Voir tout →
        </Link>
      </CardHeader>
      <CardContent>
        {ov.opportunities.length === 0 ? (
          <p className="text-muted-foreground text-sm">
            Aucune opportunité quick-win (positions 11–20) pour l'instant.
          </p>
        ) : (
          <table className="w-full text-sm">
            <thead>
              <tr className="text-left text-muted-foreground text-xs">
                <th className="pb-2 font-medium">Mot-clé</th>
                <th className="pb-2 text-right font-medium">Vol.</th>
                <th className="pb-2 text-right font-medium">Pos.</th>
                <th className="pb-2 text-right font-medium">KD</th>
                <th className="pb-2 text-right font-medium">Impr.</th>
              </tr>
            </thead>
            <tbody>
              {ov.opportunities.map((o) => (
                <tr key={o.id} className="border-border/50 border-t">
                  <td className="py-1.5 pr-2">{o.term}</td>
                  <td className="py-1.5 text-right tabular-nums">
                    {o.volume.toLocaleString("fr-FR")}
                  </td>
                  <td className="py-1.5 text-right tabular-nums">
                    {o.position.toFixed(1)}
                  </td>
                  <td className="py-1.5 text-right">
                    <KdBadge value={o.kd} />
                  </td>
                  <td className="py-1.5 text-right tabular-nums">
                    {o.impressions.toLocaleString("fr-FR")}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 2: `breakdown-card.tsx`**

```tsx
// src/components/keywords/overview/breakdown-card.tsx
"use client";

import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { useThemeLabels } from "@/lib/keywords/theme-context";
import type { KeywordOverview } from "@/lib/keywords/overview";
import type { KeywordTheme } from "@/lib/keywords/types";
import { BarList } from "./bar-list";

const INTENT_LABEL: Record<string, string> = {
  informational: "Informationnel",
  commercial: "Commercial",
  transactional: "Transactionnel",
  navigational: "Navigationnel",
};

export function BreakdownCard({ ov }: { ov: KeywordOverview }) {
  const { labelOf } = useThemeLabels();
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">Répartition</CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        <div>
          <div className="mb-2 text-muted-foreground text-xs uppercase">Intent</div>
          <BarList
            items={ov.byIntent.map((i) => ({
              label: INTENT_LABEL[i.key] ?? i.key,
              value: i.count,
            }))}
          />
        </div>
        <div>
          <div className="mb-2 text-muted-foreground text-xs uppercase">Thème</div>
          <BarList
            items={ov.byTheme.map((t) => ({
              label: t.key === "—" ? "Sans thème" : labelOf(t.key as KeywordTheme),
              value: t.count,
            }))}
          />
        </div>
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 3: `kd-histogram.tsx`**

```tsx
// src/components/keywords/overview/kd-histogram.tsx
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import type { KeywordOverview } from "@/lib/keywords/overview";
import { BarList } from "./bar-list";

export function KdHistogram({ ov }: { ov: KeywordOverview }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">Distribution KD</CardTitle>
      </CardHeader>
      <CardContent>
        <BarList
          items={ov.kdDistribution.map((b) => ({ label: b.key, value: b.count }))}
        />
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 4: `top-volume-card.tsx`**

```tsx
// src/components/keywords/overview/top-volume-card.tsx
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import type { KeywordOverview } from "@/lib/keywords/overview";

export function TopVolumeCard({ ov }: { ov: KeywordOverview }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">Top mots-clés par volume</CardTitle>
      </CardHeader>
      <CardContent>
        {ov.topByVolume.length === 0 ? (
          <p className="text-muted-foreground text-sm">Aucun mot-clé.</p>
        ) : (
          <ul className="space-y-1.5 text-sm">
            {ov.topByVolume.map((t) => (
              <li key={t.id} className="flex items-center justify-between gap-2">
                <span className="truncate">{t.term}</span>
                <span className="shrink-0 text-muted-foreground tabular-nums">
                  {t.volume.toLocaleString("fr-FR")}
                  {t.position != null ? ` · pos ${t.position.toFixed(0)}` : ""}
                </span>
              </li>
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 5: `stages-funnel.tsx`**

```tsx
// src/components/keywords/overview/stages-funnel.tsx
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import type { KeywordOverview } from "@/lib/keywords/overview";

export function StagesFunnel({ ov }: { ov: KeywordOverview }) {
  const steps = [
    { label: "Reservoir", value: ov.stages.reservoir },
    { label: "Recherche", value: ov.stages.research },
    { label: "Planning", value: ov.stages.planning },
  ];
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">Pipeline</CardTitle>
      </CardHeader>
      <CardContent>
        <div className="flex items-center gap-2">
          {steps.map((s, i) => (
            <div key={s.label} className="flex flex-1 items-center gap-2">
              <div className="flex-1 rounded-lg border bg-muted/30 p-3 text-center">
                <div className="font-semibold text-xl tabular-nums">{s.value}</div>
                <div className="text-muted-foreground text-xs">{s.label}</div>
              </div>
              {i < steps.length - 1 ? (
                <span className="text-muted-foreground">→</span>
              ) : null}
            </div>
          ))}
        </div>
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 6: `backlinks-card.tsx`**

```tsx
// src/components/keywords/overview/backlinks-card.tsx
import Link from "next/link";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import type { KeywordOverview } from "@/lib/keywords/overview";

const STATUS_LABEL: Record<string, string> = {
  prospect: "Prospects",
  planned: "Planifiés",
  live: "Obtenus",
  lost: "Perdus",
};

export function BacklinksCard({ ov }: { ov: KeywordOverview }) {
  return (
    <Card>
      <CardHeader className="flex-row items-center justify-between">
        <CardTitle className="text-sm">Backlinks (reservoir)</CardTitle>
        <Link
          href="/backlink/reservoir"
          className="text-muted-foreground text-xs hover:text-foreground"
        >
          Voir →
        </Link>
      </CardHeader>
      <CardContent>
        {!ov.backlinks ? (
          <p className="text-muted-foreground text-sm">Aucune source.</p>
        ) : (
          <div className="space-y-2">
            <div className="font-semibold text-2xl tabular-nums">
              {ov.backlinks.total.toLocaleString("fr-FR")}
              <span className="ml-1 font-normal text-muted-foreground text-sm">
                sources
              </span>
            </div>
            <div className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
              {ov.backlinks.byStatus.map((s) => (
                <span key={s.key} className="text-muted-foreground">
                  {STATUS_LABEL[s.key] ?? s.key}{" "}
                  <span className="text-foreground tabular-nums">{s.count}</span>
                </span>
              ))}
            </div>
          </div>
        )}
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 7: Typecheck + lint + commit**

```bash
bunx tsc --noEmit
bunx biome check --write src/components/keywords/overview/
git add src/components/keywords/overview/
git commit -m "feat(overview): opportunities, breakdown, KD histogram, top-volume, funnel, backlinks"
```

---

## Task 7: Container + page wiring

**Files:**
- Create: `src/components/keywords/overview/overview-container.tsx`
- Modify: `src/app/(app)/keywords/overview/page.tsx`

- [ ] **Step 1: `overview-container.tsx`**

```tsx
// src/components/keywords/overview/overview-container.tsx
"use client";

import { useCallback, useEffect, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { getKeywordOverviewAction } from "@/lib/keywords/actions";
import type { KeywordOverview } from "@/lib/keywords/overview";
import { useActiveSite } from "@/lib/keywords/site-context";
import { BacklinksCard } from "./backlinks-card";
import { BreakdownCard } from "./breakdown-card";
import { GscPerformanceHero } from "./gsc-performance-hero";
import { KdHistogram } from "./kd-histogram";
import { OpportunitiesCard } from "./opportunities-card";
import { OverviewKpis } from "./overview-kpis";
import { StagesFunnel } from "./stages-funnel";
import { TopVolumeCard } from "./top-volume-card";

export function OverviewContainer() {
  const { activeSiteId } = useActiveSite();
  const [ov, setOv] = useState<KeywordOverview | null>(null);
  const [loading, setLoading] = useState(true);

  const load = useCallback(async () => {
    setLoading(true);
    const data = await getKeywordOverviewAction(activeSiteId);
    setOv(data);
    setLoading(false);
  }, [activeSiteId]);

  useEffect(() => {
    void load().catch(() => setLoading(false));
  }, [load]);

  if (loading || !ov) {
    return (
      <div className="space-y-3 p-4">
        <Skeleton className="h-28 w-full" />
        <Skeleton className="h-20 w-full" />
        <Skeleton className="h-64 w-full" />
      </div>
    );
  }

  if (ov.totalKeywords === 0) {
    return (
      <div className="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
        <p>Aucun mot-clé pour ce site.</p>
        <p className="text-sm">
          Importe un CSV ou « Importer depuis GSC » depuis le Reservoir.
        </p>
      </div>
    );
  }

  return (
    <div className="space-y-4 p-4">
      <GscPerformanceHero ov={ov} />
      <OverviewKpis ov={ov} />
      <div className="grid gap-4 lg:grid-cols-3">
        <div className="lg:col-span-2">
          <OpportunitiesCard ov={ov} />
        </div>
        <BreakdownCard ov={ov} />
      </div>
      <div className="grid gap-4 lg:grid-cols-2">
        <KdHistogram ov={ov} />
        <TopVolumeCard ov={ov} />
      </div>
      <div className="grid gap-4 lg:grid-cols-2">
        <StagesFunnel ov={ov} />
        <BacklinksCard ov={ov} />
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Wire the page**

Replace the body of `src/app/(app)/keywords/overview/page.tsx` so it renders the container (keep the existing `DashboardHeader` + breadcrumb; replace the "en construction" div):
```tsx
// src/app/(app)/keywords/overview/page.tsx
import { DashboardHeader } from "@/components/dashboard-header";
import { OverviewContainer } from "@/components/keywords/overview/overview-container";
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";

export default function Page() {
  return (
    <>
      <DashboardHeader>
        <Breadcrumb>
          <BreadcrumbList>
            <BreadcrumbItem>Keywords</BreadcrumbItem>
            <BreadcrumbSeparator />
            <BreadcrumbItem>
              <BreadcrumbPage>Overview</BreadcrumbPage>
            </BreadcrumbItem>
          </BreadcrumbList>
        </Breadcrumb>
      </DashboardHeader>
      <OverviewContainer />
    </>
  );
}
```

- [ ] **Step 3: Typecheck + lint + commit**

```bash
bunx tsc --noEmit
bunx biome check --write src/components/keywords/overview/overview-container.tsx "src/app/(app)/keywords/overview/page.tsx"
git add src/components/keywords/overview/overview-container.tsx "src/app/(app)/keywords/overview/page.tsx"
git commit -m "feat(overview): container + page wiring"
```

---

## Task 8: Full checks + live verification

- [ ] **Step 1: Static checks**

```bash
bunx tsc --noEmit
bun run test src/lib/keywords/overview.test.ts src/lib/keywords/gsc-positioning.test.ts
bunx biome check src/lib/keywords src/components/keywords/overview "src/app/(app)/keywords/overview/page.tsx"
```
Expected: tsc 0; tests pass; biome clean.

- [ ] **Step 2: Live (d3k)**

`d3k agent-browser open http://localhost:3000/keywords/overview` (active site = a site with keywords + GSC, e.g. Acieb):
- Hero shows positionnés/position/clics/CTR + the distribution bar.
- 6 KPI cards correct; Opportunités lists quick-wins; Répartition shows intent + thèmes; KD histogram, Top volume, Pipeline, Backlinks render.
- Switch to a blank site → empty-state. Switch to a site with no GSC → hero shows the "Synchronise GSC" invite, portfolio modules still render.
- `d3k errors` clean.

- [ ] **Step 3: Final fixups commit (if any)**

```bash
git add -A && git commit -m "chore(overview): verification fixups"
```

---

## Notes / risks

- All data is real; no time-series (no GSC history) — trends are an explicit non-goal.
- `buildKeywordOverview` is the only logic-heavy unit and is fully tested; the modules are presentational.
- Confirm the `Keyword` shape in the test factory matches `src/lib/keywords/types.ts` (esp. `locale` vs flat lang/country) — adjust the factory, keep assertions.
- Charts use CSS/flex bars (no chart lib) to stay consistent with the existing custom `Sparkline`.
- Branch: `feature/keywords-overview` (already created off `dev`).
```
