# Content Planning — Design

- **Date**: 2026-06-06
- **Status**: approved (design)
- **Section**: contenu / planning (first page of the new *Contenu* section)
- **Depends on**: keywords (+ `KeywordBrief` on `keyword.briefs`) on `dev`.

## Context

The cahier des charges (`docs/project/overview.md`) defines **contenu** = planning · online · stats · import-export · overview. Design-first, page-by-page: this spec covers **only the Planning page** (the editorial pipeline, the direct continuation of keyword planning). The other four sub-pages are separate future specs.

## Goal

A per-site **editorial kanban board** of *content items* (articles to produce), each tied to a keyword (which carries its YTG/Lumenia brief). Cards flow through statuses via drag & drop. Created either from the keyword tables ("Vers Contenu") or directly on the board ("+ Nouvel article").

## Core principles

- **Active site** (client `useActiveSite`, like keyword pages).
- **Real Postgres** (a `content_items` table + migration) — consistent with the keyword section.
- **Brief is referenced, not copied**: a content item links a `primaryKeywordId`; its brief is read live from `keyword.briefs`.
- **One primary keyword per article** for v1 (clusters/secondary keywords deferred).

## Data model

`src/lib/db/domain-schema.ts` — new table + status type:
```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(),
});
```
Migration `drizzle/NNNN_*` (generated + applied to dev + test).

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

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; // joined from keywords (null if unlinked)
  keywordCountry: string | null; // for the flag
  hasBrief: boolean; // linked keyword has ≥1 brief
  lang: string;
  country: string;
  assignee: string | null;
  dueDate: string | null; // ISO
  url: string | null;
  createdAt: string; // ISO
}
```

## Pure logic — `src/lib/content/board.ts`

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

/** Group items into ordered status columns; within a column, overdue/earliest
 *  due first, 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";
        const db = b.dueDate ?? "9999";
        if (da !== db) return da < db ? -1 : 1;
        return a.createdAt < b.createdAt ? 1 : -1;
      }),
  }));
}

/** A due date strictly before `nowIso` (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);
}
```
Unit-tested (grouping order, overdue boundary, empty).

## Data + actions — `src/lib/content/queries.ts` + `src/lib/content/actions.ts`

Query `getContentItemsForSite(siteId)`: left-join `keywords` to fill `keywordTerm`, `keywordCountry`, and `hasBrief` (`Object.keys(keyword.briefs).length > 0`); map rows → `ContentItem[]`.

Actions (`"use server"`):
- `getContentItemsForSiteAction(siteId)` → `ContentItem[]`.
- `createContentItemAction(siteId, { title, primaryKeywordId, lang, country })` → inserts status `idea`; returns the new `ContentItem`.
- `createContentFromKeywordsAction(siteId, keywordIds)` → one `idea` item per keyword (title = keyword term; lang/country from the keyword); skips ids already linked by an existing item (idempotent-ish); returns `{ created }`.
- `updateContentStatusAction(id, status)` → drag-drop status change.
- `updateContentItemAction(id, patch)` → edit title/assignee/dueDate/url/status/primaryKeywordId.
- `deleteContentItemAction(id)`.

All `revalidatePath("/content/planning")` where mutating.

## Components

```
src/components/content/content-board.tsx       — client; DndContext; loads action; columns; optimistic status update on drop
src/components/content/content-column.tsx       — a droppable status column
src/components/content/content-card.tsx          — draggable card (title, flag, 📄 brief, assignee, due)
src/components/content/content-card-sheet.tsx    — detail/edit panel (brief preview from linked keyword)
src/components/content/new-content-dialog.tsx    — "+ Nouvel article" (title + keyword picker + lang/country)
src/app/(app)/content/planning/page.tsx          — DashboardHeader + <ContentBoard/>
```

- **Drag & drop**: `@dnd-kit/core` + `@dnd-kit/sortable` (new deps, installed via `sfw bun add`). Dragging a card to another column calls `updateContentStatusAction` (optimistic local update + server). v1 = status change only (within-column order is computed by `groupByStatus`, no manual reorder).
- **Card**: title; the linked keyword term with its country flag (`countryFlag`); a 📄 indicator when `hasBrief`; assignee initials; due date (red when `isOverdue`).
- **Sheet**: edit title, assignee, due date, URL, status, linked keyword; show a read-only **brief preview** (objectives / key subjects / questions) pulled from the linked keyword's `briefs` via a small server action or the already-loaded keyword data.

## Keyword → Content bridge

`src/components/keywords/bulk-actions-bar.tsx`: add a **"Vers Contenu"** button (next to "Vers Recherche"/"Vers Planning") with an `onSendToContent` prop. Reservoir/Recherche wire it to `createContentFromKeywordsAction(activeSiteId, selectedIds)` then clear selection + a brief confirmation.

## Navigation

`src/components/app-sidebar.tsx`: add a **Contenu** section (lucide `FileTextIcon`) with item **Planning → `/content/planning`** (the other sub-pages come later).

## Error / empty states

- No content items → empty board with the 5 columns + a centered "+ Nouvel article" CTA / "envoie des mots-clés vers Contenu depuis le Réservoir".
- Action failure → non-blocking message (fire-and-forget convention); on a failed drop, revert the optimistic move.
- Unlinked keyword (deleted) → card shows the title only, no flag/brief.

## Testing

- **Unit** (`board.test.ts`, pure): `groupByStatus` (5 columns always present, within-column order by due then created), `isOverdue` (null, before/after today).
- **DB-gated** (`content.test.ts`, `_test`, `--conditions=react-server`): `getContentItemsForSite` joins keyword term + hasBrief; `createContentFromKeywordsAction` creates one idea per keyword with the keyword's lang/country; `updateContentStatusAction` changes status.
- **Manual (d3k)**: board renders 5 columns; "+ Nouvel article" creates a card; drag a card across columns → status persists (reload); "Vers Contenu" from Réservoir creates idea cards; card sheet shows the brief; empty-state on a blank site; `d3k errors` clean.

## Non-goals (YAGNI)

- The other four contenu sub-pages (online / stats / import-export / overview).
- Keyword clusters (secondary keywords per article).
- The writing editor / content body, publishing integration, real online/indexation tracking.
- Manual within-column reordering (drag only changes status in v1).
- Assignee as a real user entity (free-text for now).
