# Content Overview 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 content dashboard (`/content/overview`) aggregating `content_items` — KPIs, pipeline distribution, by-assignee, upcoming deadlines.

**Architecture:** A pure `buildContentOverview(items, nowIso)` (tested) → `ContentOverview`; a `getContentOverviewAction(siteId)`; a thin client container + presentational modules reusing the existing `StatCard`/`BarList`.

**Tech Stack:** Next 16 server actions, bun + biome, shadcn. Reuses `getContentItemsForSite`, `isOverdue`, `CONTENT_STATUSES`, `StatCard`, `BarList`, `useActiveSite`.

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

---

## File structure

- Create `src/lib/content/overview.ts` + `overview.test.ts`.
- Modify `src/lib/content/actions.ts` — `getContentOverviewAction`.
- Create `src/components/content/overview/{content-overview-container,content-overview-kpis,content-status-distribution,content-by-assignee,content-upcoming}.tsx`.
- Create `src/app/(app)/content/overview/page.tsx`.
- Modify `src/components/app-sidebar.tsx` — add Overview to the Contenu section.

Tooling: tsc `bunx tsc --noEmit`; pure tests `bun run test <file>`; lint `bunx biome check --write <files>`. Dev server runs under d3k — do NOT start/restart it.

---

## Task 1: `buildContentOverview` + tests

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

- [ ] **Step 1: Implement `overview.ts`**

```ts
// src/lib/content/overview.ts — pure content overview aggregation.
import type { ContentStatus } from "@/lib/db/domain-schema";
import { isOverdue } from "./board";
import { CONTENT_STATUSES } from "./types";
import type { ContentItem } from "./types";

export interface CountBy<K> {
  key: K;
  count: number;
}
export interface UpcomingRow {
  id: string;
  title: string;
  status: ContentStatus;
  dueDate: string;
  overdue: boolean;
}
export interface ContentOverview {
  total: number;
  byStatus: CountBy<ContentStatus>[];
  published: number;
  inProgress: number;
  overdue: number;
  withBrief: number;
  unlinked: number;
  byAssignee: CountBy<string>[];
  upcoming: UpcomingRow[];
}

export function buildContentOverview(
  items: ContentItem[],
  nowIso: string,
): ContentOverview {
  const statusCounts = new Map<ContentStatus, number>(
    CONTENT_STATUSES.map((s) => [s, 0]),
  );
  for (const it of items)
    statusCounts.set(it.status, (statusCounts.get(it.status) ?? 0) + 1);

  const assigneeCounts = new Map<string, number>();
  for (const it of items) {
    const key = it.assignee?.trim() || "Non assigné";
    assigneeCounts.set(key, (assigneeCounts.get(key) ?? 0) + 1);
  }

  const overdue = items.filter(
    (it) => it.status !== "published" && isOverdue(it.dueDate, nowIso),
  ).length;

  const upcoming: UpcomingRow[] = items
    .filter((it) => it.dueDate)
    .sort((a, b) => ((a.dueDate as string) < (b.dueDate as string) ? -1 : 1))
    .slice(0, 8)
    .map((it) => ({
      id: it.id,
      title: it.title,
      status: it.status,
      dueDate: it.dueDate as string,
      overdue: it.status !== "published" && isOverdue(it.dueDate, nowIso),
    }));

  return {
    total: items.length,
    byStatus: CONTENT_STATUSES.map((key) => ({
      key,
      count: statusCounts.get(key) ?? 0,
    })),
    published: statusCounts.get("published") ?? 0,
    inProgress:
      (statusCounts.get("writing") ?? 0) + (statusCounts.get("review") ?? 0),
    overdue,
    withBrief: items.filter((it) => it.hasBrief).length,
    unlinked: items.filter((it) => it.primaryKeywordId === null).length,
    byAssignee: [...assigneeCounts.entries()]
      .map(([key, count]) => ({ key, count }))
      .sort((a, b) => b.count - a.count)
      .slice(0, 6),
    upcoming,
  };
}
```

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

`src/lib/content/overview.test.ts`:
```ts
import { expect, test } from "bun:test";
import { buildContentOverview } from "./overview";
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: "kw",
    keywordTerm: "kw",
    keywordCountry: "FR",
    hasBrief: false,
    lang: "fr",
    country: "FR",
    assignee: null,
    dueDate: null,
    url: null,
    createdAt: "2026-01-01T00:00:00Z",
    ...over,
  };
}

const NOW = "2026-06-06T10:00:00Z";

test("aggregates statuses, published/inProgress/overdue, brief, unlinked", () => {
  const ov = buildContentOverview(
    [
      item({ status: "published", dueDate: "2026-05-01" }),
      item({ status: "writing", dueDate: "2026-05-01", assignee: "marie" }), // overdue
      item({ status: "review", hasBrief: true }),
      item({ status: "idea", primaryKeywordId: null, assignee: "marie" }),
    ],
    NOW,
  );
  expect(ov.total).toBe(4);
  expect(ov.byStatus.map((b) => b.key)).toEqual([
    "idea",
    "brief",
    "writing",
    "review",
    "published",
  ]);
  expect(ov.published).toBe(1);
  expect(ov.inProgress).toBe(2); // writing + review
  expect(ov.overdue).toBe(1); // the writing one; the published one is excluded
  expect(ov.withBrief).toBe(1);
  expect(ov.unlinked).toBe(1);
  expect(ov.byAssignee.find((a) => a.key === "marie")?.count).toBe(2);
  expect(ov.byAssignee.find((a) => a.key === "Non assigné")?.count).toBe(2);
});

test("upcoming: only dated, soonest first, overdue flagged; empty otherwise", () => {
  const ov = buildContentOverview(
    [
      item({ dueDate: "2026-06-10" }),
      item({ dueDate: "2026-06-04", status: "writing" }), // overdue
      item({ dueDate: null }),
    ],
    NOW,
  );
  expect(ov.upcoming.map((u) => u.dueDate)).toEqual(["2026-06-04", "2026-06-10"]);
  expect(ov.upcoming[0].overdue).toBe(true);
  expect(ov.upcoming[1].overdue).toBe(false);
  expect(buildContentOverview([], NOW).upcoming).toEqual([]);
});
```

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

Run: `bun run test src/lib/content/overview.test.ts`
Expected: PASS (2 tests). If FAIL, fix `overview.ts` (not the assertions).

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

```bash
bunx biome check --write src/lib/content/overview.ts src/lib/content/overview.test.ts
bunx tsc --noEmit
git add src/lib/content/overview.ts src/lib/content/overview.test.ts
git commit -m "feat(content): buildContentOverview pure aggregator + tests"
```

---

## Task 2: `getContentOverviewAction`

**Files:**
- Modify: `src/lib/content/actions.ts`

- [ ] **Step 1: Add the action**

In `src/lib/content/actions.ts`, add an import for the aggregator + type (merge with existing imports):
```ts
import { buildContentOverview, type ContentOverview } from "./overview";
```
Add the action (it can sit next to `getContentItemsForSiteAction`):
```ts
export async function getContentOverviewAction(
  siteId: string,
): Promise<ContentOverview> {
  const items = await getContentItemsForSite(siteId);
  return buildContentOverview(items, new Date().toISOString());
}
```
(`getContentItemsForSite` is already imported in this file.)

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

```bash
bunx tsc --noEmit
bunx biome check --write src/lib/content/actions.ts
git add src/lib/content/actions.ts
git commit -m "feat(content): getContentOverviewAction"
```

---

## Task 3: Modules + container + page + nav

**Files:**
- Create: the five components under `src/components/content/overview/`
- Create: `src/app/(app)/content/overview/page.tsx`
- Modify: `src/components/app-sidebar.tsx`

- [ ] **Step 1: `content-overview-kpis.tsx`**

```tsx
// src/components/content/overview/content-overview-kpis.tsx
import { StatCard } from "@/components/keywords/overview/stat-card";
import type { ContentOverview } from "@/lib/content/overview";

export function ContentOverviewKpis({ ov }: { ov: ContentOverview }) {
  return (
    <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
      <StatCard label="Articles" value={ov.total.toLocaleString("fr-FR")} />
      <StatCard label="Publiés" value={ov.published.toLocaleString("fr-FR")} />
      <StatCard
        label="En cours"
        value={ov.inProgress.toLocaleString("fr-FR")}
        sub="rédaction + relecture"
      />
      <StatCard
        label="En retard"
        value={ov.overdue.toLocaleString("fr-FR")}
      />
      <StatCard label="Avec brief" value={ov.withBrief.toLocaleString("fr-FR")} />
    </div>
  );
}
```

- [ ] **Step 2: `content-status-distribution.tsx`**

```tsx
// src/components/content/overview/content-status-distribution.tsx
import { BarList } from "@/components/keywords/overview/bar-list";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { CONTENT_STATUS_LABEL } from "@/lib/content/types";
import type { ContentOverview } from "@/lib/content/overview";

export function ContentStatusDistribution({ ov }: { ov: ContentOverview }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">Pipeline</CardTitle>
      </CardHeader>
      <CardContent>
        <BarList
          items={ov.byStatus.map((b) => ({
            label: CONTENT_STATUS_LABEL[b.key],
            value: b.count,
          }))}
        />
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 3: `content-by-assignee.tsx`**

```tsx
// src/components/content/overview/content-by-assignee.tsx
import { BarList } from "@/components/keywords/overview/bar-list";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import type { ContentOverview } from "@/lib/content/overview";

export function ContentByAssignee({ ov }: { ov: ContentOverview }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">Par assigné</CardTitle>
      </CardHeader>
      <CardContent>
        <BarList
          items={ov.byAssignee.map((a) => ({ label: a.key, value: a.count }))}
        />
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 4: `content-upcoming.tsx`**

```tsx
// src/components/content/overview/content-upcoming.tsx
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { CONTENT_STATUS_LABEL } from "@/lib/content/types";
import type { ContentOverview } from "@/lib/content/overview";

export function ContentUpcoming({ ov }: { ov: ContentOverview }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-sm">Prochaines échéances</CardTitle>
      </CardHeader>
      <CardContent>
        {ov.upcoming.length === 0 ? (
          <p className="text-muted-foreground text-sm">Aucune échéance.</p>
        ) : (
          <ul className="space-y-1.5 text-sm">
            {ov.upcoming.map((u) => (
              <li key={u.id} className="flex items-center justify-between gap-2">
                <span className="truncate">{u.title}</span>
                <span className="flex shrink-0 items-center gap-2 text-xs">
                  <span className="text-muted-foreground">
                    {CONTENT_STATUS_LABEL[u.status]}
                  </span>
                  <span
                    className={
                      u.overdue ? "text-destructive" : "text-muted-foreground"
                    }
                  >
                    {u.dueDate.slice(0, 10)}
                  </span>
                </span>
              </li>
            ))}
          </ul>
        )}
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 5: `content-overview-container.tsx`**

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

import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { getContentOverviewAction } from "@/lib/content/actions";
import type { ContentOverview } from "@/lib/content/overview";
import { useActiveSite } from "@/lib/keywords/site-context";
import { ContentByAssignee } from "./content-by-assignee";
import { ContentOverviewKpis } from "./content-overview-kpis";
import { ContentStatusDistribution } from "./content-status-distribution";
import { ContentUpcoming } from "./content-upcoming";

export function ContentOverviewContainer() {
  const { activeSiteId } = useActiveSite();
  const [ov, setOv] = useState<ContentOverview | null>(null);
  const [loading, setLoading] = useState(true);

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

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

  if (loading || !ov) {
    return (
      <div className="space-y-3 p-4">
        <Skeleton className="h-20 w-full" />
        <Skeleton className="h-64 w-full" />
      </div>
    );
  }

  if (ov.total === 0) {
    return (
      <div className="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
        <p>Aucun article pour ce site.</p>
        <Link
          href="/content/planning"
          className="text-sm underline hover:text-foreground"
        >
          Créer des articles dans le Planning
        </Link>
      </div>
    );
  }

  return (
    <div className="space-y-4 p-4">
      <ContentOverviewKpis ov={ov} />
      <div className="grid gap-4 lg:grid-cols-2">
        <ContentStatusDistribution ov={ov} />
        <ContentByAssignee ov={ov} />
      </div>
      <ContentUpcoming ov={ov} />
    </div>
  );
}
```

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

```tsx
// src/app/(app)/content/overview/page.tsx
import { ContentOverviewContainer } from "@/components/content/overview/content-overview-container";
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>Overview</BreadcrumbPage>
            </BreadcrumbItem>
          </BreadcrumbList>
        </Breadcrumb>
      </DashboardHeader>
      <ContentOverviewContainer />
    </>
  );
}
```

- [ ] **Step 7: Add "Overview" to the Contenu nav**

In `src/components/app-sidebar.tsx`, change the Contenu section's `items` to include Overview before Planning:
```tsx
      items: [
        { title: "Overview", url: "/content/overview" },
        { title: "Planning", url: "/content/planning" },
      ],
```

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

```bash
bunx tsc --noEmit
bunx biome check --write src/components/content/overview/ "src/app/(app)/content/overview/page.tsx" src/components/app-sidebar.tsx
git add src/components/content/overview/ "src/app/(app)/content/overview/page.tsx" src/components/app-sidebar.tsx
git commit -m "feat(content): overview dashboard (KPIs, pipeline, assignee, deadlines) + nav"
```

---

## Task 4: Full checks + live verification

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

```bash
bunx tsc --noEmit
bun run test src/lib/content/overview.test.ts
bunx biome check src/lib/content src/components/content "src/app/(app)/content/overview/page.tsx" src/components/app-sidebar.tsx
```
Expected: tsc 0; tests pass; biome clean.

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

`d3k agent-browser open http://localhost:3000/content/overview`:
- With content items (create a couple on `/content/planning` first, or via "Vers Contenu"): KPIs (Articles/Publiés/En cours/En retard/Avec brief), Pipeline bars, Par assigné, Prochaines échéances all render and match the planning board.
- Empty site → empty-state with the Planning link.
- The Contenu nav shows Overview + Planning. `d3k errors` clean.

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

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

---

## Notes / risks

- `StatCard`/`BarList` are reused from `@/components/keywords/overview/` (generic primitives) — no duplication.
- `buildContentOverview` is the only logic unit and is fully tested; modules are presentational.
- `overdue` excludes `published` items (a shipped article past its due date isn't "late").
- Branch: `feature/content-overview` (already created off `dev`).
```
