# Page Search Console dédiée (`/gsc`) — 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:** Page top-level `/gsc` (gestion + santé Search Console) qui remplace la section GSC de Réglages → Intégrations.

**Architecture:** RSC `src/app/(app)/gsc/page.tsx` qui fetch en parallèle `getGscStatusAction()` (existant) et une nouvelle action `getGscHealthAction()` (sync par site + runs du worker Rust). Trois cards client/présentationnelles sous `src/components/gsc/`. `gsc-integrations.tsx` est éclaté puis supprimé ; Réglages → Intégrations garde un lien.

**Tech Stack:** Next.js 16 App Router (RSC + server actions), Drizzle/Postgres, shadcn (Card/Select/Button/Table), bun:test, next-intl (`messages/*.json`).

**Spec:** `docs/superpowers/specs/2026-07-23-gsc-dedicated-page-design.md`

## Global Constraints

- Copie UI en **français** (les autres locales `messages/{en,de,es}.json` reçoivent leur traduction pour les clés Nav ajoutées).
- Route exactement `/gsc` ; `callbackURL` du `linkSocial` passe de `/settings/integrations` à `/gsc`.
- Erreurs visibles avec `role="alert"` — aucun échec avalé silencieusement.
- La card « Santé des snapshots » n'est rendue que si `status.canManage` (les runs sont globaux, pas scopés org — ne pas les montrer aux members).
- Tests : suivre les patterns EXISTANTS du repo (`src/lib/gsc/actions.test.ts` : `mock.module` + `mock.restore()` en `afterAll` + `assertTestDb()` + inserts réels nettoyés en `beforeEach`). Jamais de nouveau pattern de mock.
- Commandes de test : `cd /Users/humperdoo/Github/seo-insight && bun test src/lib/gsc/` (grep les lignes pass/fail, exiger 0 fail, deux runs) ; typecheck : `bunx tsc --noEmit`.
- Conventional Commits ; chaque message se termine par les deux trailers :
  `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` puis `Claude-Session: https://claude.ai/code/session_01TPVZEibkftCoz8dwbRSMkn`.
- Hook local : ne JAMAIS combiner heredoc et `git commit` dans une même commande Bash ; si un `-m` multiple est rejeté par le hook, utiliser `git commit -F <fichier>`.
- Branche de travail : `feature/gsc-page` (créée depuis `dev`).

---

### Task 1: Module santé — statut de run pur + `getGscHealthAction`

**Files:**
- Create: `src/lib/gsc/health.ts` (fonction pure + types)
- Create: `src/lib/gsc/health-actions.ts` (server action)
- Test: `src/lib/gsc/health.test.ts` (pur) et `src/lib/gsc/health-actions.test.ts` (DB)

**Interfaces:**
- Consomme : `gscSnapshotRuns`, `sites`, `keywords` (Drizzle, `src/lib/db/domain-schema.ts`), `getActiveOrgId` (`@/lib/active-org`), `canManageGsc` (`./owner`).
- Produit (utilisé par Task 2) :
  - `type SnapshotRunStatus = "ok" | "partial" | "failed" | "running"`
  - `snapshotRunStatus(run: { finishedAt: Date | null; sitesOk: number; sitesFailed: number; error: string | null }): SnapshotRunStatus`
  - `type GscSiteSync = { siteId: string; lastGscSyncAt: string | null }`
  - `type GscSnapshotRun = { id: string; startedAt: Date; finishedAt: Date | null; sitesOk: number; sitesFailed: number; rowsUpserted: number; error: string | null }`
  - `type GscHealth = { syncBySite: GscSiteSync[]; snapshotRuns: GscSnapshotRun[] }`
  - `getGscHealthAction(): Promise<GscHealth>`

- [ ] **Step 1 : test failing de la fonction pure**

`src/lib/gsc/health.test.ts` :

```ts
import { expect, test } from "bun:test";
import { snapshotRunStatus } from "./health";

const base = { finishedAt: new Date("2026-07-23T06:05:00Z"), sitesOk: 3, sitesFailed: 0, error: null };

test("run terminé sans échec → ok", () => {
  expect(snapshotRunStatus(base)).toBe("ok");
});

test("run non terminé → running (même avec des compteurs à 0)", () => {
  expect(snapshotRunStatus({ ...base, finishedAt: null, sitesOk: 0 })).toBe("running");
});

test("succès ET échecs mélangés → partial", () => {
  expect(snapshotRunStatus({ ...base, sitesFailed: 2 })).toBe("partial");
});

test("échecs sans aucun succès → failed", () => {
  expect(snapshotRunStatus({ ...base, sitesOk: 0, sitesFailed: 2 })).toBe("failed");
});

test("erreur globale → failed même si sitesFailed=0", () => {
  expect(snapshotRunStatus({ ...base, error: "token broker 500" })).toBe("failed");
});
```

- [ ] **Step 2 : vérifier l'échec**

Run : `bun test src/lib/gsc/health.test.ts`
Attendu : FAIL (« Cannot find module './health' »).

- [ ] **Step 3 : implémentation `src/lib/gsc/health.ts`**

```ts
// src/lib/gsc/health.ts — types + dérivation pure du statut d'un run de
// snapshots GSC (worker Rust, table gsc_snapshot_runs). Pas d'I/O : testable
// en isolation, consommé par la card « Santé des snapshots » (/gsc).

export type SnapshotRunStatus = "ok" | "partial" | "failed" | "running";

export type GscSnapshotRun = {
  id: string;
  startedAt: Date;
  finishedAt: Date | null;
  sitesOk: number;
  sitesFailed: number;
  rowsUpserted: number;
  error: string | null;
};

export type GscSiteSync = { siteId: string; lastGscSyncAt: string | null };

export type GscHealth = {
  syncBySite: GscSiteSync[];
  snapshotRuns: GscSnapshotRun[];
};

/** Statut d'un run : `running` tant que non terminé ; `failed` si erreur
 *  globale ou aucun site réussi ; `partial` si succès ET échecs ; `ok` sinon. */
export function snapshotRunStatus(run: {
  finishedAt: Date | null;
  sitesOk: number;
  sitesFailed: number;
  error: string | null;
}): SnapshotRunStatus {
  if (!run.finishedAt) return "running";
  if (run.error || (run.sitesFailed > 0 && run.sitesOk === 0)) return "failed";
  if (run.sitesFailed > 0) return "partial";
  return "ok";
}
```

- [ ] **Step 4 : vérifier le pass**

Run : `bun test src/lib/gsc/health.test.ts` — attendu : 5 pass / 0 fail.

- [ ] **Step 5 : test failing de l'action (pattern actions.test.ts)**

`src/lib/gsc/health-actions.test.ts` — suivre EXACTEMENT le pattern de `src/lib/gsc/actions.test.ts` (lire ce fichier d'abord : `mock.module` pour `@/lib/active-org` et `./owner`, `assertTestDb()`, org/user/site réels insérés en `beforeEach`, `mock.restore()` en `afterAll`). Cas à couvrir :

```ts
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
import { desc, eq, inArray } from "drizzle-orm";
import { assertTestDb } from "@/lib/db/assert-test-db";
import { member, organization, user } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import {
  gscSnapshotRuns,
  keywords,
  sites as sitesTable,
} from "@/lib/db/domain-schema";

let ROLE: "owner" | "member" = "owner";
mock.module("@/lib/active-org", () => ({
  getActiveOrgId: async () => "gsc-health-org",
  getActiveUserId: async () => "gsc-health-user",
}));
mock.module("./owner", () => ({
  canManageGsc: async () => ROLE === "owner",
}));

const { getGscHealthAction } = await import("./health-actions");

beforeEach(async () => {
  await assertTestDb();
  ROLE = "owner";
  await db.delete(gscSnapshotRuns).where(inArray(gscSnapshotRuns.id, ["r1", "r2", "r3"]));
  await db.delete(keywords).where(inArray(keywords.siteId, ["gsc-health-site-a", "gsc-health-site-b"]));
  await db.delete(sitesTable).where(eq(sitesTable.orgId, "gsc-health-org"));
  await db.delete(member).where(eq(member.organizationId, "gsc-health-org"));
  await db.delete(organization).where(eq(organization.id, "gsc-health-org"));
  await db.delete(user).where(eq(user.id, "gsc-health-user"));
  await db.insert(user).values({ id: "gsc-health-user", name: "U", email: `gsc-health-${Date.now()}@example.com`, emailVerified: true });
  await db.insert(organization).values({ id: "gsc-health-org", name: "Org", slug: `gsc-health-${Date.now()}`, createdAt: new Date() });
  await db.insert(member).values({ id: "gsc-health-m", organizationId: "gsc-health-org", userId: "gsc-health-user", role: "owner", createdAt: new Date() });
});
afterAll(() => mock.restore());

test("org sans sites → syncBySite vide, runs remontés (owner)", async () => {
  await db.insert(gscSnapshotRuns).values({ id: "r1", startedAt: new Date("2026-07-23T06:00:00Z"), finishedAt: new Date("2026-07-23T06:04:00Z"), sitesOk: 2, sitesFailed: 0, rowsUpserted: 1200, error: null });
  const health = await getGscHealthAction();
  expect(health.syncBySite).toEqual([]);
  expect(health.snapshotRuns.map((r) => r.id)).toContain("r1");
});

test("lastGscSyncAt par site : max(gsc->>updatedAt), null si jamais synchronisé", async () => {
  await db.insert(sitesTable).values([
    { id: "gsc-health-site-a", orgId: "gsc-health-org", name: "A", url: "https://a.example" },
    { id: "gsc-health-site-b", orgId: "gsc-health-org", name: "B", url: "https://b.example" },
  ]);
  await db.insert(keywords).values([
    { id: "gsc-health-site-a-k1", siteId: "gsc-health-site-a", keyword: "k1", stage: "research", gsc: { updatedAt: "2026-07-20T10:00:00Z" } },
    { id: "gsc-health-site-a-k2", siteId: "gsc-health-site-a", keyword: "k2", stage: "research", gsc: { updatedAt: "2026-07-22T10:00:00Z" } },
  ]);
  const health = await getGscHealthAction();
  const bySite = new Map(health.syncBySite.map((s) => [s.siteId, s.lastGscSyncAt]));
  expect(bySite.get("gsc-health-site-a")).toBe("2026-07-22T10:00:00Z");
  expect(bySite.get("gsc-health-site-b")).toBeNull();
});

test("runs : tri décroissant par startedAt, limite 7", async () => {
  const runs = Array.from({ length: 9 }, (_, i) => ({
    id: `bulk-${i}`, startedAt: new Date(Date.UTC(2026, 6, 10 + i, 6)), finishedAt: new Date(Date.UTC(2026, 6, 10 + i, 6, 5)),
    sitesOk: 1, sitesFailed: 0, rowsUpserted: 10, error: null,
  }));
  await db.insert(gscSnapshotRuns).values(runs);
  try {
    const health = await getGscHealthAction();
    expect(health.snapshotRuns.length).toBeLessThanOrEqual(7);
    const times = health.snapshotRuns.map((r) => r.startedAt.getTime());
    expect(times).toEqual([...times].sort((a, b) => b - a));
  } finally {
    await db.delete(gscSnapshotRuns).where(inArray(gscSnapshotRuns.id, runs.map((r) => r.id)));
  }
});

test("member (canManage=false) → snapshotRuns vide, syncBySite servi quand même", async () => {
  ROLE = "member";
  await db.insert(gscSnapshotRuns).values({ id: "r2", startedAt: new Date(), finishedAt: new Date(), sitesOk: 1, sitesFailed: 0, rowsUpserted: 5, error: null });
  const health = await getGscHealthAction();
  expect(health.snapshotRuns).toEqual([]);
});
```

NB implémenteur : ajuste les colonnes NOT NULL de `keywords`/`sites` aux exigences réelles du schéma (regarde `actions.test.ts` et `domain-schema.ts` pour les valeurs minimales des inserts — p. ex. `stage`, `url`). Si un insert échoue, c'est le test qu'on corrige, pas le schéma.

- [ ] **Step 6 : vérifier l'échec** — `bun test src/lib/gsc/health-actions.test.ts` → FAIL (module `./health-actions` absent).

- [ ] **Step 7 : implémentation `src/lib/gsc/health-actions.ts`**

```ts
// src/lib/gsc/health-actions.ts — agrégat « santé » de la page /gsc :
// dernière sync GSC par site de l'org (max(keywords.gsc->>'updatedAt'), même
// source que getKeywordsSyncStatus) + derniers runs du worker Rust de
// snapshots. Les runs (table GLOBALE, non scopée org) ne sont servis qu'aux
// owner/admin (canManageGsc) — un member ne voit pas l'opérationnel global.
"use server";
import { desc, inArray, sql } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { getActiveOrgId } from "@/lib/active-org";
import { db } from "@/lib/db/client";
import { gscSnapshotRuns, keywords, sites as sitesTable } from "@/lib/db/domain-schema";
import type { GscHealth } from "./health";
import { canManageGsc } from "./owner";

const RUNS_LIMIT = 7;

export async function getGscHealthAction(): Promise<GscHealth> {
  const orgId = await getActiveOrgId();
  const siteRows = await db
    .select({ id: sitesTable.id })
    .from(sitesTable)
    .where(eq(sitesTable.orgId, orgId));
  const siteIds = siteRows.map((r) => r.id);

  const syncRows = siteIds.length
    ? await db
        .select({
          siteId: keywords.siteId,
          lastGscSyncAt: sql<string | null>`max((${keywords.gsc}->>'updatedAt'))`,
        })
        .from(keywords)
        .where(inArray(keywords.siteId, siteIds))
        .groupBy(keywords.siteId)
    : [];
  const syncMap = new Map(syncRows.map((r) => [r.siteId, r.lastGscSyncAt]));
  const syncBySite = siteIds.map((siteId) => ({
    siteId,
    lastGscSyncAt: syncMap.get(siteId) ?? null,
  }));

  const snapshotRuns = (await canManageGsc())
    ? await db
        .select()
        .from(gscSnapshotRuns)
        .orderBy(desc(gscSnapshotRuns.startedAt))
        .limit(RUNS_LIMIT)
    : [];

  return { syncBySite, snapshotRuns };
}
```

- [ ] **Step 8 : vérifier le pass** — `bun test src/lib/gsc/health-actions.test.ts` puis `bun test src/lib/gsc/` (deux fois, 0 fail) et `bunx tsc --noEmit`.

- [ ] **Step 9 : commit**

```bash
git add src/lib/gsc/health.ts src/lib/gsc/health-actions.ts src/lib/gsc/health.test.ts src/lib/gsc/health-actions.test.ts
git commit -m "feat(gsc): module sante — statut de run pur + getGscHealthAction"
```

---

### Task 2: Cards + page `/gsc` + navigation

**Files:**
- Create: `src/components/gsc/gsc-connection-card.tsx`
- Create: `src/components/gsc/gsc-properties-card.tsx`
- Create: `src/components/gsc/gsc-snapshot-health-card.tsx`
- Create: `src/app/(app)/gsc/page.tsx`
- Modify: `src/components/app-sidebar.tsx` (groupe `navMainConfig`, après le groupe `content`)
- Modify: `messages/fr.json`, `messages/en.json`, `messages/de.json`, `messages/es.json` (clés `Nav.gsc`, `Nav.gscManage`)

**Interfaces:**
- Consomme : `GscStatus`, `setSiteGscPropertyAction`, `syncGscKeywordsAction`, type `SyncGscResult` (`@/lib/gsc/actions`) ; `getGscHealthAction`, `GscHealth`, `GscSnapshotRun`, `snapshotRunStatus` (Task 1) ; `authClient` (`@/lib/auth-client`) ; shadcn `Card`, `Select`, `Button`, `Table`, `Badge`.
- Produit : la page `/gsc` fonctionnelle (Task 3 s'appuie sur son existence pour re-router les liens).

- [ ] **Step 1 : card Connexion** — `src/components/gsc/gsc-connection-card.tsx`. Reprendre le bloc « Google Search Console » de `src/components/gsc-integrations.tsx` (lignes 46-95, lire le fichier avant) tel quel, avec deux changements : composant nommé `GscConnectionCard({ status }: { status: GscStatus })`, et `callbackURL: "/gsc"` dans `connect()`. Conserver les 4 états (connecté / missingScope+canLink → « Reconnecter » / canLink → « Connecter » / member) et l'affichage d'erreur `role="alert"` local au `connect`.

```tsx
// src/components/gsc/gsc-connection-card.tsx — état de connexion du compte
// Google de l'org (page /gsc). Extrait de l'ex gsc-integrations.tsx.
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { authClient } from "@/lib/auth-client";
import type { GscStatus } from "@/lib/gsc/actions";

const WEBMASTERS_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";

export function GscConnectionCard({ status }: { status: GscStatus }) {
  const [error, setError] = useState<string | null>(null);

  async function connect() {
    setError(null);
    try {
      await authClient.linkSocial({
        provider: "google",
        scopes: [WEBMASTERS_SCOPE],
        callbackURL: "/gsc",
      });
    } catch {
      setError("La connexion Google a échoué — réessaie.");
    }
  }

  return (
    <Card>
      <CardHeader>
        <CardTitle>Google Search Console</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {error ? (
          <p className="text-destructive text-sm" role="alert">{error}</p>
        ) : null}
        {status.connected ? (
          <p className="text-muted-foreground text-sm">
            {status.isOwner
              ? "Connecté à Google Search Console ✓"
              : "Search Console est connecté par le propriétaire de l'organisation."}
          </p>
        ) : status.missingScope && status.canLink ? (
          <div className="flex flex-col items-start gap-2">
            <p className="text-muted-foreground text-sm">
              Le compte Google est connecté mais sans les droits Search Console
              — reconnecte-le en cochant la case Search Console sur l'écran
              Google.
            </p>
            <Button onClick={connect}>Reconnecter Google Search Console</Button>
          </div>
        ) : status.canLink ? (
          <div className="flex flex-col items-start gap-2">
            <p className="text-muted-foreground text-sm">
              Connecte le compte Google de l'organisation pour mapper tes
              propriétés Search Console.
            </p>
            <Button onClick={connect}>Connecter Google Search Console</Button>
          </div>
        ) : (
          <p className="text-muted-foreground text-sm">
            Le propriétaire de l'organisation n'a pas encore connecté Search
            Console.
          </p>
        )}
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 2 : card Propriétés par site** — `src/components/gsc/gsc-properties-card.tsx`. Mapping existant (Select → `setSiteGscPropertyAction`, `window.location.reload()` sur succès, pattern conservé) enrichi par ligne : dernière sync + bouton « Synchroniser ».

```tsx
// src/components/gsc/gsc-properties-card.tsx — mapping propriété↔site + sync
// manuelle par site (page /gsc). Le Select reprend le pattern de l'ex
// gsc-integrations.tsx (reload sur succès) ; la sync affiche son résultat
// inline sans reload (l'utilisateur enchaîne souvent plusieurs sites).
"use client";

import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
  type GscStatus, setSiteGscPropertyAction, syncGscKeywordsAction,
} from "@/lib/gsc/actions";
import type { GscSiteSync } from "@/lib/gsc/health";

const UNMAPPED = "__none__";

function lastSyncLabel(iso: string | null): string {
  if (!iso) return "Jamais synchronisé";
  return `Dernière sync : ${new Date(iso).toLocaleDateString("fr-FR", {
    day: "numeric", month: "short", hour: "2-digit", minute: "2-digit",
  })}`;
}

export function GscPropertiesCard({
  status, syncBySite,
}: { status: GscStatus; syncBySite: GscSiteSync[] }) {
  const [error, setError] = useState<string | null>(null);
  const [syncResult, setSyncResult] = useState<Record<string, string>>({});
  const [syncingId, setSyncingId] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();
  const lastSync = new Map(syncBySite.map((s) => [s.siteId, s.lastGscSyncAt]));

  function setProperty(siteId: string, value: string) {
    setError(null);
    const property = value === UNMAPPED ? null : value;
    startTransition(async () => {
      const res = await setSiteGscPropertyAction(siteId, property);
      if (!res.ok) setError(res.error);
      else window.location.reload();
    });
  }

  function sync(siteId: string) {
    setError(null);
    setSyncingId(siteId);
    startTransition(async () => {
      const res = await syncGscKeywordsAction(siteId);
      setSyncingId(null);
      if (!res.ok) {
        setSyncResult((m) => ({ ...m, [siteId]: `Échec : ${res.error}` }));
        return;
      }
      setSyncResult((m) => ({
        ...m,
        [siteId]: `${res.queries} requêtes, ${res.matched} matchés, ${res.created} créés`,
      }));
    });
  }

  return (
    <Card>
      <CardHeader>
        <CardTitle>Propriétés par site</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {error ? (
          <p className="text-destructive text-sm" role="alert">{error}</p>
        ) : null}
        {status.propertiesError ? (
          <p className="text-destructive text-sm" role="alert">
            Impossible de lister tes propriétés Search Console — réessaie ou
            reconnecte le compte.
          </p>
        ) : null}
        {status.sites.length === 0 ? (
          <p className="text-muted-foreground text-sm">Aucun site.</p>
        ) : (
          status.sites.map((site) => (
            <div key={site.id} className="flex flex-col gap-1">
              <div className="flex items-center justify-between gap-4">
                <span className="truncate text-sm">{site.name}</span>
                <div className="flex items-center gap-2">
                  {status.canManage && status.connected ? (
                    <Select
                      defaultValue={site.gscProperty ?? UNMAPPED}
                      onValueChange={(v) => setProperty(site.id, v)}
                      disabled={pending}
                    >
                      <SelectTrigger className="w-[260px]">
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent position="popper">
                        <SelectItem value={UNMAPPED}>Non mappé</SelectItem>
                        {status.properties.map((p) => (
                          <SelectItem key={p.siteUrl} value={p.siteUrl}>
                            {p.siteUrl}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  ) : (
                    <span className="text-muted-foreground text-sm">
                      {site.gscProperty ?? "Non mappé"}
                    </span>
                  )}
                  {status.canManage && status.connected ? (
                    <Button
                      variant="outline"
                      size="sm"
                      disabled={pending || !site.gscProperty}
                      onClick={() => sync(site.id)}
                    >
                      {syncingId === site.id ? "Sync…" : "Synchroniser"}
                    </Button>
                  ) : null}
                </div>
              </div>
              <p className="text-muted-foreground text-xs">
                {lastSyncLabel(lastSync.get(site.id) ?? null)}
                {syncResult[site.id] ? ` — ${syncResult[site.id]}` : ""}
              </p>
            </div>
          ))
        )}
      </CardContent>
    </Card>
  );
}
```

- [ ] **Step 3 : card Santé des snapshots** — `src/components/gsc/gsc-snapshot-health-card.tsx` (présentationnel, pas de "use client" nécessaire) :

```tsx
// src/components/gsc/gsc-snapshot-health-card.tsx — derniers runs du worker
// Rust de snapshots GSC (table gsc_snapshot_runs). Présentationnel ; rendu
// uniquement pour owner/admin (gating fait par la page).
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
  type GscSnapshotRun, type SnapshotRunStatus, snapshotRunStatus,
} from "@/lib/gsc/health";

const STATUS_LABEL: Record<SnapshotRunStatus, string> = {
  ok: "OK", partial: "Partiel", failed: "Échec", running: "En cours",
};
const STATUS_VARIANT: Record<SnapshotRunStatus, "default" | "secondary" | "destructive" | "outline"> = {
  ok: "secondary", partial: "outline", failed: "destructive", running: "default",
};

export function GscSnapshotHealthCard({ runs }: { runs: GscSnapshotRun[] }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Santé des snapshots quotidiens</CardTitle>
      </CardHeader>
      <CardContent>
        {runs.length === 0 ? (
          <p className="text-muted-foreground text-sm">
            Aucun run de snapshot pour l'instant (le worker tourne chaque nuit).
          </p>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>Date</TableHead>
                <TableHead>Statut</TableHead>
                <TableHead className="text-right">Sites OK</TableHead>
                <TableHead className="text-right">Échecs</TableHead>
                <TableHead className="text-right">Lignes</TableHead>
                <TableHead>Erreur</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {runs.map((run) => {
                const s = snapshotRunStatus(run);
                return (
                  <TableRow key={run.id}>
                    <TableCell>
                      {run.startedAt.toLocaleDateString("fr-FR", {
                        day: "numeric", month: "short", hour: "2-digit", minute: "2-digit",
                      })}
                    </TableCell>
                    <TableCell><Badge variant={STATUS_VARIANT[s]}>{STATUS_LABEL[s]}</Badge></TableCell>
                    <TableCell className="text-right">{run.sitesOk}</TableCell>
                    <TableCell className="text-right">{run.sitesFailed}</TableCell>
                    <TableCell className="text-right">{run.rowsUpserted}</TableCell>
                    <TableCell className="max-w-[280px] truncate text-muted-foreground text-xs">
                      {run.error ?? "—"}
                    </TableCell>
                  </TableRow>
                );
              })}
            </TableBody>
          </Table>
        )}
      </CardContent>
    </Card>
  );
}
```

NB : vérifier que `src/components/ui/table.tsx` et `badge.tsx` existent (shadcn déjà installé sur d'autres pages) ; sinon les ajouter via le pattern du repo.

- [ ] **Step 4 : page RSC** — `src/app/(app)/gsc/page.tsx` (modèle : `src/app/(app)/settings/integrations/page.tsx`, lire avant) :

```tsx
// src/app/(app)/gsc/page.tsx — page Search Console dédiée : gestion
// (connexion + mapping) et santé (syncs par site, runs du worker de
// snapshots). Spec : docs/superpowers/specs/2026-07-23-gsc-dedicated-page-design.md
import { DashboardHeader } from "@/components/dashboard-header";
import { GscConnectionCard } from "@/components/gsc/gsc-connection-card";
import { GscPropertiesCard } from "@/components/gsc/gsc-properties-card";
import { GscSnapshotHealthCard } from "@/components/gsc/gsc-snapshot-health-card";
import {
  Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage,
} from "@/components/ui/breadcrumb";
import { getGscStatusAction } from "@/lib/gsc/actions";
import { getGscHealthAction } from "@/lib/gsc/health-actions";

export default async function GscPage() {
  const [status, health] = await Promise.all([
    getGscStatusAction(),
    getGscHealthAction(),
  ]);
  return (
    <>
      <DashboardHeader>
        <Breadcrumb>
          <BreadcrumbList>
            <BreadcrumbItem>
              <BreadcrumbPage>Search Console</BreadcrumbPage>
            </BreadcrumbItem>
          </BreadcrumbList>
        </Breadcrumb>
      </DashboardHeader>
      <div className="flex max-w-3xl flex-col gap-6 p-4">
        <GscConnectionCard status={status} />
        <GscPropertiesCard status={status} syncBySite={health.syncBySite} />
        {status.canManage ? (
          <GscSnapshotHealthCard runs={health.snapshotRuns} />
        ) : null}
      </div>
    </>
  );
}
```

- [ ] **Step 5 : navigation + i18n.** Dans `src/components/app-sidebar.tsx`, ajouter au `navMainConfig` (après le groupe `content`, avant `support`) :

```tsx
  {
    labelKey: "gsc",
    url: "/gsc",
    icon: <SearchCheckIcon />,
    isActive: true,
    items: [{ labelKey: "gscManage", url: "/gsc" }],
  },
```

Importer `SearchCheckIcon` depuis `lucide-react` à côté des icônes existantes. Dans `messages/*.json`, section `Nav`, ajouter : fr `"gsc": "Search Console", "gscManage": "Connexion & santé"` ; en `"gsc": "Search Console", "gscManage": "Connection & health"` ; de `"gsc": "Search Console", "gscManage": "Verbindung & Status"` ; es `"gsc": "Search Console", "gscManage": "Conexión y salud"`.

- [ ] **Step 6 : vérifications** — `bunx tsc --noEmit` propre ; `bun test src/lib/gsc/` 0 fail. Lancement rapide du build de la route si le repo a un script (sinon typecheck suffit — la vérif visuelle vient en fin de branche).

- [ ] **Step 7 : commit**

```bash
git add src/components/gsc/ "src/app/(app)/gsc/page.tsx" src/components/app-sidebar.tsx messages/fr.json messages/en.json messages/de.json messages/es.json
git commit -m "feat(gsc): page /gsc dediee — connexion, mapping + sync par site, sante des snapshots"
```

---

### Task 3: Migrations des surfaces existantes + suppression de l'ancien composant

**Files:**
- Modify: `src/app/(app)/settings/integrations/page.tsx` (card lien à la place de `GscIntegrations`)
- Modify: `src/components/keywords/keyword-modal-sources.tsx` (CTA `/settings/integrations` → `/gsc`)
- Delete: `src/components/gsc-integrations.tsx`

**Interfaces:**
- Consomme : la page `/gsc` (Task 2).
- Produit : plus aucun import de `gsc-integrations` dans le repo.

- [ ] **Step 1 : Réglages → Intégrations.** Remplacer `<GscIntegrations initial={status} />` par une card lien, retirer l'import et le fetch `getGscStatusAction` :

```tsx
// src/app/(app)/settings/integrations/page.tsx — Settings → Intégrations.
// La gestion GSC vit sur /gsc (page dédiée) ; ici, un simple renvoi.
import Link from "next/link";
import { DashboardHeader } from "@/components/dashboard-header";
import { SerankingIntegration } from "@/components/settings/seranking-integration";
import { SettingsShell } from "@/components/settings/settings-shell";
import {
  Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getSerankingIntegrationStatusAction } from "@/lib/seranking/integration-actions";

export default async function IntegrationsPage() {
  const seranking = await getSerankingIntegrationStatusAction();
  return (
    <>
      <DashboardHeader>
        <Breadcrumb>
          <BreadcrumbList>
            <BreadcrumbItem className="hidden md:block">
              <BreadcrumbPage>Paramètres</BreadcrumbPage>
            </BreadcrumbItem>
            <BreadcrumbSeparator className="hidden md:block" />
            <BreadcrumbItem>
              <BreadcrumbPage>Intégrations</BreadcrumbPage>
            </BreadcrumbItem>
          </BreadcrumbList>
        </Breadcrumb>
      </DashboardHeader>
      <SettingsShell active="integrations">
        <Card>
          <CardHeader>
            <CardTitle>Google Search Console</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="text-muted-foreground text-sm">
              La connexion, le mapping des propriétés et la santé des syncs ont
              leur page dédiée.{" "}
              <Link className="text-primary underline underline-offset-4" href="/gsc">
                Gérer Search Console →
              </Link>
            </p>
          </CardContent>
        </Card>
        <SerankingIntegration initial={seranking} />
      </SettingsShell>
    </>
  );
}
```

- [ ] **Step 2 : CTA de la modale sources.** Dans `src/components/keywords/keyword-modal-sources.tsx`, remplacer toute occurrence du lien `/settings/integrations` par `/gsc` (grep le fichier : `grep -n "settings/integrations" src/components/keywords/keyword-modal-sources.tsx`) et mettre à jour le commentaire d'en-tête (« Connecter → /gsc »).

- [ ] **Step 3 : suppression.** `git rm src/components/gsc-integrations.tsx`, puis vérifier qu'aucun import ne subsiste :

Run : `grep -rn "gsc-integrations" src/ && echo "RESTE DES IMPORTS" || echo "OK aucun import"`
Attendu : `OK aucun import`.

Vérifier aussi les autres pointeurs restants vers l'ancienne surface : `grep -rn '"/settings/integrations"' src/` — seuls doivent rester `settings-shell`/sidebar (la page existe toujours pour SE-Ranking) ; aucun lien GSC.

- [ ] **Step 4 : vérifications finales** — `bunx tsc --noEmit` propre ; `bun test src/lib/gsc/` 0 fail (deux runs) ; `bun test src/components 2>/dev/null || true` si des tests composants existent.

- [ ] **Step 5 : commit**

```bash
git add -A
git commit -m "refactor(gsc): Reglages->Integrations renvoie vers /gsc, CTA sources re-route, suppression gsc-integrations"
```

---

## Fin de branche

Review finale de branche (diff complet `dev..HEAD`) par le reviewer le plus capable, puis merge ff-only dans `dev` + suppression de la branche (`superpowers:finishing-a-development-branch`). Vérification visuelle recommandée : `./scripts/dev-firecrawl.sh` (ou `dev-all.sh`) → `http://localhost:3000/gsc` (connexion affichée, mapping OK, card santé visible en owner).
