# /backlink/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 `/backlink/planning` page: kanban DnD board of the active site's backlinks (Prospect · Planifié · Outreach · Live · Perdu), light outreach tracking per card, toxic/disavow flags with a Google-format disavow export, and a monthly acquisition-cadence banner.

**Architecture:** One migration (status union + `in_progress`, `toxic`/`disavow` booleans, `backlink_outreach` 1-per-backlink) + server queries (`getPlanningBoard` reusing the research score/footprint joins, `getCadence`) + 4 site-scoped server actions + a board mirroring `content-board`'s @dnd-kit pattern (optimistic move + rollback) + a detail sheet (outreach form, flags, keyboard status select).

**Tech Stack:** Next 16 RSC, Drizzle, @dnd-kit/core (already a dep), bun:test, shadcn (Sheet/Select popper/Textarea/Badge), DrBadge, `relevanceScore`/`footprintAlert` (existing).

**Context the implementer must know:**
- DB tests: harness in `src/lib/backlink/research.test.ts` (mock server-only + next/cache, RUN_DB gate, assertTestDb, ORG "demo-org", WT-style sites with `ownerUserId: "seed-owner"`). Never `db:push`.
- `BacklinkStatus` lives in `src/lib/db/domain-schema.ts`; two EXHAUSTIVE `Record<BacklinkStatus, …>` maps exist (`src/components/backlink/research/research-columns.tsx:33`, `source-detail-sheet.tsx:17`) — adding `in_progress` to the union forces updating them (tsc is the guard).
- The DnD board pattern to mirror EXACTLY: `src/components/content/content-board.tsx` (DndContext + sensors + optimistic onDragEnd with rollback), `content-column.tsx` (useDroppable), `content-card.tsx` (useDraggable + CSS transform). The planning board renders from RSC props (no load()/useEffect — unlike content-board which fetches; our page is RSC-seeded like research).
- No fetch-on-mount; post-action refresh via `router.refresh()`. Real pointer events needed to live-test DnD.
- `relevanceScore` + `footprintAlert` in `src/lib/backlink/relevance.ts`; DR map pattern + footprint grouped query in `src/lib/backlink/research.ts` (copy the query shapes).

---

### Task 1: Migration + union de statut

**Files:**
- Modify: `src/lib/db/domain-schema.ts` (BacklinkStatus union, backlinks columns, new table)
- Modify: `src/components/backlink/research/research-columns.tsx` + `src/components/backlink/research/source-detail-sheet.tsx` (the 2 exhaustive Records)
- Generate: `drizzle/0019_*.sql`

- [ ] **Step 1: Schema.** In `domain-schema.ts`:
  - `export type BacklinkStatus = "prospect" | "planned" | "in_progress" | "live" | "lost";`
  - In the `backlinks` table, after `anchor`: `toxic: boolean("toxic").notNull().default(false),` and `disavow: boolean("disavow").notNull().default(false),` (add `boolean` to the pg-core import if missing).
  - After the `backlinks` table:
```ts
/** Suivi d'outreach léger — une ligne par backlink (upsert). */
export const backlinkOutreach = pgTable("backlink_outreach", {
  backlinkId: text("backlink_id")
    .primaryKey()
    .references(() => backlinks.id, { onDelete: "cascade" }),
  contact: text("contact"),
  channel: text("channel"),
  attempts: integer("attempts").notNull().default(0),
  lastContactAt: timestamp("last_contact_at", { withTimezone: true }),
  notes: text("notes"),
  updatedAt: timestamp("updated_at", { withTimezone: true })
    .defaultNow()
    .notNull(),
});
```
- [ ] **Step 2: Fix the exhaustive Records.** tsc will flag the two `Record<BacklinkStatus, …>` maps — add `in_progress: "default"` (research-columns) / matching variant (source-detail-sheet); use Badge variant `"default"` for in_progress in both.
- [ ] **Step 3:** `bun run db:generate` → inspect: ONLY alter backlinks (2 boolean columns) + create backlink_outreach. `bun run db:migrate && bun run db:migrate:test`.
- [ ] **Step 4:** `npx tsc --noEmit` clean; `bun run test src/lib/backlink/` green (32).
- [ ] **Step 5: Commit**
```bash
git add src/lib/db/domain-schema.ts src/components/backlink/research drizzle/
git commit -m "feat(db): statut in_progress + flags toxic/disavow + backlink_outreach"
```

---

### Task 2: Pur (TDD) + requêtes planning

**Files:**
- Create: `src/lib/backlink/planning-logic.ts` + `planning-logic.test.ts`
- Create: `src/lib/backlink/planning.ts`
- Test: `src/lib/backlink/planning.test.ts` (DB — board/cadence reads)

- [ ] **Step 1: Failing pure tests** (`planning-logic.test.ts`):
```ts
import { describe, expect, test } from "bun:test";
import { buildDisavowFile, monthBounds } from "./planning-logic";

describe("monthBounds", () => {
  test("current and previous month bounds (UTC), year rollover", () => {
    const b = monthBounds(new Date("2026-01-15T10:00:00Z"));
    expect(b.thisMonthStart).toBe("2026-01-01");
    expect(b.lastMonthStart).toBe("2025-12-01");
    expect(b.nextMonthStart).toBe("2026-02-01");
  });
});

describe("buildDisavowFile", () => {
  test("domain: lines, deduped, sorted; empty input → empty string", () => {
    expect(buildDisavowFile(["b.com", "a.com", "b.com"])).toBe(
      "domain:a.com\ndomain:b.com",
    );
    expect(buildDisavowFile([])).toBe("");
  });
});
```
- [ ] **Step 2: FAIL → implement** `planning-logic.ts`:
```ts
// src/lib/backlink/planning-logic.ts — helpers purs du planning netlinking.

/** Bornes de mois (UTC, ISO date) pour la cadence. */
export function monthBounds(now: Date): {
  thisMonthStart: string;
  lastMonthStart: string;
  nextMonthStart: string;
} {
  const y = now.getUTCFullYear();
  const m = now.getUTCMonth();
  const iso = (yy: number, mm: number) =>
    new Date(Date.UTC(yy, mm, 1)).toISOString().slice(0, 10);
  return {
    thisMonthStart: iso(y, m),
    lastMonthStart: iso(y, m - 1),
    nextMonthStart: iso(y, m + 1),
  };
}

/** Fichier disavow Google : `domain:x.com`, dédupliqué, trié. */
export function buildDisavowFile(domains: string[]): string {
  return [...new Set(domains)]
    .sort()
    .map((d) => `domain:${d}`)
    .join("\n");
}
```
- [ ] **Step 3: PASS**, then the queries. `src/lib/backlink/planning.ts` (server-only):
```ts
// src/lib/backlink/planning.ts — board de campagne netlinking d'un site.
import "server-only";
import { and, eq, gte, inArray, lt, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
  type BacklinkStatus,
  backlinkOutreach,
  backlinks,
  domainRatings,
  linkSources,
  sites,
} from "@/lib/db/domain-schema";
import { footprintAlert, relevanceScore } from "./relevance";
import { monthBounds } from "./planning-logic";
import type { SourceKind } from "./types";

export type PlanningCard = {
  backlinkId: string;
  status: BacklinkStatus;
  toxic: boolean;
  disavow: boolean;
  firstSeen: string | null;
  source: {
    id: string; platform: string; domainNorm: string; url: string;
    kind: SourceKind; dr: number | null; da: number | null; language: string;
  };
  score: number;
  footprint: number;
  alert: boolean;
  outreach: {
    contact: string | null; channel: string | null; attempts: number;
    lastContactAt: string | null; notes: string | null;
  } | null;
};

export async function getPlanningBoard(
  orgId: string,
  siteId: string,
): Promise<PlanningCard[]> {
  const site = (await db.select().from(sites).where(eq(sites.id, siteId)))[0];
  const siteLanguage = site?.primaryLanguage ?? "fr";

  // Backlinks du site joints à leur source (isolation stricte par site).
  const rows = await db
    .select({ b: backlinks, s: linkSources })
    .from(backlinks)
    .innerJoin(linkSources, eq(linkSources.id, backlinks.linkSourceId))
    .where(and(eq(backlinks.siteId, siteId), eq(linkSources.orgId, orgId)));
  if (rows.length === 0) return [];

  const domains = [...new Set(rows.map((r) => r.s.domainNorm))];
  const drRows = await db
    .select({ domainNorm: domainRatings.domainNorm, dr: domainRatings.dr })
    .from(domainRatings)
    .where(inArray(domainRatings.domainNorm, domains));
  const drByDomain = new Map(drRows.map((d) => [d.domainNorm, d.dr]));

  const sourceIds = [...new Set(rows.map((r) => r.s.id))];
  const fps = await db
    .select({
      linkSourceId: backlinks.linkSourceId,
      footprint: sql<string>`count(distinct ${backlinks.siteId})`,
    })
    .from(backlinks)
    .where(inArray(backlinks.linkSourceId, sourceIds))
    .groupBy(backlinks.linkSourceId);
  const fpBySource = new Map(fps.map((f) => [f.linkSourceId, Number(f.footprint)]));

  const backlinkIds = rows.map((r) => r.b.id);
  const out = await db
    .select()
    .from(backlinkOutreach)
    .where(inArray(backlinkOutreach.backlinkId, backlinkIds));
  const outByBacklink = new Map(out.map((o) => [o.backlinkId, o]));

  return rows
    .map(({ b, s }) => {
      const dr = drByDomain.get(s.domainNorm) ?? null;
      const footprint = fpBySource.get(s.id) ?? 0;
      const o = outByBacklink.get(b.id) ?? null;
      return {
        backlinkId: b.id,
        status: b.status,
        toxic: b.toxic,
        disavow: b.disavow,
        firstSeen: b.firstSeen ? b.firstSeen.toISOString() : null,
        source: {
          id: s.id, platform: s.platform, domainNorm: s.domainNorm, url: s.url,
          kind: s.kind, dr, da: s.da, language: s.language,
        },
        score: relevanceScore({ language: s.language, dr, da: s.da }, siteLanguage),
        footprint,
        alert: footprintAlert(footprint),
        outreach: o
          ? {
              contact: o.contact, channel: o.channel, attempts: o.attempts,
              lastContactAt: o.lastContactAt ? o.lastContactAt.toISOString() : null,
              notes: o.notes,
            }
          : null,
      };
    })
    .sort((a, b2) => b2.score - a.score);
}

export async function getCadence(
  siteId: string,
): Promise<{ thisMonth: number; lastMonth: number }> {
  const { thisMonthStart, lastMonthStart, nextMonthStart } = monthBounds(new Date());
  const count = async (from: string, to: string) =>
    Number(
      (
        await db
          .select({ n: sql<string>`count(*)` })
          .from(backlinks)
          .where(
            and(
              eq(backlinks.siteId, siteId),
              gte(backlinks.firstSeen, new Date(`${from}T00:00:00Z`)),
              lt(backlinks.firstSeen, new Date(`${to}T00:00:00Z`)),
            ),
          )
      )[0]?.n ?? 0,
    );
  return {
    thisMonth: await count(thisMonthStart, nextMonthStart),
    lastMonth: await count(lastMonthStart, thisMonthStart),
  };
}
```
- [ ] **Step 4: DB tests** (`planning.test.ts`, harness research.test.ts) :
```ts
test("board scopé au site : un backlink d'un autre site n'apparaît pas", async () => {
  // seed S1 (source org) ; backlinks: S1→SITE_A (planned), S1→SITE_B (live)
  const { getPlanningBoard } = await import("./planning");
  const cards = await getPlanningBoard(ORG, SITE_A);
  expect(cards).toHaveLength(1);
  expect(cards[0]?.status).toBe("planned");
  expect(cards[0]?.footprint).toBe(2); // les 2 sites comptent dans le footprint
});

test("board joint outreach + dr + score", async () => {
  // seed: S1→SITE_A + backlink_outreach (contact "bob", attempts 2)
  // + domain_ratings dr 90 ; SITE_A fr ; S1 language en → score 27
  const { getPlanningBoard } = await import("./planning");
  const card = (await getPlanningBoard(ORG, SITE_A))[0];
  expect(card?.outreach?.contact).toBe("bob");
  expect(card?.outreach?.attempts).toBe(2);
  expect(card?.score).toBe(27);
});

test("getCadence compte les firstSeen par mois", async () => {
  // seed 2 backlinks firstSeen = maintenant ; 1 backlink firstSeen = il y a ~35 jours
  const { getCadence } = await import("./planning");
  const c = await getCadence(SITE_A);
  expect(c.thisMonth).toBe(2);
  expect(c.lastMonth).toBe(1);
});
```
NOTE cadence test: ~35 days ago may fall 2 months back early in the month — compute the seed date as "first day of last month + 12h" via `monthBounds` to be deterministic.
- [ ] **Step 5: PASS** + tsc + biome + commit:
```bash
git add src/lib/backlink/planning-logic.ts src/lib/backlink/planning-logic.test.ts src/lib/backlink/planning.ts src/lib/backlink/planning.test.ts
git commit -m "feat(backlink): board planning (requêtes) + cadence + disavow (pur, TDD)"
```

---

### Task 3: Actions planning

**Files:**
- Create: `src/lib/backlink/planning-actions.ts`
- Test: extend `src/lib/backlink/planning.test.ts`

- [ ] **Step 1: Failing tests** (append):
```ts
test("moveBacklinkAction pose firstSeen sur live (sans l'écraser) + scope site", async () => {
  const { moveBacklinkAction } = await import("./planning-actions");
  // B1 = S1→SITE_A planned (firstSeen null)
  await moveBacklinkAction(SITE_A, B1, "live");
  // assert: status live, firstSeen non null → mémoriser t1
  await moveBacklinkAction(SITE_A, B1, "lost");
  await moveBacklinkAction(SITE_A, B1, "live");
  // assert: firstSeen === t1 (pas écrasé)
  // scope : moveBacklinkAction(SITE_B, B1, "prospect") → le statut de B1 ne change pas
});

test("upsertOutreachAction crée puis met à jour ; scope site", async () => {
  const { upsertOutreachAction } = await import("./planning-actions");
  await upsertOutreachAction(SITE_A, B1, { contact: "alice", attempts: 1 });
  await upsertOutreachAction(SITE_A, B1, { attempts: 2, notes: "relance" });
  // assert : 1 ligne, contact alice conservé, attempts 2, notes "relance"
  // scope : upsertOutreachAction(SITE_B, B1, {...}) → rejeté/no-op (pas de ligne créée)
});

test("setBacklinkFlagsAction + fetchDisavowDomainsAction", async () => {
  const { setBacklinkFlagsAction, fetchDisavowDomainsAction } = await import("./planning-actions");
  await setBacklinkFlagsAction(SITE_A, B1, { toxic: true, disavow: true });
  expect(await fetchDisavowDomainsAction(SITE_A)).toEqual(["s1.com"]);
});
```
- [ ] **Step 2: FAIL → implement** `planning-actions.ts` (`"use server"`):
```ts
// src/lib/backlink/planning-actions.ts — actions du board netlinking (scopées site).
"use server";
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db/client";
import {
  type BacklinkStatus,
  backlinkOutreach,
  backlinks,
  linkSources,
} from "@/lib/db/domain-schema";

const PLANNING_PATH = "/backlink/planning";

/** Avance un backlink ; passage à live pose firstSeen (sans écraser). Scopé site. */
export async function moveBacklinkAction(
  siteId: string,
  backlinkId: string,
  status: BacklinkStatus,
): Promise<{ ok: boolean }> {
  const updated = await db
    .update(backlinks)
    .set({ status })
    .where(and(eq(backlinks.id, backlinkId), eq(backlinks.siteId, siteId)))
    .returning({ id: backlinks.id });
  if (updated.length === 0) return { ok: false };
  if (status === "live") {
    await db
      .update(backlinks)
      .set({ firstSeen: sql`now()` })
      .where(and(eq(backlinks.id, backlinkId), isNull(backlinks.firstSeen)));
  }
  revalidatePath(PLANNING_PATH);
  return { ok: true };
}

/** Appartenance backlink→site (garde des actions). */
async function belongsToSite(backlinkId: string, siteId: string): Promise<boolean> {
  const row = await db
    .select({ id: backlinks.id })
    .from(backlinks)
    .where(and(eq(backlinks.id, backlinkId), eq(backlinks.siteId, siteId)));
  return row.length > 0;
}

export async function upsertOutreachAction(
  siteId: string,
  backlinkId: string,
  patch: {
    contact?: string | null;
    channel?: string | null;
    attempts?: number;
    lastContactAt?: string | null;
    notes?: string | null;
  },
): Promise<{ ok: boolean }> {
  if (!(await belongsToSite(backlinkId, siteId))) return { ok: false };
  const values = {
    backlinkId,
    contact: patch.contact ?? null,
    channel: patch.channel ?? null,
    attempts: patch.attempts ?? 0,
    lastContactAt: patch.lastContactAt ? new Date(patch.lastContactAt) : null,
    notes: patch.notes ?? null,
  };
  // Upsert partiel : ne remplace que les clés présentes dans `patch`.
  const set: Record<string, unknown> = { updatedAt: sql`now()` };
  if ("contact" in patch) set.contact = values.contact;
  if ("channel" in patch) set.channel = values.channel;
  if ("attempts" in patch) set.attempts = values.attempts;
  if ("lastContactAt" in patch) set.lastContactAt = values.lastContactAt;
  if ("notes" in patch) set.notes = values.notes;
  await db
    .insert(backlinkOutreach)
    .values(values)
    .onConflictDoUpdate({ target: backlinkOutreach.backlinkId, set });
  revalidatePath(PLANNING_PATH);
  return { ok: true };
}

export async function setBacklinkFlagsAction(
  siteId: string,
  backlinkId: string,
  flags: { toxic?: boolean; disavow?: boolean },
): Promise<{ ok: boolean }> {
  const set: Record<string, unknown> = {};
  if ("toxic" in flags) set.toxic = flags.toxic;
  if ("disavow" in flags) set.disavow = flags.disavow;
  if (Object.keys(set).length === 0) return { ok: true };
  const updated = await db
    .update(backlinks)
    .set(set)
    .where(and(eq(backlinks.id, backlinkId), eq(backlinks.siteId, siteId)))
    .returning({ id: backlinks.id });
  revalidatePath(PLANNING_PATH);
  return { ok: updated.length > 0 };
}

/** Domaines flagués disavow du site (le client génère le fichier). */
export async function fetchDisavowDomainsAction(siteId: string): Promise<string[]> {
  const rows = await db
    .select({ domainNorm: linkSources.domainNorm })
    .from(backlinks)
    .innerJoin(linkSources, eq(linkSources.id, backlinks.linkSourceId))
    .where(and(eq(backlinks.siteId, siteId), eq(backlinks.disavow, true)));
  return [...new Set(rows.map((r) => r.domainNorm))].sort();
}
```
CAVEAT upsert partiel: on first INSERT the unset keys default to null/0 — acceptable (creation = the provided fields). The conflict SET only touches provided keys (partial update). The test asserts contact survives an attempts-only update.
- [ ] **Step 3: PASS** (whole backlink dir green) + tsc + biome + commit:
```bash
git add src/lib/backlink/planning-actions.ts src/lib/backlink/planning.test.ts
git commit -m "feat(backlink): actions board (move+firstSeen, outreach upsert, flags, disavow)"
```

---

### Task 4: Page + board DnD + sheet

**Files:**
- Create: `src/app/(app)/backlink/planning/page.tsx`
- Create: `src/components/backlink/planning/planning-container.tsx`, `planning-board.tsx`, `planning-column.tsx`, `planning-card.tsx`, `backlink-detail-sheet.tsx`
- Modify: `src/components/app-sidebar.tsx` (BACKLINK: + « Planning » après Recherche)

READ FIRST: `src/components/content/content-board.tsx` + `content-column.tsx` + `content-card.tsx` (the @dnd-kit pattern — sensors, useDroppable/useDraggable, optimistic onDragEnd with rollback), `src/app/(app)/backlink/recherche/page.tsx` (RSC pattern), `src/components/backlink/research/source-detail-sheet.tsx` (sheet pattern + STATUS_VARIANT).

- [ ] **Step 1: RSC page** — org → sites → site actif → `getPlanningBoard(orgId, siteId)` + `getCadence(siteId)` → `<BacklinkPlanningContainer initialCards cadence activeSiteId siteId-keyed />` (use `key={siteId}` remount pattern). Breadcrumb Backlink → Planning.
- [ ] **Step 2: Board** — `planning-board.tsx`: 5 colonnes `[{status:"prospect",label:"Prospect"},{status:"planned",label:"Planifié"},{status:"in_progress",label:"Outreach"},{status:"live",label:"Live"},{status:"lost",label:"Perdu"}]`. DndContext + PointerSensor (copy content-board's sensors config), columns via `useDroppable({id: status})`, cards via `useDraggable({id: backlinkId})`. `onDragEnd` optimiste : update local state, `moveBacklinkAction(siteId, id, status)` en catch → rollback + toast (EXACT content-board pattern). Le container détient `cards` en state initialisé des props (le board MUTE ce state pour l'optimisme ; le refresh RSC re-seed via key).
- [ ] **Step 3: Card** (`planning-card.tsx`) — platform (font-medium, truncate) + domainNorm (muted text-xs) ; ligne badges : DrBadge (dr ?? da), score (tabular-nums), ⚠️ footprint si alert, ☠️ si toxic. Clic (hors drag — utiliser le pattern de content-card pour distinguer) → `onOpen(card)`.
- [ ] **Step 4: Container** — bandeau haut : titre + cadence (`{thisMonth} lien(s) live ce mois · {lastMonth} le mois dernier`) + bouton « Exporter disavow » :
```tsx
async function exportDisavow() {
  try {
    const domains = await fetchDisavowDomainsAction(activeSiteId);
    if (domains.length === 0) { toast.info("Aucun domaine flagué disavow"); return; }
    const blob = new Blob([buildDisavowFile(domains)], { type: "text/plain" });
    // triggerDownload pattern (copy from export-enrichment-dialog)
  } catch { toast.error("Échec de l'export disavow"); }
}
```
(`buildDisavowFile` est pur — importable côté client.) Compteurs par colonne dans les headers de colonnes.
- [ ] **Step 5: Sheet** (`backlink-detail-sheet.tsx`) — SheetTitle platform, SheetDescription url. Statut : `Select` (5 statuts, position popper) → `moveBacklinkAction` (alternative clavier au drag). Métriques (DR/score/kind/langue/footprint). **Formulaire outreach** : Input contact · Select canal (email/form/linkedin/twitter/autre) · attempts (Input number ou boutons ±) · date dernière prise de contact (Input type=date) · Textarea notes · Button « Enregistrer » → `upsertOutreachAction(siteId, id, patch)` (envoyer TOUTES les valeurs du formulaire dans patch) → toast + `router.refresh()`. **Flags** : 2 Buttons toggle (aria-pressed) « ☠️ Toxic » / « Disavow » → `setBacklinkFlagsAction` immédiat (toast).
- [ ] **Step 6: Sidebar** + gates: tsc clean, biome clean sur les nouveaux fichiers, `bun run test src/lib/backlink/` green.
- [ ] **Step 7: Commit**
```bash
git add "src/app/(app)/backlink/planning" src/components/backlink/planning src/components/app-sidebar.tsx
git commit -m "feat(backlink): board planning kanban DnD + outreach + disavow + cadence"
```

---

### Task 5: Live + roadmap + Linear

- [ ] **Step 1: Gates** — tsc ; `bun run test src/lib/backlink/` ; biome.
- [ ] **Step 2: Live (d3k, vrais événements pointeur)** — créer 2-3 prospects via la recherche (ou SQL), `/backlink/planning` : 5 colonnes + cartes triées par score ; **drag** Prospect→Planifié (DB vérifiée — le DnD exige de vrais pointerdown/move/up séquencés, utiliser dispatchEvent PointerEvent avec coordonnées départ/arrivée OU vérifier le move via le Select de statut du sheet si le drag synthétique ne prend pas — noter lequel a marché) ; sheet : outreach (contact+relance, vérif DB), toggle disavow → export → contenu fichier `domain:` ; passage live → cadence +1 ; nettoyage des rows de test.
- [ ] **Step 3: Roadmap** — Backlink : `planning` ✅ (résumé + différés re-check/campagnes/calendrier) ; reste ⬜ overview · import-export.
- [ ] **Step 4: Linear** — fermer ELEVA-63 (commentaire livraison) ; l'issue ELEVA-64 (overview+import-export) reste.
- [ ] **Step 5: Finish branch** (ff-merge → dev, push, delete).

---

## Notes for the executor
- Order 1 → 2 → 3 → 4 → 5.
- Task 1's union change breaks 2 exhaustive Records — fix them in the SAME commit (tsc gate).
- The board is RSC-seeded: no load()/useEffect in the container (unlike content-board) — state init from props + optimistic mutations + key-remount on site switch.
- DnD live-testing: synthetic drags are unreliable; the sheet's status Select is the fallback verification path (and is itself a CdC a11y requirement).
