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

1272 lines
44 KiB
Markdown

# 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`:
```typescript
// 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:
```bash
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`:
```typescript
tournamentId: uuid("tournament_id").references(() => tournaments.id, {
onDelete: "restrict",
}),
```
(Stay nullable.)
And append to the table config:
```typescript
// (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**
```bash
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**
```typescript
// 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**
```typescript
// 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**
```bash
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**
```typescript
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**
```typescript
// 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**
```bash
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**
```typescript
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**
```bash
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**
```typescript
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**
```bash
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.
```typescript
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**
```bash
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:
```typescript
// reads from season_participant_surface_elos keyed by sportsSeasonId
```
After:
```typescript
// 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**
```typescript
// 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**
```bash
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:
```typescript
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**
```typescript
// 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`**
```typescript
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**
```bash
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**
```typescript
// 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**
```typescript
// 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**
```bash
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`:
```typescript
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**
```bash
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`:
```typescript
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**
```typescript
// 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**
```bash
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:
```typescript
// 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**
```bash
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**
```typescript
// 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:
```bash
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**
```bash
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.