# GSC Custom-Domain Index Visibility 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:** Add an at-a-glance "Custom Domain Indexing" rollup card to `/admin/gsc` so admins can confirm custom-domain dealers are getting indexed by Google, even though those properties are invisible in Google's own Search Console UI.

**Architecture:** A pure classifier function buckets each active-custom-domain dealer (using URL-Inspection data the daily audit already writes) into Indexed / Settling / Needs attention / Never inspected. The existing admin-guarded `GET /api/admin/gsc/summary` route is extended to fetch the cohort, classify it, and return counts + capped triage lists. A new presentational component renders the card; clicking a flagged row reuses the existing `DealerDetailModal`. No new tables, no new Google API calls, no migration, no alerting.

**Tech Stack:** Next.js 16 App Router, React 19, Prisma 7, TypeScript, Jest (`jest-environment-jsdom` default; `@jest-environment node` docblock for route tests), `@testing-library/react`.

**Spec:** `docs/superpowers/specs/2026-05-15-gsc-custom-domain-index-visibility-design.md`

**Decisions with no code (recorded for the implementer):**
- *No separate GSC account / no sharding* — single service account at ~205/1000; nothing to build.
- *Invisible by design* — custom-domain properties stay service-account-owned; we deliberately do **not** delegate ownership to any human account. There is no task for this; it is the *absence* of owner-delegation code.
- *Alerting deferred* — there are intentionally **no** email/anomaly tasks here. Do not add any.

**Branch:** Work on `feature/gsc-indexing` (extends PR #849). Commits per task; **do not push** — the user controls when PR #849 updates.

**Task 4 is optional and independently skippable** (the `propertyHeadroom` headroom line). It touches no files Tasks 1–3/5–7 depend on. If the user opts to drop it at review, skip Task 4 entirely and omit the `propertyHeadroom` prop in Task 5/6 — nothing else changes.

---

## File Structure

| File | Responsibility | Tasks |
|---|---|---|
| `lib/gsc-constants.ts` | Add settling-window + list-cap constants (Task 1); property-cap constants (Task 4) | 1, 4 |
| `lib/gsc-index-buckets.ts` (new) | Pure, I/O-free bucket classifier — the load-bearing logic | 2 |
| `lib/__tests__/gsc-index-buckets.test.ts` (new) | RGR boundary tests for the classifier | 2 |
| `app/api/admin/gsc/summary/route.ts` | Extend response with `customDomainIndexing` (Task 3) + `propertyHeadroom` (Task 4) | 3, 4 |
| `app/api/admin/gsc/summary/__tests__/route.test.ts` | Extend route tests | 3, 4 |
| `app/admin/gsc/CustomDomainIndexing.tsx` (new) | Presentational card (counts, expandable lists, row→callback) | 5 |
| `app/admin/gsc/__tests__/CustomDomainIndexing.test.tsx` (new) | Component behavior tests | 5 |
| `app/admin/gsc/page.module.css` | New card/list/severity styles | 5 |
| `app/admin/gsc/page.tsx` | Wire data → card → `DealerDetailModal` | 6 |
| `app/admin/gsc/__tests__/page.test.tsx` (new) | Glue test: fetch → card → modal opens with dealerId | 6 |

---

## Task 1: Add settling/list constants

**Files:**
- Modify: `lib/gsc-constants.ts`

- [ ] **Step 1: Append the two constants**

Add to the end of `lib/gsc-constants.ts` (after the `QUOTA_HIGH_THRESHOLD` export):

```ts

/**
 * Custom-domain index rollup: a `not_indexed` dealer whose sitemap was
 * submitted within this many days is bucketed as "settling" (Google simply
 * hasn't crawled it yet) rather than "needs attention". Display threshold
 * only — no alerting is attached. See
 * docs/superpowers/specs/2026-05-15-gsc-custom-domain-index-visibility-design.md
 */
export const CUSTOM_DOMAIN_INDEX_SETTLING_DAYS = 14;

/** Max rows returned per triage list (needs-attention / never-inspected) in the summary API. */
export const CUSTOM_DOMAIN_LIST_CAP = 50;
```

- [ ] **Step 2: Type-check**

Run: `npx tsc --noEmit`
Expected: no new errors (pre-existing repo errors, if any, unchanged).

- [ ] **Step 3: Commit**

```bash
git add lib/gsc-constants.ts
git commit -m "feat(gsc): settling-window + list-cap constants for custom-domain rollup"
```

---

## Task 2: Pure bucket classifier (RGR)

**Files:**
- Create: `lib/gsc-index-buckets.ts`
- Test: `lib/__tests__/gsc-index-buckets.test.ts`

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

Create `lib/__tests__/gsc-index-buckets.test.ts`:

```ts
import { classifyCustomDomainIndex } from '../gsc-index-buckets';

const NOW = new Date('2026-05-15T00:00:00.000Z');
const daysAgo = (n: number) => new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000);
const SETTLING = 14;

describe('classifyCustomDomainIndex', () => {
  it('returns neverInspected when never inspected, regardless of other fields', () => {
    // Falsely-passes guard: an impl that only reads gscLastIndexStatus mis-buckets
    // this as needsAttention; asserting neverInspected forces the null-inspected check first.
    expect(
      classifyCustomDomainIndex(
        { gscLastInspectedAt: null, gscLastIndexStatus: 'not_indexed', gscLastSubmittedAt: daysAgo(99) },
        NOW, SETTLING,
      ),
    ).toBe('neverInspected');
  });

  it('returns indexed when status is indexed', () => {
    // Falsely-passes guard: a stub returning a single constant fails at least one of these cases.
    expect(
      classifyCustomDomainIndex(
        { gscLastInspectedAt: daysAgo(1), gscLastIndexStatus: 'indexed', gscLastSubmittedAt: daysAgo(40) },
        NOW, SETTLING,
      ),
    ).toBe('indexed');
  });

  it('buckets not_indexed submitted 13d ago (inside 14d window) as settling', () => {
    // Falsely-passes guard: an impl that buckets ALL not_indexed as needsAttention fails this.
    expect(
      classifyCustomDomainIndex(
        { gscLastInspectedAt: daysAgo(1), gscLastIndexStatus: 'not_indexed', gscLastSubmittedAt: daysAgo(13) },
        NOW, SETTLING,
      ),
    ).toBe('settling');
  });

  it('buckets not_indexed submitted 15d ago (outside 14d window) as needsAttention', () => {
    // Falsely-passes guard: an impl that buckets ALL not_indexed as settling fails this.
    expect(
      classifyCustomDomainIndex(
        { gscLastInspectedAt: daysAgo(1), gscLastIndexStatus: 'not_indexed', gscLastSubmittedAt: daysAgo(15) },
        NOW, SETTLING,
      ),
    ).toBe('needsAttention');
  });

  it('treats the exact boundary (submitted exactly 14d ago) as settling', () => {
    // Falsely-passes guard: an off-by-one (> instead of >=) flips this; pins the boundary.
    expect(
      classifyCustomDomainIndex(
        { gscLastInspectedAt: daysAgo(1), gscLastIndexStatus: 'not_indexed', gscLastSubmittedAt: daysAgo(14) },
        NOW, SETTLING,
      ),
    ).toBe('settling');
  });

  it('returns needsAttention for error status regardless of recency', () => {
    // Falsely-passes guard: an age-only impl would treat a fresh error as fine; pins error→needsAttention.
    expect(
      classifyCustomDomainIndex(
        { gscLastInspectedAt: daysAgo(1), gscLastIndexStatus: 'error', gscLastSubmittedAt: daysAgo(1) },
        NOW, SETTLING,
      ),
    ).toBe('needsAttention');
  });

  it('returns needsAttention for not_indexed with no submission recorded', () => {
    // Falsely-passes guard: an impl assuming gscLastSubmittedAt is always set crashes or mis-buckets.
    expect(
      classifyCustomDomainIndex(
        { gscLastInspectedAt: daysAgo(1), gscLastIndexStatus: 'not_indexed', gscLastSubmittedAt: null },
        NOW, SETTLING,
      ),
    ).toBe('needsAttention');
  });

  it('returns needsAttention for the inspected-but-null-status data anomaly (catch-all)', () => {
    // Falsely-passes guard: a positive-predicate impl leaves this in a classification gap;
    // the catch-all is what surfaces the anomaly to a human.
    expect(
      classifyCustomDomainIndex(
        { gscLastInspectedAt: daysAgo(1), gscLastIndexStatus: null, gscLastSubmittedAt: daysAgo(1) },
        NOW, SETTLING,
      ),
    ).toBe('needsAttention');
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx jest lib/__tests__/gsc-index-buckets.test.ts --no-coverage`
Expected: FAIL — `Cannot find module '../gsc-index-buckets'` resolves after Step 3; at this point the suite fails to run because the module does not exist. (This is the expected red; the module is created next.)

- [ ] **Step 3: Write minimal implementation**

Create `lib/gsc-index-buckets.ts`:

```ts
/**
 * Pure classification of a custom-domain dealer's GSC index state into one of
 * four display buckets. No I/O. Predicates are evaluated top-down, first match
 * wins, with needsAttention as the catch-all so the inspected-but-null-status
 * data anomaly is surfaced rather than dropped into a gap. See
 * docs/superpowers/specs/2026-05-15-gsc-custom-domain-index-visibility-design.md
 */
export type IndexBucket = 'indexed' | 'settling' | 'needsAttention' | 'neverInspected';

export interface ClassifiableDealer {
  gscLastIndexStatus: string | null;
  gscLastInspectedAt: Date | null;
  gscLastSubmittedAt: Date | null;
}

const DAY_MS = 24 * 60 * 60 * 1000;

export function classifyCustomDomainIndex(
  dealer: ClassifiableDealer,
  now: Date,
  settlingDays: number,
): IndexBucket {
  if (dealer.gscLastInspectedAt == null) return 'neverInspected';
  if (dealer.gscLastIndexStatus === 'indexed') return 'indexed';
  if (
    dealer.gscLastIndexStatus === 'not_indexed' &&
    dealer.gscLastSubmittedAt != null &&
    dealer.gscLastSubmittedAt.getTime() >= now.getTime() - settlingDays * DAY_MS
  ) {
    return 'settling';
  }
  return 'needsAttention';
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx jest lib/__tests__/gsc-index-buckets.test.ts --no-coverage`
Expected: PASS — 8 passed.

- [ ] **Step 5: Type-check**

Run: `npx tsc --noEmit`
Expected: no new errors.

- [ ] **Step 6: Commit**

```bash
git add lib/gsc-index-buckets.ts lib/__tests__/gsc-index-buckets.test.ts
git commit -m "feat(gsc): pure custom-domain index bucket classifier"
```

---

## Task 3: Extend summary API with `customDomainIndexing` (RGR)

**Files:**
- Modify: `app/api/admin/gsc/summary/route.ts`
- Test: `app/api/admin/gsc/summary/__tests__/route.test.ts` (full rewrite — adds `prisma.dealer` to the mock, keeps existing tests green, adds new tests)

- [ ] **Step 1: Write the failing test (replace the whole test file)**

Replace the entire contents of `app/api/admin/gsc/summary/__tests__/route.test.ts` with:

```ts
/** @jest-environment node */
import { GET } from '../route';
import { NextRequest } from 'next/server';

jest.mock('@/lib/admin-auth', () => ({ requireAdmin: jest.fn() }));
jest.mock('@/lib/prisma', () => ({
  prisma: {
    gscJob: { count: jest.fn(), findMany: jest.fn() },
    gscQuotaUsage: { findUnique: jest.fn() },
    dealer: { findMany: jest.fn() },
  },
}));
import { requireAdmin } from '@/lib/admin-auth';
import { prisma } from '@/lib/prisma';

const makeReq = () => new NextRequest('http://localhost/api/admin/gsc/summary');

// Fixed clock so the 14-day settling boundary in the route is deterministic.
const NOW = new Date('2026-05-15T12:00:00.000Z');
const daysAgo = (n: number) => new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000);

function asAdmin() {
  (requireAdmin as jest.Mock).mockResolvedValue({ session: { user: { id: 'a' } } });
}
function stubQueueZero() {
  (prisma.gscJob.count as jest.Mock).mockResolvedValue(0);
  (prisma.gscQuotaUsage.findUnique as jest.Mock).mockResolvedValue(null);
  (prisma.gscJob.findMany as jest.Mock).mockResolvedValue([]);
}

describe('GET /api/admin/gsc/summary', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    jest.useFakeTimers().setSystemTime(NOW);
    (prisma.dealer.findMany as jest.Mock).mockResolvedValue([]);
  });
  afterEach(() => jest.useRealTimers());

  it('rejects non-admin requests', async () => {
    (requireAdmin as jest.Mock).mockResolvedValue({
      response: new Response(null, { status: 401 }),
    });
    const res = await GET(makeReq());
    expect(res.status).toBe(401);
  });

  it('returns todayQuotaUsed, queueCounts, and recentFailures for admin', async () => {
    asAdmin();
    (prisma.gscJob.count as jest.Mock)
      .mockResolvedValueOnce(12)
      .mockResolvedValueOnce(3)
      .mockResolvedValueOnce(847)
      .mockResolvedValueOnce(4);
    (prisma.gscQuotaUsage.findUnique as jest.Mock).mockResolvedValue({ indexingApiCalls: 47 });
    (prisma.gscJob.findMany as jest.Mock).mockResolvedValue([
      { id: 'jf1', type: 'submit_sitemap', dealerId: 'd1', lastError: 'AUTH_FAILED' },
    ]);

    const res = await GET(makeReq());
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body).toMatchObject({
      todayQuotaUsed: 47,
      queueCounts: { pending: 12, running: 3, completed24h: 847, failed: 4 },
      recentFailures: expect.arrayContaining([
        expect.objectContaining({ id: 'jf1', lastError: 'AUTH_FAILED' }),
      ]),
    });
  });

  it('handles missing quota row as 0', async () => {
    asAdmin();
    stubQueueZero();
    const res = await GET(makeReq());
    const body = await res.json();
    expect(body.todayQuotaUsed).toBe(0);
  });

  it('classifies the active-custom-domain cohort into the four buckets', async () => {
    // Falsely-passes guard: a route that omits customDomainIndexing or hardcodes
    // zeros fails the specific bucket counts below.
    asAdmin();
    stubQueueZero();
    (prisma.dealer.findMany as jest.Mock).mockResolvedValue([
      { id: 'i1', customDomain: 'a.com', gscLastIndexStatus: 'indexed', gscLastInspectedAt: daysAgo(1), gscLastSubmittedAt: daysAgo(40) },
      { id: 's1', customDomain: 'b.com', gscLastIndexStatus: 'not_indexed', gscLastInspectedAt: daysAgo(1), gscLastSubmittedAt: daysAgo(5) },
      { id: 'n1', customDomain: 'c.com', gscLastIndexStatus: 'not_indexed', gscLastInspectedAt: daysAgo(1), gscLastSubmittedAt: daysAgo(30) },
      { id: 'e1', customDomain: 'd.com', gscLastIndexStatus: 'error', gscLastInspectedAt: daysAgo(1), gscLastSubmittedAt: daysAgo(2) },
      { id: 'x1', customDomain: 'e.com', gscLastIndexStatus: null, gscLastInspectedAt: null, gscLastSubmittedAt: null },
    ]);

    const res = await GET(makeReq());
    const body = await res.json();
    expect(body.customDomainIndexing).toMatchObject({
      active: 5,
      indexed: 1,
      settling: 1,
      needsAttention: 2, // c.com (stale not_indexed) + d.com (error)
      neverInspected: 1, // e.com
    });
  });

  it('lists needs-attention dealers oldest-submission-first, capped, with never-inspected separate', async () => {
    // Falsely-passes guard: a route that returns unsorted/unfiltered rows, or puts
    // never-inspected into the needs-attention list, fails the ordering/separation asserts.
    asAdmin();
    stubQueueZero();
    (prisma.dealer.findMany as jest.Mock).mockResolvedValue([
      { id: 'newer', customDomain: 'newer.com', gscLastIndexStatus: 'not_indexed', gscLastInspectedAt: daysAgo(1), gscLastSubmittedAt: daysAgo(20) },
      { id: 'older', customDomain: 'older.com', gscLastIndexStatus: 'not_indexed', gscLastInspectedAt: daysAgo(1), gscLastSubmittedAt: daysAgo(60) },
      { id: 'fresh', customDomain: 'fresh.com', gscLastIndexStatus: null, gscLastInspectedAt: null, gscLastSubmittedAt: null },
    ]);

    const res = await GET(makeReq());
    const body = await res.json();
    expect(body.customDomainIndexing.needsAttentionList.map((r: { dealerId: string }) => r.dealerId))
      .toEqual(['older', 'newer']);
    expect(body.customDomainIndexing.needsAttentionList[0]).toMatchObject({
      dealerId: 'older',
      customDomain: 'older.com',
      indexStatus: 'not_indexed',
    });
    expect(body.customDomainIndexing.neverInspectedList).toEqual([
      { dealerId: 'fresh', customDomain: 'fresh.com' },
    ]);
  });

  it('reports lastSweepAt as the most recent inspection across the cohort', async () => {
    // Falsely-passes guard: a route returning null or the first row's date fails this.
    asAdmin();
    stubQueueZero();
    (prisma.dealer.findMany as jest.Mock).mockResolvedValue([
      { id: 'a', customDomain: 'a.com', gscLastIndexStatus: 'indexed', gscLastInspectedAt: daysAgo(9), gscLastSubmittedAt: daysAgo(40) },
      { id: 'b', customDomain: 'b.com', gscLastIndexStatus: 'indexed', gscLastInspectedAt: daysAgo(2), gscLastSubmittedAt: daysAgo(40) },
    ]);
    const res = await GET(makeReq());
    const body = await res.json();
    expect(body.customDomainIndexing.lastSweepAt).toBe(daysAgo(2).toISOString());
  });

  it('only queries dealers whose customDomainStatus is active', async () => {
    // Falsely-passes guard: a route that omits the cohort filter would index subdomain
    // dealers too; asserting the where-clause pins the cohort.
    asAdmin();
    stubQueueZero();
    await GET(makeReq());
    expect(prisma.dealer.findMany as jest.Mock).toHaveBeenCalledWith(
      expect.objectContaining({ where: { customDomainStatus: 'active' } }),
    );
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx jest app/api/admin/gsc/summary/__tests__/route.test.ts --no-coverage`
Expected: FAIL — the four new tests fail with `expect(received).toMatchObject(...)` / `Cannot read properties of undefined (reading 'map')` because `body.customDomainIndexing` is `undefined`. The three original tests (`rejects non-admin`, `returns todayQuotaUsed...`, `handles missing quota row`) PASS.

- [ ] **Step 3: Write the implementation (replace the whole route file)**

Replace the entire contents of `app/api/admin/gsc/summary/route.ts` with:

```ts
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/admin-auth';
import { prisma } from '@/lib/prisma';
import { classifyCustomDomainIndex } from '@/lib/gsc-index-buckets';
import {
  CUSTOM_DOMAIN_INDEX_SETTLING_DAYS,
  CUSTOM_DOMAIN_LIST_CAP,
} from '@/lib/gsc-constants';

export async function GET(_request: NextRequest) {
  const auth = await requireAdmin();
  if ('response' in auth) return auth.response;

  const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
  const today = new Date();
  today.setUTCHours(0, 0, 0, 0);

  const [pending, running, completed24h, failed, quota, recentFailures, customDomainDealers] =
    await Promise.all([
      prisma.gscJob.count({ where: { status: 'pending' } }),
      prisma.gscJob.count({ where: { status: 'running' } }),
      prisma.gscJob.count({ where: { status: 'completed', updatedAt: { gte: since } } }),
      prisma.gscJob.count({ where: { status: 'failed', updatedAt: { gte: since } } }),
      prisma.gscQuotaUsage.findUnique({ where: { date: today } }),
      prisma.gscJob.findMany({
        where: { status: 'failed', updatedAt: { gte: since } },
        orderBy: { updatedAt: 'desc' },
        take: 20,
        select: {
          id: true,
          type: true,
          dealerId: true,
          lastError: true,
          updatedAt: true,
          attempts: true,
        },
      }),
      prisma.dealer.findMany({
        where: { customDomainStatus: 'active' },
        select: {
          id: true,
          customDomain: true,
          gscLastIndexStatus: true,
          gscLastInspectedAt: true,
          gscLastSubmittedAt: true,
        },
      }),
    ]);

  const now = new Date();
  const counts = { indexed: 0, settling: 0, needsAttention: 0, neverInspected: 0 };
  const needsAttentionList: {
    dealerId: string;
    customDomain: string | null;
    indexStatus: string | null;
    submittedAt: string | null;
    inspectedAt: string | null;
  }[] = [];
  const neverInspectedList: { dealerId: string; customDomain: string | null }[] = [];
  let lastSweepAt: Date | null = null;

  for (const d of customDomainDealers) {
    const bucket = classifyCustomDomainIndex(d, now, CUSTOM_DOMAIN_INDEX_SETTLING_DAYS);
    counts[bucket] += 1;
    if (d.gscLastInspectedAt && (!lastSweepAt || d.gscLastInspectedAt > lastSweepAt)) {
      lastSweepAt = d.gscLastInspectedAt;
    }
    if (bucket === 'needsAttention') {
      needsAttentionList.push({
        dealerId: d.id,
        customDomain: d.customDomain,
        indexStatus: d.gscLastIndexStatus,
        submittedAt: d.gscLastSubmittedAt ? d.gscLastSubmittedAt.toISOString() : null,
        inspectedAt: d.gscLastInspectedAt ? d.gscLastInspectedAt.toISOString() : null,
      });
    } else if (bucket === 'neverInspected') {
      neverInspectedList.push({ dealerId: d.id, customDomain: d.customDomain });
    }
  }

  // Most-overdue first: oldest submission (nulls sort first), then domain for stable ties.
  needsAttentionList.sort((a, b) => {
    const at = a.submittedAt ? Date.parse(a.submittedAt) : 0;
    const bt = b.submittedAt ? Date.parse(b.submittedAt) : 0;
    return at - bt || (a.customDomain ?? '').localeCompare(b.customDomain ?? '');
  });
  neverInspectedList.sort((a, b) =>
    (a.customDomain ?? '').localeCompare(b.customDomain ?? ''),
  );

  return NextResponse.json({
    todayQuotaUsed: quota?.indexingApiCalls ?? 0,
    queueCounts: { pending, running, completed24h, failed },
    recentFailures,
    customDomainIndexing: {
      active: customDomainDealers.length,
      indexed: counts.indexed,
      settling: counts.settling,
      needsAttention: counts.needsAttention,
      neverInspected: counts.neverInspected,
      lastSweepAt: lastSweepAt ? lastSweepAt.toISOString() : null,
      needsAttentionList: needsAttentionList.slice(0, CUSTOM_DOMAIN_LIST_CAP),
      neverInspectedList: neverInspectedList.slice(0, CUSTOM_DOMAIN_LIST_CAP),
    },
  });
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx jest app/api/admin/gsc/summary/__tests__/route.test.ts --no-coverage`
Expected: PASS — all tests pass (3 original + 4 new).

- [ ] **Step 5: Type-check**

Run: `npx tsc --noEmit`
Expected: no new errors.

- [ ] **Step 6: Commit**

```bash
git add app/api/admin/gsc/summary/route.ts app/api/admin/gsc/summary/__tests__/route.test.ts
git commit -m "feat(gsc): summary API returns custom-domain index rollup"
```

---

## Task 4: Add `propertyHeadroom` (OPTIONAL — skip to drop the headroom line)

> The user flagged this as pending confirmation. If they decline at review, **skip this entire task** and skip the `propertyHeadroom` prop in Tasks 5–6. Nothing else depends on it.

**Files:**
- Modify: `lib/gsc-constants.ts`
- Modify: `app/api/admin/gsc/summary/route.ts`
- Test: `app/api/admin/gsc/summary/__tests__/route.test.ts` (add one test)

- [ ] **Step 1: Add the failing test**

Append this test inside the `describe('GET /api/admin/gsc/summary', ...)` block in `app/api/admin/gsc/summary/__tests__/route.test.ts` (after the last `it(...)`):

```ts
  it('reports propertyHeadroom as active-custom-domain count plus the fixed properties', async () => {
    // Falsely-passes guard: a route omitting propertyHeadroom, or counting all dealers
    // instead of the active-custom-domain cohort, fails the exact ownedEstimate below.
    asAdmin();
    stubQueueZero();
    (prisma.dealer.findMany as jest.Mock).mockResolvedValue([
      { id: 'a', customDomain: 'a.com', gscLastIndexStatus: 'indexed', gscLastInspectedAt: daysAgo(1), gscLastSubmittedAt: daysAgo(40) },
      { id: 'b', customDomain: 'b.com', gscLastIndexStatus: 'indexed', gscLastInspectedAt: daysAgo(1), gscLastSubmittedAt: daysAgo(40) },
      { id: 'c', customDomain: 'c.com', gscLastIndexStatus: 'indexed', gscLastInspectedAt: daysAgo(1), gscLastSubmittedAt: daysAgo(40) },
    ]);
    const res = await GET(makeReq());
    const body = await res.json();
    expect(body.propertyHeadroom).toEqual({ ownedEstimate: 3 + 5, cap: 1000 });
  });
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx jest app/api/admin/gsc/summary/__tests__/route.test.ts -t propertyHeadroom --no-coverage`
Expected: FAIL — `expect(received).toEqual(expected)` because `body.propertyHeadroom` is `undefined`.

- [ ] **Step 3: Add the constants**

Append to `lib/gsc-constants.ts`:

```ts

/** Google's hard cap on properties owned by a single Search Console owner (the service account). */
export const GSC_PROPERTY_CAP = 1000;

/** Fixed properties the service account owns besides per-custom-domain ones: 4 sc-domain: + amsoil.aimclear.com. */
export const FIXED_GSC_PROPERTY_COUNT = 5;
```

- [ ] **Step 4: Add the response block**

In `app/api/admin/gsc/summary/route.ts`, extend the constants import:

```ts
import {
  CUSTOM_DOMAIN_INDEX_SETTLING_DAYS,
  CUSTOM_DOMAIN_LIST_CAP,
  FIXED_GSC_PROPERTY_COUNT,
  GSC_PROPERTY_CAP,
} from '@/lib/gsc-constants';
```

And add `propertyHeadroom` as the last key of the `NextResponse.json({ ... })` object (after the `customDomainIndexing` block, inside the same object):

```ts
    propertyHeadroom: {
      ownedEstimate: customDomainDealers.length + FIXED_GSC_PROPERTY_COUNT,
      cap: GSC_PROPERTY_CAP,
    },
```

- [ ] **Step 5: Run test to verify it passes**

Run: `npx jest app/api/admin/gsc/summary/__tests__/route.test.ts --no-coverage`
Expected: PASS — all tests pass (now 8).

- [ ] **Step 6: Type-check & commit**

Run: `npx tsc --noEmit` (expected: no new errors)

```bash
git add lib/gsc-constants.ts app/api/admin/gsc/summary/route.ts app/api/admin/gsc/summary/__tests__/route.test.ts
git commit -m "feat(gsc): summary API reports GSC property headroom (display-only)"
```

---

## Task 5: Presentational rollup component (RGR)

**Files:**
- Create: `app/admin/gsc/CustomDomainIndexing.tsx`
- Modify: `app/admin/gsc/page.module.css` (append styles)
- Test: `app/admin/gsc/__tests__/CustomDomainIndexing.test.tsx`

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

Create `app/admin/gsc/__tests__/CustomDomainIndexing.test.tsx`:

```tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { CustomDomainIndexing, type CustomDomainIndexingData } from '../CustomDomainIndexing';

const base: CustomDomainIndexingData = {
  active: 5,
  indexed: 2,
  settling: 1,
  needsAttention: 1,
  neverInspected: 1,
  lastSweepAt: '2026-05-15T03:04:00.000Z',
  needsAttentionList: [
    {
      dealerId: 'd-na',
      customDomain: 'joessoil.com',
      indexStatus: 'not_indexed',
      submittedAt: '2026-04-20T00:00:00.000Z',
      inspectedAt: '2026-05-15T03:04:00.000Z',
    },
  ],
  neverInspectedList: [{ dealerId: 'd-ni', customDomain: 'freshsoil.com' }],
};

describe('CustomDomainIndexing', () => {
  it('renders the four bucket counts and the active total', () => {
    // Falsely-passes guard: a component that hardcodes labels but not the data values
    // fails because these specific numbers/domains come from props.
    render(<CustomDomainIndexing data={base} onSelectDealer={jest.fn()} />);
    expect(screen.getByText('Custom Domain Indexing')).toBeInTheDocument();
    expect(screen.getByTestId('cd-count-indexed')).toHaveTextContent('2');
    expect(screen.getByTestId('cd-count-settling')).toHaveTextContent('1');
    expect(screen.getByTestId('cd-count-needsAttention')).toHaveTextContent('1');
    expect(screen.getByTestId('cd-count-neverInspected')).toHaveTextContent('1');
    expect(screen.getByText(/5 active/)).toBeInTheDocument();
  });

  it('expands Needs attention by default and collapses Never inspected by default', () => {
    // Falsely-passes guard: swapping the open attributes (or omitting them) fails these asserts.
    render(<CustomDomainIndexing data={base} onSelectDealer={jest.fn()} />);
    expect(screen.getByTestId('cd-needs-attention-details')).toHaveAttribute('open');
    expect(screen.getByTestId('cd-never-inspected-details')).not.toHaveAttribute('open');
  });

  it('invokes onSelectDealer with the dealerId when a needs-attention row is clicked', () => {
    // Falsely-passes guard: a non-interactive list (no button/handler) fails this.
    const onSelect = jest.fn();
    render(<CustomDomainIndexing data={base} onSelectDealer={onSelect} />);
    fireEvent.click(screen.getByRole('button', { name: /joessoil\.com/ }));
    expect(onSelect).toHaveBeenCalledWith('d-na');
  });

  it('renders a sensible zero-state when there are no active custom domains', () => {
    // Falsely-passes guard: a component that always renders lists/headers fails on empty data.
    const empty: CustomDomainIndexingData = {
      active: 0, indexed: 0, settling: 0, needsAttention: 0, neverInspected: 0,
      lastSweepAt: null, needsAttentionList: [], neverInspectedList: [],
    };
    render(<CustomDomainIndexing data={empty} onSelectDealer={jest.fn()} />);
    expect(screen.getByText(/No active custom domains/i)).toBeInTheDocument();
  });

  it('shows the property-headroom line only when propertyHeadroom is provided', () => {
    // Falsely-passes guard: hardcoding the line (or never rendering it) fails one of these.
    const { rerender } = render(
      <CustomDomainIndexing data={base} onSelectDealer={jest.fn()} />,
    );
    expect(screen.queryByText(/GSC properties:/)).not.toBeInTheDocument();
    rerender(
      <CustomDomainIndexing
        data={base}
        onSelectDealer={jest.fn()}
        propertyHeadroom={{ ownedEstimate: 205, cap: 1000 }}
      />,
    );
    expect(screen.getByText(/GSC properties:/)).toHaveTextContent('≈205 / 1000');
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx jest app/admin/gsc/__tests__/CustomDomainIndexing.test.tsx --no-coverage`
Expected: FAIL — `Cannot find module '../CustomDomainIndexing'`.

- [ ] **Step 3: Write the component**

Create `app/admin/gsc/CustomDomainIndexing.tsx`:

```tsx
'use client';

import React from 'react';
import styles from './page.module.css';

export interface CustomDomainIndexingData {
  active: number;
  indexed: number;
  settling: number;
  needsAttention: number;
  neverInspected: number;
  lastSweepAt: string | null;
  needsAttentionList: Array<{
    dealerId: string;
    customDomain: string | null;
    indexStatus: string | null;
    submittedAt: string | null;
    inspectedAt: string | null;
  }>;
  neverInspectedList: Array<{ dealerId: string; customDomain: string | null }>;
}

interface Props {
  data: CustomDomainIndexingData;
  onSelectDealer: (dealerId: string) => void;
  propertyHeadroom?: { ownedEstimate: number; cap: number };
}

function ageDays(iso: string | null): string {
  if (!iso) return '—';
  const days = Math.floor((Date.now() - Date.parse(iso)) / (24 * 60 * 60 * 1000));
  return `${days}d`;
}

function relative(iso: string | null): string {
  if (!iso) return 'never';
  const ms = Date.now() - Date.parse(iso);
  const h = Math.floor(ms / (60 * 60 * 1000));
  if (h < 1) return 'just now';
  if (h < 24) return `${h}h ago`;
  return `${Math.floor(h / 24)}d ago`;
}

export function CustomDomainIndexing({ data, onSelectDealer, propertyHeadroom }: Props) {
  return (
    <div className={styles.card}>
      <h2 className={styles.cardTitle}>
        Custom Domain Indexing <span className={styles.cdActive}>· {data.active} active</span>
      </h2>

      {data.active === 0 ? (
        <p className={styles.emptyState}>
          No active custom domains. (Counts populate after the daily audit has inspected them.)
        </p>
      ) : (
        <>
          <div className={styles.countsGrid}>
            <div className={styles.countCell}>
              <span className={`${styles.countValue} ${styles.countGreen}`} data-testid="cd-count-indexed">
                {data.indexed}
              </span>
              <span className={styles.countLabel}>Indexed</span>
            </div>
            <div className={styles.countCell}>
              <span className={styles.countValue} data-testid="cd-count-settling">
                {data.settling}
              </span>
              <span className={styles.countLabel}>Settling</span>
            </div>
            <div className={styles.countCell}>
              <span
                className={`${styles.countValue} ${data.needsAttention > 0 ? styles.countAmber : ''}`}
                data-testid="cd-count-needsAttention"
              >
                {data.needsAttention}
              </span>
              <span className={styles.countLabel}>Needs attention</span>
            </div>
            <div className={styles.countCell}>
              <span className={styles.countValue} data-testid="cd-count-neverInspected">
                {data.neverInspected}
              </span>
              <span className={styles.countLabel}>Never inspected</span>
            </div>
          </div>

          <p className={styles.cdSweep}>
            Last sweep: {data.lastSweepAt ? relative(data.lastSweepAt) : 'no inspections yet'}
          </p>

          <details className={styles.cdDetails} data-testid="cd-needs-attention-details" open>
            <summary className={styles.cdSummary}>
              Needs attention ({data.needsAttention}) — submitted &gt;14d ago, still not indexed
            </summary>
            {data.needsAttentionList.length === 0 ? (
              <p className={styles.emptyState}>None — every active custom domain is indexed or settling.</p>
            ) : (
              <ul className={styles.cdList}>
                {data.needsAttentionList.map((r) => (
                  <li key={r.dealerId}>
                    <button
                      type="button"
                      className={styles.cdRow}
                      onClick={() => onSelectDealer(r.dealerId)}
                    >
                      <span className={styles.code}>{r.customDomain ?? '—'}</span>
                      <span className={styles.cdRowMeta}>{r.indexStatus ?? 'unknown'}</span>
                      <span className={styles.cdRowMeta}>sub {ageDays(r.submittedAt)}</span>
                      <span className={styles.cdRowMeta}>seen {relative(r.inspectedAt)}</span>
                      <span aria-hidden="true">→</span>
                    </button>
                  </li>
                ))}
              </ul>
            )}
          </details>

          <details className={styles.cdDetails} data-testid="cd-never-inspected-details">
            <summary className={styles.cdSummary}>
              Never inspected ({data.neverInspected})
            </summary>
            {data.neverInspectedList.length === 0 ? (
              <p className={styles.emptyState}>None.</p>
            ) : (
              <ul className={styles.cdList}>
                {data.neverInspectedList.map((r) => (
                  <li key={r.dealerId}>
                    <button
                      type="button"
                      className={styles.cdRow}
                      onClick={() => onSelectDealer(r.dealerId)}
                    >
                      <span className={styles.code}>{r.customDomain ?? '—'}</span>
                      <span aria-hidden="true">→</span>
                    </button>
                  </li>
                ))}
              </ul>
            )}
          </details>

          {propertyHeadroom && (
            <p className={styles.cdHeadroom}>
              GSC properties: ≈{propertyHeadroom.ownedEstimate} / {propertyHeadroom.cap}
            </p>
          )}
        </>
      )}
    </div>
  );
}
```

- [ ] **Step 4: Append component styles**

Append to `app/admin/gsc/page.module.css`:

```css

/* ---- Custom Domain Indexing card ---- */
.cdActive {
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--color-text-secondary, #94a3b8);
}

.countAmber {
  color: #f59e0b;
}

.cdSweep {
  margin: var(--spacing-md, 16px) 0 0;
  font-size: 0.8125rem;
  color: var(--color-text-secondary, #94a3b8);
}

.cdDetails {
  margin-top: var(--spacing-md, 16px);
  border-top: 1px solid rgba(71, 85, 105, 0.2);
  padding-top: var(--spacing-sm, 8px);
}

.cdSummary {
  cursor: pointer;
  font-size: 0.8125rem;
  font-weight: 600;
  color: var(--color-text-primary, #f8fafc);
}

.cdList {
  list-style: none;
  margin: var(--spacing-sm, 8px) 0 0;
  padding: 0;
}

.cdRow {
  display: flex;
  align-items: center;
  gap: var(--spacing-md, 16px);
  width: 100%;
  padding: var(--spacing-sm, 8px) var(--spacing-md, 16px);
  background: rgba(15, 23, 42, 0.4);
  border: none;
  border-radius: 6px;
  margin-bottom: 4px;
  color: var(--color-text-primary, #f8fafc);
  font-size: 0.8125rem;
  text-align: left;
  cursor: pointer;
  transition: background 0.15s;
}

.cdRow:hover {
  background: rgba(59, 130, 246, 0.12);
}

.cdRowMeta {
  font-size: 0.75rem;
  color: var(--color-text-secondary, #94a3b8);
}

.cdRow span:last-child {
  margin-left: auto;
  color: var(--color-text-secondary, #94a3b8);
}

.cdHeadroom {
  margin: var(--spacing-md, 16px) 0 0;
  font-size: 0.75rem;
  color: var(--color-text-secondary, #94a3b8);
  text-align: right;
}
```

- [ ] **Step 5: Run test to verify it passes**

Run: `npx jest app/admin/gsc/__tests__/CustomDomainIndexing.test.tsx --no-coverage`
Expected: PASS — 5 passed.

- [ ] **Step 6: Type-check & commit**

Run: `npx tsc --noEmit` (expected: no new errors)

```bash
git add app/admin/gsc/CustomDomainIndexing.tsx app/admin/gsc/page.module.css app/admin/gsc/__tests__/CustomDomainIndexing.test.tsx
git commit -m "feat(gsc): custom-domain index rollup card component"
```

---

## Task 6: Wire card + modal into the dashboard page (RGR)

**Files:**
- Modify: `app/admin/gsc/page.tsx`
- Test: `app/admin/gsc/__tests__/page.test.tsx`

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

Create `app/admin/gsc/__tests__/page.test.tsx`:

```tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import GscAdminPage from '../page';

// Stub the heavy detail modal so this test verifies WIRING, not the modal's internals.
jest.mock('@/components/admin/DealerDetailModal', () => ({
  DealerDetailModal: ({ isOpen, dealerId }: { isOpen: boolean; dealerId: string | null }) =>
    isOpen ? <div data-testid="detail-modal">modal for {dealerId}</div> : null,
}));

const summary = {
  todayQuotaUsed: 10,
  queueCounts: { pending: 0, running: 0, completed24h: 0, failed: 0 },
  recentFailures: [],
  customDomainIndexing: {
    active: 1,
    indexed: 0,
    settling: 0,
    needsAttention: 1,
    neverInspected: 0,
    lastSweepAt: '2026-05-15T03:04:00.000Z',
    needsAttentionList: [
      {
        dealerId: 'dealer-42',
        customDomain: 'joessoil.com',
        indexStatus: 'not_indexed',
        submittedAt: '2026-04-20T00:00:00.000Z',
        inspectedAt: '2026-05-15T03:04:00.000Z',
      },
    ],
    neverInspectedList: [],
  },
};

beforeEach(() => {
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    status: 200,
    json: async () => summary,
  }) as unknown as typeof fetch;
});
afterEach(() => jest.restoreAllMocks());

describe('GscAdminPage — custom-domain card wiring', () => {
  it('renders the rollup card from fetched summary data', async () => {
    // Falsely-passes guard: a page that never renders CustomDomainIndexing fails this.
    render(<GscAdminPage />);
    expect(await screen.findByText('Custom Domain Indexing')).toBeInTheDocument();
    expect(screen.getByTestId('cd-count-needsAttention')).toHaveTextContent('1');
  });

  it('opens DealerDetailModal with the dealerId when a flagged row is clicked', async () => {
    // Falsely-passes guard: wiring that drops the dealerId (or never mounts the modal)
    // fails — the stub only renders when isOpen and echoes the dealerId.
    render(<GscAdminPage />);
    const row = await screen.findByRole('button', { name: /joessoil\.com/ });
    fireEvent.click(row);
    await waitFor(() =>
      expect(screen.getByTestId('detail-modal')).toHaveTextContent('modal for dealer-42'),
    );
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx jest app/admin/gsc/__tests__/page.test.tsx --no-coverage`
Expected: FAIL — `Unable to find an element with the text: Custom Domain Indexing` (the page does not render the card yet).

- [ ] **Step 3: Implement the wiring (four precise edits to `app/admin/gsc/page.tsx`)**

**Edit 3a — imports.** Replace this exact block near the top:

```tsx
import React, { useState, useEffect, useCallback } from 'react';
import styles from './page.module.css';
import { INDEXING_API_DAILY_CAP } from '@/lib/gsc-constants';
```

with:

```tsx
import React, { useState, useEffect, useCallback } from 'react';
import styles from './page.module.css';
import { INDEXING_API_DAILY_CAP } from '@/lib/gsc-constants';
import { CustomDomainIndexing, type CustomDomainIndexingData } from './CustomDomainIndexing';
import { DealerDetailModal } from '@/components/admin/DealerDetailModal';
```

**Edit 3b — extend `SummaryData`.** Replace this exact interface:

```tsx
interface SummaryData {
  todayQuotaUsed: number;
  queueCounts: QueueCounts;
  recentFailures: RecentFailure[];
}
```

with:

```tsx
interface SummaryData {
  todayQuotaUsed: number;
  queueCounts: QueueCounts;
  recentFailures: RecentFailure[];
  customDomainIndexing: CustomDomainIndexingData;
  propertyHeadroom?: { ownedEstimate: number; cap: number };
}
```

**Edit 3c — add modal state.** In `export default function GscAdminPage() {`, replace:

```tsx
  const [data, setData] = useState<SummaryData | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
```

with:

```tsx
  const [data, setData] = useState<SummaryData | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [selectedDealerId, setSelectedDealerId] = useState<string | null>(null);
```

**Edit 3d — render the card + modal.** Replace this exact block:

```tsx
      {data && (
        <>
          <QuotaBar used={data.todayQuotaUsed} />
          <QueueHealth counts={data.queueCounts} />
          <FailureTable failures={data.recentFailures} />
        </>
      )}

      <BackfillSection />
    </div>
  );
}
```

with:

```tsx
      {data && (
        <>
          <QuotaBar used={data.todayQuotaUsed} />
          <QueueHealth counts={data.queueCounts} />
          <CustomDomainIndexing
            data={data.customDomainIndexing}
            onSelectDealer={setSelectedDealerId}
            propertyHeadroom={data.propertyHeadroom}
          />
          <FailureTable failures={data.recentFailures} />
        </>
      )}

      <BackfillSection />

      <DealerDetailModal
        isOpen={selectedDealerId !== null}
        onClose={() => setSelectedDealerId(null)}
        dealerId={selectedDealerId}
      />
    </div>
  );
}
```

> If Task 4 was skipped: keep `propertyHeadroom?` in `SummaryData` and the `propertyHeadroom={data.propertyHeadroom}` prop exactly as written — `data.propertyHeadroom` is simply `undefined` and the component already hides the line when it is absent. No change needed.

- [ ] **Step 4: Run test to verify it passes**

Run: `npx jest app/admin/gsc/__tests__/page.test.tsx --no-coverage`
Expected: PASS — 2 passed.

- [ ] **Step 5: Type-check**

Run: `npx tsc --noEmit`
Expected: no new errors.

- [ ] **Step 6: Commit**

```bash
git add app/admin/gsc/page.tsx app/admin/gsc/__tests__/page.test.tsx
git commit -m "feat(gsc): wire custom-domain rollup card + detail modal into /admin/gsc"
```

---

## Task 7: Full verification

**Files:** none (verification gate — no success claim without this output).

- [ ] **Step 1: Run the full GSC-related test set**

Run: `npx jest gsc-index-buckets gsc/summary gsc/__tests__/CustomDomainIndexing gsc/__tests__/page --no-coverage`
Expected: PASS — all suites green (classifier 8, summary 7 or 8, component 5, page 2).

- [ ] **Step 2: Full type-check**

Run: `npx tsc --noEmit`
Expected: no new errors versus the pre-Task-1 baseline. If the repo had pre-existing errors, confirm the set is unchanged (none added by these files).

- [ ] **Step 3: Lint the changed files**

Run: `npx eslint lib/gsc-index-buckets.ts app/admin/gsc/CustomDomainIndexing.tsx app/admin/gsc/page.tsx app/api/admin/gsc/summary/route.ts`
Expected: clean (no errors). Fix any reported issues, re-run, then amend the relevant task's commit if needed.

- [ ] **Step 4: Confirm scope — no alerting, no migration, no Google calls**

Run: `git diff --stat 46a3a0c..HEAD`
Expected: only the files listed in the File Structure table changed. Confirm there is **no** new migration, **no** new `lib/google/*` call, and **no** email/anomaly code (alerting was explicitly deferred).

- [ ] **Step 5: Report**

State the actual test counts and `tsc`/`eslint` results. Do not claim completion without the Step 1–4 output. Then hand back to the user: the work is committed on `feature/gsc-indexing` but **not pushed** — pushing PR #849 is the user's call.

---

## Self-Review (completed by plan author)

**Spec coverage:**
- Decision 1 (no account/sharding) → no code; recorded in plan intro. ✓
- Decision 2 (invisible by design) → no code (absence of delegation); recorded in plan intro. ✓
- Decision 3 (at-a-glance only; alerting deferred) → card built (Tasks 5–6); zero alerting tasks; Task 7 Step 4 asserts no alerting code added. ✓
- Decision 4 (4-bucket, 14-day cutoff, catch-all) → Task 2 classifier + boundary/anomaly tests. ✓
- Rollup card (counts, lists, default expand/collapse, last sweep, row→modal) → Tasks 5–6. ✓
- Data source (extend summary API, cohort filter, no new tables/Google/quota) → Task 3; Task 7 Step 4 verifies. ✓
- `propertyHeadroom` (pending confirmation, cuttable) → Task 4, isolated and explicitly skippable. ✓
- Constants → Task 1 (+ Task 4 for property constants, kept with the optional work so skipping leaves no dead code). ✓
- Falsely-passes guards on every behavioral test → present in Tasks 2/3/4/5/6. ✓
- Rollout (commits on `feature/gsc-indexing`, inert until audit runs) → branch noted; "inert until audit" is natural (everything reads as Never inspected until the daily audit populates data — itself a useful signal). ✓

**Placeholder scan:** No TBD/TODO/"add error handling"/"similar to Task N". Every code step shows complete code. ✓

**Type consistency:** `IndexBucket` union ('indexed'|'settling'|'needsAttention'|'neverInspected') is consistent across `lib/gsc-index-buckets.ts`, the route's `counts` object keys, and `CustomDomainIndexingData`. Route output keys (`active, indexed, settling, needsAttention, neverInspected, lastSweepAt, needsAttentionList, neverInspectedList`, plus optional `propertyHeadroom {ownedEstimate, cap}`) match `CustomDomainIndexingData` and the page's `SummaryData` extension exactly. List item shapes match between route and component (`needsAttentionList`: `{dealerId, customDomain, indexStatus, submittedAt, inspectedAt}`; `neverInspectedList`: `{dealerId, customDomain}`). `DealerDetailModal` is invoked with exactly its real props `{ isOpen, onClose, dealerId }` (verified against `components/admin/DealerDetailModal.tsx` and the existing call site in `components/admin/DealerTable.tsx`). ✓
