# Keyword Reservoir → Backlinks Parity 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:** Bring `/keywords/reservoir` to feature + design parity with `/backlink/reservoir` — persist bulk actions, polish badges, add delete + editable detail sheet + manual single-keyword add — while keeping the per-site architecture.

**Architecture:** Per-site `keywords` table (unchanged). New site-scoped server actions in `src/lib/keywords/actions.ts` mutate the DB, then the client calls the existing `refresh()` seam (`fetchKeywordsForSiteAction`) — no SSR `revalidatePath`. A shared `Pill` primitive (extracted from the backlinks badges) gives keyword categorical badges the "dark-substrate" look. KD/CPC keep their continuous HSL gradient.

**Tech Stack:** Next 16 (App Router), React 19, TanStack Table, Drizzle ORM (Postgres), shadcn/ui, bun test, biome.

**Conventions:**
- DB integration tests are gated on `DB_TESTS=1` and run only against the `*_test` DB. Run command (used throughout):
  `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test <file>`
- Lint/format: `bun run lint` (biome).
- Conventional commits; work happens on branch `feature/keywords-reservoir-parity` (already created).
- `keywords` table columns (reference): `id, siteId, term, lang, country, theme, status, intent, primarySource, sources, metrics(jsonb), trend(jsonb), serpFeatures`.

---

## File structure

| File | Responsibility | Action |
|---|---|---|
| `src/components/ui/pill.tsx` | Shared dark-substrate `PILL`/`ACCENT`/`Pill` | Create (T1) |
| `src/components/backlink/source-badges.tsx` | Backlink badges | Modify — re-import pill (T1) |
| `src/lib/keywords/types.ts` | Keyword types | Modify — add `"manual"` source (T2) |
| `src/components/keywords/status-badge.tsx` | Status pill | Modify (T2) |
| `src/components/keywords/intent-badge.tsx` | Intent pills | Modify (T2) |
| `src/components/keywords/source-badge.tsx` | Source pill | Create (T2) |
| `src/components/keywords/columns.tsx` | Table columns | Modify — Source cell (T2) |
| `src/lib/keywords/actions.ts` | Server actions (read + new writes) | Modify (T3,T4,T5,T6) |
| `src/lib/keywords/actions.test.ts` | DB tests for new actions | Create (T3,T4,T5,T6) |
| `src/components/keywords/reservoir.tsx` | Container wiring | Modify (T3,T4,T5,T6) |
| `src/components/keywords/bulk-actions-bar.tsx` | Bulk actions + delete | Modify (T4) |
| `src/components/keywords/keyword-sheet.tsx` | Editable detail sheet | Modify (T5) |
| `src/components/keywords/add-keyword-dialog.tsx` | Manual add dialog | Create (T6) |
| `src/components/keywords/keywords-toolbar.tsx` | Toolbar add button | Modify (T6) |

---

## Task 1: Extract shared `Pill` primitive

**Files:**
- Create: `src/components/ui/pill.tsx`
- Modify: `src/components/backlink/source-badges.tsx:14-24` (remove local `PILL`/`ACCENT`, import them)

- [ ] **Step 1: Create the shared pill module**

`src/components/ui/pill.tsx`:
```tsx
// src/components/ui/pill.tsx
import { cn } from "@/lib/utils";

/**
 * Shared "dark substrate" pill: accent @10% background, @30% border,
 * full-opacity accent text. Harmonized across backlink + keyword badges.
 */
export const PILL =
  "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold tracking-wide";

export const ACCENT = {
  emerald: "border-emerald-500/30 bg-emerald-500/10 text-emerald-400",
  amber: "border-amber-500/30 bg-amber-500/10 text-amber-400",
  rose: "border-rose-500/30 bg-rose-500/10 text-rose-400",
  primary: "border-primary/30 bg-primary/10 text-primary",
  neutral: "border-border bg-muted text-muted-foreground",
} as const;

export type PillAccent = keyof typeof ACCENT;

export function Pill({
  accent = "neutral",
  className,
  title,
  children,
}: {
  accent?: PillAccent;
  className?: string;
  title?: string;
  children: React.ReactNode;
}) {
  return (
    <span className={cn(PILL, ACCENT[accent], className)} title={title}>
      {children}
    </span>
  );
}
```

- [ ] **Step 2: Repoint backlink badges to the shared module**

In `src/components/backlink/source-badges.tsx`, delete the local `PILL` const and `ACCENT` const (lines 11-24) and add the import near the other imports (keep the existing `cn` import):
```tsx
import { ACCENT, PILL } from "@/components/ui/pill";
```
Leave `KindBadge`, `LinkTypeBadge`, `EffortBadge`, `FlagIcons`, `LanguageFlag` bodies unchanged — they already reference `PILL` and `ACCENT`.

- [ ] **Step 3: Verify lint + types pass**

Run: `bun run lint`
Expected: no new errors in `src/components/ui/pill.tsx` or `src/components/backlink/source-badges.tsx`.

- [ ] **Step 4: Visual smoke check (backlinks unchanged)**

Run: `bun dev` and open `http://localhost:3000/backlink/reservoir`. Expected: Kind / LinkType pills render identically to before.

- [ ] **Step 5: Commit**

```bash
git add src/components/ui/pill.tsx src/components/backlink/source-badges.tsx
git commit -m "refactor(ui): extract shared Pill primitive from backlink badges"
```

---

## Task 2: Keyword badge polish + `"manual"` source type

**Files:**
- Modify: `src/lib/keywords/types.ts:11` and `:20-25`
- Modify: `src/components/keywords/status-badge.tsx`
- Modify: `src/components/keywords/intent-badge.tsx`
- Create: `src/components/keywords/source-badge.tsx`
- Modify: `src/components/keywords/columns.tsx:261-269` (Source cell) + imports

- [ ] **Step 1: Add `"manual"` to the `KeywordSource` type (NOT the providers array)**

In `src/lib/keywords/types.ts`, change line 11:
```ts
export type KeywordSource = "mangools" | "ahrefs" | "gsc" | "semrush" | "manual";
```
Leave `KEYWORD_SOURCES` (lines 20-25) as the four real providers — it is iterated in the sheet/import UI and must not include `manual`. Add a comment above it:
```ts
/** Real data providers (iterated in the sheet/import UI). "manual" is a valid
 *  KeywordSource but intentionally excluded here — hand-added keywords carry no
 *  provider metrics. */
export const KEYWORD_SOURCES: KeywordSource[] = [
  "mangools",
  "ahrefs",
  "gsc",
  "semrush",
];
```

- [ ] **Step 2: Rewrite `status-badge.tsx` as a pill**

Replace the whole file:
```tsx
// src/components/keywords/status-badge.tsx
import { Pill, type PillAccent } from "@/components/ui/pill";
import type { KeywordStatus } from "@/lib/keywords/types";

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

const ACCENTS: Record<KeywordStatus, PillAccent> = {
  idea: "neutral",
  watch: "amber",
  active: "emerald",
  archived: "rose",
};

export function StatusBadge({ status }: { status: KeywordStatus }) {
  return <Pill accent={ACCENTS[status]}>{LABELS[status]}</Pill>;
}
```

- [ ] **Step 3: Rewrite `intent-badge.tsx` as pills**

Replace the whole file:
```tsx
// src/components/keywords/intent-badge.tsx
import { Pill, type PillAccent } from "@/components/ui/pill";
import type { SearchIntent } from "@/lib/keywords/types";

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

const ACCENTS: Record<SearchIntent, PillAccent> = {
  informational: "primary",
  commercial: "amber",
  transactional: "emerald",
  navigational: "neutral",
};

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

- [ ] **Step 4: Create `source-badge.tsx`**

`src/components/keywords/source-badge.tsx`:
```tsx
// src/components/keywords/source-badge.tsx
import { Pill } from "@/components/ui/pill";
import type { KeywordSource } from "@/lib/keywords/types";

const LABELS: Record<KeywordSource, string> = {
  mangools: "Mangools",
  ahrefs: "Ahrefs",
  gsc: "GSC",
  semrush: "Semrush",
  manual: "Manuel",
};

export function SourceBadge({ source }: { source: KeywordSource }) {
  return <Pill accent="neutral">{LABELS[source] ?? source}</Pill>;
}
```

- [ ] **Step 5: Use `SourceBadge` in the Source column**

In `src/components/keywords/columns.tsx`:
- Add to imports (near line 15): `import { SourceBadge } from "./source-badge";` and `import type { KeywordSource } from "@/lib/keywords/types";` (extend the existing type import on lines 7-11 instead of duplicating — add `KeywordSource` to that import list).
- Replace the `source` column cell (lines 261-269):
```tsx
  {
    id: "source",
    header: "Source",
    enableGlobalFilter: false,
    accessorFn: (k) => k.primarySource,
    cell: ({ getValue }) => <SourceBadge source={getValue() as KeywordSource} />,
  },
```

- [ ] **Step 6: Verify lint + types pass**

Run: `bun run lint`
Expected: no errors. The `Record<KeywordSource, ...>` maps are exhaustive (include `manual`).

- [ ] **Step 7: Visual smoke check**

Open `http://localhost:3000/keywords/reservoir`. Expected: Statut / Intent render as dark-substrate pills; enabling the Source column shows neutral pills; KD/CPC unchanged (gradient).

- [ ] **Step 8: Commit**

```bash
git add src/lib/keywords/types.ts src/components/keywords/status-badge.tsx src/components/keywords/intent-badge.tsx src/components/keywords/source-badge.tsx src/components/keywords/columns.tsx
git commit -m "feat(keywords): dark-substrate pills for status/intent/source + manual source type"
```

---

## Task 3: Persist bulk status/theme actions

**Files:**
- Modify: `src/lib/keywords/actions.ts` (add two actions)
- Create: `src/lib/keywords/actions.test.ts`
- Modify: `src/components/keywords/reservoir.tsx:73-85` and imports

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

`src/lib/keywords/actions.test.ts`:
```ts
// Integration test — gated on DB_TESTS=1, run against the *_test DB only.
import { beforeEach, describe, expect, mock, test } from "bun:test";

// actions/queries import "server-only" (throws outside Next) — stub it.
mock.module("server-only", () => ({}));

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

describe.skipIf(!RUN_DB)("keyword write actions (postgres)", () => {
  let db: typeof import("@/lib/db/client").db;
  let schema: typeof import("@/lib/db/domain-schema");
  let actions: typeof import("./actions");

  const SITE_A = "lustre-lab";
  const SITE_B = "atelier-forge";

  async function insertKeyword(id: string, siteId: string, term: string) {
    await db.insert(schema.keywords).values({
      id,
      siteId,
      term,
      lang: "fr",
      country: "FR",
      theme: "lab-diamonds",
      status: "idea",
      intent: ["informational"],
      primarySource: "mangools",
      sources: ["mangools"],
      metrics: {
        mangools: { volume: 100, kd: 10, cpc: 1.2, growthPct: 5, updatedAt: "" },
      },
      trend: [],
    });
  }

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    schema = await import("@/lib/db/domain-schema");
    actions = await import("./actions");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    await assertTestDb();
    await db.delete(schema.keywords);
    await db.delete(schema.sites);
    await db.insert(schema.sites).values([
      { id: SITE_A, orgId: "demo-org", ownerUserId: "seed-owner", name: "Lustre Lab", url: "https://lustrelab.com", primaryLanguage: "fr", languages: [], targetCountries: ["FR"] },
      { id: SITE_B, orgId: "demo-org", ownerUserId: "seed-owner", name: "Atelier Forge", url: "https://atelierforge.com", primaryLanguage: "fr", languages: [], targetCountries: ["FR"] },
    ]);
  });

  test("setKeywordsStatusAction updates only the given ids, site-scoped", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await insertKeyword("k2", SITE_A, "bravo");
    await insertKeyword("kx", SITE_B, "foreign");
    await actions.setKeywordsStatusAction(SITE_A, ["k1"], "active");
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    expect(all.find((k) => k.id === "k1")?.status).toBe("active");
    expect(all.find((k) => k.id === "k2")?.status).toBe("idea");
    const foreign = await actions.fetchKeywordsForSiteAction(SITE_B);
    expect(foreign[0].status).toBe("idea"); // wrong-site id ignored
  });

  test("setKeywordsThemeAction updates theme for the given ids", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await actions.setKeywordsThemeAction(SITE_A, ["k1"], "metals");
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    expect(all.find((k) => k.id === "k1")?.theme).toBe("metals");
  });
});
```

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

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/keywords/actions.test.ts`
Expected: FAIL — `actions.setKeywordsStatusAction is not a function`.

- [ ] **Step 3: Implement the two actions**

Replace `src/lib/keywords/actions.ts` with:
```ts
// src/lib/keywords/actions.ts — server actions for keywords.
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { keywords } from "@/lib/db/domain-schema";
import { getKeywordsForSite } from "./queries";
import type { Keyword, KeywordStatus, KeywordTheme } from "./types";

/** Fetch a site's keywords from the DB (callable from client components). */
export async function fetchKeywordsForSiteAction(
  siteId: string,
): Promise<Keyword[]> {
  return getKeywordsForSite(siteId);
}

/** Bulk-set status on the given keyword ids (scoped to the site). */
export async function setKeywordsStatusAction(
  siteId: string,
  ids: string[],
  status: KeywordStatus,
): Promise<void> {
  if (ids.length === 0) return;
  await db
    .update(keywords)
    .set({ status })
    .where(and(eq(keywords.siteId, siteId), inArray(keywords.id, ids)));
}

/** Bulk-set theme on the given keyword ids (scoped to the site). */
export async function setKeywordsThemeAction(
  siteId: string,
  ids: string[],
  theme: KeywordTheme,
): Promise<void> {
  if (ids.length === 0) return;
  await db
    .update(keywords)
    .set({ theme })
    .where(and(eq(keywords.siteId, siteId), inArray(keywords.id, ids)));
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/keywords/actions.test.ts`
Expected: PASS (2 tests).

- [ ] **Step 5: Wire the actions into `reservoir.tsx`**

In `src/components/keywords/reservoir.tsx`:
- Extend the actions import (line 12):
```tsx
import {
  fetchKeywordsForSiteAction,
  setKeywordsStatusAction,
  setKeywordsThemeAction,
} from "@/lib/keywords/actions";
```
- Replace `setStatusForSelected` and `setThemeForSelected` (lines 73-85) with:
```tsx
  function selectedIds() {
    return Object.keys(rowSelection).filter((id) => rowSelection[id]);
  }

  async function setStatusForSelected(status: KeywordStatus) {
    await setKeywordsStatusAction(activeSiteId, selectedIds(), status);
    setRowSelection({});
    refresh();
  }

  async function setThemeForSelected(theme: KeywordTheme) {
    await setKeywordsThemeAction(activeSiteId, selectedIds(), theme);
    setRowSelection({});
    refresh();
  }
```
- Remove the now-stale comment on line 63 (`// local mutation for bulk status change …`). The `data`/`cpcRange` memos stay.

- [ ] **Step 6: Verify lint + manual check**

Run: `bun run lint`
Then on `http://localhost:3000/keywords/reservoir`: select rows → "Changer le statut"/"Catégorie" → reload page → change persists.

- [ ] **Step 7: Commit**

```bash
git add src/lib/keywords/actions.ts src/lib/keywords/actions.test.ts src/components/keywords/reservoir.tsx
git commit -m "feat(keywords): persist bulk status/theme changes to DB"
```

---

## Task 4: Delete keywords

**Files:**
- Modify: `src/lib/keywords/actions.ts` (add `deleteKeywordsAction`)
- Modify: `src/lib/keywords/actions.test.ts` (add test)
- Modify: `src/components/keywords/bulk-actions-bar.tsx` (Delete button + confirm)
- Modify: `src/components/keywords/reservoir.tsx` (wire `onDelete`)

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

Append inside the `describe` block in `src/lib/keywords/actions.test.ts`:
```ts
  test("deleteKeywordsAction removes only the given ids, site-scoped", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await insertKeyword("k2", SITE_A, "bravo");
    await insertKeyword("kx", SITE_B, "foreign");
    await actions.deleteKeywordsAction(SITE_A, ["k1", "kx"]); // kx is wrong-site
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    expect(all.map((k) => k.id)).toEqual(["k2"]);
    const foreign = await actions.fetchKeywordsForSiteAction(SITE_B);
    expect(foreign.map((k) => k.id)).toEqual(["kx"]); // untouched
  });
```

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

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/keywords/actions.test.ts`
Expected: FAIL — `actions.deleteKeywordsAction is not a function`.

- [ ] **Step 3: Implement `deleteKeywordsAction`**

Add to `src/lib/keywords/actions.ts` (after `setKeywordsThemeAction`):
```ts
/** Bulk-delete the given keyword ids (scoped to the site). */
export async function deleteKeywordsAction(
  siteId: string,
  ids: string[],
): Promise<void> {
  if (ids.length === 0) return;
  await db
    .delete(keywords)
    .where(and(eq(keywords.siteId, siteId), inArray(keywords.id, ids)));
}
```

- [ ] **Step 4: Run to verify it passes**

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/keywords/actions.test.ts`
Expected: PASS (3 tests).

- [ ] **Step 5: Add a Delete button + confirm to `BulkActionsBar`**

In `src/components/keywords/bulk-actions-bar.tsx`:
- Add the prop `onDelete: () => void` to the component's props type and destructure it.
- Add the import:
```tsx
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
```
- Insert the delete control just before the trailing "Annuler" button (the one with `className="ml-auto"`). Move `ml-auto` onto this delete block's wrapper so the destructive action is right-aligned; keep the "Annuler" (clear) button after it without `ml-auto`:
```tsx
      <AlertDialog>
        <AlertDialogTrigger asChild>
          <Button size="sm" variant="destructive" className="ml-auto">
            Supprimer
          </Button>
        </AlertDialogTrigger>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Supprimer {count} mot(s)-clé(s) ?</AlertDialogTitle>
            <AlertDialogDescription>
              Cette action est irréversible.
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>Annuler</AlertDialogCancel>
            <AlertDialogAction onClick={onDelete}>Supprimer</AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>

      <Button size="sm" variant="ghost" onClick={onClear}>
        Annuler
      </Button>
```
(Remove the `ml-auto` from the original clear button on line 142.)

- [ ] **Step 6: Wire `onDelete` in `reservoir.tsx`**

In `src/components/keywords/reservoir.tsx`:
- Extend the actions import to include `deleteKeywordsAction`.
- Add the handler near the other bulk handlers:
```tsx
  async function deleteSelected() {
    await deleteKeywordsAction(activeSiteId, selectedIds());
    setRowSelection({});
    refresh();
  }
```
- Pass it to the bar:
```tsx
      <BulkActionsBar
        count={selectedCount}
        onSetStatus={setStatusForSelected}
        onSetTheme={setThemeForSelected}
        onDelete={deleteSelected}
        onClear={() => setRowSelection({})}
      />
```

- [ ] **Step 7: Verify lint + manual check**

Run: `bun run lint`. Then select rows → "Supprimer" → confirm → rows disappear and stay gone after reload.

- [ ] **Step 8: Commit**

```bash
git add src/lib/keywords/actions.ts src/lib/keywords/actions.test.ts src/components/keywords/bulk-actions-bar.tsx src/components/keywords/reservoir.tsx
git commit -m "feat(keywords): bulk delete with confirm"
```

---

## Task 5: Editable detail sheet

**Files:**
- Modify: `src/lib/keywords/actions.ts` (add `updateKeywordAction`)
- Modify: `src/lib/keywords/actions.test.ts` (add test)
- Modify: `src/components/keywords/keyword-sheet.tsx` (editable status/theme/intent)
- Modify: `src/components/keywords/reservoir.tsx` (pass `siteId` + `onSaved`)

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

Append inside the `describe` block in `src/lib/keywords/actions.test.ts`:
```ts
  test("updateKeywordAction patches only provided fields, site-scoped", async () => {
    await insertKeyword("k1", SITE_A, "alpha");
    await actions.updateKeywordAction(SITE_A, "k1", {
      status: "watch",
      intent: ["commercial", "transactional"],
    });
    const [k] = await actions.fetchKeywordsForSiteAction(SITE_A);
    expect(k.status).toBe("watch");
    expect(k.intent).toEqual(["commercial", "transactional"]);
    expect(k.theme).toBe("lab-diamonds"); // untouched (not in patch)
  });
```

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

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/keywords/actions.test.ts`
Expected: FAIL — `actions.updateKeywordAction is not a function`.

- [ ] **Step 3: Implement `updateKeywordAction`**

Add to `src/lib/keywords/actions.ts`. Extend the `./types` import to also import `SearchIntent`, and add:
```ts
/** Patch a single keyword's workflow fields (status/theme/intent). Site-scoped. */
export async function updateKeywordAction(
  siteId: string,
  id: string,
  patch: {
    status?: KeywordStatus;
    theme?: KeywordTheme | null;
    intent?: SearchIntent[];
  },
): Promise<void> {
  const set: { status?: KeywordStatus; theme?: KeywordTheme | null; intent?: SearchIntent[] } = {};
  if (patch.status !== undefined) set.status = patch.status;
  if (patch.theme !== undefined) set.theme = patch.theme;
  if (patch.intent !== undefined) set.intent = patch.intent;
  if (Object.keys(set).length === 0) return;
  await db
    .update(keywords)
    .set(set)
    .where(and(eq(keywords.siteId, siteId), eq(keywords.id, id)));
}
```
(The `./types` import line becomes: `import type { Keyword, KeywordStatus, KeywordTheme, SearchIntent } from "./types";`)

- [ ] **Step 4: Run to verify it passes**

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/keywords/actions.test.ts`
Expected: PASS (4 tests).

- [ ] **Step 5: Make the sheet editable**

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

import { useEffect, useState } from "react";
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
} from "@/components/ui/sheet";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { updateKeywordAction } from "@/lib/keywords/actions";
import { useThemeLabels } from "@/lib/keywords/theme-context";
import {
  KEYWORD_SOURCES,
  KEYWORD_STATUSES,
  type Keyword,
  type KeywordStatus,
  type KeywordTheme,
  type SearchIntent,
} from "@/lib/keywords/types";
import { IntentBadges } from "./intent-badge";
import { Pill } from "@/components/ui/pill";
import { Sparkline } from "./sparkline";
import { StatusBadge } from "./status-badge";

const NONE_THEME = "__none__";
const INTENTS: SearchIntent[] = [
  "informational",
  "commercial",
  "transactional",
  "navigational",
];
const INTENT_LABELS: Record<SearchIntent, string> = {
  informational: "Info",
  commercial: "Commercial",
  transactional: "Transac",
  navigational: "Navig",
};

export function KeywordSheet({
  keyword,
  open,
  onOpenChange,
  siteId,
  onSaved,
}: {
  keyword: Keyword | null;
  open: boolean;
  onOpenChange: (open: boolean) => void;
  siteId: string;
  onSaved: () => void;
}) {
  const { labelOf, themes } = useThemeLabels();
  const [status, setStatus] = useState<KeywordStatus>("idea");
  const [theme, setTheme] = useState<KeywordTheme | null>(null);
  const [intent, setIntent] = useState<SearchIntent[]>([]);

  // Re-seed local edit state whenever a different keyword opens.
  useEffect(() => {
    if (!keyword) return;
    setStatus(keyword.status);
    setTheme(keyword.theme);
    setIntent(keyword.intent);
  }, [keyword]);

  async function save(patch: {
    status?: KeywordStatus;
    theme?: KeywordTheme | null;
    intent?: SearchIntent[];
  }) {
    if (!keyword) return;
    await updateKeywordAction(siteId, keyword.id, patch);
    onSaved();
  }

  function toggleIntent(i: SearchIntent) {
    const next = intent.includes(i)
      ? intent.filter((x) => x !== i)
      : [...intent, i];
    setIntent(next);
    save({ intent: next });
  }

  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent className="overflow-y-auto sm:max-w-lg">
        {keyword ? (
          <>
            <SheetHeader>
              <SheetTitle>{keyword.term}</SheetTitle>
              <SheetDescription>
                {theme ? labelOf(theme) : "—"} {keyword.locale.lang}-
                {keyword.locale.country}
              </SheetDescription>
            </SheetHeader>

            <div className="space-y-6 px-4 pb-6">
              <section className="grid gap-3">
                <div className="grid gap-1.5">
                  <span className="text-xs font-medium">Statut</span>
                  <Select
                    value={status}
                    onValueChange={(v) => {
                      const s = v as KeywordStatus;
                      setStatus(s);
                      save({ status: s });
                    }}
                  >
                    <SelectTrigger size="sm" className="w-48" aria-label="Statut">
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent position="popper">
                      {KEYWORD_STATUSES.map((s) => (
                        <SelectItem key={s} value={s}>
                          {s}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                </div>

                <div className="grid gap-1.5">
                  <span className="text-xs font-medium">Thème</span>
                  <Select
                    value={theme ?? NONE_THEME}
                    onValueChange={(v) => {
                      const t = v === NONE_THEME ? null : (v as KeywordTheme);
                      setTheme(t);
                      save({ theme: t });
                    }}
                  >
                    <SelectTrigger size="sm" className="w-64" aria-label="Thème">
                      <SelectValue />
                    </SelectTrigger>
                    <SelectContent position="popper">
                      <SelectItem value={NONE_THEME}>— Aucun</SelectItem>
                      {themes.map((t) => (
                        <SelectItem key={t.id} value={t.id}>
                          {t.label}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                </div>

                <div className="grid gap-1.5">
                  <span className="text-xs font-medium">Intent</span>
                  <div className="flex flex-wrap gap-1">
                    {INTENTS.map((i) => {
                      const on = intent.includes(i);
                      return (
                        <button
                          key={i}
                          type="button"
                          onClick={() => toggleIntent(i)}
                          aria-pressed={on}
                          className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                        >
                          <Pill accent={on ? "primary" : "neutral"}>
                            {INTENT_LABELS[i]}
                          </Pill>
                        </button>
                      );
                    })}
                  </div>
                </div>
              </section>

              <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 === null
                                  ? "N/A"
                                  : `${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>
  );
}
```
Note: `StatusBadge`/`IntentBadges` are no longer rendered in the sheet header area (replaced by editable controls); they remain used by the table. Keep the imports only if still referenced — since they are NOT used in this new version, remove the `IntentBadges` and `StatusBadge` imports to satisfy lint. (Verify with `bun run lint`.)

- [ ] **Step 6: Pass `siteId` + `onSaved` from `reservoir.tsx`**

In `src/components/keywords/reservoir.tsx`, update the `KeywordSheet` usage (lines 126-130):
```tsx
      <KeywordSheet
        keyword={openKeyword}
        open={openKeyword !== null}
        onOpenChange={(o) => !o && setOpenKeyword(null)}
        siteId={activeSiteId}
        onSaved={refresh}
      />
```

- [ ] **Step 7: Verify lint + manual check**

Run: `bun run lint` (fix any unused-import warnings flagged in Step 5). Then click a row → change status/theme/intent in the sheet → reload → changes persisted.

- [ ] **Step 8: Commit**

```bash
git add src/lib/keywords/actions.ts src/lib/keywords/actions.test.ts src/components/keywords/keyword-sheet.tsx src/components/keywords/reservoir.tsx
git commit -m "feat(keywords): editable detail sheet (status/theme/intent)"
```

---

## Task 6: Manual single-keyword add

**Files:**
- Modify: `src/lib/keywords/actions.ts` (add `addKeywordAction` + `slugify`)
- Modify: `src/lib/keywords/actions.test.ts` (add tests)
- Create: `src/components/keywords/add-keyword-dialog.tsx`
- Modify: `src/components/keywords/keywords-toolbar.tsx` (add button)

- [ ] **Step 1: Add the failing tests**

Append inside the `describe` block in `src/lib/keywords/actions.test.ts`:
```ts
  test("addKeywordAction inserts a manual keyword (status idea, source manual)", async () => {
    const res = await actions.addKeywordAction(SITE_A, {
      term: "Bagues Or Éthique",
      lang: "fr",
      country: "FR",
      theme: "metals",
      intent: ["commercial"],
    });
    expect(res.ok).toBe(true);
    const all = await actions.fetchKeywordsForSiteAction(SITE_A);
    const k = all.find((x) => x.term === "Bagues Or Éthique");
    expect(k).toBeDefined();
    expect(k?.id).toBe("lustre-lab-bagues-or-ethique");
    expect(k?.status).toBe("idea");
    expect(k?.primarySource).toBe("manual");
    expect(k?.sources).toEqual(["manual"]);
    expect(k?.theme).toBe("metals");
  });

  test("addKeywordAction rejects an empty term", async () => {
    const res = await actions.addKeywordAction(SITE_A, {
      term: "   ",
      lang: "fr",
      country: "FR",
      intent: [],
    });
    expect(res.ok).toBe(false);
  });

  test("addKeywordAction rejects a duplicate term", async () => {
    const input = { term: "alpha", lang: "fr", country: "FR", intent: [] as const };
    await actions.addKeywordAction(SITE_A, { ...input, intent: [] });
    const res = await actions.addKeywordAction(SITE_A, { ...input, intent: [] });
    expect(res.ok).toBe(false);
  });
```

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

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/keywords/actions.test.ts`
Expected: FAIL — `actions.addKeywordAction is not a function`.

- [ ] **Step 3: Implement `addKeywordAction`**

In `src/lib/keywords/actions.ts`, add the `keywordId` import (`import { keywordId } from "./import";`) and append:
```ts
/** ASCII-slug a term for the deterministic per-site id. */
function slugify(term: string): string {
  return term
    .toLowerCase()
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "")
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/(^-|-$)/g, "");
}

/** Manually add a single keyword (no provider metrics). Site-scoped. */
export async function addKeywordAction(
  siteId: string,
  input: {
    term: string;
    lang: string;
    country: string;
    theme?: KeywordTheme | null;
    intent: SearchIntent[];
  },
): Promise<{ ok: true } | { ok: false; error: string }> {
  const term = input.term.trim();
  if (!term) return { ok: false, error: "Le terme est requis." };
  const slug = slugify(term);
  if (!slug) return { ok: false, error: "Terme invalide." };
  const id = keywordId(siteId, slug);
  const existing = await db
    .select({ id: keywords.id })
    .from(keywords)
    .where(and(eq(keywords.siteId, siteId), eq(keywords.id, id)))
    .limit(1);
  if (existing.length > 0)
    return { ok: false, error: "Ce mot-clé existe déjà pour ce site." };
  await db.insert(keywords).values({
    id,
    siteId,
    term,
    lang: input.lang || "en",
    country: input.country || "US",
    theme: input.theme ?? null,
    status: "idea",
    intent: input.intent,
    primarySource: "manual",
    sources: ["manual"],
    metrics: {},
    trend: [],
  });
  return { ok: true };
}
```

- [ ] **Step 4: Run to verify it passes**

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/keywords/actions.test.ts`
Expected: PASS (7 tests total).

- [ ] **Step 5: Create `AddKeywordDialog`**

`src/components/keywords/add-keyword-dialog.tsx`:
```tsx
// src/components/keywords/add-keyword-dialog.tsx
"use client";

import { useState } from "react";
import { Pill } from "@/components/ui/pill";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { addKeywordAction } from "@/lib/keywords/actions";
import { useThemeLabels } from "@/lib/keywords/theme-context";
import type { KeywordTheme, SearchIntent } from "@/lib/keywords/types";

const NONE_THEME = "__none__";
const INTENTS: SearchIntent[] = [
  "informational",
  "commercial",
  "transactional",
  "navigational",
];
const INTENT_LABELS: Record<SearchIntent, string> = {
  informational: "Info",
  commercial: "Commercial",
  transactional: "Transac",
  navigational: "Navig",
};

export function AddKeywordDialog({
  open,
  onOpenChange,
  siteId,
  onAdded,
}: {
  open: boolean;
  onOpenChange: (o: boolean) => void;
  siteId: string;
  onAdded: () => void;
}) {
  const { themes } = useThemeLabels();
  const [term, setTerm] = useState("");
  const [lang, setLang] = useState("fr");
  const [country, setCountry] = useState("FR");
  const [theme, setTheme] = useState<string>(NONE_THEME);
  const [intent, setIntent] = useState<SearchIntent[]>([]);
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  function reset() {
    setTerm("");
    setLang("fr");
    setCountry("FR");
    setTheme(NONE_THEME);
    setIntent([]);
    setError(null);
    setPending(false);
  }

  function toggleIntent(i: SearchIntent) {
    setIntent((prev) =>
      prev.includes(i) ? prev.filter((x) => x !== i) : [...prev, i],
    );
  }

  async function submit() {
    setError(null);
    setPending(true);
    try {
      const res = await addKeywordAction(siteId, {
        term,
        lang,
        country,
        theme: theme === NONE_THEME ? null : (theme as KeywordTheme),
        intent,
      });
      if (!res.ok) {
        setError(res.error);
        return;
      }
      onAdded();
      onOpenChange(false);
      reset();
    } finally {
      setPending(false);
    }
  }

  return (
    <Dialog
      open={open}
      onOpenChange={(o) => {
        if (!o) reset();
        onOpenChange(o);
      }}
    >
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Ajouter un mot-clé</DialogTitle>
          <DialogDescription>
            Ajouté au réservoir du site actif (statut « idée », sans métriques).
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-3">
          <Input
            placeholder="Terme (ex. bagues or éthique)"
            aria-label="Terme"
            value={term}
            onChange={(e) => setTerm(e.target.value)}
          />
          <div className="flex gap-2">
            <Input
              placeholder="Langue"
              aria-label="Langue"
              className="w-24"
              value={lang}
              onChange={(e) => setLang(e.target.value)}
            />
            <Input
              placeholder="Pays"
              aria-label="Pays"
              className="w-24"
              value={country}
              onChange={(e) => setCountry(e.target.value)}
            />
            <Select value={theme} onValueChange={setTheme}>
              <SelectTrigger className="flex-1" aria-label="Thème">
                <SelectValue />
              </SelectTrigger>
              <SelectContent position="popper">
                <SelectItem value={NONE_THEME}>— Aucun thème</SelectItem>
                {themes.map((t) => (
                  <SelectItem key={t.id} value={t.id}>
                    {t.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
          <div className="flex flex-wrap gap-1">
            {INTENTS.map((i) => {
              const on = intent.includes(i);
              return (
                <button
                  key={i}
                  type="button"
                  onClick={() => toggleIntent(i)}
                  aria-pressed={on}
                  className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <Pill accent={on ? "primary" : "neutral"}>
                    {INTENT_LABELS[i]}
                  </Pill>
                </button>
              );
            })}
          </div>
          {error ? (
            <p className="text-destructive text-sm" role="alert">
              {error}
            </p>
          ) : null}
          <div className="flex justify-end">
            <Button onClick={submit} disabled={!term.trim() || pending}>
              {pending ? "Ajout…" : "Ajouter"}
            </Button>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}
```

- [ ] **Step 6: Add the button to `KeywordsToolbar`**

Replace `src/components/keywords/keywords-toolbar.tsx` with:
```tsx
// src/components/keywords/keywords-toolbar.tsx
"use client";
import { PlusIcon, UploadIcon } from "lucide-react";
import { useState } from "react";
import { AddKeywordDialog } from "@/components/keywords/add-keyword-dialog";
import { ImportKeywordsDialog } from "@/components/keywords/import-keywords-dialog";
import { Button } from "@/components/ui/button";

export function KeywordsToolbar({
  siteId,
  onImported,
}: {
  siteId: string;
  onImported: () => void;
}) {
  const [importOpen, setImportOpen] = useState(false);
  const [addOpen, setAddOpen] = useState(false);
  return (
    <>
      <Button variant="outline" onClick={() => setAddOpen(true)}>
        <PlusIcon /> Ajouter
      </Button>
      <Button variant="outline" onClick={() => setImportOpen(true)}>
        <UploadIcon /> Importer
      </Button>
      <AddKeywordDialog
        open={addOpen}
        onOpenChange={setAddOpen}
        siteId={siteId}
        onAdded={onImported}
      />
      <ImportKeywordsDialog
        open={importOpen}
        onOpenChange={setImportOpen}
        siteId={siteId}
        onImported={onImported}
      />
    </>
  );
}
```

- [ ] **Step 7: Verify lint + manual check**

Run: `bun run lint`. Then open `http://localhost:3000/keywords/reservoir` → "Ajouter" → fill term + intent → submit → row appears with Source "Manuel", status "Idée"; duplicate term shows the inline error.

- [ ] **Step 8: Commit**

```bash
git add src/lib/keywords/actions.ts src/lib/keywords/actions.test.ts src/components/keywords/add-keyword-dialog.tsx src/components/keywords/keywords-toolbar.tsx
git commit -m "feat(keywords): manual single-keyword add (manual source)"
```

---

## Final verification

- [ ] Run the full keyword DB test suite:
  `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/keywords`
  Expected: all pass (existing queries/import tests + 7 new action tests).
- [ ] Run `bun run lint` — clean.
- [ ] Manual end-to-end on `http://localhost:3000/keywords/reservoir`: import CSV, bulk status/theme persist, delete, edit in sheet, manual add — all survive reload. Backlinks page badges unchanged.
- [ ] Merge `feature/keywords-reservoir-parity` → `dev` (ff-only), delete the branch. (Do NOT touch `main`.)

---

## Self-review notes

- **Spec coverage:** shared pill (T1), badge polish + KD/CPC kept (T2), persist bulk (T3), delete (T4), editable sheet (T5), manual add + `"manual"` source (T6) — all covered.
- **Type consistency:** `setKeywordsStatusAction`/`setKeywordsThemeAction`/`deleteKeywordsAction`/`updateKeywordAction`/`addKeywordAction` signatures are referenced identically in `reservoir.tsx`, the sheet, the dialog, and the tests. `selectedIds()` defined once in `reservoir.tsx` (T3) and reused in T4. `"manual"` added to the `KeywordSource` type in T2 before any T6 use.
- **`KEYWORD_SOURCES`** kept to 4 providers so the sheet's per-source cards don't show an empty "manual" card.
