# Sync GSC dimensions pays + page — 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:** la sync GSC interactive remplit aussi les métriques **par pays cible** (`keywords.gsc_by_country`) et la **page qui ranke** (`gsc.page`), affichées dans la matrice Recherche, la keyword sheet et Online.

**Architecture:** appels searchAnalytics supplémentaires (1/pays cible + 1 query×page), best-effort autour du sync global inchangé ; stockage jsonb par pays (miroir du pattern `serp`), champ `page` dans le blob global.

**Tech Stack:** Next 16, Drizzle/Postgres (migration 0021), bun:test, REST GSC sans dépendance.

**Spec:** `docs/superpowers/specs/2026-06-10-gsc-country-page-design.md`. **Branche:** `feature/gsc-country-page-dimensions` (créée). Ne JAMAIS toucher `docs/project/bug-fix.md`. Tests via `bun run test <path>` uniquement.

---

### Task 1: Helpers purs (TDD) — mapping ISO3, parser pages, top page

**Files:**
- Create: `src/lib/gsc/country-iso3.ts` + `src/lib/gsc/country-iso3.test.ts`
- Modify: `src/lib/gsc/types.ts` (ajout `GscQueryPageRow`), `src/lib/gsc/parse.ts` (+ test dans `src/lib/gsc/parse.test.ts` — créer s'il n'existe pas ; vérifier si les tests de parse vivent dans `search-analytics.test.ts`)
- Create: `src/lib/gsc/pages.ts` + `src/lib/gsc/pages.test.ts`

- [ ] **Step 1: tests qui échouent**

```ts
// src/lib/gsc/country-iso3.test.ts
import { expect, test } from "bun:test";
import { alpha2ToIso3 } from "./country-iso3";

test("mapping alpha2 → alpha3 GSC (minuscule)", () => {
  expect(alpha2ToIso3("FR")).toBe("fra");
  expect(alpha2ToIso3("fr")).toBe("fra");
  expect(alpha2ToIso3("BE")).toBe("bel");
  expect(alpha2ToIso3("CH")).toBe("che");
  expect(alpha2ToIso3("CA")).toBe("can");
  expect(alpha2ToIso3("US")).toBe("usa");
  expect(alpha2ToIso3("GB")).toBe("gbr");
  expect(alpha2ToIso3("DE")).toBe("deu");
  expect(alpha2ToIso3("ES")).toBe("esp");
  expect(alpha2ToIso3("IT")).toBe("ita");
});

test("pays inconnu → null", () => {
  expect(alpha2ToIso3("ZZ")).toBe(null);
  expect(alpha2ToIso3("")).toBe(null);
});
```

```ts
// src/lib/gsc/pages.test.ts
import { expect, test } from "bun:test";
import { topPageByQuery } from "./pages";
import type { GscQueryPageRow } from "./types";

const row = (
  query: string,
  page: string,
  clicks: number,
  impressions = 0,
): GscQueryPageRow => ({ query, page, clicks, impressions, ctr: 0, position: 1 });

test("topPageByQuery: max clics par query (insensible à la casse de la query)", () => {
  const m = topPageByQuery([
    row("Audit", "https://a.fr/p1", 5),
    row("audit", "https://a.fr/p2", 9),
    row("autre", "https://a.fr/x", 1),
  ]);
  expect(m.get("audit")).toBe("https://a.fr/p2");
  expect(m.get("autre")).toBe("https://a.fr/x");
});

test("topPageByQuery: tie-break par impressions", () => {
  const m = topPageByQuery([
    row("q", "https://a.fr/p1", 3, 10),
    row("q", "https://a.fr/p2", 3, 50),
  ]);
  expect(m.get("q")).toBe("https://a.fr/p2");
});
```

Test du parser pages (dans le fichier de test où vit déjà `parseSearchAnalytics` — cherche-le, sinon crée `src/lib/gsc/parse.test.ts`) :

```ts
test("parseSearchAnalyticsPages: keys [query, page], lignes malformées sautées", () => {
  const body = {
    rows: [
      { keys: ["audit", "https://a.fr/p"], clicks: 3, impressions: 40, ctr: 0.075, position: 6.2 },
      { keys: ["sans-page"], clicks: 1 },
      { clicks: 2 },
    ],
  };
  const rows = parseSearchAnalyticsPages(body);
  expect(rows).toHaveLength(1);
  expect(rows[0]).toEqual({
    query: "audit",
    page: "https://a.fr/p",
    clicks: 3,
    impressions: 40,
    ctr: 0.075,
    position: 6.2,
  });
});
```

- [ ] **Step 2: vérifier l'échec** — `bun run test src/lib/gsc` → FAIL (modules/exports manquants)

- [ ] **Step 3: implémenter**

```ts
// src/lib/gsc/country-iso3.ts — mapping ISO-3166 alpha-2 → alpha-3 (minuscule, format GSC).
const ISO3: Record<string, string> = {
  FR: "fra", BE: "bel", CH: "che", CA: "can", US: "usa", GB: "gbr",
  DE: "deu", ES: "esp", IT: "ita", NL: "nld", PT: "prt", LU: "lux",
  AT: "aut", IE: "irl", AU: "aus", NZ: "nzl", MA: "mar", TN: "tun",
  SN: "sen", CI: "civ",
};

/** Code pays GSC (alpha-3 minuscule) pour un alpha-2 ; null si inconnu. */
export function alpha2ToIso3(alpha2: string): string | null {
  return ISO3[alpha2.toUpperCase()] ?? null;
}
```

Dans `src/lib/gsc/types.ts` :

```ts
/** One row of a GSC searchAnalytics query (dimensions: ["query", "page"]). */
export type GscQueryPageRow = GscQueryRow & { page: string };
```

Dans `src/lib/gsc/parse.ts` :

```ts
/** Map a searchAnalytics response (dimensions ["query", "page"]) to typed rows. */
export function parseSearchAnalyticsPages(body: unknown): GscQueryPageRow[] {
  const rows = (body as { rows?: unknown })?.rows;
  if (!Array.isArray(rows)) return [];
  const out: GscQueryPageRow[] = [];
  for (const r of rows) {
    const keys = (r as { keys?: unknown })?.keys;
    const query = Array.isArray(keys) ? keys[0] : undefined;
    const page = Array.isArray(keys) ? keys[1] : undefined;
    if (typeof query !== "string" || typeof page !== "string") continue;
    const row = r as Record<string, unknown>;
    out.push({
      query,
      page,
      clicks: Number(row.clicks ?? 0),
      impressions: Number(row.impressions ?? 0),
      ctr: Number(row.ctr ?? 0),
      position: Number(row.position ?? 0),
    });
  }
  return out;
}
```

(+ import du type `GscQueryPageRow`.)

```ts
// src/lib/gsc/pages.ts — réduction pure query → page qui ranke.
import type { GscQueryPageRow } from "./types";

/** Par query (lowercase), la page au max de clics (tie-break : impressions). */
export function topPageByQuery(rows: GscQueryPageRow[]): Map<string, string> {
  const best = new Map<string, GscQueryPageRow>();
  for (const r of rows) {
    const key = r.query.toLowerCase();
    const cur = best.get(key);
    if (
      !cur ||
      r.clicks > cur.clicks ||
      (r.clicks === cur.clicks && r.impressions > cur.impressions)
    ) {
      best.set(key, r);
    }
  }
  return new Map([...best].map(([k, v]) => [k, v.page]));
}
```

- [ ] **Step 4: vérifier le pass** — `bun run test src/lib/gsc` → tous verts (baseline 22 + nouveaux)

- [ ] **Step 5: commit**

```bash
git add src/lib/gsc/country-iso3.ts src/lib/gsc/country-iso3.test.ts src/lib/gsc/pages.ts src/lib/gsc/pages.test.ts src/lib/gsc/types.ts src/lib/gsc/parse.ts <fichier test parser>
git commit -m "feat(gsc): helpers purs — mapping ISO3, parser query×page, top page par query"
```

---

### Task 2: Migration 0021 + types + sync extras (TDD DB)

**Files:**
- Modify: `src/lib/db/domain-schema.ts` (keywords + `gsc_by_country`), `src/lib/keywords/types.ts` (`GscTracking.page`, `Keyword.gscByCountry`), `src/lib/keywords/queries.ts` (mapper `toKeyword`), `src/lib/keywords/fixtures.ts` + tout constructeur littéral de `Keyword` que tsc signale
- Modify: `src/lib/gsc/sync.ts` (+ param `extras`), `src/lib/gsc/sync.test.ts` (nouveaux cas)
- Migration générée : `drizzle/0021_*.sql`

- [ ] **Step 1: schema + migration**

Dans `domain-schema.ts`, table `keywords`, à côté de la colonne `gsc` existante (cherche-la) :

```ts
gscByCountry: jsonb("gsc_by_country")
  .$type<Partial<Record<string, import("@/lib/keywords/types").GscTracking>>>()
  .notNull()
  .default({}),
```

Puis `bun run db:generate` (vérifier que le SQL généré est UN SEUL `ALTER TABLE "keywords" ADD COLUMN`), `bun run db:migrate`, `bun run db:migrate:test`. JAMAIS `db:push`.

- [ ] **Step 2: types + mapping**

`src/lib/keywords/types.ts` :
- `GscTracking` gagne `page?: string | null; // URL qui ranke (dimension query×page)`
- `Keyword` gagne `gscByCountry: Partial<Record<string, GscTracking>>;` (clé = alpha-2)

`src/lib/keywords/queries.ts` (`toKeyword`) : `gscByCountry: r.gscByCountry ?? {},`. Lancer `bunx tsc --noEmit` et corriger chaque constructeur littéral signalé (fixtures, tests) en ajoutant `gscByCountry: {}`.

- [ ] **Step 3: tests sync qui échouent**

Dans `src/lib/gsc/sync.test.ts` (lis-le d'abord — il est DB-gated `DB_TESTS=1`, pattern du projet), ajouter :

```ts
test("extras.byCountry : overlay par pays sur les matchés seulement, merge préservant", async () => {
  // seed : keyword existant "audit" + aucun keyword "inconnu"
  // 1er sync avec byCountry = { FR: [row("audit", pos 4)], BE: [row("audit", pos 9)] }
  // → gsc_by_country = { FR: {...4}, BE: {...9} } sur "audit" ; rien créé pour "inconnu"
  // 2e sync avec byCountry = { FR: [row("audit", pos 3)] }
  // → FR mis à jour (3), BE préservé (9)
});

test("extras.topPageByQuery : gsc.page posé sur les matchés", async () => {
  // sync avec topPageByQuery = Map([["audit", "https://a.fr/p"]])
  // → keywords.gsc.page === "https://a.fr/p" ; un matché sans entrée → page null
});

test("sans extras : comportement identique (gsc_by_country intact)", async () => {
  // sync simple → gsc_by_country reste {}
});
```

Écris les vrais corps en suivant les helpers du fichier existant (insertion site/keyword, appel `syncGscForSite`). Run → FAIL.

- [ ] **Step 4: implémenter `sync.ts`**

Signature :

```ts
export type GscSyncExtras = {
  /** alpha-2 → lignes query du pays (appel filtré country). */
  byCountry?: Record<string, GscQueryRow[]>;
  /** queryLower → URL qui ranke (réduction topPageByQuery). */
  topPageByQuery?: Map<string, string>;
};

export async function syncGscForSite(
  siteId: string,
  rows: GscQueryRow[],
  extras: GscSyncExtras = {},
): Promise<GscSyncResult> {
```

Dans le passage matched existant, étendre le `gsc` posé :

```ts
const gsc: GscTracking = {
  clicks: row.clicks,
  impressions: row.impressions,
  ctr: row.ctr,
  position: row.position,
  updatedAt: now,
  page: extras.topPageByQuery?.get(row.query.toLowerCase()) ?? null,
};
```

Après la transaction existante, second passage pays (dans la même transaction de préférence — déplace si simple, sinon une transaction dédiée) :

```ts
// Overlay par pays — matchés seulement, merge jsonb préservant les autres pays.
const byCountry = extras.byCountry ?? {};
for (const [country, countryRows] of Object.entries(byCountry)) {
  for (const row of countryRows) {
    const found = byTerm.get(row.query.toLowerCase());
    if (!found) continue;
    const tracking: GscTracking = {
      clicks: row.clicks,
      impressions: row.impressions,
      ctr: row.ctr,
      position: row.position,
      updatedAt: now,
    };
    await tx
      .update(keywordsTable)
      .set({
        gscByCountry: sql`${keywordsTable.gscByCountry} || ${JSON.stringify({ [country]: tracking })}::jsonb`,
      })
      .where(eq(keywordsTable.id, found.id));
  }
}
```

(Optimisation acceptable : regrouper les pays par keyword et faire un seul update par keyword — fais-le si direct, sinon garde la boucle simple ; volumes bornés.) Import `sql` de drizzle-orm.

- [ ] **Step 5: vérifier** — `bun run test src/lib/gsc` (anciens tests sync inchangés et verts + nouveaux), `bun run test src/lib/keywords`, `bunx tsc --noEmit`, biome sur les fichiers touchés.

- [ ] **Step 6: commit**

```bash
git add -A drizzle src/lib/db/domain-schema.ts src/lib/keywords src/lib/gsc/sync.ts src/lib/gsc/sync.test.ts
git commit -m "feat(gsc): sync — overlay par pays (gsc_by_country, migration 0021) + page qui ranke (gsc.page)"
```

---

### Task 3: Orchestration de l'action + client REST

**Files:**
- Modify: `src/lib/gsc/search-analytics.ts`, `src/lib/gsc/actions.ts`

- [ ] **Step 1: client REST**

`querySearchAnalytics` gagne `opts.countryIso3?: string` :

```ts
body: JSON.stringify({
  startDate: isoDaysAgo(days),
  endDate: isoDaysAgo(0),
  dimensions: ["query"],
  rowLimit: opts.rowLimit ?? 1000,
  ...(opts.countryIso3
    ? {
        dimensionFilterGroups: [
          {
            filters: [
              {
                dimension: "country",
                operator: "equals",
                expression: opts.countryIso3,
              },
            ],
          },
        ],
      }
    : {}),
}),
```

Nouvelle fonction (même fichier) :

```ts
/** Query GSC searchAnalytics, dimensions=[query, page]. */
export async function querySearchAnalyticsPages(
  accessToken: string,
  property: string,
  opts: { days?: number; rowLimit?: number } = {},
): Promise<GscQueryPageRow[]> {
  const days = opts.days ?? 28;
  const url = `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`;
  const res = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      startDate: isoDaysAgo(days),
      endDate: isoDaysAgo(0),
      dimensions: ["query", "page"],
      rowLimit: opts.rowLimit ?? 1000,
    }),
  });
  if (!res.ok) {
    throw new Error(`GSC searchAnalytics (pages) failed: HTTP ${res.status}`);
  }
  return parseSearchAnalyticsPages(await res.json());
}
```

- [ ] **Step 2: orchestration dans `syncGscKeywordsAction`** (`src/lib/gsc/actions.ts`, lis le bloc existant lignes ~113-145)

Après le fetch global réussi (qui reste bloquant), avant `syncGscForSite` :

```ts
// Dimensions supplémentaires — best-effort : un échec pays/pages ne bloque pas le sync global.
const [siteRow] = await db
  .select({ countries: sitesTable.targetCountries })
  .from(sitesTable)
  .where(eq(sitesTable.id, siteId));
const byCountry: Record<string, GscQueryRow[]> = {};
let countriesFailed = 0;
for (const alpha2 of siteRow?.countries ?? []) {
  const iso3 = alpha2ToIso3(alpha2);
  if (!iso3) {
    countriesFailed++;
    continue;
  }
  try {
    byCountry[alpha2] = await querySearchAnalytics(token, site.gscProperty, {
      days: 28,
      rowLimit: 25000,
      countryIso3: iso3,
    });
  } catch {
    countriesFailed++;
  }
}
let pageMap: Map<string, string> | undefined;
try {
  pageMap = topPageByQuery(
    await querySearchAnalyticsPages(token, site.gscProperty, {
      days: 28,
      rowLimit: 25000,
    }),
  );
} catch {
  pageMap = undefined;
}

const { matched, created, queries } = await syncGscForSite(siteId, rows, {
  byCountry,
  topPageByQuery: pageMap,
});
```

Étendre `SyncGscResult` (ok:true) avec `countries: { synced: number; failed: number }` et `pages: boolean` et les renseigner (`synced = Object.keys(byCountry).length`, `pages = pageMap != null`). Adapter le(s) caller(s) UI si le type est consommé (cherche `SyncGscResult` / `syncGscKeywordsAction` dans `src/components` — le message peut rester tel quel ; ne casse pas le contrat existant, ajoute seulement).

- [ ] **Step 3: vérifier** — `bunx tsc --noEmit`, `bun run test src/lib/gsc`, biome.

- [ ] **Step 4: commit**

```bash
git add src/lib/gsc/search-analytics.ts src/lib/gsc/actions.ts <ui si touché>
git commit -m "feat(gsc): action de sync — appels par pays cible + query×page (best-effort)"
```

---

### Task 4: UI — matrice, keyword sheet, online

**Files:**
- Modify: `src/components/keywords/research/coverage-matrix.tsx`
- Modify: `src/components/keywords/keyword-sheet.tsx`
- Modify: `src/lib/content/online.ts` (`OnlineRow.gscPage`), `src/lib/content/queries.ts` (mapping), `src/components/content/online/online-table.tsx` (badge mismatch) + test pur mismatch
- Create: `src/lib/content/online-mismatch.ts` + test (ou intègre dans `online.ts` — suis le style du fichier)

- [ ] **Step 1: matrice — position du pays sous le badge**

Dans `coverage-matrix.tsx`, le composant `Cell` gagne `position?: number` et l'affiche sous le badge :

```tsx
{status ? <TargetStatusBadge status={status} /> : <span className="text-muted-foreground">—</span>}
{position != null ? (
  <span className="text-muted-foreground text-xs tabular-nums">
    p. {position.toFixed(1)}
  </span>
) : null}
```

(passe le bouton en `flex-col items-center gap-0.5` si nécessaire pour empiler proprement). Au call-site :

```tsx
<Cell
  status={targets[k.id]?.[code]}
  position={k.gscByCountry?.[code]?.position}
  …
/>
```

- [ ] **Step 2: keyword sheet — section « GSC par pays » + page qui ranke**

Lis `src/components/keywords/keyword-sheet.tsx` et insère (après la section métriques/GSC existante, en suivant le style du fichier) :

```tsx
{Object.keys(keyword.gscByCountry ?? {}).length > 0 ? (
  <div className="space-y-1">
    <p className="font-medium text-sm">GSC par pays</p>
    {Object.entries(keyword.gscByCountry).map(([code, t]) =>
      t ? (
        <div key={code} className="flex items-center gap-3 text-sm tabular-nums">
          <span title={code}>{countryFlag(code)}</span>
          <span>pos. {t.position.toFixed(1)}</span>
          <span className="text-muted-foreground">{t.clicks} clics</span>
          <span className="text-muted-foreground">{t.impressions} impr.</span>
        </div>
      ) : null,
    )}
  </div>
) : null}
{keyword.gsc?.page ? (
  <p className="truncate text-sm">
    Page qui ranke :{" "}
    <a
      href={keyword.gsc.page}
      target="_blank"
      rel="noreferrer"
      className="text-muted-foreground underline"
    >
      {keyword.gsc.page}
    </a>
  </p>
) : null}
```

(`countryFlag` existe : `@/lib/keywords/country-flag`.)

- [ ] **Step 3: online — page réelle + badge mismatch (TDD du mismatch)**

Test pur d'abord (place-le selon le style : `src/lib/content/online.test.ts` existe) :

```ts
test("gscPageMismatch: normalisation (slash final, host case), null-safe", () => {
  expect(gscPageMismatch("https://a.fr/p/", "https://A.fr/p")).toBe(false);
  expect(gscPageMismatch("https://a.fr/p", "https://a.fr/autre")).toBe(true);
  expect(gscPageMismatch(null, "https://a.fr/p")).toBe(false);
  expect(gscPageMismatch("https://a.fr/p", null)).toBe(false);
});
```

Implémentation dans `src/lib/content/online.ts` (réutilise `normalizeUrl` de `@/lib/content/stats-logic` si compatible — lis-la ; sinon normalisation locale trim slash + lowercase host) :

```ts
/** true quand la page qui ranke (GSC) diffère de l'URL de l'article. */
export function gscPageMismatch(
  articleUrl: string | null,
  gscPage: string | null | undefined,
): boolean {
  if (!articleUrl || !gscPage) return false;
  return normalizeUrl(articleUrl) !== normalizeUrl(gscPage);
}
```

`OnlineRow` gagne `gscPage: string | null`. Dans `src/lib/content/queries.ts` (`getPublishedContentForSite`), le mapping ajoute `gscPage: r.gsc?.page ?? null` (le select récupère déjà `gsc`). Dans `online-table.tsx`, sur la ligne, quand `gscPageMismatch(row.url, row.gscPage)` :

```tsx
<Badge variant="outline" title={row.gscPage ?? undefined}>
  ⚠️ autre page ranke
</Badge>
```

(placement : colonne URL ou à côté du titre — suis la structure du tableau.)

- [ ] **Step 4: vérifier** — `bunx tsc --noEmit`, `bun run test src/lib/content`, `bun run test src/lib/keywords`, `bun run test:all`, biome sur les fichiers touchés.

- [ ] **Step 5: commit**

```bash
git add src/components/keywords/research/coverage-matrix.tsx src/components/keywords/keyword-sheet.tsx src/lib/content src/components/content/online
git commit -m "feat(gsc): UI pays+page — positions par pays (matrice, sheet), page qui ranke (sheet, online)"
```

---

### Task 5: Vérification live + docs + Linear (contrôleur — pas de subagent)

- [ ] Sync GSC live sur Acieb (d3k — vérifier le site actif) : bouton « Synchroniser GSC » sur Recherche → DB `gsc_by_country ? 'FR'`, `gsc->>'page'` rempli ; matrice (vue Matrice) avec « p. X.X » ; sheet « GSC par pays » + page qui ranke ; online si articles publiés.
- [ ] `docs/project/roadmap.md` : ligne Keywords « GSC dimension page/pays (ex-slice 5) » → ✅.
- [ ] Linear ELEVA-57 → Done + commentaire.
- [ ] Review finale de branche, ff-merge `dev`, push, suppression branche.
