# Content Planning Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** A per-site editorial kanban board (`/content/planning`) of content items flowing through statuses via drag & drop, created from the keyword tables ("Vers Contenu") or directly ("+ Nouvel article").

**Architecture:** New `content_items` Postgres table; a pure `groupByStatus`/`isOverdue` (tested) feeds a `@dnd-kit/core` board; owner-agnostic server actions for CRUD + status moves; a keyword bulk action bridges keywords → content. Brief is referenced live from the linked keyword.

**Tech Stack:** Next 16 server actions, Drizzle/Postgres (+ migration), `@dnd-kit/core`, shadcn, bun + biome. Reuses `useActiveSite`, `countryFlag`, `BriefPanel`, the BulkActionsBar pattern.

Spec: `docs/superpowers/specs/2026-06-06-content-planning-design.md`

---

## File structure

- Modify `src/lib/db/domain-schema.ts` — `ContentStatus` + `contentItems` table (+ generated migration).
- Create `src/lib/content/types.ts` — `ContentItem`, `CONTENT_STATUSES`, labels.
- Create `src/lib/content/board.ts` + `board.test.ts` — `groupByStatus`, `isOverdue`.
- Create `src/lib/content/queries.ts` — `getContentItemsForSite`.
- Create `src/lib/content/actions.ts` — content CRUD + status + brief fetch + `createContentFromKeywordsAction`.
- Create `src/lib/content/content.test.ts` — DB-gated.
- Modify `src/components/keywords/bulk-actions-bar.tsx` + `reservoir.tsx` + `research/research-container.tsx` — "Vers Contenu".
- Modify `src/components/app-sidebar.tsx` — Contenu section.
- Create `src/components/content/{content-board,content-column,content-card,content-card-sheet,new-content-dialog}.tsx`.
- Create `src/app/(app)/content/planning/page.tsx`.

Tooling: tsc `bunx tsc --noEmit`; pure tests `bun run test <file>`; DB tests `DB_TESTS=1 DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun --conditions=react-server test <file>`; lint `bunx biome check --write <files>`. Dev server runs under d3k — do NOT start/restart it.

---

## Task 1: Schema + migration

**Files:**
- Modify: `src/lib/db/domain-schema.ts`
- Generated: `drizzle/0008_*.sql`

- [ ] **Step 1: Add the status type + table**

In `src/lib/db/domain-schema.ts`, after the `backlinks` table (before the relations section), add:
```ts
export type ContentStatus =
  | "idea"
  | "brief"
  | "writing"
  | "review"
  | "published";

export const contentItems = pgTable("content_items", {
  id: text("id").primaryKey(),
  siteId: text("site_id")
    .notNull()
    .references(() => sites.id, { onDelete: "cascade" }),
  title: text("title").notNull(),
  status: text("status").$type<ContentStatus>().notNull().default("idea"),
  primaryKeywordId: text("primary_keyword_id").references(() => keywords.id, {
    onDelete: "set null",
  }),
  lang: text("lang").notNull().default("fr"),
  country: text("country").notNull().default("FR"),
  assignee: text("assignee"),
  dueDate: timestamp("due_date", { withTimezone: true }),
  url: text("url"),
  createdAt: timestamp("created_at", { withTimezone: true })
    .defaultNow()
    .notNull(),
});
```
(`timestamp`, `text`, `pgTable` are already imported; `sites`/`keywords` are defined above.)

- [ ] **Step 2: Generate + apply the migration**

Run:
```bash
bun run db:generate
bun run db:migrate
bun run db:migrate:test
```
Expected: a new `drizzle/0008_*.sql` creating `content_items`; both dev and test DBs migrated without error.

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

```bash
bunx tsc --noEmit
git add src/lib/db/domain-schema.ts drizzle/
git commit -m "feat(content): content_items table + migration"
```

---

## Task 2: Content types + pure board logic

**Files:**
- Create: `src/lib/content/types.ts`
- Create: `src/lib/content/board.ts`
- Test: `src/lib/content/board.test.ts`

- [ ] **Step 1: `types.ts`**

```ts
// src/lib/content/types.ts
import type { ContentStatus } from "@/lib/db/domain-schema";

export type { ContentStatus };

export const CONTENT_STATUSES: ContentStatus[] = [
  "idea",
  "brief",
  "writing",
  "review",
  "published",
];

export const CONTENT_STATUS_LABEL: Record<ContentStatus, string> = {
  idea: "Idée",
  brief: "Brief",
  writing: "Rédaction",
  review: "Relecture",
  published: "Publié",
};

export interface ContentItem {
  id: string;
  siteId: string;
  title: string;
  status: ContentStatus;
  primaryKeywordId: string | null;
  keywordTerm: string | null;
  keywordCountry: string | null;
  hasBrief: boolean;
  lang: string;
  country: string;
  assignee: string | null;
  dueDate: string | null;
  url: string | null;
  createdAt: string;
}
```

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

`src/lib/content/board.test.ts`:
```ts
import { expect, test } from "bun:test";
import { groupByStatus, isOverdue } from "./board";
import type { ContentItem } from "./types";

let n = 0;
function item(over: Partial<ContentItem> = {}): ContentItem {
  n += 1;
  return {
    id: `c${n}`,
    siteId: "s",
    title: `art ${n}`,
    status: "idea",
    primaryKeywordId: null,
    keywordTerm: null,
    keywordCountry: null,
    hasBrief: false,
    lang: "fr",
    country: "FR",
    assignee: null,
    dueDate: null,
    url: null,
    createdAt: "2026-01-01T00:00:00Z",
    ...over,
  };
}

test("groupByStatus returns the 5 columns in order, items sorted by due then created", () => {
  const cols = groupByStatus([
    item({ status: "writing", dueDate: "2026-02-10" }),
    item({ status: "writing", dueDate: "2026-02-01" }),
    item({ status: "idea" }),
  ]);
  expect(cols.map((c) => c.status)).toEqual([
    "idea",
    "brief",
    "writing",
    "review",
    "published",
  ]);
  const writing = cols.find((c) => c.status === "writing");
  expect(writing?.items.map((i) => i.dueDate)).toEqual(["2026-02-01", "2026-02-10"]);
  expect(cols.find((c) => c.status === "brief")?.items.length).toBe(0);
});

test("isOverdue: null false; before today true; today/after false", () => {
  expect(isOverdue(null, "2026-06-06T00:00:00Z")).toBe(false);
  expect(isOverdue("2026-06-05", "2026-06-06T10:00:00Z")).toBe(true);
  expect(isOverdue("2026-06-06", "2026-06-06T10:00:00Z")).toBe(false);
  expect(isOverdue("2026-06-07", "2026-06-06T10:00:00Z")).toBe(false);
});
```

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

Run: `bun run test src/lib/content/board.test.ts`
Expected: FAIL (module not found).

- [ ] **Step 4: Implement `board.ts`**

```ts
// src/lib/content/board.ts — pure board grouping + due helpers.
import type { ContentStatus } from "@/lib/db/domain-schema";
import { CONTENT_STATUSES } from "./types";
import type { ContentItem } from "./types";

/** Group items into the 5 ordered status columns; within a column, earliest due
 *  first (nulls last), then newest created. */
export function groupByStatus(
  items: ContentItem[],
): { status: ContentStatus; items: ContentItem[] }[] {
  return CONTENT_STATUSES.map((status) => ({
    status,
    items: items
      .filter((i) => i.status === status)
      .sort((a, b) => {
        const da = a.dueDate ?? "9999-12-31";
        const db = b.dueDate ?? "9999-12-31";
        if (da !== db) return da < db ? -1 : 1;
        return a.createdAt < b.createdAt ? 1 : -1;
      }),
  }));
}

/** A due date strictly before today (date-only compare). */
export function isOverdue(dueDate: string | null, nowIso: string): boolean {
  if (!dueDate) return false;
  return dueDate.slice(0, 10) < nowIso.slice(0, 10);
}
```

- [ ] **Step 5: Run — verify pass; lint; commit**

```bash
bun run test src/lib/content/board.test.ts
bunx biome check --write src/lib/content/types.ts src/lib/content/board.ts src/lib/content/board.test.ts
bunx tsc --noEmit
git add src/lib/content/types.ts src/lib/content/board.ts src/lib/content/board.test.ts
git commit -m "feat(content): types + pure board grouping (groupByStatus, isOverdue)"
```

---

## Task 3: Queries + actions

**Files:**
- Create: `src/lib/content/queries.ts`
- Create: `src/lib/content/actions.ts`
- Test: `src/lib/content/content.test.ts`

- [ ] **Step 1: `queries.ts`**

```ts
// src/lib/content/queries.ts — read content items joined with their keyword.
import "server-only";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { contentItems, keywords } from "@/lib/db/domain-schema";
import type { ContentItem } from "./types";

export async function getContentItemsForSite(
  siteId: string,
): Promise<ContentItem[]> {
  const rows = await db
    .select({
      id: contentItems.id,
      siteId: contentItems.siteId,
      title: contentItems.title,
      status: contentItems.status,
      primaryKeywordId: contentItems.primaryKeywordId,
      lang: contentItems.lang,
      country: contentItems.country,
      assignee: contentItems.assignee,
      dueDate: contentItems.dueDate,
      url: contentItems.url,
      createdAt: contentItems.createdAt,
      keywordTerm: keywords.term,
      keywordCountry: keywords.country,
      keywordBriefs: keywords.briefs,
    })
    .from(contentItems)
    .leftJoin(keywords, eq(keywords.id, contentItems.primaryKeywordId))
    .where(eq(contentItems.siteId, siteId));

  return rows.map((r) => ({
    id: r.id,
    siteId: r.siteId,
    title: r.title,
    status: r.status,
    primaryKeywordId: r.primaryKeywordId,
    keywordTerm: r.keywordTerm ?? null,
    keywordCountry: r.keywordCountry ?? null,
    hasBrief: r.keywordBriefs
      ? Object.keys(r.keywordBriefs).length > 0
      : false,
    lang: r.lang,
    country: r.country,
    assignee: r.assignee,
    dueDate: r.dueDate ? r.dueDate.toISOString() : null,
    url: r.url,
    createdAt: r.createdAt.toISOString(),
  }));
}
```

- [ ] **Step 2: `actions.ts`**

```ts
// src/lib/content/actions.ts — content item CRUD + keyword bridge.
"use server";
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db/client";
import { contentItems, keywords } from "@/lib/db/domain-schema";
import type { ContentStatus } from "@/lib/db/domain-schema";
import type { KeywordBrief } from "@/lib/keywords/ytg-types";
import { getContentItemsForSite } from "./queries";
import type { ContentItem } from "./types";

export async function getContentItemsForSiteAction(
  siteId: string,
): Promise<ContentItem[]> {
  return getContentItemsForSite(siteId);
}

export async function createContentItemAction(
  siteId: string,
  input: {
    title: string;
    primaryKeywordId?: string | null;
    lang?: string;
    country?: string;
  },
): Promise<void> {
  const title = input.title.trim();
  if (!title) return;
  await db.insert(contentItems).values({
    id: randomUUID(),
    siteId,
    title,
    status: "idea",
    primaryKeywordId: input.primaryKeywordId ?? null,
    lang: input.lang ?? "fr",
    country: input.country ?? "FR",
  });
  revalidatePath("/content/planning");
}

/** Create one "idea" content item per selected keyword (title = term). Skips
 *  keywords that already have a content item. */
export async function createContentFromKeywordsAction(
  siteId: string,
  keywordIds: string[],
): Promise<{ created: number }> {
  if (keywordIds.length === 0) return { created: 0 };
  const kws = await db
    .select({
      id: keywords.id,
      term: keywords.term,
      lang: keywords.lang,
      country: keywords.country,
    })
    .from(keywords)
    .where(and(eq(keywords.siteId, siteId), inArray(keywords.id, keywordIds)));
  const existing = new Set(
    (
      await db
        .select({ kid: contentItems.primaryKeywordId })
        .from(contentItems)
        .where(eq(contentItems.siteId, siteId))
    ).map((r) => r.kid),
  );
  const toCreate = kws.filter((k) => !existing.has(k.id));
  if (toCreate.length === 0) return { created: 0 };
  await db.insert(contentItems).values(
    toCreate.map((k) => ({
      id: randomUUID(),
      siteId,
      title: k.term,
      status: "idea" as ContentStatus,
      primaryKeywordId: k.id,
      lang: k.lang,
      country: k.country,
    })),
  );
  revalidatePath("/content/planning");
  return { created: toCreate.length };
}

export async function updateContentStatusAction(
  id: string,
  status: ContentStatus,
): Promise<void> {
  await db.update(contentItems).set({ status }).where(eq(contentItems.id, id));
  revalidatePath("/content/planning");
}

export async function updateContentItemAction(
  id: string,
  patch: {
    title?: string;
    assignee?: string | null;
    dueDate?: string | null;
    url?: string | null;
    status?: ContentStatus;
    primaryKeywordId?: string | null;
  },
): Promise<void> {
  await db
    .update(contentItems)
    .set({
      ...(patch.title !== undefined ? { title: patch.title } : {}),
      ...(patch.assignee !== undefined ? { assignee: patch.assignee } : {}),
      ...(patch.dueDate !== undefined
        ? { dueDate: patch.dueDate ? new Date(patch.dueDate) : null }
        : {}),
      ...(patch.url !== undefined ? { url: patch.url } : {}),
      ...(patch.status !== undefined ? { status: patch.status } : {}),
      ...(patch.primaryKeywordId !== undefined
        ? { primaryKeywordId: patch.primaryKeywordId }
        : {}),
    })
    .where(eq(contentItems.id, id));
  revalidatePath("/content/planning");
}

export async function deleteContentItemAction(id: string): Promise<void> {
  await db.delete(contentItems).where(eq(contentItems.id, id));
  revalidatePath("/content/planning");
}

/** The linked keyword's briefs (for the detail sheet preview). */
export async function getKeywordBriefsAction(
  keywordId: string,
): Promise<KeywordBrief[]> {
  const [row] = await db
    .select({ briefs: keywords.briefs })
    .from(keywords)
    .where(eq(keywords.id, keywordId));
  return row?.briefs ? Object.values(row.briefs) : [];
}
```

- [ ] **Step 3: DB-gated test**

`src/lib/content/content.test.ts`:
```ts
import { afterAll, beforeEach, expect, test } from "bun:test";
import { eq } from "drizzle-orm";
import { assertTestDb } from "@/lib/db/assert-test-db";
import { organization, user } from "@/lib/db/auth-schema";
import { client, db } from "@/lib/db/client";
import { contentItems, keywords, sites } from "@/lib/db/domain-schema";
import {
  createContentFromKeywordsAction,
  getContentItemsForSiteAction,
  updateContentStatusAction,
} from "./actions";

const SITE = "content-site";
const ORG = "content-org";
const USR = "content-user";

beforeEach(async () => {
  await assertTestDb();
  await db.delete(contentItems).where(eq(contentItems.siteId, SITE));
  await db.delete(keywords).where(eq(keywords.siteId, SITE));
  await db.delete(sites).where(eq(sites.id, SITE));
  await db.delete(organization).where(eq(organization.id, ORG));
  await db.delete(user).where(eq(user.id, USR));
  await db.insert(user).values({ id: USR, name: "U", email: `content-${Date.now()}@e.com`, emailVerified: true });
  await db.insert(organization).values({ id: ORG, name: "O", slug: `content-${Date.now()}`, createdAt: new Date() });
  await db.insert(sites).values({ id: SITE, orgId: ORG, ownerUserId: USR, name: "S", url: "https://e.com", primaryLanguage: "fr", targetCountries: ["FR"] });
  await db.insert(keywords).values({
    id: "kw-1", siteId: SITE, term: "panneau solaire", lang: "fr", country: "FR",
    status: "active", primarySource: "mangools", sources: ["mangools"],
    metrics: { mangools: { volume: 100, kd: 20, cpc: 1, growthPct: null, updatedAt: "" } },
  });
});

afterAll(async () => {
  // NB: do NOT client.end() — shared singleton; closing breaks directory runs.
  void client;
});

test("createContentFromKeywords creates one idea item per keyword, skips dupes", async () => {
  const r1 = await createContentFromKeywordsAction(SITE, ["kw-1"]);
  expect(r1).toEqual({ created: 1 });
  const r2 = await createContentFromKeywordsAction(SITE, ["kw-1"]);
  expect(r2).toEqual({ created: 0 }); // already linked
  const items = await getContentItemsForSiteAction(SITE);
  expect(items.length).toBe(1);
  expect(items[0].title).toBe("panneau solaire");
  expect(items[0].status).toBe("idea");
  expect(items[0].keywordTerm).toBe("panneau solaire");
  expect(items[0].country).toBe("FR");
});

test("updateContentStatus changes status", async () => {
  await createContentFromKeywordsAction(SITE, ["kw-1"]);
  const [it] = await getContentItemsForSiteAction(SITE);
  await updateContentStatusAction(it.id, "writing");
  const [after] = await getContentItemsForSiteAction(SITE);
  expect(after.status).toBe("writing");
});
```

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

Run: `DB_TESTS=1 DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun --conditions=react-server test src/lib/content/content.test.ts`
Expected: PASS (2 tests). `--conditions=react-server` required (`queries.ts` imports `server-only`).

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

```bash
bunx biome check --write src/lib/content/queries.ts src/lib/content/actions.ts src/lib/content/content.test.ts
bunx tsc --noEmit
git add src/lib/content/
git commit -m "feat(content): queries + CRUD actions + keyword→content bridge action"
```

---

## Task 4: Install @dnd-kit + Contenu nav

**Files:**
- Modify: `package.json` (dep)
- Modify: `src/components/app-sidebar.tsx`

- [ ] **Step 1: Install @dnd-kit/core (via sfw)**

Run: `sfw bun add @dnd-kit/core`
Expected: `@dnd-kit/core` added to dependencies; install succeeds (sfw blocks malicious packages).

- [ ] **Step 2: Add the Contenu sidebar section**

In `src/components/app-sidebar.tsx`: import `FileTextIcon` from "lucide-react" (add to the existing import). Insert a new section into `data.navMain`, right after the "Backlink" entry:
```tsx
    {
      title: "Contenu",
      url: "/content/planning",
      icon: <FileTextIcon />,
      isActive: true,
      items: [{ title: "Planning", url: "/content/planning" }],
    },
```

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

```bash
bunx tsc --noEmit
bunx biome check --write src/components/app-sidebar.tsx
git add package.json bun.lock src/components/app-sidebar.tsx
git commit -m "feat(content): install @dnd-kit/core + Contenu sidebar section"
```

---

## Task 5: Keyword → Content bridge UI ("Vers Contenu")

**Files:**
- Modify: `src/components/keywords/bulk-actions-bar.tsx`
- Modify: `src/components/keywords/reservoir.tsx`
- Modify: `src/components/keywords/research/research-container.tsx`

- [ ] **Step 1: Add the `onSendToContent` prop + button**

In `bulk-actions-bar.tsx`: add `onSendToContent: () => void;` to the props type and the destructured params. After the existing "Vers Planning" button, add:
```tsx
      <Button size="sm" variant="outline" onClick={onSendToContent}>
        Vers Contenu
      </Button>
```

- [ ] **Step 2: Wire it in `reservoir.tsx`**

Add an import: `import { createContentFromKeywordsAction } from "@/lib/content/actions";`. Add a handler near `sendSelectedToStage`:
```tsx
  async function sendSelectedToContent() {
    try {
      await createContentFromKeywordsAction(activeSiteId, selectedIds());
      setRowSelection({});
    } catch {
      // swallow — matches the codebase's fire-and-forget convention
    }
  }
```
On the `<BulkActionsBar ... />`, add `onSendToContent={sendSelectedToContent}`.

- [ ] **Step 3: Wire it in `research-container.tsx` (if it renders BulkActionsBar)**

Check whether `research-container.tsx` uses `<BulkActionsBar>`. If it does, add the same handler + prop. If it uses a different bar (e.g. `TargetingBar`) and does NOT render `BulkActionsBar`, skip this file and note it — the bulk-actions-bar prop is only required where the component is actually rendered. (tsc will tell you: the new required prop breaks any unfilled `<BulkActionsBar>` usage.)

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

Run `bunx tsc --noEmit` — it MUST flag every `<BulkActionsBar>` missing `onSendToContent`; fix each. Then:
```bash
bunx biome check --write src/components/keywords/bulk-actions-bar.tsx src/components/keywords/reservoir.tsx src/components/keywords/research/research-container.tsx
git add src/components/keywords/bulk-actions-bar.tsx src/components/keywords/reservoir.tsx src/components/keywords/research/research-container.tsx
git commit -m "feat(content): 'Vers Contenu' keyword bulk action"
```

---

## Task 6: Board (card + column + board) + page

**Files:**
- Create: `src/components/content/content-card.tsx`
- Create: `src/components/content/content-column.tsx`
- Create: `src/components/content/content-board.tsx`
- Create: `src/app/(app)/content/planning/page.tsx`

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

```tsx
// src/components/content/content-card.tsx
"use client";

import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { FileTextIcon } from "lucide-react";
import { countryFlag } from "@/lib/keywords/country-flag";
import { isOverdue } from "@/lib/content/board";
import type { ContentItem } from "@/lib/content/types";

export function ContentCard({
  item,
  nowIso,
  onOpen,
}: {
  item: ContentItem;
  nowIso: string;
  onOpen: (item: ContentItem) => void;
}) {
  const { attributes, listeners, setNodeRef, transform, isDragging } =
    useDraggable({ id: item.id });
  return (
    <button
      type="button"
      ref={setNodeRef}
      {...listeners}
      {...attributes}
      onClick={() => onOpen(item)}
      style={{ transform: CSS.Translate.toString(transform) }}
      className={`w-full rounded-md border bg-card p-3 text-left text-sm shadow-sm ${
        isDragging ? "opacity-50" : ""
      }`}
    >
      <div className="font-medium">{item.title}</div>
      <div className="mt-2 flex items-center gap-2 text-muted-foreground text-xs">
        {item.keywordTerm ? (
          <span className="inline-flex items-center gap-1">
            <span aria-hidden>{countryFlag(item.keywordCountry ?? item.country)}</span>
            <span className="max-w-[10rem] truncate">{item.keywordTerm}</span>
          </span>
        ) : null}
        {item.hasBrief ? <FileTextIcon className="size-3.5" aria-label="brief" /> : null}
        {item.assignee ? (
          <span className="ml-auto rounded-full bg-muted px-1.5">{item.assignee}</span>
        ) : null}
        {item.dueDate ? (
          <span className={isOverdue(item.dueDate, nowIso) ? "text-destructive" : ""}>
            {item.dueDate.slice(0, 10)}
          </span>
        ) : null}
      </div>
    </button>
  );
}
```

- [ ] **Step 2: `content-column.tsx`**

```tsx
// src/components/content/content-column.tsx
"use client";

import { useDroppable } from "@dnd-kit/core";
import type { ContentStatus } from "@/lib/db/domain-schema";
import { CONTENT_STATUS_LABEL } from "@/lib/content/types";
import type { ContentItem } from "@/lib/content/types";
import { ContentCard } from "./content-card";

export function ContentColumn({
  status,
  items,
  nowIso,
  onOpen,
}: {
  status: ContentStatus;
  items: ContentItem[];
  nowIso: string;
  onOpen: (item: ContentItem) => void;
}) {
  const { setNodeRef, isOver } = useDroppable({ id: status });
  return (
    <div
      ref={setNodeRef}
      className={`flex min-h-40 w-64 shrink-0 flex-col gap-2 rounded-lg border bg-muted/20 p-2 ${
        isOver ? "ring-1 ring-primary" : ""
      }`}
    >
      <div className="flex items-center justify-between px-1 text-sm">
        <span className="font-medium">{CONTENT_STATUS_LABEL[status]}</span>
        <span className="text-muted-foreground tabular-nums">{items.length}</span>
      </div>
      {items.map((it) => (
        <ContentCard key={it.id} item={it} nowIso={nowIso} onOpen={onOpen} />
      ))}
    </div>
  );
}
```

- [ ] **Step 3: `content-board.tsx`**

```tsx
// src/components/content/content-board.tsx
"use client";

import {
  DndContext,
  type DragEndEvent,
  PointerSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { groupByStatus } from "@/lib/content/board";
import {
  getContentItemsForSiteAction,
  updateContentStatusAction,
} from "@/lib/content/actions";
import { CONTENT_STATUSES } from "@/lib/content/types";
import type { ContentItem } from "@/lib/content/types";
import type { ContentStatus } from "@/lib/db/domain-schema";
import { useActiveSite } from "@/lib/keywords/site-context";
import { ContentColumn } from "./content-column";
import { ContentCardSheet } from "./content-card-sheet";
import { NewContentDialog } from "./new-content-dialog";

const STATUS_SET = new Set<string>(CONTENT_STATUSES);

export function ContentBoard() {
  const { activeSiteId } = useActiveSite();
  const [items, setItems] = useState<ContentItem[]>([]);
  const [loading, setLoading] = useState(true);
  const [open, setOpen] = useState<ContentItem | null>(null);
  const [newOpen, setNewOpen] = useState(false);
  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
  );

  const load = useCallback(async () => {
    setLoading(true);
    setItems(await getContentItemsForSiteAction(activeSiteId));
    setLoading(false);
  }, [activeSiteId]);

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

  function onDragEnd(e: DragEndEvent) {
    const id = String(e.active.id);
    const over = e.over ? String(e.over.id) : null;
    if (!over || !STATUS_SET.has(over)) return;
    const status = over as ContentStatus;
    const current = items.find((i) => i.id === id);
    if (!current || current.status === status) return;
    const prev = items;
    setItems((xs) => xs.map((i) => (i.id === id ? { ...i, status } : i)));
    updateContentStatusAction(id, status).catch(() => setItems(prev));
  }

  const nowIso = new Date().toISOString();
  const columns = groupByStatus(items);

  return (
    <div className="space-y-3 p-4">
      <div className="flex items-center justify-between">
        <h1 className="font-semibold text-lg">Planning du contenu</h1>
        <Button size="sm" onClick={() => setNewOpen(true)}>
          + Nouvel article
        </Button>
      </div>
      {loading ? (
        <Skeleton className="h-64 w-full" />
      ) : (
        <DndContext sensors={sensors} onDragEnd={onDragEnd}>
          <div className="flex gap-3 overflow-x-auto pb-2">
            {columns.map((c) => (
              <ContentColumn
                key={c.status}
                status={c.status}
                items={c.items}
                nowIso={nowIso}
                onOpen={setOpen}
              />
            ))}
          </div>
        </DndContext>
      )}
      <NewContentDialog
        open={newOpen}
        onOpenChange={setNewOpen}
        siteId={activeSiteId}
        onCreated={load}
      />
      <ContentCardSheet
        item={open}
        open={open !== null}
        onOpenChange={(o) => !o && setOpen(null)}
        onSaved={load}
      />
    </div>
  );
}
```
(Note: `ContentCardSheet` and `NewContentDialog` are created in Task 7 — this file imports them; implement Task 7 before typechecking/running this page, or stub the two imports temporarily. The plan's Task order builds Task 7 right after, so just commit this file after Task 7 if tsc requires both present. If committing Task 6 alone, create minimal stub exports for the two components first, then flesh them out in Task 7.)

- [ ] **Step 4: `page.tsx`**

```tsx
// src/app/(app)/content/planning/page.tsx
import { ContentBoard } from "@/components/content/content-board";
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>Contenu</BreadcrumbItem>
            <BreadcrumbSeparator />
            <BreadcrumbItem>
              <BreadcrumbPage>Planning</BreadcrumbPage>
            </BreadcrumbItem>
          </BreadcrumbList>
        </Breadcrumb>
      </DashboardHeader>
      <ContentBoard />
    </>
  );
}
```

- [ ] **Step 5: Commit (after Task 7 so imports resolve)**

Implement Task 7, then:
```bash
bunx tsc --noEmit
bunx biome check --write src/components/content/ "src/app/(app)/content/planning/page.tsx"
git add src/components/content/content-card.tsx src/components/content/content-column.tsx src/components/content/content-board.tsx "src/app/(app)/content/planning/page.tsx"
git commit -m "feat(content): kanban board (cards, columns, dnd) + planning page"
```

---

## Task 7: New-content dialog + card detail sheet

**Files:**
- Create: `src/components/content/new-content-dialog.tsx`
- Create: `src/components/content/content-card-sheet.tsx`

- [ ] **Step 1: `new-content-dialog.tsx`**

```tsx
// src/components/content/new-content-dialog.tsx
"use client";

import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { createContentItemAction } from "@/lib/content/actions";
import { fetchKeywordsForSiteAction } from "@/lib/keywords/actions";
import type { Keyword } from "@/lib/keywords/types";

export function NewContentDialog({
  open,
  onOpenChange,
  siteId,
  onCreated,
}: {
  open: boolean;
  onOpenChange: (o: boolean) => void;
  siteId: string;
  onCreated: () => void;
}) {
  const [title, setTitle] = useState("");
  const [keywordId, setKeywordId] = useState<string>("none");
  const [keywords, setKeywords] = useState<Keyword[]>([]);
  const [pending, setPending] = useState(false);

  useEffect(() => {
    if (!open) return;
    fetchKeywordsForSiteAction(siteId)
      .then(setKeywords)
      .catch(() => setKeywords([]));
  }, [open, siteId]);

  async function submit() {
    if (!title.trim()) return;
    setPending(true);
    const kw = keywords.find((k) => k.id === keywordId);
    try {
      await createContentItemAction(siteId, {
        title,
        primaryKeywordId: keywordId === "none" ? null : keywordId,
        lang: kw?.locale.lang,
        country: kw?.locale.country,
      });
      onCreated();
      onOpenChange(false);
      setTitle("");
      setKeywordId("none");
    } finally {
      setPending(false);
    }
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Nouvel article</DialogTitle>
        </DialogHeader>
        <div className="grid gap-3">
          <div className="grid gap-1">
            <Label htmlFor="content-title">Titre</Label>
            <Input
              id="content-title"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              placeholder="Titre de l'article"
              autoFocus
            />
          </div>
          <div className="grid gap-1">
            <Label>Mot-clé cible</Label>
            <Select value={keywordId} onValueChange={setKeywordId}>
              <SelectTrigger aria-label="Mot-clé cible">
                <SelectValue />
              </SelectTrigger>
              <SelectContent position="popper">
                <SelectItem value="none">Aucun</SelectItem>
                {keywords.slice(0, 200).map((k) => (
                  <SelectItem key={k.id} value={k.id}>
                    {k.term}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
        </div>
        <DialogFooter>
          <Button onClick={submit} disabled={pending || !title.trim()}>
            {pending ? "Création…" : "Créer"}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
```
> NOTE: Verify the `Dialog*` exports against `src/components/ui/dialog.tsx` (Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter). If `DialogFooter` doesn't exist, use a plain `<div className="flex justify-end">`.

- [ ] **Step 2: `content-card-sheet.tsx`**

```tsx
// src/components/content/content-card-sheet.tsx
"use client";

import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Sheet,
  SheetContent,
  SheetHeader,
  SheetTitle,
} from "@/components/ui/sheet";
import { BriefPanel } from "@/components/keywords/brief-panel";
import {
  deleteContentItemAction,
  getKeywordBriefsAction,
  updateContentItemAction,
} from "@/lib/content/actions";
import {
  CONTENT_STATUS_LABEL,
  CONTENT_STATUSES,
} from "@/lib/content/types";
import type { ContentItem } from "@/lib/content/types";
import type { ContentStatus } from "@/lib/db/domain-schema";
import type { KeywordBrief } from "@/lib/keywords/ytg-types";

export function ContentCardSheet({
  item,
  open,
  onOpenChange,
  onSaved,
}: {
  item: ContentItem | null;
  open: boolean;
  onOpenChange: (o: boolean) => void;
  onSaved: () => void;
}) {
  const [title, setTitle] = useState("");
  const [status, setStatus] = useState<ContentStatus>("idea");
  const [assignee, setAssignee] = useState("");
  const [dueDate, setDueDate] = useState("");
  const [url, setUrl] = useState("");
  const [brief, setBrief] = useState<KeywordBrief | null>(null);

  useEffect(() => {
    if (!item) return;
    setTitle(item.title);
    setStatus(item.status);
    setAssignee(item.assignee ?? "");
    setDueDate(item.dueDate ? item.dueDate.slice(0, 10) : "");
    setUrl(item.url ?? "");
    setBrief(null);
    if (item.primaryKeywordId) {
      getKeywordBriefsAction(item.primaryKeywordId)
        .then((bs) => setBrief(bs[0] ?? null))
        .catch(() => setBrief(null));
    }
  }, [item]);

  if (!item) return null;

  async function save() {
    await updateContentItemAction(item.id, {
      title,
      status,
      assignee: assignee.trim() || null,
      dueDate: dueDate || null,
      url: url.trim() || null,
    });
    onSaved();
    onOpenChange(false);
  }

  async function remove() {
    await deleteContentItemAction(item.id);
    onSaved();
    onOpenChange(false);
  }

  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent className="w-full overflow-y-auto sm:max-w-lg">
        <SheetHeader>
          <SheetTitle>Article</SheetTitle>
        </SheetHeader>
        <div className="grid gap-3 p-4">
          <div className="grid gap-1">
            <Label htmlFor="cs-title">Titre</Label>
            <Input id="cs-title" value={title} onChange={(e) => setTitle(e.target.value)} />
          </div>
          <div className="grid grid-cols-2 gap-3">
            <div className="grid gap-1">
              <Label>Statut</Label>
              <Select value={status} onValueChange={(v) => setStatus(v as ContentStatus)}>
                <SelectTrigger aria-label="Statut">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent position="popper">
                  {CONTENT_STATUSES.map((s) => (
                    <SelectItem key={s} value={s}>
                      {CONTENT_STATUS_LABEL[s]}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
            <div className="grid gap-1">
              <Label htmlFor="cs-due">Échéance</Label>
              <Input id="cs-due" type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
            </div>
          </div>
          <div className="grid grid-cols-2 gap-3">
            <div className="grid gap-1">
              <Label htmlFor="cs-assignee">Assigné</Label>
              <Input id="cs-assignee" value={assignee} onChange={(e) => setAssignee(e.target.value)} />
            </div>
            <div className="grid gap-1">
              <Label htmlFor="cs-url">URL</Label>
              <Input id="cs-url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://…" />
            </div>
          </div>
          {item.keywordTerm ? (
            <div className="text-muted-foreground text-sm">
              Mot-clé : {item.keywordTerm}
            </div>
          ) : null}
          {brief ? (
            <div className="rounded-lg border p-3">
              <div className="mb-2 font-medium text-sm">Brief</div>
              <BriefPanel brief={brief} />
            </div>
          ) : item.hasBrief ? (
            <p className="text-muted-foreground text-sm">Chargement du brief…</p>
          ) : null}
          <div className="flex justify-between pt-2">
            <Button variant="ghost" className="text-destructive" onClick={remove}>
              Supprimer
            </Button>
            <Button onClick={save}>Enregistrer</Button>
          </div>
        </div>
      </SheetContent>
    </Sheet>
  );
}
```
> Verify the exact prop name `BriefPanel` expects (open `src/components/keywords/brief-panel.tsx`: its props — likely `brief: KeywordBrief`). Adapt the usage to match. Verify `Sheet`/`SheetContent`/`SheetHeader`/`SheetTitle` and `Label` exist under `src/components/ui/` (Sheet is used by the existing KeywordSheet; Label by the import dialog).

- [ ] **Step 3: Typecheck + lint + commit (covers Task 6 too)**

```bash
bunx tsc --noEmit
bunx biome check --write src/components/content/ "src/app/(app)/content/planning/page.tsx"
git add src/components/content/new-content-dialog.tsx src/components/content/content-card-sheet.tsx
git commit -m "feat(content): new-article dialog + card detail sheet (brief preview)"
```
(Then run Task 6 Step 5's commit for the board files if not already committed.)

---

## Task 8: Full checks + live verification

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

```bash
bunx tsc --noEmit
bun run test src/lib/content/board.test.ts
DB_TESTS=1 DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test bun --conditions=react-server test src/lib/content/content.test.ts
bunx biome check src/lib/content src/components/content "src/app/(app)/content/planning/page.tsx"
```
Expected: tsc 0; pure + DB tests pass; biome clean.

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

`d3k agent-browser open http://localhost:3000/content/planning` (active site with keywords, e.g. Acieb):
- The board shows the 5 columns (Idée → Publié), initially empty.
- "+ Nouvel article" → dialog → create → a card appears in Idée.
- Drag a card to another column → status changes; reload → it persists in the new column.
- From `/keywords/reservoir`, select keywords → "Vers Contenu" → cards appear in Idée on the board.
- Click a card → the sheet opens; for a keyword with a brief, the brief preview shows; edit + Enregistrer persists; Supprimer removes the card.
- `d3k errors` clean. Sidebar shows the new "Contenu" section.

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

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

---

## Notes / risks

- `@dnd-kit/core` is a new dependency — install via `sfw bun add @dnd-kit/core` (Socket Firewall). v1 uses only `useDraggable`/`useDroppable` + `DndContext` (no sortable) since drag only changes status (within-column order is computed). `@dnd-kit/utilities` ships with core for `CSS.Translate`; if it's a separate package, `transform` can be applied manually as `translate3d(${transform.x}px, ${transform.y}px, 0)`.
- PointerSensor `activationConstraint: { distance: 5 }` lets a click open the sheet while a drag (>5px) moves the card.
- The brief is referenced, never copied — `getKeywordBriefsAction` reads the linked keyword's `briefs` live.
- The `content.test.ts` must NOT `client.end()` (shared singleton — same gotcha as the gsc tests).
- Migration order: run `db:generate` then BOTH `db:migrate` and `db:migrate:test`.
- Branch: `feature/content-planning` (already created off `dev`).
```
