# Keywords Reservoir 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:** Build the `/keywords/reservoir` page — a per-site keyword staging table with filters, multi-source detail Sheet, and trend sparklines — establishing the reusable dashboard gabarit.

**Architecture:** Server `page.tsx` in the `(app)` route group renders the dashboard header + a client `<Reservoir />` container. The container holds all interaction state (active site, search, column filters, row selection, open row) and composes a generic `DataTable`, a filters bar, a bulk-actions bar, and a detail `Sheet`. Mock data lives in typed fixtures derived from the real Mangools CSV, partitioned across 3 fictional sites. Active site comes from a small React context written by the shell's team-switcher.

**Tech Stack:** Next.js 16 (App Router), React 19, TanStack Table v8, shadcn/ui, Tailwind v4 (tokens), bun + `bun:test`, biome.

---

## Pre-flight (read before coding)

The project's `AGENTS.md` mandates: **this is Next.js 16 with breaking changes — read `node_modules/next/dist/docs/01-app/` for any unfamiliar API before writing code.** This plan only uses stable App Router primitives (route groups, `page.tsx` default export, `"use client"`), no `params`/`searchParams` (which are async in 16), so no doc surprises are expected — but verify if you deviate.

Conventions enforced (from `docs/project/conventions.md`):
- **Isolation par site** (`site_id`) partout sur cette page.
- Données **mockées**, jamais inventées : valeurs réelles du CSV `~/Downloads/kwfinder_import_export (1).csv`.
- biome (`bun run lint`), bun, shadcn via MCP.

## File Structure

**Create:**
- `src/lib/keywords/types.ts` — `Keyword`, enums, `SourceMetrics`, `MonthlyVolume`, `Site`.
- `src/lib/keywords/fixtures.ts` — ~31 curated keywords grouped by site (real CSV values).
- `src/lib/keywords/sites.ts` — `SITES` (3) + `getKeywordsForSite(siteId)`.
- `src/lib/keywords/fixtures.test.ts` — fixtures/sites validation.
- `src/lib/keywords/site-context.tsx` — `SiteProvider` + `useActiveSite` client context.
- `src/components/ui/data-table.tsx` — generic reusable DataTable (the gabarit).
- `src/components/keywords/sparkline.tsx` — custom SVG mini-chart.
- `src/components/keywords/sparkline.test.ts` — `sparklinePath` geometry.
- `src/components/keywords/status-badge.tsx` · `intent-badge.tsx` — Badge variants.
- `src/components/keywords/columns.tsx` — `ColumnDef<Keyword>[]`.
- `src/components/keywords/filters-bar.tsx` — search + facets + ranges.
- `src/components/keywords/bulk-actions-bar.tsx` — selection actions.
- `src/components/keywords/keyword-sheet.tsx` — multi-source detail Sheet.
- `src/components/keywords/reservoir.tsx` — client container.
- `src/app/(app)/keywords/reservoir/page.tsx` — route.
- `src/app/(app)/keywords/{overview,recherche,planning,export-import}/page.tsx` — stub pages.

**Modify:**
- `src/app/(app)/layout.tsx` — wrap children in `SiteProvider`.
- `src/components/app-sidebar.tsx` — replace "Models" group with "Keywords"; teams → 3 sites.
- `src/components/team-switcher.tsx` — drive active site from `SiteContext`.

---

## Task 0: Install missing shadcn components

**Files:** none authored; adds `src/components/ui/select.tsx`, `popover.tsx`.

- [ ] **Step 1: Add components**

Run: `bunx shadcn@latest add select popover --yes`
Expected: creates `src/components/ui/select.tsx` and `src/components/ui/popover.tsx`.

- [ ] **Step 2: Verify they exist**

Run: `ls src/components/ui/{select,popover}.tsx`
Expected: both paths listed.

- [ ] **Step 3: Commit**

```bash
git add src/components/ui/select.tsx src/components/ui/popover.tsx package.json bun.lock
git commit -m "chore: add shadcn select + popover for keywords filters"
```

---

## Task 1: Keyword types

**Files:**
- Create: `src/lib/keywords/types.ts`

- [ ] **Step 1: Write the types file**

```ts
// src/lib/keywords/types.ts

export type KeywordStatus = "idea" | "watch" | "active" | "archived";

export type SearchIntent =
  | "informational"
  | "commercial"
  | "transactional"
  | "navigational";

export type KeywordSource = "mangools" | "ahrefs" | "gsc";

export const KEYWORD_STATUSES: KeywordStatus[] = [
  "idea",
  "watch",
  "active",
  "archived",
];

export const KEYWORD_SOURCES: KeywordSource[] = ["mangools", "ahrefs", "gsc"];

/** Per-source metrics. Only the connected source(s) carry values. */
export interface SourceMetrics {
  volume: number; // monthly search volume (last known)
  kd: number; // keyword difficulty 0-100
  cpc: number; // CPC in USD
  growthPct: number; // keyword interest growth %
  updatedAt: string; // ISO date
}

/** One point of the monthly volume series; feeds the sparkline. */
export interface MonthlyVolume {
  month: string; // e.g. "2025-12"
  volume: number;
}

export interface Keyword {
  id: string;
  siteId: string;
  term: string;
  locale: { lang: string; country: string };
  theme: KeywordTheme;
  status: KeywordStatus;
  intent: SearchIntent[];
  primarySource: KeywordSource;
  sources: KeywordSource[];
  metrics: Partial<Record<KeywordSource, SourceMetrics>>;
  trend: MonthlyVolume[];
  serpFeatures?: string[];
}

export type KeywordTheme =
  | "lab-diamonds"
  | "supplies"
  | "metals"
  | "manufacturing"
  | "ethics-industry";

export const KEYWORD_THEMES: { id: KeywordTheme; label: string }[] = [
  { id: "lab-diamonds", label: "Diamants de labo & pierres" },
  { id: "supplies", label: "Fournitures & outillage" },
  { id: "metals", label: "Or, métaux & titrages" },
  { id: "manufacturing", label: "Fabrication & CAO" },
  { id: "ethics-industry", label: "Éthique & industrie" },
];

export interface Site {
  id: string;
  name: string;
  plan: string;
}
```

- [ ] **Step 2: Type-check**

Run: `bunx tsc --noEmit`
Expected: no errors (file has no consumers yet).

- [ ] **Step 3: Commit**

```bash
git add src/lib/keywords/types.ts
git commit -m "feat: keyword domain types (multi-source shape)"
```

---

## Task 2: Curated fixtures from the Mangools CSV

**Files:**
- Create: `src/lib/keywords/fixtures.ts`

**Source:** `~/Downloads/kwfinder_import_export (1).csv`. **Do not invent numbers** — transcribe from the CSV row whose `Keyword` matches.

**Extraction recipe (CSV column → field):**
- `term` ← `Keyword`
- `metrics.mangools.volume` ← `Avg. Search Volume (Last Known Values)`
- `metrics.mangools.kd` ← `Keyword Difficulty`
- `metrics.mangools.cpc` ← `CPC/USD` (0 if blank)
- `metrics.mangools.growthPct` ← `Keyword interest growth (%)` (0 if blank)
- `metrics.mangools.updatedAt` ← `"2026-04-01"` (the CSV's last column is `Search Volume 4/2026`)
- `intent` ← split `Search Intent` on `, ` (e.g. `"informational, commercial"` → `["informational","commercial"]`)
- `serpFeatures` ← split `Serp Features` on `, ` (omit if blank)
- `trend` ← the **last 12** monthly `Search Volume m/yyyy` columns (`5/2025`…`4/2026`), as `{ month: "2025-05", volume }` … `{ month: "2026-04", volume }`. Skip a keyword's trend entry only if that month's cell is blank.
- `locale` ← `{ lang: "en", country: "US" }` for all (CSV `Location` = United States)
- `primarySource: "mangools"`, `sources: ["mangools"]`, `metrics` has only the `mangools` key.

**Curated partition (31 keywords, ~10/site). `status` is assigned per the table to vary states:**

`lustre-lab` (Lustre Lab):
| term | theme | status |
|---|---|---|
| lab grown diamond jewelry | lab-diamonds | active |
| lab grown diamond vs natural | lab-diamonds | active |
| conflict free diamonds | lab-diamonds | watch |
| cz vs moissanite vs diamond | lab-diamonds | idea |
| lab grown diamonds vs mined | lab-diamonds | watch |
| kimberley process | lab-diamonds | idea |
| sustainable jewelry brands | ethics-industry | watch |
| ethical jewelry brands | ethics-industry | active |
| recycled gold jewelry | ethics-industry | idea |
| responsible jewelry council | ethics-industry | archived |

`atelier-forge` (Atelier Forge):
| term | theme | status |
|---|---|---|
| suppliers for jewelry making | supplies | active |
| jewelry findings supplier | supplies | watch |
| jewelry polishing | supplies | idea |
| jewelry soldering | supplies | idea |
| jewelry electroplating | supplies | watch |
| stone setting types | supplies | idea |
| custom jewelry design | manufacturing | active |
| lost wax casting jewelry | manufacturing | watch |
| jewelry 3d printing | manufacturing | idea |
| jewelry cad design | manufacturing | archived |
| ring manufacturer | manufacturing | idea |

`maison-aurum` (Maison Aurum):
| term | theme | status |
|---|---|---|
| gold filled vs gold plated | metals | active |
| what is vermeil | metals | watch |
| what is 925 sterling silver | metals | active |
| what is 14k gold | metals | idea |
| what is 18k gold | metals | idea |
| gold plating jewelry | metals | watch |
| rhodium plating jewelry | metals | idea |
| 10k vs 14k vs 18k gold | metals | idea |
| jewelry trade fairs | ethics-industry | watch |
| vicenzaoro | ethics-industry | archived |

- [ ] **Step 1: Write the fixtures file**

Structure (two fully-worked example rows shown; transcribe the rest the same way). Note `id` = `<siteId>-<kebab term>`.

```ts
// src/lib/keywords/fixtures.ts
import type { Keyword } from "./types";

export const KEYWORDS: Keyword[] = [
  // ── lustre-lab ──────────────────────────────────────────────
  {
    id: "lustre-lab-lab-grown-diamond-jewelry",
    siteId: "lustre-lab",
    term: "lab grown diamond jewelry",
    locale: { lang: "en", country: "US" },
    theme: "lab-diamonds",
    status: "active",
    intent: ["informational"],
    primarySource: "mangools",
    sources: ["mangools"],
    metrics: {
      mangools: {
        volume: 25600,
        kd: 25,
        cpc: 5.24,
        growthPct: 235,
        updatedAt: "2026-04-01",
      },
    },
    serpFeatures: ["AI Overview", "People Also Ask"],
    // last 12 months from CSV columns 5/2025..4/2026:
    trend: [
      { month: "2025-05", volume: 33100 },
      { month: "2025-06", volume: 27100 },
      { month: "2025-07", volume: 27100 },
      { month: "2025-08", volume: 33100 },
      { month: "2025-09", volume: 27100 },
      { month: "2025-10", volume: 33100 },
      { month: "2025-11", volume: 14800 },
      { month: "2025-12", volume: 12100 },
      // NOTE: the CSV row has 12 trailing monthly cells; transcribe ALL 12.
      // The 4 above 2025-12 plus the next months — verify against the row.
    ],
  },
  {
    id: "lustre-lab-conflict-free-diamonds",
    siteId: "lustre-lab",
    term: "conflict free diamonds",
    locale: { lang: "en", country: "US" },
    theme: "lab-diamonds",
    status: "watch",
    intent: ["informational", "commercial"],
    primarySource: "mangools",
    sources: ["mangools"],
    metrics: {
      mangools: {
        volume: 5000,
        kd: 26,
        cpc: 3.24,
        growthPct: 70,
        updatedAt: "2026-04-01",
      },
    },
    serpFeatures: ["AI Overview"],
    trend: [
      // transcribe 12 monthly cells from the "conflict free diamonds" row
    ],
  },
  // … remaining lustre-lab rows …
  // ── atelier-forge … ── maison-aurum … (same shape, per partition table) ──
];
```

**Important on `trend`:** open the CSV, find the row, and copy the final 12 `Search Volume` columns verbatim. Months map left-to-right: `5/2025→2025-05` … `4/2026→2026-04`. A blank cell → omit that point (some short-history keywords have fewer).

- [ ] **Step 2: Type-check**

Run: `bunx tsc --noEmit`
Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add src/lib/keywords/fixtures.ts
git commit -m "feat: curated keyword fixtures from Mangools dataset"
```

---

## Task 3: Sites registry + per-site selector (TDD)

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

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

```ts
// src/lib/keywords/fixtures.test.ts
import { expect, test } from "bun:test";
import { KEYWORDS } from "./fixtures";
import { getKeywordsForSite, SITES } from "./sites";
import { KEYWORD_THEMES } from "./types";

const VALID_THEMES = new Set(KEYWORD_THEMES.map((t) => t.id));

test("has 3 sites", () => {
  expect(SITES.map((s) => s.id)).toEqual([
    "lustre-lab",
    "atelier-forge",
    "maison-aurum",
  ]);
});

test("every keyword belongs to a known site", () => {
  const siteIds = new Set(SITES.map((s) => s.id));
  for (const k of KEYWORDS) expect(siteIds.has(k.siteId)).toBe(true);
});

test("each site has between 9 and 12 keywords", () => {
  for (const s of SITES) {
    const n = getKeywordsForSite(s.id).length;
    expect(n).toBeGreaterThanOrEqual(9);
    expect(n).toBeLessThanOrEqual(12);
  }
});

test("getKeywordsForSite isolates by siteId", () => {
  for (const s of SITES)
    for (const k of getKeywordsForSite(s.id)) expect(k.siteId).toBe(s.id);
});

test("every keyword has valid theme, mangools metrics, and a trend", () => {
  for (const k of KEYWORDS) {
    expect(VALID_THEMES.has(k.theme)).toBe(true);
    expect(k.metrics.mangools).toBeDefined();
    expect(k.metrics.mangools?.volume).toBeGreaterThan(0);
    expect(k.intent.length).toBeGreaterThan(0);
    expect(k.trend.length).toBeGreaterThan(0);
    expect(k.id).toBe(
      `${k.siteId}-${k.term.replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "")}`,
    );
  }
});

test("ids are unique", () => {
  const ids = KEYWORDS.map((k) => k.id);
  expect(new Set(ids).size).toBe(ids.length);
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test src/lib/keywords/fixtures.test.ts`
Expected: FAIL — cannot resolve `./sites`.

- [ ] **Step 3: Write `sites.ts`**

```ts
// src/lib/keywords/sites.ts
import { KEYWORDS } from "./fixtures";
import type { Keyword, Site } from "./types";

export const SITES: Site[] = [
  { id: "lustre-lab", name: "Lustre Lab", plan: "Lab-grown diamonds" },
  { id: "atelier-forge", name: "Atelier Forge", plan: "Jewelry supplies" },
  { id: "maison-aurum", name: "Maison Aurum", plan: "Gold brand" },
];

export const DEFAULT_SITE_ID = SITES[0].id;

export function getKeywordsForSite(siteId: string): Keyword[] {
  return KEYWORDS.filter((k) => k.siteId === siteId);
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `bun test src/lib/keywords/fixtures.test.ts`
Expected: PASS (6 tests). If the `id` test fails, fix the offending fixture `id` to match `<siteId>-<kebab term>`.

- [ ] **Step 5: Commit**

```bash
git add src/lib/keywords/sites.ts src/lib/keywords/fixtures.test.ts
git commit -m "feat: keyword sites registry + per-site selector (tested)"
```

---

## Task 4: Active-site context + shell wiring

**Files:**
- Create: `src/lib/keywords/site-context.tsx`
- Modify: `src/app/(app)/layout.tsx`
- Modify: `src/components/app-sidebar.tsx`
- Modify: `src/components/team-switcher.tsx`

- [ ] **Step 1: Write the context**

```tsx
// src/lib/keywords/site-context.tsx
"use client";

import { createContext, useContext, useState } from "react";
import { DEFAULT_SITE_ID, SITES } from "./sites";
import type { Site } from "./types";

interface SiteContextValue {
  activeSiteId: string;
  activeSite: Site;
  setActiveSiteId: (id: string) => void;
  sites: Site[];
}

const SiteContext = createContext<SiteContextValue | null>(null);

export function SiteProvider({ children }: { children: React.ReactNode }) {
  const [activeSiteId, setActiveSiteId] = useState(DEFAULT_SITE_ID);
  const activeSite =
    SITES.find((s) => s.id === activeSiteId) ?? SITES[0];
  return (
    <SiteContext value={{ activeSiteId, activeSite, setActiveSiteId, sites: SITES }}>
      {children}
    </SiteContext>
  );
}

export function useActiveSite(): SiteContextValue {
  const ctx = useContext(SiteContext);
  if (!ctx) throw new Error("useActiveSite must be used within SiteProvider");
  return ctx;
}
```

> Note (React 19): `<SiteContext value=…>` is valid — Context is directly renderable as a provider. If your editor flags it, `SiteContext.Provider` also works.

- [ ] **Step 2: Wrap the (app) layout**

Read `src/app/(app)/layout.tsx`, then wrap the existing subtree (sidebar + content) in `<SiteProvider>`. Add the import:

```tsx
import { SiteProvider } from "@/lib/keywords/site-context";
```

and wrap the outermost returned element's children with `<SiteProvider>…</SiteProvider>` so both `AppSidebar` (team-switcher) and the page content share the context.

- [ ] **Step 3: Point team-switcher at the context**

Modify `src/components/team-switcher.tsx`: replace its internal `activeTeam` state and its `teams` prop usage with the context. The dropdown lists `sites`; selecting one calls `setActiveSiteId(site.id)`; the trigger shows `activeSite.name` / `activeSite.plan`. Keep the existing shadcn `SidebarMenu` + `DropdownMenu` markup; only swap the data source:

```tsx
"use client";
import { useActiveSite } from "@/lib/keywords/site-context";
// …existing shadcn imports…

export function TeamSwitcher() {
  const { sites, activeSite, setActiveSiteId } = useActiveSite();
  // render trigger with activeSite.name / activeSite.plan
  // render one DropdownMenuItem per `sites`, onSelect={() => setActiveSiteId(s.id)}
}
```

Remove the now-unused `teams` prop from the `TeamSwitcher` call site in `app-sidebar.tsx`.

- [ ] **Step 4: Replace the "Models" nav group with "Keywords"**

In `src/components/app-sidebar.tsx`, swap the `Models` object in `data.navMain` for:

```tsx
{
  title: "Keywords",
  url: "/keywords/reservoir",
  icon: <SearchIcon />,
  isActive: true,
  items: [
    { title: "Overview", url: "/keywords/overview" },
    { title: "Reservoir", url: "/keywords/reservoir" },
    { title: "Recherche", url: "/keywords/recherche" },
    { title: "Planning", url: "/keywords/planning" },
    { title: "Import / Export", url: "/keywords/export-import" },
  ],
},
```

Add `SearchIcon` to the lucide import; remove `BotIcon` if now unused. Also delete the sample `teams` array (team-switcher no longer takes a prop).

- [ ] **Step 5: Verify build + lint**

Run: `bun run lint && bunx tsc --noEmit`
Expected: no errors. (Visual check happens in Task 12.)

- [ ] **Step 6: Commit**

```bash
git add src/lib/keywords/site-context.tsx "src/app/(app)/layout.tsx" src/components/app-sidebar.tsx src/components/team-switcher.tsx
git commit -m "feat: active-site context wired to team-switcher + Keywords nav group"
```

---

## Task 5: Generic reusable DataTable (the gabarit)

**Files:**
- Create: `src/components/ui/data-table.tsx`

Generalized from `src/components/flight-check/data-table.tsx` (which stays untouched). Adds optional row-click (for the detail Sheet) and a generic `meta` passthrough.

- [ ] **Step 1: Write the component**

```tsx
// src/components/ui/data-table.tsx
"use client";

import {
  type ColumnDef,
  type ColumnFiltersState,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  type RowSelectionState,
  type TableMeta,
  useReactTable,
} from "@tanstack/react-table";
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
  globalFilter?: string;
  columnFilters?: ColumnFiltersState;
  rowSelection?: RowSelectionState;
  onRowSelectionChange?: (next: RowSelectionState) => void;
  onRowClick?: (row: TData) => void;
  getRowId?: (row: TData) => string;
  meta?: TableMeta<TData>;
  caption?: string;
}

export function DataTable<TData, TValue>({
  columns,
  data,
  globalFilter = "",
  columnFilters = [],
  rowSelection = {},
  onRowSelectionChange,
  onRowClick,
  getRowId,
  meta,
  caption,
}: DataTableProps<TData, TValue>) {
  const table = useReactTable({
    data,
    columns,
    state: { globalFilter, columnFilters, rowSelection },
    enableRowSelection: true,
    onRowSelectionChange: (updater) => {
      if (!onRowSelectionChange) return;
      const next =
        typeof updater === "function" ? updater(rowSelection) : updater;
      onRowSelectionChange(next);
    },
    getRowId,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    meta,
  });

  return (
    <div className="overflow-hidden rounded-md border">
      <Table>
        {caption ? (
          <TableCaption className="sr-only">{caption}</TableCaption>
        ) : null}
        <TableHeader>
          {table.getHeaderGroups().map((hg) => (
            <TableRow key={hg.id}>
              {hg.headers.map((header) => (
                <TableHead key={header.id} scope="col">
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.header,
                        header.getContext(),
                      )}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows.length ? (
            table.getRowModel().rows.map((row) => (
              <TableRow
                key={row.id}
                data-state={row.getIsSelected() ? "selected" : undefined}
                onClick={() => onRowClick?.(row.original)}
                className={onRowClick ? "cursor-pointer" : undefined}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className="h-24 text-center">
                Aucun résultat.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  );
}
```

- [ ] **Step 2: Type-check**

Run: `bunx tsc --noEmit`
Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add src/components/ui/data-table.tsx
git commit -m "feat: generic reusable DataTable (row selection + click)"
```

---

## Task 6: Sparkline (TDD on geometry)

**Files:**
- Create: `src/components/keywords/sparkline.tsx`
- Test: `src/components/keywords/sparkline.test.ts`

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

```ts
// src/components/keywords/sparkline.test.ts
import { expect, test } from "bun:test";
import { sparklinePath } from "./sparkline";

test("maps a flat series to a horizontal line", () => {
  const d = sparklinePath([5, 5, 5], 100, 20);
  // 3 points → x at 0, 50, 100; flat → all y identical
  expect(d).toBe("M0,10 L50,10 L100,10");
});

test("min maps to bottom, max maps to top (y inverted)", () => {
  const d = sparklinePath([0, 10], 100, 20);
  // pad of 2 keeps points inside [2, 18]; min→18 (bottom), max→2 (top)
  expect(d).toBe("M0,18 L100,2");
});

test("single point renders a dot-line at mid height", () => {
  expect(sparklinePath([7], 100, 20)).toBe("M0,10 L0,10");
});

test("empty series returns empty path", () => {
  expect(sparklinePath([], 100, 20)).toBe("");
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `bun test src/components/keywords/sparkline.test.ts`
Expected: FAIL — cannot resolve `./sparkline`.

- [ ] **Step 3: Implement**

```tsx
// src/components/keywords/sparkline.tsx
const PAD = 2;

/** Build an SVG path `d` mapping values across width×height (y inverted). */
export function sparklinePath(
  values: number[],
  width: number,
  height: number,
): string {
  if (values.length === 0) return "";
  const mid = height / 2;
  if (values.length === 1) return `M0,${mid} L0,${mid}`;

  const min = Math.min(...values);
  const max = Math.max(...values);
  const span = max - min;
  const innerH = height - PAD * 2;
  const stepX = width / (values.length - 1);

  const y = (v: number) =>
    span === 0 ? mid : PAD + innerH * (1 - (v - min) / span);

  return values
    .map((v, i) => `${i === 0 ? "M" : "L"}${i * stepX},${y(v)}`)
    .join(" ");
}

export function Sparkline({
  values,
  width = 88,
  height = 24,
  className,
}: {
  values: number[];
  width?: number;
  height?: number;
  className?: string;
}) {
  const d = sparklinePath(values, width, height);
  const rising = values.length >= 2 && values.at(-1)! >= values[0];
  return (
    <svg
      width={width}
      height={height}
      viewBox={`0 0 ${width} ${height}`}
      className={className}
      role="img"
      aria-label={
        rising ? "Tendance à la hausse" : "Tendance à la baisse"
      }
    >
      <path
        d={d}
        fill="none"
        stroke={rising ? "var(--chart-1)" : "var(--chart-3)"}
        strokeWidth={1.5}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `bun test src/components/keywords/sparkline.test.ts`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add src/components/keywords/sparkline.tsx src/components/keywords/sparkline.test.ts
git commit -m "feat: custom SVG sparkline (tested geometry)"
```

---

## Task 7: Status + intent badges

**Files:**
- Create: `src/components/keywords/status-badge.tsx`
- Create: `src/components/keywords/intent-badge.tsx`

- [ ] **Step 1: Write `status-badge.tsx`**

```tsx
// src/components/keywords/status-badge.tsx
import { Badge } from "@/components/ui/badge";
import type { KeywordStatus } from "@/lib/keywords/types";

const LABELS: Record<KeywordStatus, string> = {
  idea: "Idée",
  watch: "À surveiller",
  active: "Actif",
  archived: "Archivé",
};

const VARIANTS: Record<
  KeywordStatus,
  "default" | "secondary" | "outline" | "destructive"
> = {
  idea: "outline",
  watch: "secondary",
  active: "default",
  archived: "destructive",
};

export function StatusBadge({ status }: { status: KeywordStatus }) {
  return <Badge variant={VARIANTS[status]}>{LABELS[status]}</Badge>;
}
```

- [ ] **Step 2: Write `intent-badge.tsx`**

```tsx
// src/components/keywords/intent-badge.tsx
import { Badge } from "@/components/ui/badge";
import type { SearchIntent } from "@/lib/keywords/types";

const LABELS: Record<SearchIntent, string> = {
  informational: "Info",
  commercial: "Commercial",
  transactional: "Transac",
  navigational: "Navig",
};

export function IntentBadges({ intents }: { intents: SearchIntent[] }) {
  return (
    <div className="flex flex-wrap gap-1">
      {intents.map((i) => (
        <Badge key={i} variant="outline" className="text-xs">
          {LABELS[i]}
        </Badge>
      ))}
    </div>
  );
}
```

- [ ] **Step 3: Type-check + commit**

Run: `bunx tsc --noEmit` (Expected: no errors)

```bash
git add src/components/keywords/status-badge.tsx src/components/keywords/intent-badge.tsx
git commit -m "feat: keyword status + intent badges"
```

---

## Task 8: Table columns

**Files:**
- Create: `src/components/keywords/columns.tsx`

- [ ] **Step 1: Write the columns**

```tsx
// src/components/keywords/columns.tsx
"use client";

import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@/components/ui/checkbox";
import { IntentBadges } from "./intent-badge";
import { Sparkline } from "./sparkline";
import { StatusBadge } from "./status-badge";
import { KEYWORD_THEMES, type Keyword } from "@/lib/keywords/types";

const THEME_LABEL = new Map(KEYWORD_THEMES.map((t) => [t.id, t.label]));

const arrIncludesSome = (
  row: { getValue: (id: string) => unknown },
  columnId: string,
  filterValue: string[],
) => {
  if (!filterValue?.length) return true;
  const cell = row.getValue(columnId);
  if (Array.isArray(cell)) return cell.some((v) => filterValue.includes(v));
  return filterValue.includes(cell as string);
};

const numberRange = (
  row: { getValue: (id: string) => unknown },
  columnId: string,
  [min, max]: [number | undefined, number | undefined],
) => {
  const v = row.getValue(columnId) as number;
  if (min != null && v < min) return false;
  if (max != null && v > max) return false;
  return true;
};

export const columns: ColumnDef<Keyword>[] = [
  {
    id: "select",
    enableGlobalFilter: false,
    header: ({ table }) => (
      <Checkbox
        checked={
          table.getIsAllRowsSelected() ||
          (table.getIsSomeRowsSelected() && "indeterminate")
        }
        onCheckedChange={(v) => table.toggleAllRowsSelected(!!v)}
        aria-label="Tout sélectionner"
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        checked={row.getIsSelected()}
        onCheckedChange={(v) => row.toggleSelected(!!v)}
        onClick={(e) => e.stopPropagation()}
        aria-label={`Sélectionner ${row.original.term}`}
      />
    ),
  },
  {
    accessorKey: "term",
    header: "Terme",
    cell: ({ row }) => (
      <span className="font-medium">{row.original.term}</span>
    ),
  },
  {
    accessorKey: "theme",
    header: "Thème",
    enableGlobalFilter: false,
    filterFn: arrIncludesSome,
    cell: ({ row }) => (
      <span className="text-muted-foreground text-sm">
        {THEME_LABEL.get(row.original.theme)}
      </span>
    ),
  },
  {
    accessorKey: "status",
    header: "Statut",
    enableGlobalFilter: false,
    filterFn: arrIncludesSome,
    cell: ({ row }) => <StatusBadge status={row.original.status} />,
  },
  {
    accessorKey: "intent",
    header: "Intent",
    enableGlobalFilter: false,
    filterFn: arrIncludesSome,
    cell: ({ row }) => <IntentBadges intents={row.original.intent} />,
  },
  {
    id: "volume",
    accessorFn: (k) => k.metrics[k.primarySource]?.volume ?? 0,
    header: "Volume",
    enableGlobalFilter: false,
    filterFn: numberRange,
    cell: ({ getValue }) => (getValue() as number).toLocaleString("fr-FR"),
  },
  {
    id: "kd",
    accessorFn: (k) => k.metrics[k.primarySource]?.kd ?? 0,
    header: "KD",
    enableGlobalFilter: false,
    filterFn: numberRange,
    cell: ({ getValue }) => getValue() as number,
  },
  {
    id: "cpc",
    accessorFn: (k) => k.metrics[k.primarySource]?.cpc ?? 0,
    header: "CPC",
    enableGlobalFilter: false,
    filterFn: numberRange,
    cell: ({ getValue }) => `$${(getValue() as number).toFixed(2)}`,
  },
  {
    id: "growthPct",
    accessorFn: (k) => k.metrics[k.primarySource]?.growthPct ?? 0,
    header: "Croissance",
    enableGlobalFilter: false,
    filterFn: numberRange,
    cell: ({ getValue }) => {
      const v = getValue() as number;
      return (
        <span className={v >= 0 ? "text-emerald-600" : "text-red-600"}>
          {v > 0 ? "+" : ""}
          {v}%
        </span>
      );
    },
  },
  {
    id: "trend",
    header: "Tendance",
    enableGlobalFilter: false,
    cell: ({ row }) => (
      <Sparkline values={row.original.trend.map((t) => t.volume)} />
    ),
  },
  {
    accessorKey: "sources",
    header: "Sources",
    enableGlobalFilter: false,
    cell: ({ row }) => (
      <span className="text-muted-foreground text-xs uppercase">
        {row.original.sources.join(", ")}
      </span>
    ),
  },
  {
    id: "updatedAt",
    accessorFn: (k) => k.metrics[k.primarySource]?.updatedAt ?? "",
    header: "MAJ",
    enableGlobalFilter: false,
    cell: ({ getValue }) => (
      <span className="text-muted-foreground text-xs">
        {getValue() as string}
      </span>
    ),
  },
];
```

- [ ] **Step 2: Type-check + commit**

Run: `bunx tsc --noEmit` (Expected: no errors)

```bash
git add src/components/keywords/columns.tsx
git commit -m "feat: keyword table columns (sparkline, faceted + range filters)"
```

---

## Task 9: Filters bar

**Files:**
- Create: `src/components/keywords/filters-bar.tsx`

Exposes search + faceted multi-selects (theme, status, intent) + numeric range popover (volume/KD). It is **controlled**: parent owns `globalFilter` and `columnFilters`.

- [ ] **Step 1: Write the component**

```tsx
// src/components/keywords/filters-bar.tsx
"use client";

import type { ColumnFiltersState } from "@tanstack/react-table";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  KEYWORD_STATUSES,
  KEYWORD_THEMES,
  type Keyword,
} from "@/lib/keywords/types";

interface Props {
  search: string;
  onSearch: (v: string) => void;
  columnFilters: ColumnFiltersState;
  onColumnFilters: (next: ColumnFiltersState) => void;
}

// helper: set or clear a single-value facet stored as string[]
function setFacet(
  filters: ColumnFiltersState,
  id: string,
  value: string,
): ColumnFiltersState {
  const rest = filters.filter((f) => f.id !== id);
  if (!value || value === "all") return rest;
  return [...rest, { id, value: [value] }];
}

const facetValue = (filters: ColumnFiltersState, id: string): string =>
  ((filters.find((f) => f.id === id)?.value as string[]) ?? [])[0] ?? "all";

export function FiltersBar({
  search,
  onSearch,
  columnFilters,
  onColumnFilters,
}: Props) {
  return (
    <div className="flex flex-wrap items-center gap-2">
      <Input
        placeholder="Rechercher un terme…"
        value={search}
        onChange={(e) => onSearch(e.target.value)}
        className="max-w-xs"
        aria-label="Rechercher un mot-clé"
      />

      <Select
        value={facetValue(columnFilters, "theme")}
        onValueChange={(v) => onColumnFilters(setFacet(columnFilters, "theme", v))}
      >
        <SelectTrigger className="w-44" aria-label="Filtrer par thème">
          <SelectValue placeholder="Thème" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="all">Tous les thèmes</SelectItem>
          {KEYWORD_THEMES.map((t) => (
            <SelectItem key={t.id} value={t.id}>
              {t.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>

      <Select
        value={facetValue(columnFilters, "status")}
        onValueChange={(v) =>
          onColumnFilters(setFacet(columnFilters, "status", v))
        }
      >
        <SelectTrigger className="w-40" aria-label="Filtrer par statut">
          <SelectValue placeholder="Statut" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="all">Tous les statuts</SelectItem>
          {KEYWORD_STATUSES.map((s) => (
            <SelectItem key={s} value={s}>
              {s}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>

      <Select
        value={facetValue(columnFilters, "intent")}
        onValueChange={(v) =>
          onColumnFilters(setFacet(columnFilters, "intent", v))
        }
      >
        <SelectTrigger className="w-40" aria-label="Filtrer par intent">
          <SelectValue placeholder="Intent" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="all">Tous les intents</SelectItem>
          {["informational", "commercial", "transactional", "navigational"].map(
            (i) => (
              <SelectItem key={i} value={i}>
                {i}
              </SelectItem>
            ),
          )}
        </SelectContent>
      </Select>
    </div>
  );
}
```

> Range filters (volume/KD/CPC/growth) are wired via `numberRange` in columns. For the proto, ship the categorical facets above; a numeric-range popover can be added later without touching the column `filterFn`s (they already accept `[min,max]`). This keeps the first cut small per "petit bout par petit bout". If you add the popover now, push `{ id: "volume", value: [min, max] }` into `columnFilters`.

- [ ] **Step 2: Type-check + commit**

Run: `bunx tsc --noEmit` (Expected: no errors)

```bash
git add src/components/keywords/filters-bar.tsx
git commit -m "feat: keyword filters bar (search + faceted selects)"
```

---

## Task 10: Detail Sheet (multi-source, honest empty state)

**Files:**
- Create: `src/components/keywords/keyword-sheet.tsx`

- [ ] **Step 1: Write the component**

```tsx
// src/components/keywords/keyword-sheet.tsx
"use client";

import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
} from "@/components/ui/sheet";
import { IntentBadges } from "./intent-badge";
import { Sparkline } from "./sparkline";
import { StatusBadge } from "./status-badge";
import {
  KEYWORD_SOURCES,
  KEYWORD_THEMES,
  type Keyword,
} from "@/lib/keywords/types";

const THEME_LABEL = new Map(KEYWORD_THEMES.map((t) => [t.id, t.label]));

export function KeywordSheet({
  keyword,
  open,
  onOpenChange,
}: {
  keyword: Keyword | null;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}) {
  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent className="overflow-y-auto sm:max-w-lg">
        {keyword ? (
          <>
            <SheetHeader>
              <SheetTitle>{keyword.term}</SheetTitle>
              <SheetDescription>
                {THEME_LABEL.get(keyword.theme)} · {keyword.locale.lang}-
                {keyword.locale.country}
              </SheetDescription>
            </SheetHeader>

            <div className="space-y-6 px-4 pb-6">
              <div className="flex items-center gap-2">
                <StatusBadge status={keyword.status} />
                <IntentBadges intents={keyword.intent} />
              </div>

              <section>
                <h3 className="mb-2 text-sm font-medium">Tendance (12 mois)</h3>
                <Sparkline
                  values={keyword.trend.map((t) => t.volume)}
                  width={420}
                  height={64}
                />
              </section>

              <section>
                <h3 className="mb-2 text-sm font-medium">Métriques par source</h3>
                <div className="space-y-2">
                  {KEYWORD_SOURCES.map((src) => {
                    const m = keyword.metrics[src];
                    return (
                      <div
                        key={src}
                        className="rounded-md border p-3 text-sm"
                      >
                        <div className="mb-1 font-medium uppercase">{src}</div>
                        {m ? (
                          <dl className="grid grid-cols-4 gap-2 text-muted-foreground">
                            <div>
                              <dt className="text-xs">Volume</dt>
                              <dd className="text-foreground">
                                {m.volume.toLocaleString("fr-FR")}
                              </dd>
                            </div>
                            <div>
                              <dt className="text-xs">KD</dt>
                              <dd className="text-foreground">{m.kd}</dd>
                            </div>
                            <div>
                              <dt className="text-xs">CPC</dt>
                              <dd className="text-foreground">
                                ${m.cpc.toFixed(2)}
                              </dd>
                            </div>
                            <div>
                              <dt className="text-xs">Croissance</dt>
                              <dd className="text-foreground">
                                {m.growthPct > 0 ? "+" : ""}
                                {m.growthPct}%
                              </dd>
                            </div>
                          </dl>
                        ) : (
                          <p className="text-muted-foreground text-xs italic">
                            Source non connectée — aucune donnée.
                          </p>
                        )}
                      </div>
                    );
                  })}
                </div>
              </section>

              {keyword.serpFeatures?.length ? (
                <section>
                  <h3 className="mb-2 text-sm font-medium">SERP features</h3>
                  <p className="text-muted-foreground text-sm">
                    {keyword.serpFeatures.join(", ")}
                  </p>
                </section>
              ) : null}
            </div>
          </>
        ) : null}
      </SheetContent>
    </Sheet>
  );
}
```

- [ ] **Step 2: Type-check + commit**

Run: `bunx tsc --noEmit` (Expected: no errors)

```bash
git add src/components/keywords/keyword-sheet.tsx
git commit -m "feat: keyword detail Sheet (multi-source, honest empty state)"
```

---

## Task 11: Bulk-actions bar

**Files:**
- Create: `src/components/keywords/bulk-actions-bar.tsx`

- [ ] **Step 1: Write the component**

```tsx
// src/components/keywords/bulk-actions-bar.tsx
"use client";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { KEYWORD_STATUSES, type KeywordStatus } from "@/lib/keywords/types";

export function BulkActionsBar({
  count,
  onSetStatus,
  onClear,
}: {
  count: number;
  onSetStatus: (status: KeywordStatus) => void;
  onClear: () => void;
}) {
  if (count === 0) return null;
  return (
    <div className="flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm">
      <span className="font-medium">{count} sélectionné(s)</span>
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button size="sm" variant="outline">
            Changer le statut
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="start">
          {KEYWORD_STATUSES.map((s) => (
            <DropdownMenuItem key={s} onSelect={() => onSetStatus(s)}>
              {s}
            </DropdownMenuItem>
          ))}
        </DropdownMenuContent>
      </DropdownMenu>
      <Button size="sm" variant="ghost" onClick={onClear} className="ml-auto">
        Annuler
      </Button>
    </div>
  );
}
```

- [ ] **Step 2: Type-check + commit**

Run: `bunx tsc --noEmit` (Expected: no errors)

```bash
git add src/components/keywords/bulk-actions-bar.tsx
git commit -m "feat: keyword bulk-actions bar"
```

---

## Task 12: Reservoir container (assembles everything)

**Files:**
- Create: `src/components/keywords/reservoir.tsx`

- [ ] **Step 1: Write the container**

```tsx
// src/components/keywords/reservoir.tsx
"use client";

import type {
  ColumnFiltersState,
  RowSelectionState,
} from "@tanstack/react-table";
import { useEffect, useMemo, useState } from "react";
import { DataTable } from "@/components/ui/data-table";
import { Skeleton } from "@/components/ui/skeleton";
import { useActiveSite } from "@/lib/keywords/site-context";
import { getKeywordsForSite } from "@/lib/keywords/sites";
import type { Keyword, KeywordStatus } from "@/lib/keywords/types";
import { BulkActionsBar } from "./bulk-actions-bar";
import { columns } from "./columns";
import { FiltersBar } from "./filters-bar";
import { KeywordSheet } from "./keyword-sheet";

export function Reservoir() {
  const { activeSiteId } = useActiveSite();

  // mock "loading" so the future API swap has a seam
  const [loading, setLoading] = useState(true);
  const [rows, setRows] = useState<Keyword[]>([]);

  useEffect(() => {
    setLoading(true);
    setRowSelection({});
    const id = setTimeout(() => {
      setRows(getKeywordsForSite(activeSiteId));
      setLoading(false);
    }, 200);
    return () => clearTimeout(id);
  }, [activeSiteId]);

  const [search, setSearch] = useState("");
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
  const [openKeyword, setOpenKeyword] = useState<Keyword | null>(null);

  const selectedCount = Object.values(rowSelection).filter(Boolean).length;

  // local mutation for bulk status change (mock; resets on site switch)
  const data = useMemo(() => rows, [rows]);

  function setStatusForSelected(status: KeywordStatus) {
    setRows((prev) =>
      prev.map((k) => (rowSelection[k.id] ? { ...k, status } : k)),
    );
    setRowSelection({});
  }

  if (loading) {
    return (
      <div className="space-y-3 p-4">
        <Skeleton className="h-9 w-full max-w-xl" />
        <Skeleton className="h-64 w-full" />
      </div>
    );
  }

  return (
    <div className="space-y-3 p-4">
      <FiltersBar
        search={search}
        onSearch={setSearch}
        columnFilters={columnFilters}
        onColumnFilters={setColumnFilters}
      />
      <BulkActionsBar
        count={selectedCount}
        onSetStatus={setStatusForSelected}
        onClear={() => setRowSelection({})}
      />
      <DataTable
        columns={columns}
        data={data}
        getRowId={(k) => k.id}
        globalFilter={search}
        columnFilters={columnFilters}
        rowSelection={rowSelection}
        onRowSelectionChange={setRowSelection}
        onRowClick={(k) => setOpenKeyword(k)}
        caption="Réservoir de mots-clés du site actif"
      />
      <KeywordSheet
        keyword={openKeyword}
        open={openKeyword !== null}
        onOpenChange={(o) => !o && setOpenKeyword(null)}
      />
    </div>
  );
}
```

- [ ] **Step 2: Type-check + lint**

Run: `bun run lint && bunx tsc --noEmit`
Expected: no errors.

- [ ] **Step 3: Commit**

```bash
git add src/components/keywords/reservoir.tsx
git commit -m "feat: reservoir container (state, loading, bulk, sheet)"
```

---

## Task 13: Route + page

**Files:**
- Create: `src/app/(app)/keywords/reservoir/page.tsx`

- [ ] **Step 1: Write the page**

```tsx
// src/app/(app)/keywords/reservoir/page.tsx
import { DashboardHeader } from "@/components/dashboard-header";
import { Reservoir } from "@/components/keywords/reservoir";
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";

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

- [ ] **Step 2: Run the app and verify visually**

Run: `bun run dev`, open `http://localhost:3000/keywords/reservoir`.
Expected & verify each acceptance criterion:
- Table renders ~10 keywords with sparklines.
- Typing in search narrows rows; theme/status/intent selects narrow rows.
- Clicking a row opens the Sheet; ahrefs/gsc show "Source non connectée".
- Selecting rows shows the bulk bar; "Changer le statut" updates badges.
- Switching site in the sidebar team-switcher re-mocks the table (different keywords).
- Tab navigation reaches search, selects, checkboxes; focus rings visible.

- [ ] **Step 3: Commit**

```bash
git add "src/app/(app)/keywords/reservoir/page.tsx"
git commit -m "feat: /keywords/reservoir route"
```

---

## Task 14: Stub pages for the other keywords routes

**Files:**
- Create: `src/app/(app)/keywords/overview/page.tsx`
- Create: `src/app/(app)/keywords/recherche/page.tsx`
- Create: `src/app/(app)/keywords/planning/page.tsx`
- Create: `src/app/(app)/keywords/export-import/page.tsx`

So the sidebar sub-menu links navigate to an explicit "en construction" state instead of a 404.

- [ ] **Step 1: Write a shared stub (repeat for each, changing title)**

Example for `overview` (repeat identically for `recherche`, `planning`, `export-import`, changing the `BreadcrumbPage` text and the `title` string):

```tsx
// src/app/(app)/keywords/overview/page.tsx
import { DashboardHeader } from "@/components/dashboard-header";
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>
      <div className="flex h-64 items-center justify-center text-muted-foreground">
        Page « Overview » — en construction.
      </div>
    </>
  );
}
```

The four stub pages map: `overview` → "Overview", `recherche` → "Recherche", `planning` → "Planning", `export-import` → "Import / Export".

- [ ] **Step 2: Verify nav**

Run: `bun run dev`; click each Keywords sub-menu link. Expected: each shows its "en construction" page (no 404).

- [ ] **Step 3: Commit**

```bash
git add "src/app/(app)/keywords/overview/page.tsx" "src/app/(app)/keywords/recherche/page.tsx" "src/app/(app)/keywords/planning/page.tsx" "src/app/(app)/keywords/export-import/page.tsx"
git commit -m "feat: stub pages for keywords overview/recherche/planning/export-import"
```

---

## Task 15: Final verification

- [ ] **Step 1: Full test suite**

Run: `bun test`
Expected: all pass (existing flight-check tests + new fixtures + sparkline tests).

- [ ] **Step 2: Lint + types + build**

Run: `bun run lint && bunx tsc --noEmit && bun run build`
Expected: clean lint, no type errors, successful production build (all `/keywords/*` routes compiled).

- [ ] **Step 3: Acceptance walkthrough**

Re-run the Task 13 Step 2 checklist end-to-end on the production build (`bun run start`). Confirm every acceptance box in the spec is satisfied.

---

## Self-Review (filled)

**Spec coverage:** types ✔ (T1) · fixtures multi-source/no-invent ✔ (T2) · 5 themes ✔ (T1 enum + T2 partition) · 3 sites + isolation ✔ (T3) · site context wiring ✔ (T4) · Keywords nav ✔ (T4) · generic DataTable ✔ (T5) · sparkline ✔ (T6) · badges ✔ (T7) · columns incl. all spec columns ✔ (T8) · filters (facets; ranges seam) ✔ (T9) · multi-source Sheet w/ honest empty ✔ (T10) · bulk bar ✔ (T11) · states skeleton/empty ✔ (T12, DataTable) · route + a11y ✔ (T13) · stub nav pages ✔ (T14). Range-filter UI is intentionally deferred to a follow-up (column `filterFn`s already accept ranges) — noted in spec "petit bout par petit bout".

**Placeholder scan:** Fixtures task intentionally requires CSV transcription (recipe + 2 worked rows + exact partition) — this is data entry from a real source, not an unspecified placeholder. No "TODO"/"add error handling" left.

**Type consistency:** `Keyword`, `KeywordStatus`, `KeywordTheme`, `SourceMetrics`, `getKeywordsForSite`, `useActiveSite`/`SiteProvider`, `sparklinePath` names are used identically across tasks. `columnFilters` facet values are `string[]` everywhere; range values `[min,max]` consumed by `numberRange`.
