# GSC Positioning — Actionable (Slice 3) — Design

- **Date**: 2026-06-06
- **Status**: approved (design), pending spec review
- **Section**: Google Search Console → keywords / recherche
- **Depends on**: slice 2 (GSC positioning overlay — `keywords.gsc`) on `dev`.

## Goal

Turn the slice-2 GSC overlay (currently a passive badge + tooltip) into an
**actionable prioritization workflow** on keywords → recherche: sort keywords by
their GSC position, filter by positioning band, and surface "quick win"
opportunities (striking-distance keywords with high impression potential).

## Core principle

- **No new data.** Everything derives from `kw.gsc` already synced in slice 2
  (`{ clicks, impressions, ctr, position }`). Band classification + opportunity
  score are **pure functions**.
- **Research-only.** The column, filter, and opportunity sort appear only on
  recherche. The Reservoir view and the shared `columns.tsx` stay untouched.
- **GSC stays a positioning overlay** — vol/kd/cpc columns are unchanged.

## Decisions (from brainstorming)

- **Position bands**: `top10` (≤ 10), `quickwin` (10 < pos ≤ 20, the
  striking-distance band), `beyond` (> 20), `none` (no GSC = `gsc` is null).
- **Quick-win marker**: a ⚡ shown in the Pos. GSC cell only for the `quickwin`
  band.
- **Opportunity score** (for the "Trier par opportunité" sort): in the quickwin
  band only, `impressions × (21 − position)`; `0` otherwise. This rewards both
  high impressions AND proximity to page 1 (pos 11 > pos 20 at equal
  impressions). Out-of-band rows score 0 and sink to the bottom.
- **Filter** "Positionnement GSC": a single-select facet (consistent with
  Thème/Statut/Intent) with 5 options — Tous / Top 10 (≤ 10) / Quick wins
  (11–20) ⚡ / Au-delà (> 20) / Non positionné.
- **Surface**: one sortable "Pos. GSC" column (position value + ⚡), plus a
  toolbar toggle "Trier par opportunité". Clicks/impressions stay in the
  existing badge tooltip — no new visible columns for them.
- **Isolation approach**: extend the shared `FiltersBar` via optional props
  (consistent with the existing `languages?`/`actions?` props) rather than a
  separate `ResearchFiltersBar` wrapper — fewer files, GSC selects stay grouped
  with the other facets.

## Non-goals (YAGNI)

- Persisting sort/filter state across navigation.
- New visible columns for clicks/impressions/CTR (stay in the tooltip).
- Any change to the Reservoir view or shared `columns.tsx`.
- Opportunity scoring outside the quick-win band.
- Per-page / per-country positioning (that is slice 5).

## Data model

No schema change. `Keyword.gsc: GscTracking | null` already exists:

```ts
interface GscTracking {
  clicks: number;
  impressions: number;
  ctr: number;      // 0..1
  position: number; // average position
  updatedAt: string;
}
```

## Components / files

```
src/lib/keywords/gsc-positioning.ts        — NEW: pure band + opportunity helpers
src/lib/keywords/gsc-positioning.test.ts   — NEW: unit tests (boundaries + score)
src/components/keywords/research/research-columns.tsx — add gscPosition + hidden gscOpportunity columns
src/components/keywords/filters-bar.tsx    — optional gscPositionFilter? + extraColumns? props
src/components/keywords/research/research-container.tsx — controlled sorting + opportunity toggle + pass filter/extraColumns
src/components/ui/data-table.tsx           — optional controlled sorting?/onSortingChange? props
```

## Pure module: `gsc-positioning.ts`

```ts
import type { GscTracking } from "./types";

export type GscBand = "top10" | "quickwin" | "beyond" | "none";

/** Classify a keyword's GSC positioning into a band. */
export function gscBand(gsc: GscTracking | null | undefined): GscBand {
  if (!gsc) return "none";
  if (gsc.position <= 10) return "top10";
  if (gsc.position <= 20) return "quickwin";
  return "beyond";
}

/** Striking-distance opportunity: rewards impressions AND proximity to page 1.
 *  Non-quick-win rows score 0 (they sink to the bottom of the opportunity sort). */
export function opportunityScore(gsc: GscTracking | null | undefined): number {
  if (!gsc || gscBand(gsc) !== "quickwin") return 0;
  return gsc.impressions * (21 - gsc.position);
}
```

## Column behavior (`research-columns.tsx`)

- **`gscPosition`** — `accessorFn: (k) => k.gsc?.position ?? null`.
  - Sortable by numeric position; `none` (null) rows sort **last in both
    directions** (custom `sortingFn` that pushes null to the bottom, or
    `sortUndefined: "last"` with the accessor returning `undefined`).
  - Cell: `gsc.position.toFixed(1)` plus a ⚡ when `gscBand === "quickwin"`;
    muted `—` when null.
  - `filterFn`: derive the row's band via `gscBand(row.original.gsc)` and match
    it against the selected facet value (`["top10"|"quickwin"|"beyond"|"none"]`).
  - `enableGlobalFilter: false`.
- **`gscOpportunity`** — hidden, sort-only. `accessorFn: opportunityScore`,
  `enableSorting: true`, not in the Colonnes menu, default not visible.
  TanStack sorts on hidden columns, so the toolbar toggle can target it.

Column placement: inserted into `researchColumns` (not shared `columns.tsx`),
near the metric cluster (after `cpc`/`growthPct`).

## Filter (`filters-bar.tsx`)

- New optional prop `gscPositionFilter?: boolean`. When true, render a `Select`
  "Positionnement GSC" using the existing `setFacet`/`facetValue` helpers,
  writing a `{ id: "gscPosition", value: [band] }` column filter.
  - Options: `all` → "Tous", `top10` → "Top 10 (≤ 10)", `quickwin` →
    "Quick wins (11–20)", `beyond` → "Au-delà (> 20)", `none` →
    "Non positionné".
- New optional prop `extraColumns?: { id: string; label: string }[]`, appended
  to the "Colonnes" dropdown after `TOGGLEABLE_COLUMNS`. Recherche passes
  `[{ id: "gscPosition", label: "Pos. GSC" }]`.
- Reservoir passes neither → unchanged.

## Sorting toggle (`research-container.tsx`)

- Add controlled `sorting` state; pass `sorting`/`onSortingChange` to
  `DataTable`.
- Add a toolbar toggle button "Trier par opportunité" beside "Synchroniser
  GSC". Active → `setSorting([{ id: "gscOpportunity", desc: true }])`; toggling
  off → `setSorting([])`. Reflect active state with the toggle's variant.
- Add `gscPosition` to the recherche default column visibility (visible by
  default; the hidden `gscOpportunity` column is never shown).

## DataTable change (`data-table.tsx`)

- Add optional `sorting?: SortingState` and `onSortingChange?` props. When
  provided, use them; otherwise fall back to the existing internal `useState`.
  Backward compatible — Reservoir and any other caller are unaffected.

## Error / edge handling

- Fractional positions are handled by `≤ 10` / `≤ 20` boundaries.
- No GSC (`gsc` null): band `none`, cell `—`, excluded by Top10/Quick
  wins/Au-delà filters (matched only by "Non positionné"), sorts last,
  opportunity 0.
- Empty reservoir / before first sync: all rows show `—`; the column, filter,
  and toggle still render harmlessly.

## Testing

- **Unit** (`gsc-positioning.test.ts`, pure, no DB):
  - `gscBand`: null → none; 10 → top10; 10.4 → quickwin; 20 → quickwin; 20.1 →
    beyond.
  - `opportunityScore`: 0 for none/top10/beyond; quickwin pos 11 (900 impr) →
    9000 > pos 20 (900 impr) → 900; pos 18 (120 impr) → 360.
- **Manual (d3k)**: owner on recherche with a synced site → sort "Pos. GSC"
  (asc/desc, `—` always last); filter each of the 5 bands; toggle "Trier par
  opportunité" floats high-impression quick-wins to the top; Reservoir view
  unchanged; `d3k errors` clean.

## Open items for the plan

- Confirm `DataTable` prop names and that controlled sorting coexists with the
  existing header sort handlers.
- Confirm the null-last sort behavior choice (custom `sortingFn` vs
  `sortUndefined`) against the installed TanStack version.

## Addendum — Table UX (recherche + reservoir)

Added after the GSC design; applies to BOTH the reservoir and recherche tables
(shared `columns.tsx`), per user request:

- **Country flag in the term cell**: prefix each keyword term with its country
  flag (`locale.country` → regional-indicator emoji via a pure `countryFlag`
  helper). Replaces the **visible** "Langue" column.
- **Remove the "Langue" display column**: drop `lang` from `TOGGLEABLE_COLUMNS`
  so it is no longer user-toggleable. KEEP the `lang` column definition (default
  hidden) so the language-facet filter and reservoir's default-language filter
  keep working — only the visible/toggleable column goes away.
- **Row actions column** (rightmost, hover-revealed): a "Copier" icon
  (`navigator.clipboard.writeText(term)`) and a "Supprimer" icon that opens an
  `AlertDialog` confirmation, then calls `meta.onDeleteKeyword(id)`. Each
  container wires `onDeleteKeyword` to `deleteKeywordsAction(siteId, [id])` +
  reload. Hover reveal requires a `group` class on `DataTable` body rows.
- The flag and actions column appear in both views; the GSC column/filter/sort
  remain recherche-only.
