brackt/docs/superpowers/plans/2026-05-02-phase3-canonical-cutover.md
Chris Parsons 81af8907cc
Add design + 5 phase plans: canonical tournament & participant layer
Spec and phased implementation plans for eliminating data duplication
across overlapping qualifying-points windows (golf, tennis, CS2) by
introducing canonical tournaments, participants, tournament_results,
and surface-Elo tables above the existing per-window layer.

- Phase 1a: rename existing per-window tables to season_* prefix
- Phase 1b: create canonical tables + nullable FKs
- Phase 2:  backfill canonical from existing in-flight windows
- Phase 3:  cutover - sync service, admin UI, simulator read switch
- Phase 4:  cleanup - drop deprecated tables and routes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 05:20:34 +00:00

44 KiB

Phase 3 — Canonical Cutover 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: Make the canonical layer the source of truth. Flip FKs to NOT NULL, implement syncTournamentResults, add the canonical admin result-entry route, switch the tennis simulator's surface-Elo read path to canonical, and convert old per-window result-entry routes to back-compat wrappers.

Architecture: Canonical writes fan out to every window that contains a given tournament. Surface-Elo reads come from canonical. Old routes stay as thin wrappers (call upsertTournamentResult + syncTournamentResults) — removal is Phase 4. A feature flag gates the new admin route until the cutover window.

Tech Stack: TypeScript, React Router 7, Drizzle, Vitest, Cypress.

Prerequisite: Phase 2 complete. Tag phase-2-complete exists. All canonical tables populated in prod.

Reference: docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md — Phase 3 section.


Task 1: Enforce NOT NULL on the two new FK columns

Why: Phase 2 backfilled every row. Now lock it in to prevent future NULLs.

Files:

  • Modify: database/schema.ts

  • Create: drizzle/XXXX_lock_canonical_fks.sql

  • Step 1: Update schema

In database/schema.ts:

// In scoringEvents:
tournamentId: uuid("tournament_id").notNull().references(() => tournaments.id, {
  onDelete: "restrict", // strengthened from set null
}),

// In seasonParticipants:
participantId: uuid("participant_id").notNull().references(() => participants.id, {
  onDelete: "restrict",
}),
  • Step 2: Generate migration

Run: npm run db:generate -- --name=lock_canonical_fks Expected: Migration with two ALTER COLUMN ... SET NOT NULL and FK constraint changes.

  • Step 3: Pre-flight sanity check in prod snapshot

Run:

psql $DATABASE_URL -c "SELECT count(*) FROM scoring_events WHERE tournament_id IS NULL;"
psql $DATABASE_URL -c "SELECT count(*) FROM season_participants WHERE participant_id IS NULL;"

Expected: Both zero. If non-zero for any qualifying_points sport, Phase 2 was incomplete — halt and fix first. For team-sport scoring_events, tournament_id IS NULL is expected because they weren't backfilled; we need to scope the NOT NULL constraint, OR restrict backfill scope.

Scoping decision: Only qualifying-points seasons have tournaments. Team-sport scoring events (NHL playoff games, NBA, etc.) don't map to "tournaments" in our canonical sense.

Change approach: Do not make scoring_events.tournament_id NOT NULL globally. Instead, enforce it via a partial index / check constraint:

Replace the schema edit for scoringEvents.tournamentId:

tournamentId: uuid("tournament_id").references(() => tournaments.id, {
  onDelete: "restrict",
}),

(Stay nullable.)

And append to the table config:

// (t) => ({
//   ... existing index definitions ...
//   qualifyingEventsRequireTournament: check(
//     "scoring_events_qualifying_require_tournament",
//     sql`NOT is_qualifying_event OR tournament_id IS NOT NULL`,
//   ),
// })

Drizzle supports check constraints via sql templating. Adjust imports accordingly.

For seasonParticipants.participantId: every season_participants row belongs to a sports_season, which has a scoring_pattern. We only require it for qualifying_points seasons. Implement as a check constraint using a scalar subquery is awkward in Postgres; instead, enforce in application code at insert time (with a test), and leave the DB column nullable. Document this decision in the schema file as a comment on the column.

  • Step 4: Regenerate migration with the corrected approach

Remove any previous generated migration for this change, then:

Run: npm run db:generate -- --name=add_qualifying_tournament_check Expected: Migration adds the check constraint only.

  • Step 5: Apply and test

Run: npm run db:migrate Verify in psql that the check constraint exists and blocks inserting a scoring_events row with is_qualifying_event=true, tournament_id=NULL.

  • Step 6: Commit
git add database/schema.ts drizzle/
git commit -m "migration: require tournament_id for qualifying scoring_events"

Task 2: Implement syncTournamentResults service (TDD)

Files:

  • Create: app/services/sync-tournament-results.ts

  • Create: app/services/__tests__/sync-tournament-results.test.ts

  • Step 1: Write the first failing test — single window, full roster match

// app/services/__tests__/sync-tournament-results.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { db } from "~/db/context";
import {
  tournaments,
  participants,
  tournamentResults,
  scoringEvents,
  seasonParticipants,
  sportsSeasons,
  sports,
  eventResults,
  qualifyingPointConfig,
} from "database/schema";
import { eq, and } from "drizzle-orm";
import { syncTournamentResults } from "../sync-tournament-results";

async function seedTennisWindowWithWimbledon(): Promise<{
  sportId: string;
  ssId: string;
  wimbledon: { id: string };
  eventId: string;
  canonicalIds: { djokovic: string; alcaraz: string };
  seasonParticipantIds: { djokovic: string; alcaraz: string };
}> {
  const [sport] = await db.insert(sports).values({
    name: "Tennis",
    type: "individual",
    simulatorType: "tennis_qualifying_points",
  }).returning();

  const [canonDjokovic] = await db.insert(participants).values({
    sportId: sport.id,
    name: "Novak Djokovic",
  }).returning();
  const [canonAlcaraz] = await db.insert(participants).values({
    sportId: sport.id,
    name: "Carlos Alcaraz",
  }).returning();

  const [wimbledon] = await db.insert(tournaments).values({
    sportId: sport.id,
    name: "Wimbledon",
    year: 2026,
  }).returning();

  const [ss] = await db.insert(sportsSeasons).values({
    sportId: sport.id,
    year: 2026,
    scoringPattern: "qualifying_points",
    totalMajors: 4,
  }).returning();

  const [scoringEv] = await db.insert(scoringEvents).values({
    sportsSeasonId: ss.id,
    name: "Wimbledon",
    eventDate: "2026-07-12",
    eventType: "major_tournament",
    isQualifyingEvent: true,
    tournamentId: wimbledon.id,
  }).returning();

  const [spDjokovic] = await db.insert(seasonParticipants).values({
    sportsSeasonId: ss.id,
    name: "Novak Djokovic",
    participantId: canonDjokovic.id,
  }).returning();
  const [spAlcaraz] = await db.insert(seasonParticipants).values({
    sportsSeasonId: ss.id,
    name: "Carlos Alcaraz",
    participantId: canonAlcaraz.id,
  }).returning();

  // Seed QP point config: 1st=100, 2nd=50, default 0
  await db.insert(qualifyingPointConfig).values([
    { sportsSeasonId: ss.id, placement: 1, points: "100.00" },
    { sportsSeasonId: ss.id, placement: 2, points: "50.00" },
  ]);

  return {
    sportId: sport.id,
    ssId: ss.id,
    wimbledon: { id: wimbledon.id },
    eventId: scoringEv.id,
    canonicalIds: { djokovic: canonDjokovic.id, alcaraz: canonAlcaraz.id },
    seasonParticipantIds: { djokovic: spDjokovic.id, alcaraz: spAlcaraz.id },
  };
}

describe("syncTournamentResults", () => {
  beforeEach(async () => {
    // Broad wipe order matters (FKs)
    await db.delete(tournamentResults);
    await db.delete(eventResults);
    await db.delete(qualifyingPointConfig);
    await db.delete(seasonParticipants);
    await db.delete(scoringEvents);
    await db.delete(sportsSeasons);
    await db.delete(participants);
    await db.delete(tournaments);
    await db.delete(sports);
  });

  it("single window: creates event_results from tournament_results and processes QP", async () => {
    const seed = await seedTennisWindowWithWimbledon();

    // Enter canonical results
    await db.insert(tournamentResults).values([
      { tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.djokovic, placement: 1 },
      { tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.alcaraz, placement: 2 },
    ]);

    const report = await syncTournamentResults(seed.wimbledon.id);
    expect(report.windowsSynced).toBe(1);
    expect(report.windowsFailed).toBe(0);

    const ers = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, seed.eventId));
    expect(ers).toHaveLength(2);

    const djokER = ers.find((r) => r.seasonParticipantId === seed.seasonParticipantIds.djokovic)!;
    expect(djokER.placement).toBe(1);
    expect(djokER.qualifyingPointsAwarded).toBe("100.00");

    const alcER = ers.find((r) => r.seasonParticipantId === seed.seasonParticipantIds.alcaraz)!;
    expect(alcER.placement).toBe(2);
    expect(alcER.qualifyingPointsAwarded).toBe("50.00");
  });
});
  • Step 2: Run failing test

Run: npm run test:run -- app/services/__tests__/sync-tournament-results.test.ts Expected: FAIL (module missing).

  • Step 3: Implement the service
// app/services/sync-tournament-results.ts
import { db } from "~/db/context";
import {
  tournamentResults,
  scoringEvents,
  seasonParticipants,
  eventResults,
  sportsSeasons,
} from "database/schema";
import { eq, and, inArray } from "drizzle-orm";
import { processQualifyingEvent } from "~/models/scoring-calculator";

export interface SyncReport {
  tournamentId: string;
  windowsSynced: number;
  windowsFailed: number;
  failures: Array<{ scoringEventId: string; sportsSeasonId: string; error: string }>;
}

export async function syncTournamentResults(tournamentId: string): Promise<SyncReport> {
  const report: SyncReport = {
    tournamentId,
    windowsSynced: 0,
    windowsFailed: 0,
    failures: [],
  };

  // Load canonical results for this tournament
  const canonicalResults = await db
    .select()
    .from(tournamentResults)
    .where(eq(tournamentResults.tournamentId, tournamentId));

  // Load every scoring event that points at this tournament
  const linkedEvents = await db
    .select()
    .from(scoringEvents)
    .where(eq(scoringEvents.tournamentId, tournamentId));

  for (const ev of linkedEvents) {
    try {
      await db.transaction(async (tx) => {
        // All season_participants for this window's sports_season
        const rosters = await tx
          .select()
          .from(seasonParticipants)
          .where(eq(seasonParticipants.sportsSeasonId, ev.sportsSeasonId));

        // Upsert event_results for every canonical result where the
        // canonical participant is in the window's roster.
        for (const cr of canonicalResults) {
          const sp = rosters.find((r) => r.participantId === cr.participantId);
          if (!sp) continue;

          const [existing] = await tx
            .select()
            .from(eventResults)
            .where(
              and(
                eq(eventResults.scoringEventId, ev.id),
                eq(eventResults.seasonParticipantId, sp.id),
              ),
            );

          if (existing) {
            await tx
              .update(eventResults)
              .set({
                placement: cr.placement,
                rawScore: cr.rawScore,
                updatedAt: new Date(),
              })
              .where(eq(eventResults.id, existing.id));
          } else {
            await tx.insert(eventResults).values({
              scoringEventId: ev.id,
              seasonParticipantId: sp.id,
              placement: cr.placement,
              rawScore: cr.rawScore,
            });
          }
        }

        // Apply the existing "0-QP filler" pass: every season participant
        // without an eventResults row gets one with placement=null, QP=0.
        // This preserves the behavior from the batch-qualifying-results design.
        const afterRows = await tx
          .select()
          .from(eventResults)
          .where(eq(eventResults.scoringEventId, ev.id));
        const covered = new Set(afterRows.map((r) => r.seasonParticipantId));
        for (const sp of rosters) {
          if (covered.has(sp.id)) continue;
          await tx.insert(eventResults).values({
            scoringEventId: ev.id,
            seasonParticipantId: sp.id,
            placement: null,
            qualifyingPointsAwarded: "0",
          });
        }

        // Delegate to the existing scoring engine (idempotent: replaces QP)
        await processQualifyingEvent(ev.id, { tx });
      });
      report.windowsSynced += 1;
    } catch (e: any) {
      report.windowsFailed += 1;
      report.failures.push({
        scoringEventId: ev.id,
        sportsSeasonId: ev.sportsSeasonId,
        error: String(e?.message ?? e),
      });
    }
  }

  return report;
}

Note: processQualifyingEvent currently does not accept a tx option. A preparatory micro-refactor may be required — see Task 2b below.

  • Step 4: Run the first test

Run: npm run test:run -- app/services/__tests__/sync-tournament-results.test.ts Expected: PASS.

  • Step 5: Commit
git add app/services/
git commit -m "feat: syncTournamentResults service (single-window case)"

Task 2b: Teach processQualifyingEvent to accept an external transaction

Files:

  • Modify: app/models/scoring-calculator.ts:584 (and signature)

  • Modify: app/services/sync-tournament-results.ts (if needed after signature change)

  • Step 1: Read the current signature

Open app/models/scoring-calculator.ts around line 584. Note the current signature (likely async function processQualifyingEvent(scoringEventId: string): Promise<...>).

  • Step 2: Add optional { tx } parameter
export async function processQualifyingEvent(
  scoringEventId: string,
  opts?: { tx?: typeof db },
): Promise<ProcessQualifyingEventResult> {
  const q = opts?.tx ?? db;
  // ... replace every `db.` inside the function body with `q.`
}

If the function is long, extract the body to a helper processQualifyingEventWith(q, scoringEventId) and have the outer function call it.

  • Step 3: Add a targeted test
// app/models/__tests__/scoring-calculator.tx.test.ts (new)
// Verifies that passing a tx that gets rolled back reverts QP writes.
import { describe, it, expect } from "vitest";
import { db } from "~/db/context";
import { processQualifyingEvent } from "../scoring-calculator";
// ... seed, run within a rollback transaction, verify state is unchanged.
  • Step 4: Run the tx test + all existing scoring-calculator tests

Run: npm run test:run -- app/models/__tests__/scoring-calculator Expected: PASS.

  • Step 5: Commit
git add app/models/scoring-calculator.ts app/models/__tests__/scoring-calculator.tx.test.ts
git commit -m "refactor(scoring-calculator): accept optional tx for composability"

Task 3: Sync — two windows, overlapping rosters

Files:

  • Modify: app/services/__tests__/sync-tournament-results.test.ts

  • Step 1: Add failing test

it("two windows sharing a tournament: only matching participants get event_results per window", async () => {
  const base = await seedTennisWindowWithWimbledon();

  // Create a second tennis window (June 2026) that also includes Wimbledon 2026
  // but has a DIFFERENT roster — Alcaraz only (no Djokovic).
  const [ss2] = await db.insert(sportsSeasons).values({
    sportId: base.sportId,
    year: 2026,
    scoringPattern: "qualifying_points",
    totalMajors: 4,
  }).returning();

  const [ev2] = await db.insert(scoringEvents).values({
    sportsSeasonId: ss2.id,
    name: "Wimbledon",
    eventDate: "2026-07-12",
    eventType: "major_tournament",
    isQualifyingEvent: true,
    tournamentId: base.wimbledon.id,
  }).returning();

  const [spAlcaraz2] = await db.insert(seasonParticipants).values({
    sportsSeasonId: ss2.id,
    name: "Carlos Alcaraz",
    participantId: base.canonicalIds.alcaraz,
  }).returning();

  await db.insert(qualifyingPointConfig).values([
    { sportsSeasonId: ss2.id, placement: 1, points: "100.00" },
    { sportsSeasonId: ss2.id, placement: 2, points: "50.00" },
  ]);

  await db.insert(tournamentResults).values([
    { tournamentId: base.wimbledon.id, participantId: base.canonicalIds.djokovic, placement: 1 },
    { tournamentId: base.wimbledon.id, participantId: base.canonicalIds.alcaraz, placement: 2 },
  ]);

  const report = await syncTournamentResults(base.wimbledon.id);
  expect(report.windowsSynced).toBe(2);

  // Window 1: both participants present
  const w1Results = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, base.eventId));
  expect(w1Results).toHaveLength(2);

  // Window 2: only Alcaraz present with real placement; Djokovic is absent (not in roster) → 0-QP filler NOT created for him either
  const w2Results = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, ev2.id));
  const realResults = w2Results.filter((r) => r.placement !== null);
  expect(realResults).toHaveLength(1);
  expect(realResults[0].seasonParticipantId).toBe(spAlcaraz2.id);
});
  • Step 2: Run failing test

Expected: Likely PASS already if the implementation is correct. If it fails, investigate — the service should iterate per linked scoring event and respect each window's roster independently.

  • Step 3: If fail, fix the service

Trace the bug. Common cause: the service cached rosters between loop iterations. Fix.

  • Step 4: Commit
git add app/services/
git commit -m "test: two-window sync respects per-window rosters"

Task 4: Sync — correction path and idempotency

Files:

  • Modify: app/services/__tests__/sync-tournament-results.test.ts

  • Step 1: Add failing tests

it("re-running sync with unchanged results is idempotent", async () => {
  const seed = await seedTennisWindowWithWimbledon();
  await db.insert(tournamentResults).values([
    { tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.djokovic, placement: 1 },
    { tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.alcaraz, placement: 2 },
  ]);
  await syncTournamentResults(seed.wimbledon.id);
  const before = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, seed.eventId));

  await syncTournamentResults(seed.wimbledon.id);
  const after = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, seed.eventId));

  expect(after).toHaveLength(before.length);
  expect(after.map((r) => [r.seasonParticipantId, r.placement, r.qualifyingPointsAwarded]))
    .toEqual(before.map((r) => [r.seasonParticipantId, r.placement, r.qualifyingPointsAwarded]));
});

it("correction flow: editing a tournament_results placement propagates to every window", async () => {
  const seed = await seedTennisWindowWithWimbledon();
  await db.insert(tournamentResults).values([
    { tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.djokovic, placement: 1 },
    { tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.alcaraz, placement: 2 },
  ]);
  await syncTournamentResults(seed.wimbledon.id);

  // Correction: swap placements
  await db.update(tournamentResults)
    .set({ placement: 2 })
    .where(and(eq(tournamentResults.tournamentId, seed.wimbledon.id), eq(tournamentResults.participantId, seed.canonicalIds.djokovic)));
  await db.update(tournamentResults)
    .set({ placement: 1 })
    .where(and(eq(tournamentResults.tournamentId, seed.wimbledon.id), eq(tournamentResults.participantId, seed.canonicalIds.alcaraz)));

  await syncTournamentResults(seed.wimbledon.id);
  const updated = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, seed.eventId));
  const djok = updated.find((r) => r.seasonParticipantId === seed.seasonParticipantIds.djokovic)!;
  const alc = updated.find((r) => r.seasonParticipantId === seed.seasonParticipantIds.alcaraz)!;
  expect(djok.placement).toBe(2);
  expect(djok.qualifyingPointsAwarded).toBe("50.00");
  expect(alc.placement).toBe(1);
  expect(alc.qualifyingPointsAwarded).toBe("100.00");
});
  • Step 2: Run tests

Expected: PASS.

  • Step 3: Commit
git add app/services/
git commit -m "test: sync idempotency and correction propagation"

Task 5: Sync — partial failure isolation

Files:

  • Modify: app/services/__tests__/sync-tournament-results.test.ts

  • Step 1: Add failing test

Test a scenario where one window's sync fails (e.g., qualifyingPointConfig missing for that season) — other windows must still succeed and the failure must be reported without being swallowed.

it("one window failure does not block other windows; failure is reported", async () => {
  const seed = await seedTennisWindowWithWimbledon();

  // Second window WITHOUT qualifyingPointConfig — processQualifyingEvent should error.
  const [ss2] = await db.insert(sportsSeasons).values({
    sportId: seed.sportId, year: 2026, scoringPattern: "qualifying_points", totalMajors: 4,
  }).returning();
  const [ev2] = await db.insert(scoringEvents).values({
    sportsSeasonId: ss2.id, name: "Wimbledon", eventType: "major_tournament",
    isQualifyingEvent: true, tournamentId: seed.wimbledon.id, eventDate: "2026-07-12",
  }).returning();
  await db.insert(seasonParticipants).values({
    sportsSeasonId: ss2.id, name: "Novak Djokovic", participantId: seed.canonicalIds.djokovic,
  });
  // Note: no qualifyingPointConfig rows for ss2 — this should cause processQualifyingEvent to error.

  await db.insert(tournamentResults).values({
    tournamentId: seed.wimbledon.id, participantId: seed.canonicalIds.djokovic, placement: 1,
  });

  const report = await syncTournamentResults(seed.wimbledon.id);
  expect(report.windowsSynced).toBe(1);
  expect(report.windowsFailed).toBe(1);
  expect(report.failures).toHaveLength(1);
  expect(report.failures[0].sportsSeasonId).toBe(ss2.id);

  // First window still processed
  const w1Results = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, seed.eventId));
  expect(w1Results.some((r) => r.qualifyingPointsAwarded === "100.00")).toBe(true);
});
  • Step 2: Run test

Expected: PASS. Per-window transactions in the service already isolate failures; verify behavior matches.

  • Step 3: Commit
git add app/services/
git commit -m "test: sync partial failure isolation"

Task 6: Switch canonical surface-Elo reads in tennis simulator

Files:

  • Modify: app/models/surface-elo.ts

  • Modify: app/services/simulations/tennis-simulator.ts

  • Modify: app/services/simulations/__tests__/tennis-simulator.test.ts (update)

  • Step 1: Update getSurfaceEloMap to read from canonical

In app/models/surface-elo.ts, change the query. Before:

// reads from season_participant_surface_elos keyed by sportsSeasonId

After:

// app/models/surface-elo.ts
import { db } from "~/db/context";
import { seasonParticipants, participants, participantSurfaceElos } from "database/schema";
import { eq } from "drizzle-orm";

export interface SurfaceEloEntry {
  seasonParticipantId: string;
  canonicalParticipantId: string;
  eloHard: number | null;
  eloClay: number | null;
  eloGrass: number | null;
  worldRanking: number | null;
}

export async function getSurfaceEloMap(sportsSeasonId: string): Promise<Map<string, SurfaceEloEntry>> {
  const rows = await db
    .select({
      spId: seasonParticipants.id,
      canonId: participants.id,
      eloHard: participantSurfaceElos.eloHard,
      eloClay: participantSurfaceElos.eloClay,
      eloGrass: participantSurfaceElos.eloGrass,
      worldRanking: participantSurfaceElos.worldRanking,
    })
    .from(seasonParticipants)
    .innerJoin(participants, eq(seasonParticipants.participantId, participants.id))
    .leftJoin(participantSurfaceElos, eq(participantSurfaceElos.participantId, participants.id))
    .where(eq(seasonParticipants.sportsSeasonId, sportsSeasonId));

  const map = new Map<string, SurfaceEloEntry>();
  for (const r of rows) {
    map.set(r.spId, {
      seasonParticipantId: r.spId,
      canonicalParticipantId: r.canonId,
      eloHard: r.eloHard,
      eloClay: r.eloClay,
      eloGrass: r.eloGrass,
      worldRanking: r.worldRanking,
    });
  }
  return map;
}
  • Step 2: Update tennis-simulator to consume the new return shape (if changed)

If SurfaceEloEntry shape matches what the simulator expects, no change. Otherwise rewrite the adapter.

  • Step 3: Run existing tennis simulator tests

Run: npm run test:run -- app/services/simulations/__tests__/tennis-simulator Expected: PASS against the canonical data. If a fixture is keyed by participantId in a way that breaks with the new canonical lookup, update the fixture.

  • Step 4: Add a regression test against the pre-Phase-1 baseline fixture
// app/services/simulations/__tests__/tennis-simulator.baseline.test.ts
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";
import { runTennisSimulator } from "../tennis-simulator";
// Assumes a test helper that seeds canonical + season tables from baseline fixtures
import { seedFromBaseline } from "~/test/fixtures/seed-from-baseline";

describe("tennis simulator baseline regression", () => {
  it("produces the same Monte Carlo output distribution as pre-Phase-1", async () => {
    const baseline = JSON.parse(readFileSync(join(__dirname, "../../../../test-fixtures/baselines/tennis-sim-output-pre-phase1.json"), "utf8"));
    const ssId = await seedFromBaseline(baseline);
    const got = await runTennisSimulator(ssId, { seed: baseline.seed, iterations: 10000 });
    for (const p of baseline.expected.participants) {
      const match = got.participants.find((x) => x.canonicalName === p.canonicalName)!;
      expect(match.probFirst).toBeCloseTo(p.probFirst, 2);
      expect(match.probTop3).toBeCloseTo(p.probTop3, 2);
    }
  });
});

Note: This test requires both a baseline fixture (to be captured per the spec, Task 0 in Phase 1a, extended with Monte Carlo output) and a seedFromBaseline helper. If those don't yet exist, add a preliminary task to create them. Gate Phase 3 on these existing before Task 6 runs.

  • Step 5: Commit
git add app/models/surface-elo.ts app/services/simulations/
git commit -m "refactor(simulator): read surface Elo from canonical table"

Task 7: Admin UI — canonical tournament list page

Files:

  • Create: app/routes/admin.tournaments._index.tsx

  • Modify: app/routes.ts (register the route)

  • Step 1: Register the route

In app/routes.ts, add to the admin section:

route("admin/tournaments", "routes/admin.tournaments._index.tsx"),
route("admin/tournaments/:id", "routes/admin.tournaments.$id.tsx"),
  • Step 2: Write the list loader and component
// app/routes/admin.tournaments._index.tsx
import { requireAdmin } from "~/lib/auth";
import { findAllTournamentsGroupedBySport } from "~/models/tournament";
import type { LoaderFunctionArgs } from "react-router";
import { Link, useLoaderData } from "react-router";

export async function loader({ request }: LoaderFunctionArgs) {
  await requireAdmin(request);
  const grouped = await findAllTournamentsGroupedBySport();
  return { grouped };
}

export default function AdminTournamentsIndex() {
  const { grouped } = useLoaderData<typeof loader>();
  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-4">Tournaments</h1>
      {grouped.map((g) => (
        <section key={g.sport.id} className="mb-8">
          <h2 className="text-xl font-semibold">{g.sport.name}</h2>
          <ul>
            {g.tournaments.map((t) => (
              <li key={t.id}>
                <Link to={`/admin/tournaments/${t.id}`} className="text-blue-600 hover:underline">
                  {t.name} {t.year}
                </Link>
                <span className="ml-2 text-gray-500">({t.status})</span>
              </li>
            ))}
          </ul>
        </section>
      ))}
    </div>
  );
}
  • Step 3: Add findAllTournamentsGroupedBySport to app/models/tournament.ts
export async function findAllTournamentsGroupedBySport() {
  const rows = await db
    .select({
      tournament: tournaments,
      sport: sports,
    })
    .from(tournaments)
    .innerJoin(sports, eq(tournaments.sportId, sports.id))
    .orderBy(sports.name, tournaments.year, tournaments.startsAt);

  const grouped = new Map<string, { sport: typeof sports.$inferSelect; tournaments: typeof tournaments.$inferSelect[] }>();
  for (const r of rows) {
    if (!grouped.has(r.sport.id)) {
      grouped.set(r.sport.id, { sport: r.sport, tournaments: [] });
    }
    grouped.get(r.sport.id)!.tournaments.push(r.tournament);
  }
  return Array.from(grouped.values());
}

Import sports at the top of the file.

  • Step 4: Test loader

Create app/routes/__tests__/admin.tournaments._index.test.tsx with loader tests (admin returns list; non-admin 403).

  • Step 5: Commit
git add app/routes/admin.tournaments._index.tsx app/routes/__tests__/admin.tournaments._index.test.tsx app/models/tournament.ts app/routes.ts
git commit -m "feat(admin): canonical tournaments list route"

Task 8: Admin UI — canonical tournament detail + paste-and-parse result entry

Files:

  • Create: app/routes/admin.tournaments.$id.tsx

  • Create: app/routes/__tests__/admin.tournaments.$id.test.tsx

  • Step 1: Write the loader + action test

// app/routes/__tests__/admin.tournaments.$id.test.tsx
import { describe, it, expect, beforeEach } from "vitest";
import { loader, action } from "../admin.tournaments.$id";
// Test helpers that mock requireAdmin and build a Request

describe("admin tournament detail", () => {
  it("loader returns tournament + canonical participants + current results", async () => {
    // Seed a tournament, participants, tournament_results.
    // Call loader with admin session.
    // Assert shape.
  });

  it("action 'batch-upsert-results' writes tournament_results and triggers sync", async () => {
    // Seed a tournament + window linked via scoring_events.
    // POST form with intent=batch-upsert-results and a list of parsed results.
    // Assert tournament_results written AND event_results propagated.
  });

  it("non-admin returns 403", async () => {
    // ...
  });
});
  • Step 2: Run failing test

Expected: FAIL.

  • Step 3: Implement route
// app/routes/admin.tournaments.$id.tsx
import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
import { Form, useLoaderData, useActionData } from "react-router";
import { requireAdmin } from "~/lib/auth";
import { getTournamentById } from "~/models/tournament";
import { getTournamentResults, upsertTournamentResult } from "~/models/tournament-result";
import { findParticipantsBySport, upsertParticipantBySportName } from "~/models/participant";
import { syncTournamentResults } from "~/services/sync-tournament-results";
import { parseResultsText } from "~/lib/parse-results-text";
import { BatchResultEntry } from "~/components/BatchResultEntry";
import { updateTournamentStatus } from "~/models/tournament";

export async function loader({ request, params }: LoaderFunctionArgs) {
  await requireAdmin(request);
  const tournament = await getTournamentById(params.id!);
  if (!tournament) throw new Response("Not Found", { status: 404 });
  const [results, canonicalParticipants] = await Promise.all([
    getTournamentResults(tournament.id),
    findParticipantsBySport(tournament.sportId),
  ]);
  return { tournament, results, canonicalParticipants };
}

export async function action({ request, params }: ActionFunctionArgs) {
  await requireAdmin(request);
  const formData = await request.formData();
  const intent = formData.get("intent");

  const tournament = await getTournamentById(params.id!);
  if (!tournament) throw new Response("Not Found", { status: 404 });

  if (intent === "batch-upsert-results") {
    const raw = String(formData.get("results-json") ?? "[]");
    const parsed: Array<{ placement: number; canonicalParticipantId?: string; newName?: string }> =
      JSON.parse(raw);

    for (const row of parsed) {
      let participantId = row.canonicalParticipantId;
      if (!participantId && row.newName) {
        const created = await upsertParticipantBySportName(tournament.sportId, row.newName);
        participantId = created.id;
      }
      if (!participantId) continue;

      await upsertTournamentResult({
        tournamentId: tournament.id,
        participantId,
        placement: row.placement,
      });
    }

    if (tournament.status !== "completed") {
      await updateTournamentStatus(tournament.id, "completed");
    }

    const report = await syncTournamentResults(tournament.id);
    return { success: true, syncReport: report };
  }

  throw new Response("Bad Request", { status: 400 });
}

export default function AdminTournamentDetail() {
  const { tournament, results, canonicalParticipants } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold">
        {tournament.name} {tournament.year}
      </h1>
      <p className="text-gray-500">Status: {tournament.status}</p>

      <section className="mt-6">
        <h2 className="text-xl font-semibold mb-2">Current Results</h2>
        <table>
          <tbody>
            {results.map((r) => (
              <tr key={r.id}>
                <td>{r.placement}</td>
                <td>{canonicalParticipants.find((p) => p.id === r.participantId)?.name}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </section>

      <section className="mt-6">
        <h2 className="text-xl font-semibold mb-2">Enter Results</h2>
        <BatchResultEntry
          availableParticipants={canonicalParticipants.map((p) => ({ id: p.id, name: p.name }))}
          intent="batch-upsert-results"
        />
      </section>

      {actionData?.syncReport && (
        <aside className="mt-6 p-4 bg-green-50 rounded">
          <p>Synced to {actionData.syncReport.windowsSynced} window(s).</p>
          {actionData.syncReport.windowsFailed > 0 && (
            <>
              <p className="text-red-600">
                Failed: {actionData.syncReport.windowsFailed}
              </p>
              <ul>
                {actionData.syncReport.failures.map((f) => (
                  <li key={f.scoringEventId} className="text-sm">
                    {f.sportsSeasonId}: {f.error}
                  </li>
                ))}
              </ul>
            </>
          )}
        </aside>
      )}
    </div>
  );
}

Note: BatchResultEntry component may need a prop intent added if the existing component hardcodes "batch-add-results" — check app/components/BatchResultEntry.tsx and adjust.

  • Step 4: Update BatchResultEntry if needed

If BatchResultEntry.tsx:175 hardcodes formData.set("intent", "batch-add-results"), change the component to accept an intent prop defaulting to "batch-add-results" so callers can override.

  • Step 5: Run tests

Expected: PASS.

  • Step 6: Commit
git add app/routes/admin.tournaments.$id.tsx app/routes/__tests__/admin.tournaments.$id.test.tsx app/components/BatchResultEntry.tsx
git commit -m "feat(admin): canonical tournament result entry with sync fan-out"

Task 9: Admin UI — surface-Elo canonical edit page

Files:

  • Create: app/routes/admin.participants.$id.surface-elo.tsx

  • Modify: app/routes.ts

  • Step 1: Register route

In app/routes.ts:

route("admin/participants/:id/surface-elo", "routes/admin.participants.$id.surface-elo.tsx"),
  • Step 2: Implement the page

Simple form that reads canonical participant + current participantSurfaceElos row, allows editing eloHard/eloClay/eloGrass/worldRanking, and posts to an action that calls upsertCanonicalSurfaceElo. Non-admin → 403. Standard React Router 7 pattern.

  • Step 3: Leave the existing per-window surface-elo admin UI in place for now

It still reads from the (now-renamed) season_participant_surface_elos table. Phase 4 removes it.

  • Step 4: Link from the canonical tournament detail page

On the existing admin.tournaments.$id.tsx, add a "Manage Surface Elo" link next to each participant name that navigates to /admin/participants/{participantId}/surface-elo.

  • Step 5: Test + commit
git add app/routes/admin.participants.$id.surface-elo.tsx app/routes.ts
git commit -m "feat(admin): canonical surface-elo edit page"

Task 10: Create-window admin flow additions

Goal: When creating a new qualifying-points sports_seasons, admin picks from canonical tournaments + canonical participants instead of manually entering scoring-event rows and participant rows.

Files:

  • Modify: existing admin route that creates sports_seasons (find via grep -l "sportsSeasons.*insert" app/routes/admin.*.tsx)

  • Create: new UI components for tournament + participant multi-selects

  • Modify: app/models/season-sport.ts or app/models/sports-season.ts to accept canonical IDs

  • Step 1: Find the existing create flow

Run: grep -l "insert(sportsSeasons)\|createSportsSeason" app/routes/ app/models/

Identify the route(s) handling creation. Read them fully.

  • Step 2: Add canonical-aware create helper

Add to app/models/sports-season.ts:

export async function createQualifyingPointsSeason(params: {
  sportId: string;
  year: number;
  tournamentIds: string[]; // canonical tournaments
  canonicalParticipantIds: string[]; // canonical participants
  scoringConfig: { pointsFor1st: string; /* ... */ };
}): Promise<typeof sportsSeasons.$inferSelect> {
  return db.transaction(async (tx) => {
    const [ss] = await tx.insert(sportsSeasons).values({
      sportId: params.sportId,
      year: params.year,
      scoringPattern: "qualifying_points",
      totalMajors: params.tournamentIds.length,
    }).returning();

    // Create scoring_events linked to tournaments
    for (const tid of params.tournamentIds) {
      const [t] = await tx.select().from(tournaments).where(eq(tournaments.id, tid));
      if (!t) throw new Error(`tournament ${tid} not found`);
      await tx.insert(scoringEvents).values({
        sportsSeasonId: ss.id,
        name: `${t.name}`,
        eventDate: t.startsAt?.toISOString().slice(0, 10),
        eventType: "major_tournament",
        isQualifyingEvent: true,
        tournamentId: t.id,
      });
    }

    // Create season_participants linked to canonical
    for (const pid of params.canonicalParticipantIds) {
      const [p] = await tx.select().from(participants).where(eq(participants.id, pid));
      if (!p) throw new Error(`participant ${pid} not found`);
      await tx.insert(seasonParticipants).values({
        sportsSeasonId: ss.id,
        name: p.name,
        participantId: p.id,
      });
    }

    return ss;
  });
}
  • Step 3: Update the admin create route UI

Add tournament multi-select (with inline "create new tournament" button) and participant multi-select (with inline "create new participant" button) to the existing form. On submit, call createQualifyingPointsSeason.

  • Step 4: E2E test
// cypress/e2e/admin-create-qualifying-window.cy.ts
describe("Admin creates a qualifying-points window", () => {
  it("picks tournaments + participants from canonical", () => {
    cy.loginAsAdmin();
    cy.visit("/admin/sports-seasons/new");
    // ... select sport = Tennis, pick 4 tournaments, pick N participants, submit.
    cy.url().should("match", /\/admin\/sports-seasons\/[^/]+$/);
    cy.findByText(/4 scoring events/i).should("exist");
  });
});
  • Step 5: Commit
git add app/models/ app/routes/ cypress/e2e/
git commit -m "feat(admin): canonical tournaments + participants in create-window flow"

Task 11: Convert old per-window result-entry route to wrapper

Goal: Keep the old route URL working (admin muscle memory) but have it write to canonical and call sync.

Files:

  • Modify: the existing admin route that handles per-window qualifying result entry (find via grep -l "batch-add-results" app/routes/)

  • Step 1: Find the route

Run: grep -l "batch-add-results" app/routes/

Read the current action handler.

  • Step 2: Replace the action body

Before: writes directly to event_results. After:

// Pseudocode for the new action handler:
async function action({ request, params }: ActionFunctionArgs) {
  await requireAdmin(request);
  const formData = await request.formData();
  const intent = formData.get("intent");

  if (intent === "batch-add-results") {
    // Load the scoring event
    const event = await getScoringEventById(params.eventId);
    if (!event || !event.tournamentId) {
      throw new Response("Event not linked to a canonical tournament", { status: 409 });
    }

    // Translate season_participant IDs → canonical participant IDs
    const rows: Array<{ seasonParticipantId: string; placement: number }> = JSON.parse(String(formData.get("results-json")));
    for (const row of rows) {
      const [sp] = await db.select().from(seasonParticipants).where(eq(seasonParticipants.id, row.seasonParticipantId));
      if (!sp || !sp.participantId) continue;
      await upsertTournamentResult({
        tournamentId: event.tournamentId,
        participantId: sp.participantId,
        placement: row.placement,
      });
    }

    await syncTournamentResults(event.tournamentId);
    return { success: true };
  }

  // ... other intents preserved
}
  • Step 3: Test the wrapper

Existing E2E tests for per-window result entry should continue to pass. Run them.

  • Step 4: Commit
git add app/routes/
git commit -m "refactor(admin): per-window result entry writes canonical via sync"

Task 12: Feature-flag the new admin routes + roll out

Files:

  • Modify: app/routes/admin.tournaments._index.tsx, app/routes/admin.tournaments.$id.tsx — wrap in feature flag

  • Step 1: Decide flag mechanism

If the project has an existing feature-flag mechanism (check grep -rn "featureFlag\|FEATURE_" app/), use it. If not, use an env var: ENABLE_CANONICAL_ADMIN_UI=true.

  • Step 2: Gate the new routes
// Inside loader:
if (!process.env.ENABLE_CANONICAL_ADMIN_UI) {
  throw new Response("Not Found", { status: 404 });
}
  • Step 3: Deploy to staging with flag=true; prod with flag=false; smoke-test

Run the E2E flows in staging. Verify old flows still work in prod with flag=false.

  • Step 4: Choose a no-draft window and flip the prod flag

Coordinate with active leagues. Flip flag, verify admin can reach /admin/tournaments.

  • Step 5: Run full Cypress suite post-flip

Run: npm run test:e2e:headless Expected: Green.

  • Step 6: Verify baseline regression

Re-run scripts/capture-baseline.ts on prod; diff against pre-Phase-1 baseline:

diff test-fixtures/baselines/qp-totals-pre-phase1.json /tmp/post-phase3/qp-totals-*.json
diff test-fixtures/baselines/event-results-pre-phase1.json /tmp/post-phase3/event-results-*.json

Expected: Empty (QP math did not drift).

  • Step 7: Tag
git tag phase-3-complete

Self-Review Checklist

  • All new tests pass.
  • syncTournamentResults is idempotent, isolates per-window failures, and respects per-window rosters.
  • Tennis simulator output matches pre-Phase-1 baseline fixture.
  • Check constraint prevents a qualifying scoring_event with null tournament_id.
  • Admin can enter Wimbledon 2026 results once via /admin/tournaments/<id> and see both the original golf window and any new tennis window's QP standings update.
  • Old per-window result entry route still works (wrapper path).
  • Surface Elo edits via the canonical page affect all windows.
  • Feature flag enabled in prod; tag phase-3-complete on main.

Rollback

If the new admin UI causes a problem in prod, set ENABLE_CANONICAL_ADMIN_UI=false — this hides the canonical UI. Old wrappers still run, but the old wrappers write to canonical too, so rolling back only the UI doesn't rollback data writes.

If the sync service is broken (wrong QP math, wrong fan-out), revert the wrapper change first (make the old route write directly to event_results again), then investigate sync. Phase 4 must not begin until the cause is fixed.

Phase 4 begins only after phase-3-complete has been green in production for 7 days with active draft activity.