diff --git a/docs/superpowers/plans/2026-05-01-phase1a-rename-per-window-tables.md b/docs/superpowers/plans/2026-05-01-phase1a-rename-per-window-tables.md new file mode 100644 index 0000000..f13624d --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-phase1a-rename-per-window-tables.md @@ -0,0 +1,638 @@ +# Phase 1a — Rename Per-Window Tables 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:** Rename the existing per-window tables (`participants`, `participant_expected_values`, `participant_qualifying_totals`, `participant_results`, `participant_surface_elos`) to `season_`-prefixed names, and rename `event_results.participant_id` to `season_participant_id`. Rename `app/models/participant.ts` to `app/models/season-participant.ts`. Ship a fully green build with zero behavior changes. + +**Architecture:** Pure mechanical rename across ~100 files. No new tables, no new columns, no logic changes. This phase must land cleanly before Phase 1b can create the new canonical `participants` table (which reuses the freed name). + +**Tech Stack:** Drizzle ORM, PostgreSQL, React Router 7, TypeScript, Vitest. + +**Reference:** `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md` — Phase 1a section. + +**Rename mapping (authoritative):** + +| Old (table / column / symbol) | New | +|---|---| +| table `participants` | `season_participants` | +| table `participant_expected_values` | `season_participant_expected_values` | +| table `participant_qualifying_totals` | `season_participant_qualifying_totals` | +| table `participant_results` | `season_participant_results` | +| table `participant_surface_elos` | `season_participant_surface_elos` | +| column `event_results.participant_id` | `event_results.season_participant_id` | +| Drizzle export `participants` | `seasonParticipants` | +| Drizzle export `participantExpectedValues` | `seasonParticipantExpectedValues` | +| Drizzle export `participantQualifyingTotals` | `seasonParticipantQualifyingTotals` | +| Drizzle export `participantResults` | `seasonParticipantResults` | +| Drizzle export `participantSurfaceElos` | `seasonParticipantSurfaceElos` | +| Drizzle export `participantsRelations` | `seasonParticipantsRelations` | +| Drizzle export `participantExpectedValuesRelations` | `seasonParticipantExpectedValuesRelations` | +| Drizzle export `participantQualifyingTotalsRelations` | `seasonParticipantQualifyingTotalsRelations` | +| Drizzle export `participantResultsRelations` | `seasonParticipantResultsRelations` | +| Drizzle export `participantSurfaceElosRelations` | `seasonParticipantSurfaceElosRelations` | +| File `app/models/participant.ts` | `app/models/season-participant.ts` | +| Relation field `eventResults.participant` | `eventResults.seasonParticipant` | + +**Files affected (counts from pre-flight audit):** +- `database/schema.ts` (1 — critical) +- `app/models/index.ts` (1) +- `app/models/` — 11 model files + 2 test files +- `app/routes/` — ~20 route files +- `app/services/` — probability-updater + 19 simulator files +- `app/utils/` — `sports-data-sync.server.ts` +- `server/` — `socket.ts`, test files +- `cypress/` — verify none reference these symbols by name + +**Note on granularity:** Because this is a mechanical rename, tasks are batched by scope rather than broken into micro-TDD steps. Each task ends with typecheck + test run as the verification step. + +--- + +### Task 0: Create baseline snapshot (one-time, before any code changes) + +**Why:** Phase 2 verification depends on proving QP totals didn't drift. Capture now. + +**Files:** +- Create: `scripts/capture-baseline.ts` + +- [ ] **Step 1: Write the capture script** + +```typescript +// scripts/capture-baseline.ts +import { db } from "~/db/context"; +import { sql } from "drizzle-orm"; +import { writeFileSync, mkdirSync } from "fs"; +import { join } from "path"; + +const OUT_DIR = join(process.cwd(), "test-fixtures", "baselines"); + +async function main() { + mkdirSync(OUT_DIR, { recursive: true }); + + const qpTotals = await db.execute(sql` + SELECT pqt.*, ss.id as sports_season_id, s.name as sport_name + FROM participant_qualifying_totals pqt + JOIN sports_seasons ss ON ss.id = pqt.sports_season_id + JOIN sports s ON s.id = ss.sport_id + WHERE ss.scoring_pattern = 'qualifying_points' + ORDER BY ss.id, pqt.participant_id + `); + + const eventResults = await db.execute(sql` + SELECT er.*, se.sports_season_id + FROM event_results er + JOIN scoring_events se ON se.id = er.scoring_event_id + JOIN sports_seasons ss ON ss.id = se.sports_season_id + WHERE ss.scoring_pattern = 'qualifying_points' + AND er.qualifying_points_awarded IS NOT NULL + ORDER BY se.sports_season_id, er.scoring_event_id, er.participant_id + `); + + const surfaceElos = await db.execute(sql` + SELECT pse.*, ss.sport_id + FROM participant_surface_elos pse + JOIN sports_seasons ss ON ss.id = pse.sports_season_id + ORDER BY ss.id, pse.participant_id + `); + + writeFileSync(join(OUT_DIR, "qp-totals-pre-phase1.json"), JSON.stringify(qpTotals.rows, null, 2)); + writeFileSync(join(OUT_DIR, "event-results-pre-phase1.json"), JSON.stringify(eventResults.rows, null, 2)); + writeFileSync(join(OUT_DIR, "surface-elos-pre-phase1.json"), JSON.stringify(surfaceElos.rows, null, 2)); + + console.log(`Wrote baselines: ${qpTotals.rows.length} QP totals, ${eventResults.rows.length} event results, ${surfaceElos.rows.length} surface elo rows.`); +} + +main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); }); +``` + +- [ ] **Step 2: Run against production data read replica (or local dev DB loaded from `npm run db:sync-prod`)** + +Run: `npx tsx scripts/capture-baseline.ts` +Expected: three JSON files created under `test-fixtures/baselines/`; non-zero row counts for QP totals and surface elos; Masters 2026 results visible in `event-results-pre-phase1.json`. + +- [ ] **Step 3: Also capture Monte Carlo simulator output baselines** + +Extend `scripts/capture-baseline.ts` to, for each in-flight qualifying-points `sports_seasons` row, run the matching simulator with a **fixed random seed** and save its output. This JSON is what Phase 3 Task 6's tennis-simulator regression test reads. + +Pattern: + +```typescript +// Pseudocode appended to scripts/capture-baseline.ts main(): +import { getSimulator } from "~/services/simulations/registry"; +// ... for each ss in qualifying-points seasons: +const sim = getSimulator(ss.simulatorType!); +const output = await sim.run(ss.id, { seed: 42, iterations: 10000 }); +writeFileSync(join(OUT_DIR, `sim-output-${ss.simulatorType}-${ss.id}.json`), JSON.stringify(output, null, 2)); +``` + +Check the existing `Simulator` interface in `app/services/simulations/types.ts` for the actual method signature; adapt accordingly. If a simulator does not currently accept a `seed`, add one as a non-breaking optional param in this task — it's needed for reproducible baselines. + +- [ ] **Step 4: Commit the baseline fixtures** + +```bash +git add scripts/capture-baseline.ts test-fixtures/baselines/ +git commit -m "chore: capture pre-rename baselines for qualifying-points data and simulator output" +``` + +--- + +### Task 1: Rename schema exports and table names in `database/schema.ts` + +**Why first:** Schema is the source of truth; the whole codebase compiles against its exports. Easiest to do in one sweep. + +**Files:** +- Modify: `database/schema.ts` + +- [ ] **Step 1: Rename table definitions and string names** + +Apply these replacements in `database/schema.ts`. The safest order: string names first (they're unambiguous), then export names (which must be done with care to avoid breaking references in relations). + +Table rename block 1 — `participants` (~ lines 376-390): +```typescript +// OLD: +export const participants = pgTable("participants", { + // ... +}); +// NEW: +export const seasonParticipants = pgTable("season_participants", { + // ... +}); +``` + +Table rename block 2 — `participant_expected_values` (~ lines 642-672): +```typescript +// OLD: +export const participantExpectedValues = pgTable("participant_expected_values", { ... }); +// NEW: +export const seasonParticipantExpectedValues = pgTable("season_participant_expected_values", { ... }); +``` + +Table rename block 3 — `participant_qualifying_totals` (~ lines 537-550): +```typescript +// OLD: +export const participantQualifyingTotals = pgTable("participant_qualifying_totals", { ... }); +// NEW: +export const seasonParticipantQualifyingTotals = pgTable("season_participant_qualifying_totals", { ... }); +``` + +Table rename block 4 — `participant_results` (~ lines 414-428): +```typescript +// OLD: +export const participantResults = pgTable("participant_results", { ... }); +// NEW: +export const seasonParticipantResults = pgTable("season_participant_results", { ... }); +``` + +Table rename block 5 — `participant_surface_elos` (~ lines 1219-1235): +```typescript +// OLD: +export const participantSurfaceElos = pgTable("participant_surface_elos", { ... }); +// NEW: +export const seasonParticipantSurfaceElos = pgTable("season_participant_surface_elos", { ... }); +``` + +Column rename in `eventResults` table (~ lines 458-476): +```typescript +// OLD: +participantId: uuid("participant_id").notNull().references(() => participants.id, { onDelete: "cascade" }), +// NEW: +seasonParticipantId: uuid("season_participant_id").notNull().references(() => seasonParticipants.id, { onDelete: "cascade" }), +``` + +- [ ] **Step 2: Rename relations exports and their field references** + +In `database/schema.ts`, update each `*Relations` export and every `one()/many()` call that referenced the renamed tables. Replacements needed: +- `participantsRelations` → `seasonParticipantsRelations` +- `participantExpectedValuesRelations` → `seasonParticipantExpectedValuesRelations` +- `participantQualifyingTotalsRelations` → `seasonParticipantQualifyingTotalsRelations` +- `participantResultsRelations` → `seasonParticipantResultsRelations` +- `participantSurfaceElosRelations` → `seasonParticipantSurfaceElosRelations` + +Within `eventResultsRelations` (~ lines 952-961): +```typescript +// OLD: +participant: one(participants, { + fields: [eventResults.participantId], + references: [participants.id], +}), +// NEW: +seasonParticipant: one(seasonParticipants, { + fields: [eventResults.seasonParticipantId], + references: [seasonParticipants.id], +}), +``` + +Update every other relation `one()`/`many()` that referenced `participants`, `participantResults`, `participantQualifyingTotals`, `participantSurfaceElos`, or `participantExpectedValues` to use the new table export names. + +- [ ] **Step 3: Verify schema parses** + +Run: `npm run typecheck` +Expected: Will fail with many errors in model/route files that still import old names. That's fine for now — schema.ts itself compiling is the win. Confirm the errors are only in files outside `database/schema.ts`. + +- [ ] **Step 4: Commit schema rename alone** + +```bash +git add database/schema.ts +git commit -m "refactor(schema): rename per-window tables to season_* prefix + +Renames participants, participant_expected_values, participant_qualifying_totals, +participant_results, participant_surface_elos to season_* prefixed names. +Renames event_results.participant_id to season_participant_id. +Phase 1a of canonical tournament layer migration." +``` + +--- + +### Task 2: Generate and hand-edit the Drizzle rename migration + +**Why:** `drizzle-kit generate` treats renames as drop+create by default; this loses data. For safe renames, we generate with `--custom` and hand-edit, OR use `drizzle-kit generate` and answer its interactive rename prompts. Using custom is safer here because multiple renames in one migration can confuse the prompt sequence. + +**Files:** +- Create: `drizzle/0087_rename_per_window_tables.sql` (filename assigned by drizzle-kit; adjust to whatever it generates) +- Create: `drizzle/meta/0087_snapshot.json` (generated) + +- [ ] **Step 1: Generate a custom migration scaffold** + +Run: `npm run db:generate -- --custom --name=rename_per_window_tables` +Expected: Creates a new empty `.sql` file in `drizzle/` and a paired snapshot in `drizzle/meta/`. + +- [ ] **Step 2: Write the rename SQL manually** + +Open the new `drizzle/XXXX_rename_per_window_tables.sql` and replace its contents with: + +```sql +-- Rename per-window tables to season_* prefix +-- Part of canonical tournament layer migration (Phase 1a) + +-- Drop the FK on event_results.participant_id before renaming target +ALTER TABLE "event_results" DROP CONSTRAINT IF EXISTS "event_results_participant_id_participants_id_fk"; + +-- Rename tables +ALTER TABLE "participants" RENAME TO "season_participants"; +ALTER TABLE "participant_expected_values" RENAME TO "season_participant_expected_values"; +ALTER TABLE "participant_qualifying_totals" RENAME TO "season_participant_qualifying_totals"; +ALTER TABLE "participant_results" RENAME TO "season_participant_results"; +ALTER TABLE "participant_surface_elos" RENAME TO "season_participant_surface_elos"; + +-- Rename column on event_results +ALTER TABLE "event_results" RENAME COLUMN "participant_id" TO "season_participant_id"; + +-- Re-add the FK against the renamed table +ALTER TABLE "event_results" + ADD CONSTRAINT "event_results_season_participant_id_season_participants_id_fk" + FOREIGN KEY ("season_participant_id") REFERENCES "season_participants"("id") + ON DELETE cascade ON UPDATE no action; + +-- Rename any unique indexes that embed the old table names +ALTER INDEX IF EXISTS "participant_surface_elos_unique" RENAME TO "season_participant_surface_elos_unique"; +-- (Add further ALTER INDEX ... RENAME TO for any other indexes whose names embed old table names. +-- Run `\d ` in psql against a fresh migrate to confirm nothing lingers.) +``` + +**Note:** FK and index constraint names are auto-generated based on table names. After the `ALTER TABLE ... RENAME TO`, Postgres does NOT auto-rename the FK constraint on `event_results` pointing at the old name — we drop and re-create explicitly above. Run the migration against a scratch DB and inspect `\d event_results` and `\d season_participants` to catch any leftover naming. + +- [ ] **Step 3: Verify snapshot is consistent** + +Run: `npm run db:generate` +Expected: "No schema changes" — confirms the snapshot chain is valid. If drizzle-kit wants to generate another migration, stop and diagnose; the snapshot is out of sync with the custom SQL. + +- [ ] **Step 4: Run migration on local DB** + +Run: `npm run db:migrate` +Expected: New migration applied; existing data intact (verify row counts match pre-rename via `psql ... -c "SELECT count(*) FROM season_participants;"` = prior `SELECT count(*) FROM participants;`). + +- [ ] **Step 5: Commit migration** + +```bash +git add drizzle/ +git commit -m "migration: rename per-window tables to season_* prefix" +``` + +--- + +### Task 3: Rename and update `app/models/participant.ts` → `app/models/season-participant.ts` + +**Files:** +- Rename: `app/models/participant.ts` → `app/models/season-participant.ts` +- Modify: `app/models/index.ts` + +- [ ] **Step 1: Git-rename the file** + +```bash +git mv app/models/participant.ts app/models/season-participant.ts +``` + +- [ ] **Step 2: Update the file's internal symbol references** + +Open `app/models/season-participant.ts`. Replace all references to renamed Drizzle exports: +- `participants` → `seasonParticipants` +- `participantResults` → `seasonParticipantResults` (if referenced) +- `participantQualifyingTotals` → `seasonParticipantQualifyingTotals` (if referenced) + +Update any exported function names that embed "participant" that logically should now be "seasonParticipant" — but **only if they are window-scoped**. E.g., a function `findParticipantsForSeason` likely stays named as-is since callers in routes don't distinguish; a function `createParticipant(sportsSeasonId, data)` likely stays named. Rename only if the name would be genuinely misleading. **Default: keep exported function names to minimize route-layer churn.** + +- [ ] **Step 3: Update `app/models/index.ts`** + +Change: +```typescript +export * from "./participant"; +``` +To: +```typescript +export * from "./season-participant"; +``` + +- [ ] **Step 4: Verify file compiles in isolation** + +Run: `npx tsc --noEmit app/models/season-participant.ts` (may still reference other unrenamed files — acceptable as long as the error is about imports, not the rename itself.) + +- [ ] **Step 5: Commit** + +```bash +git add app/models/season-participant.ts app/models/index.ts +git commit -m "refactor: rename participant.ts model file to season-participant.ts" +``` + +--- + +### Task 4: Update all model files referencing renamed schema exports + +**Files (update each to use new export names):** +- `app/models/draft-pick.ts` +- `app/models/draft-utils.ts` +- `app/models/event-result.ts` +- `app/models/group-stage-match.ts` +- `app/models/participant-result.ts` +- `app/models/qualifying-points.ts` +- `app/models/scoring-calculator.ts` +- `app/models/scoring-event.ts` +- `app/models/sports-season.ts` +- `app/models/surface-elo.ts` +- `app/models/team-score-events.ts` + +- [ ] **Step 1: In each file, replace imports and references** + +Replacement map for imports and schema symbol references throughout the file body: + +| Old symbol | New symbol | +|---|---| +| `participants` (Drizzle table) | `seasonParticipants` | +| `participantExpectedValues` | `seasonParticipantExpectedValues` | +| `participantQualifyingTotals` | `seasonParticipantQualifyingTotals` | +| `participantResults` | `seasonParticipantResults` | +| `participantSurfaceElos` | `seasonParticipantSurfaceElos` | +| `eventResults.participantId` | `eventResults.seasonParticipantId` | +| Relation access `.participant` on an `eventResults` row | `.seasonParticipant` | + +**Keep unchanged:** +- Local variable names like `participant`, `participants`, `participantId` — these are runtime identifiers used by business logic; renaming them would cascade into hundreds of lines for zero benefit. Only the *schema symbol* and *Drizzle relation field* change. +- Function signatures and param names. +- JSDoc. + +**Example `draft-pick.ts` update:** + +```typescript +// BEFORE +import { participants, draftPicks } from "database/schema"; +const [p] = await db.select().from(participants).where(eq(participants.id, participantId)); + +// AFTER +import { seasonParticipants, draftPicks } from "database/schema"; +const [p] = await db.select().from(seasonParticipants).where(eq(seasonParticipants.id, participantId)); +// ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^ schema symbol only +// `participantId` param name stays. +``` + +- [ ] **Step 2: Run typecheck after each batch of 3 files** + +Run: `npm run typecheck 2>&1 | head -40` +Expected: Errors shrink with each file updated. Stop and diagnose if error count grows. + +- [ ] **Step 3: Update model test files** + +Files: +- `app/models/__tests__/sports-season.clone.test.ts` +- `app/models/__tests__/auto-pick.test.ts` +- `app/models/__tests__/executeAutoPick.timer.test.ts` + +Same replacement rules. Run affected tests after update: + +Run: `npm run test:run -- app/models/__tests__` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add app/models/ +git commit -m "refactor(models): update model layer to use renamed schema exports" +``` + +--- + +### Task 5: Update all route files referencing renamed schema exports + +**Files:** +- `app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts` +- `app/routes/admin.sports-seasons.$id.events.$eventId.server.ts` +- `app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx` +- `app/routes/admin.sports-seasons.$id.expected-values.server.ts` +- `app/routes/admin.sports-seasons.$id.surface-elo.tsx` +- `app/routes/admin.sports-seasons.$id.simulate.tsx` +- `app/routes/admin.sports-seasons.$id.standings.tsx` +- `app/routes/admin.sports-seasons.$id.elo-ratings.tsx` +- `app/routes/admin.sports-seasons.$id.tsx` +- `app/routes/admin.sports-seasons.$id.golf-skills.tsx` +- `app/routes/admin.sports-seasons.$id.regular-standings.tsx` +- `app/routes/admin.sports-seasons.$id.futures-odds.tsx` +- `app/routes/admin.data-sync.tsx` +- `app/routes/api/draft.force-manual-pick.ts` +- `app/routes/api/draft.make-pick.ts` +- `app/routes/api/draft.replace-pick.ts` +- `app/routes/api/seasons.$seasonId.draft.ts` +- `app/routes/leagues/$leagueId.draft-board.$seasonId.tsx` +- `app/routes/leagues/$leagueId.draft.$seasonId.tsx` +- `app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts` + +- [ ] **Step 1: Apply replacement rules (same map as Task 4) to each file** + +Use find-and-replace per-file. After every ~5 files, run: + +Run: `npm run typecheck 2>&1 | wc -l` +(Track error count; should decrease monotonically.) + +- [ ] **Step 2: Verify route tests still pass** + +Run: `npm run test:run -- app/routes` +Expected: PASS (or same-as-before count; no new failures). + +- [ ] **Step 3: Commit** + +```bash +git add app/routes/ +git commit -m "refactor(routes): update route layer to use renamed schema exports" +``` + +--- + +### Task 6: Update simulators and services + +**Files:** +- `app/services/probability-updater.ts` +- `app/services/standings-sync/index.ts` +- `app/services/simulations/registry.ts` +- `app/services/simulations/simulator-config.ts` +- `app/services/simulations/types.ts` +- `app/services/simulations/afl-simulator.ts` +- `app/services/simulations/auto-racing-simulator.ts` +- `app/services/simulations/bracket-simulator.ts` +- `app/services/simulations/cs-major-simulator.ts` +- `app/services/simulations/darts-simulator.ts` +- `app/services/simulations/golf-simulator.ts` +- `app/services/simulations/llws-simulator.ts` +- `app/services/simulations/mlb-simulator.ts` +- `app/services/simulations/nba-simulator.ts` +- `app/services/simulations/ncaa-football-simulator.ts` +- `app/services/simulations/ncaam-simulator.ts` +- `app/services/simulations/ncaaw-simulator.ts` +- `app/services/simulations/nfl-simulator.ts` +- `app/services/simulations/nhl-simulator.ts` +- `app/services/simulations/snooker-simulator.ts` +- `app/services/simulations/tennis-simulator.ts` +- `app/services/simulations/ucl-simulator.ts` +- `app/services/simulations/wnba-simulator.ts` +- `app/services/simulations/world-cup-simulator.ts` +- `app/utils/sports-data-sync.server.ts` +- `server/socket.ts` +- `server/__tests__/timer-autodraft.test.ts` + +- [ ] **Step 1: Apply replacement rules** + +Same map as Task 4. Simulators are largely symmetric — one simulator's pattern will reveal all; batch 5 at a time and verify with typecheck. + +- [ ] **Step 2: Run simulator unit tests** + +Run: `npm run test:run -- app/services/simulations/__tests__` +Expected: PASS. These tests consume Drizzle types extensively — a good regression canary. + +- [ ] **Step 3: Run full typecheck** + +Run: `npm run typecheck` +Expected: PASS (0 errors). Any remaining errors indicate a file missed in earlier tasks. + +- [ ] **Step 4: Commit** + +```bash +git add app/services/ app/utils/ server/ +git commit -m "refactor(services): update simulators and services for renamed schema" +``` + +--- + +### Task 7: Full test suite + Cypress verification + +**Why:** Mechanical renames are easy to get 99% right and 1% broken in a way typecheck doesn't catch — especially relation-field renames (`.participant` → `.seasonParticipant`). + +- [ ] **Step 1: Run full unit test suite** + +Run: `npm run test:run` +Expected: PASS. If failures, inspect each: a common miss is a relation accessor like `result.participant.name` that needs to become `result.seasonParticipant.name`. + +- [ ] **Step 2: Run a key E2E flow** + +Run: `npx cypress run --spec "cypress/e2e/draft-room.cy.ts"` (or equivalent flagship flow) +Expected: PASS. E2E is the final guard — it exercises real relation loading. + +- [ ] **Step 3: Manual smoke in dev** + +Run: `npm run dev` +Manually: +1. Load the admin dashboard for an in-flight golf sports season — verify participant list renders. +2. Load a league's draft room — verify participant cards render. +3. Open admin surface-elo page for the tennis sports season — verify Elo values load. + +Fix any broken UI before committing. (The rename may have missed a relation accessor somewhere.) + +- [ ] **Step 4: Final commit if any fixups were needed** + +```bash +git add -A +git commit -m "refactor: fix straggler relation accessors after rename" +``` + +--- + +### Task 8: Verify baselines still match + +**Why:** The entire point of Phase 1a is zero data change. Verify. + +- [ ] **Step 1: Re-capture and diff** + +Run: +```bash +npx tsx scripts/capture-baseline.ts --out=/tmp/post-phase1a +``` +(Update the script to accept `--out` or copy the baseline JSON files from `test-fixtures/baselines/` and re-run capture manually into `/tmp/post-phase1a/`.) + +Run: +```bash +diff test-fixtures/baselines/qp-totals-pre-phase1.json /tmp/post-phase1a/qp-totals-pre-phase1.json +diff test-fixtures/baselines/event-results-pre-phase1.json /tmp/post-phase1a/event-results-pre-phase1.json +diff test-fixtures/baselines/surface-elos-pre-phase1.json /tmp/post-phase1a/surface-elos-pre-phase1.json +``` +Expected: All three diffs empty. + +- [ ] **Step 2: Tag the branch** + +```bash +git tag phase-1a-complete +``` + +--- + +### Task 9: Update documentation + +**Files:** +- Modify: `docs/agents/database.md` — if it references the old table names +- Modify: `docs/agents/domain-models.md` — if it describes these tables + +- [ ] **Step 1: Scan docs for old names** + +Run: `grep -l "participant_qualifying_totals\|participant_expected_values\|participant_results\|participant_surface_elos\|event_results.participant_id" docs/` +Expected: list of docs to update. + +- [ ] **Step 2: Update each doc to use new names** + +For each file found, apply the rename map. Keep prose clear; where it helps readers, note "renamed from `X` in 2026-05". + +- [ ] **Step 3: Commit** + +```bash +git add docs/ +git commit -m "docs: update table names in agent guides" +``` + +--- + +## Self-Review Checklist + +Before marking this plan complete, verify: + +- [ ] `git log --oneline phase-1a-complete` shows ~8 commits corresponding to Tasks 0–9. +- [ ] `grep -rn "from \"./participant\"" app/` returns zero results. +- [ ] `grep -rn "participantExpectedValues\|participantQualifyingTotals\|participantSurfaceElos" app/ database/ server/ scripts/` returns zero results (excluding string literals in UI labels, which are fine). +- [ ] `grep -rn "participantResults\b" app/ database/ server/` returns zero results. +- [ ] `npm run test:all` is green. +- [ ] `psql` confirms the renamed tables exist and the old names do not: + ``` + SELECT tablename FROM pg_tables WHERE tablename LIKE '%participant%' OR tablename LIKE '%season_participant%'; + ``` + Expected: `season_participants`, `season_participant_expected_values`, `season_participant_qualifying_totals`, `season_participant_results`, `season_participant_surface_elos`. None without the `season_` prefix. + +## Rollback + +If Phase 1a must be undone: + +1. `git revert` the commits from Tasks 1–9 (skip Task 0 baseline capture — keep fixtures). +2. Create a reverse migration that renames all five tables back and the column back, re-creates the old FK constraint. +3. Run `npm run db:migrate`. + +Phase 1b must not begin until Phase 1a is shipped, green in production for at least 24 hours, and the tag `phase-1a-complete` is on `main`. diff --git a/docs/superpowers/plans/2026-05-01-phase1b-create-canonical-tables.md b/docs/superpowers/plans/2026-05-01-phase1b-create-canonical-tables.md new file mode 100644 index 0000000..fcb6370 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-phase1b-create-canonical-tables.md @@ -0,0 +1,990 @@ +# Phase 1b — Create Canonical Tables 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:** Create the canonical tables (`tournaments`, `participants`, `tournament_results`, `participant_surface_elos`) and add nullable FK columns (`scoring_events.tournament_id`, `season_participants.participant_id`). No code paths read or write canonical data yet — this phase is pure schema prep. + +**Architecture:** Additive schema migration. New Drizzle exports + relations, new Drizzle-kit migration, new (empty) model files scaffolded with CRUD primitives so subsequent phases import them. No runtime behavior changes. + +**Tech Stack:** Drizzle ORM, PostgreSQL, TypeScript, Vitest. + +**Prerequisite:** Phase 1a (`2026-05-01-phase1a-rename-per-window-tables.md`) complete and shipped. Tag `phase-1a-complete` must exist. + +**Reference:** `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md` — Phase 1b section and "Canonical tables" schema block. + +--- + +### Task 1: Add canonical `tournaments` table to schema + +**Files:** +- Modify: `database/schema.ts` + +- [ ] **Step 1: Add the tournament status enum** + +At the top of `database/schema.ts` alongside other enums: + +```typescript +export const tournamentStatusEnum = pgEnum("tournament_status", [ + "scheduled", + "in_progress", + "completed", +]); +``` + +- [ ] **Step 2: Add the `tournaments` table definition** + +Place it near `scoringEvents` (after the existing qualifying-points-related tables in the file): + +```typescript +export const tournaments = pgTable( + "tournaments", + { + id: uuid("id").primaryKey().defaultRandom(), + sportId: uuid("sport_id") + .notNull() + .references(() => sports.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + year: integer("year").notNull(), + startsAt: timestamp("starts_at"), + endsAt: timestamp("ends_at"), + surface: varchar("surface", { length: 50 }), // hard | clay | grass (tennis only) + location: varchar("location", { length: 255 }), + status: tournamentStatusEnum("status").notNull().default("scheduled"), + externalKey: varchar("external_key", { length: 255 }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (t) => ({ + uniqueSportNameYear: uniqueIndex("tournaments_sport_name_year_unique").on( + t.sportId, + t.name, + t.year, + ), + }), +); + +export const tournamentsRelations = relations(tournaments, ({ one, many }) => ({ + sport: one(sports, { + fields: [tournaments.sportId], + references: [sports.id], + }), + scoringEvents: many(scoringEvents), + tournamentResults: many(tournamentResults), +})); +``` + +(The `tournamentResults` reference will fail to resolve until Task 3 — that's expected; don't run typecheck yet.) + +--- + +### Task 2: Add canonical `participants` table to schema + +**Files:** +- Modify: `database/schema.ts` + +- [ ] **Step 1: Add the canonical `participants` table** + +Add near `seasonParticipants`: + +```typescript +export const participants = pgTable( + "participants", + { + id: uuid("id").primaryKey().defaultRandom(), + sportId: uuid("sport_id") + .notNull() + .references(() => sports.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + externalKey: varchar("external_key", { length: 255 }), + metadata: jsonb("metadata"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (t) => ({ + uniqueSportName: uniqueIndex("participants_sport_name_unique").on( + t.sportId, + t.name, + ), + }), +); + +export const participantsRelations = relations(participants, ({ one, many }) => ({ + sport: one(sports, { + fields: [participants.sportId], + references: [sports.id], + }), + seasonParticipants: many(seasonParticipants), + tournamentResults: many(tournamentResults), + surfaceElo: one(participantSurfaceElos, { + fields: [participants.id], + references: [participantSurfaceElos.participantId], + }), +})); +``` + +--- + +### Task 3: Add canonical `tournament_results` table + +**Files:** +- Modify: `database/schema.ts` + +- [ ] **Step 1: Add the table** + +```typescript +export const tournamentResults = pgTable( + "tournament_results", + { + id: uuid("id").primaryKey().defaultRandom(), + tournamentId: uuid("tournament_id") + .notNull() + .references(() => tournaments.id, { onDelete: "cascade" }), + participantId: uuid("participant_id") + .notNull() + .references(() => participants.id, { onDelete: "cascade" }), + placement: integer("placement"), + rawScore: decimal("raw_score", { precision: 10, scale: 2 }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (t) => ({ + uniqueTournamentParticipant: uniqueIndex( + "tournament_results_tournament_participant_unique", + ).on(t.tournamentId, t.participantId), + }), +); + +export const tournamentResultsRelations = relations(tournamentResults, ({ one }) => ({ + tournament: one(tournaments, { + fields: [tournamentResults.tournamentId], + references: [tournaments.id], + }), + participant: one(participants, { + fields: [tournamentResults.participantId], + references: [participants.id], + }), +})); +``` + +--- + +### Task 4: Add canonical `participant_surface_elos` table + +**Files:** +- Modify: `database/schema.ts` + +**Important:** The old per-window `participant_surface_elos` table was renamed to `season_participant_surface_elos` in Phase 1a. The name is free for the canonical table. + +- [ ] **Step 1: Add the table** + +```typescript +export const participantSurfaceElos = pgTable( + "participant_surface_elos", + { + id: uuid("id").primaryKey().defaultRandom(), + participantId: uuid("participant_id") + .notNull() + .references(() => participants.id, { onDelete: "cascade" }), + worldRanking: integer("world_ranking"), + eloHard: integer("elo_hard"), + eloClay: integer("elo_clay"), + eloGrass: integer("elo_grass"), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (t) => ({ + uniqueParticipant: uniqueIndex("participant_surface_elos_participant_unique").on( + t.participantId, + ), + }), +); + +export const participantSurfaceElosRelations = relations( + participantSurfaceElos, + ({ one }) => ({ + participant: one(participants, { + fields: [participantSurfaceElos.participantId], + references: [participants.id], + }), + }), +); +``` + +--- + +### Task 5: Add nullable FK columns to existing per-window tables + +**Files:** +- Modify: `database/schema.ts` + +- [ ] **Step 1: Add `tournament_id` to `scoringEvents`** + +Inside the existing `scoringEvents` table definition, add the column (nullable for now): + +```typescript +// Inside scoringEvents pgTable(...) columns block: +tournamentId: uuid("tournament_id").references(() => tournaments.id, { + onDelete: "set null", +}), +``` + +Update `scoringEventsRelations` to include: + +```typescript +tournament: one(tournaments, { + fields: [scoringEvents.tournamentId], + references: [tournaments.id], +}), +``` + +- [ ] **Step 2: Add `participant_id` to `seasonParticipants`** + +Inside `seasonParticipants` table definition: + +```typescript +participantId: uuid("participant_id").references(() => participants.id, { + onDelete: "restrict", +}), +``` + +Update `seasonParticipantsRelations` to include: + +```typescript +participant: one(participants, { + fields: [seasonParticipants.participantId], + references: [participants.id], +}), +``` + +- [ ] **Step 3: Run typecheck on schema** + +Run: `npm run typecheck 2>&1 | head -20` +Expected: 0 errors. All new symbols are internally consistent. + +- [ ] **Step 4: Commit the schema changes together** + +```bash +git add database/schema.ts +git commit -m "schema: add canonical tournament & participant tables + +Adds tournaments, participants (canonical), tournament_results, and +participant_surface_elos (canonical). Adds nullable tournament_id to +scoring_events and nullable participant_id to season_participants. +Part of Phase 1b of canonical tournament layer migration." +``` + +--- + +### Task 6: Generate Drizzle migration + +**Files:** +- Create: `drizzle/XXXX_add_canonical_tables.sql` (drizzle-kit assigns number) +- Create: `drizzle/meta/XXXX_snapshot.json` (generated) + +- [ ] **Step 1: Generate migration** + +Run: `npm run db:generate -- --name=add_canonical_tables` +Expected: Creates SQL file with `CREATE TABLE tournaments ...`, `CREATE TABLE participants ...`, `CREATE TABLE tournament_results ...`, `CREATE TABLE participant_surface_elos ...`, and `ALTER TABLE scoring_events ADD COLUMN tournament_id ...`, `ALTER TABLE season_participants ADD COLUMN participant_id ...`. + +- [ ] **Step 2: Inspect the generated SQL** + +Open the new `drizzle/XXXX_add_canonical_tables.sql`. Verify: +- All four new tables have `CREATE TABLE` statements. +- All FKs reference the correct parent tables. +- Both `ALTER TABLE ... ADD COLUMN` statements are present for `scoring_events` and `season_participants`. +- The new columns are nullable (no `NOT NULL` clause). +- Unique indexes are created. + +If anything looks off, fix the schema and re-run `npm run db:generate`. + +- [ ] **Step 3: Verify snapshot chain** + +Run: `npm run db:generate` +Expected: "No schema changes". Snapshot is consistent. + +- [ ] **Step 4: Apply migration to local DB** + +Run: `npm run db:migrate` +Expected: Migration applies; verify in psql: +``` +\d tournaments +\d participants +\d tournament_results +\d participant_surface_elos +\d scoring_events -- should now show tournament_id column +\d season_participants -- should now show participant_id column +``` + +- [ ] **Step 5: Commit migration** + +```bash +git add drizzle/ +git commit -m "migration: create canonical tables, add nullable FKs" +``` + +--- + +### Task 7: Scaffold `app/models/tournament.ts` with CRUD primitives + +**Why:** Phase 3 code depends on this module. Scaffolding it now in Phase 1b keeps Phase 3 focused on sync-service logic. + +**Files:** +- Create: `app/models/tournament.ts` +- Create: `app/models/__tests__/tournament.test.ts` +- Modify: `app/models/index.ts` + +- [ ] **Step 1: Write the failing test** + +Create `app/models/__tests__/tournament.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from "vitest"; +import { db } from "~/db/context"; +import { + createTournament, + getTournamentById, + findTournamentsBySport, + updateTournamentStatus, +} from "../tournament"; +import { tournaments } from "database/schema"; + +// Assumes a test helper that seeds a sport row and returns its id. +// If the project has a different test helper, adapt. +import { withTestSport } from "~/test/fixtures/sport"; + +describe("tournament model", () => { + beforeEach(async () => { + await db.delete(tournaments); + }); + + it("creates a tournament with UNIQUE (sport, name, year)", async () => { + await withTestSport(async (sportId) => { + const t = await createTournament({ + sportId, + name: "Wimbledon", + year: 2026, + startsAt: new Date("2026-06-29"), + endsAt: new Date("2026-07-12"), + surface: "grass", + }); + expect(t.id).toBeDefined(); + expect(t.status).toBe("scheduled"); + + await expect( + createTournament({ sportId, name: "Wimbledon", year: 2026 }), + ).rejects.toThrow(); + }); + }); + + it("getTournamentById returns null if not found", async () => { + const t = await getTournamentById("00000000-0000-0000-0000-000000000000"); + expect(t).toBeNull(); + }); + + it("findTournamentsBySport filters by sport", async () => { + await withTestSport(async (sportId) => { + await createTournament({ sportId, name: "Masters", year: 2026 }); + await createTournament({ sportId, name: "US Open", year: 2026 }); + const ts = await findTournamentsBySport(sportId); + expect(ts).toHaveLength(2); + }); + }); + + it("updateTournamentStatus advances status", async () => { + await withTestSport(async (sportId) => { + const t = await createTournament({ sportId, name: "French Open", year: 2026 }); + const updated = await updateTournamentStatus(t.id, "completed"); + expect(updated.status).toBe("completed"); + }); + }); +}); +``` + +- [ ] **Step 2: Run the failing test** + +Run: `npm run test:run -- app/models/__tests__/tournament.test.ts` +Expected: FAIL with "Cannot find module '../tournament'". + +- [ ] **Step 3: Implement `app/models/tournament.ts`** + +```typescript +import { db } from "~/db/context"; +import { tournaments } from "database/schema"; +import { eq, and } from "drizzle-orm"; + +export type Tournament = typeof tournaments.$inferSelect; +export type NewTournament = typeof tournaments.$inferInsert; +export type TournamentStatus = Tournament["status"]; + +export async function createTournament(data: NewTournament): Promise { + const [row] = await db.insert(tournaments).values(data).returning(); + return row; +} + +export async function getTournamentById(id: string): Promise { + const [row] = await db.select().from(tournaments).where(eq(tournaments.id, id)); + return row ?? null; +} + +export async function findTournamentsBySport(sportId: string): Promise { + return db + .select() + .from(tournaments) + .where(eq(tournaments.sportId, sportId)) + .orderBy(tournaments.year, tournaments.startsAt); +} + +export async function findTournamentBySportNameYear( + sportId: string, + name: string, + year: number, +): Promise { + const [row] = await db + .select() + .from(tournaments) + .where( + and( + eq(tournaments.sportId, sportId), + eq(tournaments.name, name), + eq(tournaments.year, year), + ), + ); + return row ?? null; +} + +export async function upsertTournament(data: NewTournament): Promise { + const existing = await findTournamentBySportNameYear(data.sportId, data.name, data.year); + if (existing) return existing; + return createTournament(data); +} + +export async function updateTournamentStatus( + id: string, + status: TournamentStatus, +): Promise { + const [row] = await db + .update(tournaments) + .set({ status, updatedAt: new Date() }) + .where(eq(tournaments.id, id)) + .returning(); + return row; +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npm run test:run -- app/models/__tests__/tournament.test.ts` +Expected: PASS (all 4 tests green). + +- [ ] **Step 5: Export from index** + +In `app/models/index.ts`, add: +```typescript +export * from "./tournament"; +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/models/tournament.ts app/models/__tests__/tournament.test.ts app/models/index.ts +git commit -m "feat(models): add canonical tournament CRUD module" +``` + +--- + +### Task 8: Scaffold canonical `app/models/participant.ts` + +**Note:** This file was freed by Phase 1a (old file renamed to `season-participant.ts`). Now we create a new one for the canonical entity. + +**Files:** +- Create: `app/models/participant.ts` +- Create: `app/models/__tests__/participant.test.ts` +- Modify: `app/models/index.ts` + +- [ ] **Step 1: Write the failing test** + +Create `app/models/__tests__/participant.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from "vitest"; +import { db } from "~/db/context"; +import { + createParticipant, + getParticipantById, + findParticipantsBySport, + upsertParticipantBySportName, +} from "../participant"; +import { participants } from "database/schema"; +import { withTestSport } from "~/test/fixtures/sport"; + +describe("participant (canonical) model", () => { + beforeEach(async () => { + await db.delete(participants); + }); + + it("creates a canonical participant", async () => { + await withTestSport(async (sportId) => { + const p = await createParticipant({ sportId, name: "Novak Djokovic" }); + expect(p.id).toBeDefined(); + expect(p.name).toBe("Novak Djokovic"); + }); + }); + + it("enforces UNIQUE (sport, name)", async () => { + await withTestSport(async (sportId) => { + await createParticipant({ sportId, name: "Rory McIlroy" }); + await expect( + createParticipant({ sportId, name: "Rory McIlroy" }), + ).rejects.toThrow(); + }); + }); + + it("upsertParticipantBySportName is idempotent", async () => { + await withTestSport(async (sportId) => { + const a = await upsertParticipantBySportName(sportId, "Iga Swiatek"); + const b = await upsertParticipantBySportName(sportId, "Iga Swiatek"); + expect(a.id).toBe(b.id); + }); + }); + + it("getParticipantById returns null if not found", async () => { + const p = await getParticipantById("00000000-0000-0000-0000-000000000000"); + expect(p).toBeNull(); + }); + + it("findParticipantsBySport filters by sport", async () => { + await withTestSport(async (sportId) => { + await createParticipant({ sportId, name: "Aryna Sabalenka" }); + await createParticipant({ sportId, name: "Coco Gauff" }); + const rows = await findParticipantsBySport(sportId); + expect(rows).toHaveLength(2); + }); + }); +}); +``` + +- [ ] **Step 2: Run failing test** + +Run: `npm run test:run -- app/models/__tests__/participant.test.ts` +Expected: FAIL with module not found. + +- [ ] **Step 3: Implement `app/models/participant.ts`** + +```typescript +import { db } from "~/db/context"; +import { participants } from "database/schema"; +import { eq, and } from "drizzle-orm"; + +export type Participant = typeof participants.$inferSelect; +export type NewParticipant = typeof participants.$inferInsert; + +export async function createParticipant( + data: NewParticipant, +): Promise { + const [row] = await db.insert(participants).values(data).returning(); + return row; +} + +export async function getParticipantById(id: string): Promise { + const [row] = await db.select().from(participants).where(eq(participants.id, id)); + return row ?? null; +} + +export async function findParticipantsBySport(sportId: string): Promise { + return db + .select() + .from(participants) + .where(eq(participants.sportId, sportId)) + .orderBy(participants.name); +} + +export async function findParticipantBySportName( + sportId: string, + name: string, +): Promise { + const [row] = await db + .select() + .from(participants) + .where(and(eq(participants.sportId, sportId), eq(participants.name, name))); + return row ?? null; +} + +export async function upsertParticipantBySportName( + sportId: string, + name: string, + extra?: Partial>, +): Promise { + const existing = await findParticipantBySportName(sportId, name); + if (existing) return existing; + return createParticipant({ sportId, name, ...extra }); +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npm run test:run -- app/models/__tests__/participant.test.ts` +Expected: PASS. + +- [ ] **Step 5: Export from index** + +In `app/models/index.ts`, add: +```typescript +export * from "./participant"; +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/models/participant.ts app/models/__tests__/participant.test.ts app/models/index.ts +git commit -m "feat(models): add canonical participant CRUD module" +``` + +--- + +### Task 9: Scaffold `app/models/tournament-result.ts` + +**Files:** +- Create: `app/models/tournament-result.ts` +- Create: `app/models/__tests__/tournament-result.test.ts` +- Modify: `app/models/index.ts` + +- [ ] **Step 1: Write the failing test** + +Create `app/models/__tests__/tournament-result.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from "vitest"; +import { db } from "~/db/context"; +import { + upsertTournamentResult, + getTournamentResults, + getTournamentResultByParticipant, +} from "../tournament-result"; +import { tournaments, participants, tournamentResults } from "database/schema"; +import { withTestSport } from "~/test/fixtures/sport"; + +async function seed() { + return await withTestSport(async (sportId) => { + const [tournament] = await db + .insert(tournaments) + .values({ sportId, name: "Wimbledon", year: 2026 }) + .returning(); + const [p1] = await db + .insert(participants) + .values({ sportId, name: "Novak Djokovic" }) + .returning(); + const [p2] = await db + .insert(participants) + .values({ sportId, name: "Carlos Alcaraz" }) + .returning(); + return { tournament, p1, p2 }; + }); +} + +describe("tournament-result model", () => { + beforeEach(async () => { + await db.delete(tournamentResults); + await db.delete(participants); + await db.delete(tournaments); + }); + + it("upserts a placement (insert path)", async () => { + const { tournament, p1 } = await seed(); + const r = await upsertTournamentResult({ + tournamentId: tournament.id, + participantId: p1.id, + placement: 1, + }); + expect(r.placement).toBe(1); + }); + + it("upserts a placement (update path — idempotent)", async () => { + const { tournament, p1 } = await seed(); + await upsertTournamentResult({ + tournamentId: tournament.id, + participantId: p1.id, + placement: 1, + }); + const updated = await upsertTournamentResult({ + tournamentId: tournament.id, + participantId: p1.id, + placement: 2, + }); + expect(updated.placement).toBe(2); + + const all = await getTournamentResults(tournament.id); + expect(all).toHaveLength(1); + }); + + it("getTournamentResults returns all results for a tournament", async () => { + const { tournament, p1, p2 } = await seed(); + await upsertTournamentResult({ tournamentId: tournament.id, participantId: p1.id, placement: 1 }); + await upsertTournamentResult({ tournamentId: tournament.id, participantId: p2.id, placement: 2 }); + const all = await getTournamentResults(tournament.id); + expect(all).toHaveLength(2); + }); +}); +``` + +- [ ] **Step 2: Run failing test** + +Run: `npm run test:run -- app/models/__tests__/tournament-result.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement `app/models/tournament-result.ts`** + +```typescript +import { db } from "~/db/context"; +import { tournamentResults } from "database/schema"; +import { eq, and } from "drizzle-orm"; + +export type TournamentResult = typeof tournamentResults.$inferSelect; +export type NewTournamentResult = typeof tournamentResults.$inferInsert; + +export async function upsertTournamentResult( + data: NewTournamentResult, +): Promise { + const [row] = await db + .insert(tournamentResults) + .values(data) + .onConflictDoUpdate({ + target: [tournamentResults.tournamentId, tournamentResults.participantId], + set: { + placement: data.placement ?? null, + rawScore: data.rawScore ?? null, + updatedAt: new Date(), + }, + }) + .returning(); + return row; +} + +export async function getTournamentResults( + tournamentId: string, +): Promise { + return db + .select() + .from(tournamentResults) + .where(eq(tournamentResults.tournamentId, tournamentId)) + .orderBy(tournamentResults.placement); +} + +export async function getTournamentResultByParticipant( + tournamentId: string, + participantId: string, +): Promise { + const [row] = await db + .select() + .from(tournamentResults) + .where( + and( + eq(tournamentResults.tournamentId, tournamentId), + eq(tournamentResults.participantId, participantId), + ), + ); + return row ?? null; +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npm run test:run -- app/models/__tests__/tournament-result.test.ts` +Expected: PASS. + +- [ ] **Step 5: Export and commit** + +In `app/models/index.ts`, add: +```typescript +export * from "./tournament-result"; +``` + +Commit: +```bash +git add app/models/tournament-result.ts app/models/__tests__/tournament-result.test.ts app/models/index.ts +git commit -m "feat(models): add canonical tournament-result CRUD module" +``` + +--- + +### Task 10: Scaffold canonical surface-elo helpers + +**Note:** `app/models/surface-elo.ts` already exists (reads from the renamed `season_participant_surface_elos`). This task adds a sibling file for the canonical table. Phase 3 will switch readers over. + +**Files:** +- Create: `app/models/canonical-surface-elo.ts` +- Create: `app/models/__tests__/canonical-surface-elo.test.ts` +- Modify: `app/models/index.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/models/__tests__/canonical-surface-elo.test.ts +import { describe, it, expect, beforeEach } from "vitest"; +import { db } from "~/db/context"; +import { + upsertCanonicalSurfaceElo, + getCanonicalSurfaceElo, +} from "../canonical-surface-elo"; +import { participants, participantSurfaceElos } from "database/schema"; +import { withTestSport } from "~/test/fixtures/sport"; + +describe("canonical-surface-elo model", () => { + beforeEach(async () => { + await db.delete(participantSurfaceElos); + await db.delete(participants); + }); + + it("inserts a row", async () => { + await withTestSport(async (sportId) => { + const [p] = await db + .insert(participants) + .values({ sportId, name: "Rafael Nadal" }) + .returning(); + const row = await upsertCanonicalSurfaceElo({ + participantId: p.id, + eloClay: 2200, + eloHard: 2050, + eloGrass: 1950, + worldRanking: 5, + }); + expect(row.eloClay).toBe(2200); + }); + }); + + it("upserts (update path)", async () => { + await withTestSport(async (sportId) => { + const [p] = await db + .insert(participants) + .values({ sportId, name: "Daniil Medvedev" }) + .returning(); + await upsertCanonicalSurfaceElo({ participantId: p.id, eloHard: 2100 }); + const updated = await upsertCanonicalSurfaceElo({ + participantId: p.id, + eloHard: 2150, + }); + expect(updated.eloHard).toBe(2150); + + const fetched = await getCanonicalSurfaceElo(p.id); + expect(fetched?.eloHard).toBe(2150); + }); + }); + + it("getCanonicalSurfaceElo returns null if missing", async () => { + const row = await getCanonicalSurfaceElo("00000000-0000-0000-0000-000000000000"); + expect(row).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run failing test** + +Run: `npm run test:run -- app/models/__tests__/canonical-surface-elo.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement `app/models/canonical-surface-elo.ts`** + +```typescript +import { db } from "~/db/context"; +import { participantSurfaceElos } from "database/schema"; +import { eq } from "drizzle-orm"; + +export type CanonicalSurfaceElo = typeof participantSurfaceElos.$inferSelect; +export type NewCanonicalSurfaceElo = typeof participantSurfaceElos.$inferInsert; + +export async function upsertCanonicalSurfaceElo( + data: NewCanonicalSurfaceElo, +): Promise { + const [row] = await db + .insert(participantSurfaceElos) + .values(data) + .onConflictDoUpdate({ + target: [participantSurfaceElos.participantId], + set: { + worldRanking: data.worldRanking ?? null, + eloHard: data.eloHard ?? null, + eloClay: data.eloClay ?? null, + eloGrass: data.eloGrass ?? null, + updatedAt: new Date(), + }, + }) + .returning(); + return row; +} + +export async function getCanonicalSurfaceElo( + participantId: string, +): Promise { + const [row] = await db + .select() + .from(participantSurfaceElos) + .where(eq(participantSurfaceElos.participantId, participantId)); + return row ?? null; +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npm run test:run -- app/models/__tests__/canonical-surface-elo.test.ts` +Expected: PASS. + +- [ ] **Step 5: Export and commit** + +In `app/models/index.ts`: +```typescript +export * from "./canonical-surface-elo"; +``` + +Commit: +```bash +git add app/models/canonical-surface-elo.ts app/models/__tests__/canonical-surface-elo.test.ts app/models/index.ts +git commit -m "feat(models): add canonical surface-elo CRUD module" +``` + +--- + +### Task 11: Final verification + +- [ ] **Step 1: Run full test suite** + +Run: `npm run test:all` +Expected: Green. + +- [ ] **Step 2: Verify DB schema is as expected** + +```bash +psql $DATABASE_URL -c "\dt" | grep -E "(tournaments|^ *participants|tournament_results|participant_surface_elos|season_participant)" +``` +Expected: All of `tournaments`, `participants`, `tournament_results`, `participant_surface_elos`, `season_participants`, `season_participant_expected_values`, `season_participant_qualifying_totals`, `season_participant_results`, `season_participant_surface_elos`. + +- [ ] **Step 3: Verify baselines still unchanged** + +Re-run baseline capture to `/tmp/post-phase1b/`: +```bash +npx tsx scripts/capture-baseline.ts --out=/tmp/post-phase1b +diff test-fixtures/baselines/ /tmp/post-phase1b/ +``` +Expected: Empty diff. Phase 1b adds tables but does not touch existing data. + +- [ ] **Step 4: Tag** + +```bash +git tag phase-1b-complete +``` + +--- + +## Self-Review Checklist + +- [ ] All four canonical tables exist in `database/schema.ts` with Drizzle exports and relations. +- [ ] `scoring_events.tournament_id` and `season_participants.participant_id` are nullable. +- [ ] Four new model files exist with CRUD + tests, all exported from `index.ts`. +- [ ] `npm run test:all` is green. +- [ ] Pre/post baselines diff is empty. +- [ ] Tag `phase-1b-complete` exists. + +## Rollback + +1. `git revert` all commits in this phase. +2. Create reverse migration that drops the four new tables and the two new columns. +3. Run `npm run db:migrate`. + +Phase 2 must not begin until `phase-1b-complete` is shipped and green in production for 24 hours. diff --git a/docs/superpowers/plans/2026-05-02-phase2-backfill-canonical-layer.md b/docs/superpowers/plans/2026-05-02-phase2-backfill-canonical-layer.md new file mode 100644 index 0000000..4827a96 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-phase2-backfill-canonical-layer.md @@ -0,0 +1,978 @@ +# Phase 2 — Backfill Canonical Layer 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:** Populate canonical tables (`tournaments`, `participants`, `tournament_results`, `participant_surface_elos`) from existing per-window rows, and set the FK columns (`scoring_events.tournament_id`, `season_participants.participant_id`). No runtime behavior changes; canonical tables become populated but are not yet read by any code path. + +**Architecture:** One-off script with `--dry-run` and `--sport` flags. For each `sports_seasons` row with `scoring_pattern='qualifying_points'`, upsert canonical rows by `(sport_id, name, year)` for tournaments and `(sport_id, name)` for participants; copy completed `event_results` up to `tournament_results`; merge existing `season_participant_surface_elos` rows into canonical `participant_surface_elos`. Real run is preceded by an exact-match dry-run review. + +**Tech Stack:** TypeScript, Drizzle ORM, PostgreSQL, Vitest + test DB. + +**Prerequisite:** Phase 1b complete. Tag `phase-1b-complete` exists. Baseline fixtures in `test-fixtures/baselines/` captured in Phase 1a. + +**Reference:** `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md` — Phase 2 section. + +**Important constraints:** +- Do not copy `qualifying_points_awarded` from `event_results` to `tournament_results`. QP stays per-window. +- Do not write `participant_qualifying_totals`-equivalent data to canonical. QP totals stay per-window. +- Abort if two windows disagree on a canonical participant's surface Elo values (not expected today; fail loud). + +--- + +### Task 1: Write the scoring-event → tournament matcher (pure function, TDD) + +**Why:** The matcher has zero DB dependency; extract and test it in isolation first. + +**Files:** +- Create: `scripts/backfill/match-tournament.ts` +- Create: `scripts/backfill/__tests__/match-tournament.test.ts` + +- [ ] **Step 1: Write failing tests** + +```typescript +// scripts/backfill/__tests__/match-tournament.test.ts +import { describe, it, expect } from "vitest"; +import { extractTournamentIdentity } from "../match-tournament"; + +describe("extractTournamentIdentity", () => { + it("extracts (name, year) from a scoring event for golf", () => { + const got = extractTournamentIdentity({ + name: "Masters Tournament", + eventDate: "2026-04-09", + eventType: "major_tournament", + }); + expect(got).toEqual({ name: "Masters Tournament", year: 2026 }); + }); + + it("uses eventDate year when event name doesn't embed year", () => { + const got = extractTournamentIdentity({ + name: "Wimbledon", + eventDate: "2026-07-01", + eventType: "major_tournament", + }); + expect(got).toEqual({ name: "Wimbledon", year: 2026 }); + }); + + it("strips embedded year from name if present", () => { + const got = extractTournamentIdentity({ + name: "Wimbledon 2026", + eventDate: "2026-07-01", + eventType: "major_tournament", + }); + expect(got).toEqual({ name: "Wimbledon", year: 2026 }); + }); + + it("throws if eventDate is null and name has no year", () => { + expect(() => + extractTournamentIdentity({ + name: "Wimbledon", + eventDate: null, + eventType: "major_tournament", + }), + ).toThrow(/cannot determine year/i); + }); +}); +``` + +- [ ] **Step 2: Run failing test** + +Run: `npm run test:run -- scripts/backfill/__tests__/match-tournament.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement the pure function** + +```typescript +// scripts/backfill/match-tournament.ts +interface ScoringEventInput { + name: string; + eventDate: string | null; + eventType: string; +} + +export interface TournamentIdentity { + name: string; + year: number; +} + +const YEAR_SUFFIX = /\s+(\d{4})\s*$/; + +export function extractTournamentIdentity(ev: ScoringEventInput): TournamentIdentity { + const trimmed = ev.name.trim(); + const yearMatch = trimmed.match(YEAR_SUFFIX); + let cleanName = trimmed; + let year: number | null = null; + + if (yearMatch) { + year = parseInt(yearMatch[1], 10); + cleanName = trimmed.replace(YEAR_SUFFIX, "").trim(); + } + + if (year === null && ev.eventDate) { + year = parseInt(ev.eventDate.slice(0, 4), 10); + } + + if (year === null) { + throw new Error( + `cannot determine year for event "${ev.name}" — no year in name and eventDate is null`, + ); + } + + return { name: cleanName, year }; +} +``` + +- [ ] **Step 4: Verify tests pass** + +Run: `npm run test:run -- scripts/backfill/__tests__/match-tournament.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/backfill/ +git commit -m "feat(backfill): tournament identity extraction from scoring events" +``` + +--- + +### Task 2: Write the backfill orchestrator with dry-run (TDD against test DB) + +**Files:** +- Create: `scripts/backfill-canonical-layer.ts` +- Create: `scripts/__tests__/backfill-canonical-layer.test.ts` + +- [ ] **Step 1: Write the test — empty DB no-op** + +```typescript +// scripts/__tests__/backfill-canonical-layer.test.ts +import { describe, it, expect, beforeEach } from "vitest"; +import { db } from "~/db/context"; +import { + tournaments, + participants, + tournamentResults, + participantSurfaceElos, + scoringEvents, + seasonParticipants, + sportsSeasons, + sports, + eventResults, + seasonParticipantSurfaceElos, +} from "database/schema"; +import { runBackfill } from "../backfill-canonical-layer"; + +async function truncateAll() { + await db.delete(tournamentResults); + await db.delete(participantSurfaceElos); + await db.delete(participants); + await db.delete(tournaments); + await db.delete(eventResults); + await db.delete(seasonParticipantSurfaceElos); + await db.delete(seasonParticipants); + await db.delete(scoringEvents); + await db.delete(sportsSeasons); + await db.delete(sports); +} + +describe("backfill-canonical-layer", () => { + beforeEach(async () => { + await truncateAll(); + }); + + it("no-ops on empty DB", async () => { + const result = await runBackfill({ dryRun: false }); + expect(result.tournamentsCreated).toBe(0); + expect(result.participantsCreated).toBe(0); + expect(result.tournamentResultsCreated).toBe(0); + expect(result.surfaceElosCreated).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run failing test** + +Expected: FAIL (module missing). + +- [ ] **Step 3: Implement skeleton** + +```typescript +// scripts/backfill-canonical-layer.ts +import { db } from "~/db/context"; +import { + tournaments, + participants, + tournamentResults, + participantSurfaceElos, + scoringEvents, + seasonParticipants, + sportsSeasons, + eventResults, + seasonParticipantSurfaceElos, +} from "database/schema"; +import { eq, and, isNull } from "drizzle-orm"; +import { extractTournamentIdentity } from "./backfill/match-tournament"; + +export interface BackfillOptions { + dryRun: boolean; + sportId?: string; +} + +export interface BackfillReport { + tournamentsCreated: number; + tournamentsLinked: number; // scoringEvents with tournament_id now set + participantsCreated: number; + participantsLinked: number; // seasonParticipants with participant_id now set + tournamentResultsCreated: number; + surfaceElosCreated: number; + warnings: string[]; + errors: string[]; +} + +export async function runBackfill(opts: BackfillOptions): Promise { + const report: BackfillReport = { + tournamentsCreated: 0, + tournamentsLinked: 0, + participantsCreated: 0, + participantsLinked: 0, + tournamentResultsCreated: 0, + surfaceElosCreated: 0, + warnings: [], + errors: [], + }; + + // Eligible sports seasons + const seasons = await db + .select() + .from(sportsSeasons) + .where(eq(sportsSeasons.scoringPattern, "qualifying_points")); + + for (const season of seasons) { + if (opts.sportId && season.sportId !== opts.sportId) continue; + await backfillSeason(season, opts, report); + } + + return report; +} + +async function backfillSeason( + season: typeof sportsSeasons.$inferSelect, + opts: BackfillOptions, + report: BackfillReport, +): Promise { + // Implemented in later tasks. +} +``` + +- [ ] **Step 4: Verify empty-DB test passes** + +Run: `npm run test:run -- scripts/__tests__/backfill-canonical-layer.test.ts` +Expected: PASS (empty DB no-op). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/backfill-canonical-layer.ts scripts/__tests__/backfill-canonical-layer.test.ts +git commit -m "feat(backfill): orchestrator skeleton with empty-DB test" +``` + +--- + +### Task 3: Tournament + scoring-event linking + +**Files:** +- Modify: `scripts/backfill-canonical-layer.ts` +- Modify: `scripts/__tests__/backfill-canonical-layer.test.ts` + +- [ ] **Step 1: Add failing test — golf window with 4 events** + +Append to the test file: + +```typescript +async function seedGolfWindowWith4Events() { + const [sport] = await db + .insert(sports) + .values({ name: "Golf", type: "individual", simulatorType: "golf_qualifying_points" }) + .returning(); + + const [ss] = await db + .insert(sportsSeasons) + .values({ + sportId: sport.id, + year: 2026, + scoringPattern: "qualifying_points", + totalMajors: 4, + majorsCompleted: 1, + }) + .returning(); + + const events = await db + .insert(scoringEvents) + .values([ + { sportsSeasonId: ss.id, name: "Masters Tournament", eventDate: "2026-04-09", eventType: "major_tournament", isQualifyingEvent: true }, + { sportsSeasonId: ss.id, name: "PGA Championship", eventDate: "2026-05-14", eventType: "major_tournament", isQualifyingEvent: true }, + { sportsSeasonId: ss.id, name: "US Open", eventDate: "2026-06-18", eventType: "major_tournament", isQualifyingEvent: true }, + { sportsSeasonId: ss.id, name: "The Open Championship", eventDate: "2026-07-16", eventType: "major_tournament", isQualifyingEvent: true }, + ]) + .returning(); + + return { sport, ss, events }; +} + +it("creates tournaments and links scoringEvents for a golf window", async () => { + const { sport, events } = await seedGolfWindowWith4Events(); + const report = await runBackfill({ dryRun: false }); + + expect(report.tournamentsCreated).toBe(4); + expect(report.tournamentsLinked).toBe(4); + + const ts = await db.select().from(tournaments).where(eq(tournaments.sportId, sport.id)); + expect(ts.map((t) => t.name).sort()).toEqual( + ["Masters Tournament", "PGA Championship", "The Open Championship", "US Open"], + ); + expect(ts.every((t) => t.year === 2026)).toBe(true); + + const linkedEvents = await db.select().from(scoringEvents).where(eq(scoringEvents.sportsSeasonId, events[0].sportsSeasonId)); + expect(linkedEvents.every((e) => e.tournamentId !== null)).toBe(true); +}); +``` + +- [ ] **Step 2: Run failing test** + +Expected: FAIL (nothing is created yet). + +- [ ] **Step 3: Implement tournament backfill in `backfillSeason`** + +```typescript +async function backfillSeason(season, opts, report) { + const events = await db + .select() + .from(scoringEvents) + .where(eq(scoringEvents.sportsSeasonId, season.id)); + + for (const ev of events) { + if (ev.tournamentId) continue; // already linked + + let identity; + try { + identity = extractTournamentIdentity({ + name: ev.name, + eventDate: ev.eventDate, + eventType: ev.eventType, + }); + } catch (e: any) { + report.errors.push(`event ${ev.id}: ${e.message}`); + continue; + } + + const existing = await db + .select() + .from(tournaments) + .where( + and( + eq(tournaments.sportId, season.sportId), + eq(tournaments.name, identity.name), + eq(tournaments.year, identity.year), + ), + ); + + let tournamentId: string; + if (existing.length > 0) { + tournamentId = existing[0].id; + } else { + if (!opts.dryRun) { + const [row] = await db + .insert(tournaments) + .values({ + sportId: season.sportId, + name: identity.name, + year: identity.year, + startsAt: ev.eventDate ? new Date(ev.eventDate) : null, + status: ev.eventDate && new Date(ev.eventDate) < new Date() ? "completed" : "scheduled", + }) + .returning(); + tournamentId = row.id; + } else { + tournamentId = "00000000-0000-0000-0000-000000000000"; // placeholder + } + report.tournamentsCreated += 1; + } + + if (!opts.dryRun) { + await db + .update(scoringEvents) + .set({ tournamentId }) + .where(eq(scoringEvents.id, ev.id)); + } + report.tournamentsLinked += 1; + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npm run test:run -- scripts/__tests__/backfill-canonical-layer.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/ +git commit -m "feat(backfill): link scoring events to canonical tournaments" +``` + +--- + +### Task 4: Participant backfill + linking + +**Files:** +- Modify: `scripts/backfill-canonical-layer.ts` +- Modify: `scripts/__tests__/backfill-canonical-layer.test.ts` + +- [ ] **Step 1: Add failing test** + +```typescript +it("creates canonical participants and links season_participants", async () => { + const { sport, ss } = await seedGolfWindowWith4Events(); + const [sp1] = await db + .insert(seasonParticipants) + .values({ sportsSeasonId: ss.id, name: "Rory McIlroy" }) + .returning(); + const [sp2] = await db + .insert(seasonParticipants) + .values({ sportsSeasonId: ss.id, name: "Scottie Scheffler" }) + .returning(); + + const report = await runBackfill({ dryRun: false }); + expect(report.participantsCreated).toBe(2); + expect(report.participantsLinked).toBe(2); + + const canonical = await db.select().from(participants).where(eq(participants.sportId, sport.id)); + expect(canonical.map((p) => p.name).sort()).toEqual(["Rory McIlroy", "Scottie Scheffler"]); + + const [relinkedSP] = await db.select().from(seasonParticipants).where(eq(seasonParticipants.id, sp1.id)); + expect(relinkedSP.participantId).toBe(canonical.find((p) => p.name === "Rory McIlroy")!.id); +}); + +it("re-running the backfill does not create duplicate canonical participants", async () => { + const { ss } = await seedGolfWindowWith4Events(); + await db.insert(seasonParticipants).values({ sportsSeasonId: ss.id, name: "Jon Rahm" }); + + await runBackfill({ dryRun: false }); + await runBackfill({ dryRun: false }); + + const rows = await db.select().from(participants); + expect(rows.filter((r) => r.name === "Jon Rahm")).toHaveLength(1); +}); +``` + +- [ ] **Step 2: Run failing tests** + +Expected: FAIL. + +- [ ] **Step 3: Extend `backfillSeason` with participant logic** + +Append inside `backfillSeason` (after the event loop): + +```typescript +const seasonPs = await db + .select() + .from(seasonParticipants) + .where(eq(seasonParticipants.sportsSeasonId, season.id)); + +for (const sp of seasonPs) { + if (sp.participantId) continue; + + const existing = await db + .select() + .from(participants) + .where(and(eq(participants.sportId, season.sportId), eq(participants.name, sp.name))); + + let canonicalId: string; + if (existing.length > 0) { + canonicalId = existing[0].id; + } else { + if (!opts.dryRun) { + const [row] = await db + .insert(participants) + .values({ sportId: season.sportId, name: sp.name }) + .returning(); + canonicalId = row.id; + } else { + canonicalId = "00000000-0000-0000-0000-000000000000"; + } + report.participantsCreated += 1; + } + + if (!opts.dryRun) { + await db + .update(seasonParticipants) + .set({ participantId: canonicalId }) + .where(eq(seasonParticipants.id, sp.id)); + } + report.participantsLinked += 1; +} +``` + +- [ ] **Step 4: Run tests** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/ +git commit -m "feat(backfill): canonical participants + season-participant linking" +``` + +--- + +### Task 5: Copy completed event results to tournament_results (Masters 2026 case) + +**Files:** +- Modify: `scripts/backfill-canonical-layer.ts` +- Modify: `scripts/__tests__/backfill-canonical-layer.test.ts` + +- [ ] **Step 1: Add failing test — Masters 2026 round-trip** + +```typescript +it("copies completed event_results to tournament_results without touching QP", async () => { + const { ss, events } = await seedGolfWindowWith4Events(); + const masters = events.find((e) => e.name === "Masters Tournament")!; + const [sp] = await db + .insert(seasonParticipants) + .values({ sportsSeasonId: ss.id, name: "Scottie Scheffler" }) + .returning(); + + await db.insert(eventResults).values({ + scoringEventId: masters.id, + seasonParticipantId: sp.id, + placement: 1, + rawScore: "-10", + qualifyingPointsAwarded: "100.00", + }); + + const report = await runBackfill({ dryRun: false }); + expect(report.tournamentResultsCreated).toBe(1); + + const trs = await db.select().from(tournamentResults); + expect(trs).toHaveLength(1); + expect(trs[0].placement).toBe(1); + expect(trs[0].rawScore).toBe("-10"); + + // QP on event_results must be untouched + const [er] = await db.select().from(eventResults).where(eq(eventResults.scoringEventId, masters.id)); + expect(er.qualifyingPointsAwarded).toBe("100.00"); +}); +``` + +- [ ] **Step 2: Run failing test** + +Expected: FAIL. + +- [ ] **Step 3: Extend `backfillSeason`** + +Append inside `backfillSeason` after participant linking: + +```typescript +// Refresh events to get tournament_ids set earlier in this loop +const refreshedEvents = await db + .select() + .from(scoringEvents) + .where(eq(scoringEvents.sportsSeasonId, season.id)); + +for (const ev of refreshedEvents) { + if (!ev.tournamentId) continue; + const ers = await db + .select() + .from(eventResults) + .where(eq(eventResults.scoringEventId, ev.id)); + + for (const er of ers) { + // Find canonical participant via the season participant + const [sp] = await db + .select() + .from(seasonParticipants) + .where(eq(seasonParticipants.id, er.seasonParticipantId)); + if (!sp || !sp.participantId) continue; + + // Skip the zero-QP filler rows (they carry no new information) + if (er.placement === null && er.rawScore === null) continue; + + const existing = await db + .select() + .from(tournamentResults) + .where( + and( + eq(tournamentResults.tournamentId, ev.tournamentId), + eq(tournamentResults.participantId, sp.participantId), + ), + ); + + if (existing.length > 0) continue; + + if (!opts.dryRun) { + await db.insert(tournamentResults).values({ + tournamentId: ev.tournamentId, + participantId: sp.participantId, + placement: er.placement, + rawScore: er.rawScore, + }); + } + report.tournamentResultsCreated += 1; + } +} +``` + +- [ ] **Step 4: Run tests** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/ +git commit -m "feat(backfill): copy completed event_results into tournament_results" +``` + +--- + +### Task 6: Surface Elo backfill (with conflict detection) + +**Files:** +- Modify: `scripts/backfill-canonical-layer.ts` +- Modify: `scripts/__tests__/backfill-canonical-layer.test.ts` + +- [ ] **Step 1: Add failing tests** + +```typescript +async function seedTennisWithSurfaceElo() { + const [sport] = await db + .insert(sports) + .values({ name: "Tennis", type: "individual", simulatorType: "tennis_qualifying_points" }) + .returning(); + const [ss] = await db + .insert(sportsSeasons) + .values({ sportId: sport.id, year: 2026, scoringPattern: "qualifying_points", totalMajors: 4 }) + .returning(); + const [sp] = await db + .insert(seasonParticipants) + .values({ sportsSeasonId: ss.id, name: "Novak Djokovic" }) + .returning(); + await db + .insert(seasonParticipantSurfaceElos) + .values({ + participantId: sp.id, + sportsSeasonId: ss.id, + eloHard: 2100, + eloClay: 2000, + eloGrass: 2050, + worldRanking: 3, + }); + return { sport, ss, sp }; +} + +it("backfills canonical surface elo from the single per-window row", async () => { + const { sport } = await seedTennisWithSurfaceElo(); + const report = await runBackfill({ dryRun: false }); + + expect(report.surfaceElosCreated).toBe(1); + const canonicalPs = await db.select().from(participants).where(eq(participants.sportId, sport.id)); + const [canonicalElo] = await db + .select() + .from(participantSurfaceElos) + .where(eq(participantSurfaceElos.participantId, canonicalPs[0].id)); + expect(canonicalElo.eloHard).toBe(2100); + expect(canonicalElo.eloClay).toBe(2000); + expect(canonicalElo.eloGrass).toBe(2050); + expect(canonicalElo.worldRanking).toBe(3); +}); + +it("aborts if two windows disagree on a canonical participant's surface elo", async () => { + const { sport, ss, sp } = await seedTennisWithSurfaceElo(); + + // Second window with same canonical participant name but different elo + const [ss2] = await db + .insert(sportsSeasons) + .values({ sportId: sport.id, year: 2027, scoringPattern: "qualifying_points", totalMajors: 4 }) + .returning(); + const [sp2] = await db + .insert(seasonParticipants) + .values({ sportsSeasonId: ss2.id, name: "Novak Djokovic" }) + .returning(); + await db.insert(seasonParticipantSurfaceElos).values({ + participantId: sp2.id, + sportsSeasonId: ss2.id, + eloHard: 2099, // different + eloClay: 2000, + eloGrass: 2050, + }); + + const report = await runBackfill({ dryRun: false }); + expect(report.errors.some((e) => /conflict/i.test(e))).toBe(true); +}); +``` + +- [ ] **Step 2: Run failing tests** + +Expected: FAIL. + +- [ ] **Step 3: Extend `backfillSeason`** + +Append to `backfillSeason`: + +```typescript +const elos = await db + .select() + .from(seasonParticipantSurfaceElos) + .where(eq(seasonParticipantSurfaceElos.sportsSeasonId, season.id)); + +for (const row of elos) { + const [sp] = await db + .select() + .from(seasonParticipants) + .where(eq(seasonParticipants.id, row.participantId)); + if (!sp || !sp.participantId) continue; + + const [existing] = await db + .select() + .from(participantSurfaceElos) + .where(eq(participantSurfaceElos.participantId, sp.participantId)); + + if (existing) { + const mismatched = + existing.eloHard !== row.eloHard || + existing.eloClay !== row.eloClay || + existing.eloGrass !== row.eloGrass || + existing.worldRanking !== row.worldRanking; + if (mismatched) { + report.errors.push( + `surface-elo conflict for canonical participant ${sp.participantId} (name=${sp.name})`, + ); + } + continue; + } + + if (!opts.dryRun) { + await db.insert(participantSurfaceElos).values({ + participantId: sp.participantId, + eloHard: row.eloHard, + eloClay: row.eloClay, + eloGrass: row.eloGrass, + worldRanking: row.worldRanking, + }); + } + report.surfaceElosCreated += 1; +} +``` + +- [ ] **Step 4: Run tests** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/ +git commit -m "feat(backfill): canonical surface-elo with conflict detection" +``` + +--- + +### Task 7: CLI wrapper + dry-run output + +**Files:** +- Create: `scripts/backfill-cli.ts` (CLI entry) +- Modify: `scripts/backfill-canonical-layer.ts` (optional — if the main file exported a report shape, we format it here) + +- [ ] **Step 1: Write the CLI** + +```typescript +// scripts/backfill-cli.ts +import { runBackfill, BackfillOptions } from "./backfill-canonical-layer"; + +function parseArgs(): BackfillOptions { + const args = process.argv.slice(2); + const opts: BackfillOptions = { dryRun: true }; + for (const a of args) { + if (a === "--apply") opts.dryRun = false; + else if (a === "--dry-run") opts.dryRun = true; + else if (a.startsWith("--sport=")) opts.sportId = a.slice("--sport=".length); + else if (a === "--help") { + console.log("Usage: tsx scripts/backfill-cli.ts [--dry-run|--apply] [--sport=]"); + process.exit(0); + } else { + console.error(`unknown arg: ${a}`); + process.exit(1); + } + } + return opts; +} + +async function main() { + const opts = parseArgs(); + console.log(`Running backfill (dryRun=${opts.dryRun}, sportId=${opts.sportId ?? "all"})`); + const report = await runBackfill(opts); + + console.log("---"); + console.log(`tournamentsCreated: ${report.tournamentsCreated}`); + console.log(`tournamentsLinked: ${report.tournamentsLinked}`); + console.log(`participantsCreated: ${report.participantsCreated}`); + console.log(`participantsLinked: ${report.participantsLinked}`); + console.log(`tournamentResultsCreated: ${report.tournamentResultsCreated}`); + console.log(`surfaceElosCreated: ${report.surfaceElosCreated}`); + if (report.warnings.length) { + console.log("\nWARNINGS:"); + for (const w of report.warnings) console.log(` ${w}`); + } + if (report.errors.length) { + console.log("\nERRORS:"); + for (const e of report.errors) console.log(` ${e}`); + process.exit(2); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); +``` + +- [ ] **Step 2: Add npm script** + +In `package.json` scripts block, add: + +```json +"backfill:canonical": "dotenv -- tsx scripts/backfill-cli.ts" +``` + +- [ ] **Step 3: Run dry-run locally** + +Against the dev DB (loaded with `npm run db:sync-prod`): + +Run: `npm run backfill:canonical -- --dry-run` +Expected: Non-zero `tournamentsCreated` etc., zero `errors`. Save stdout as `/tmp/backfill-dryrun.txt`. + +- [ ] **Step 4: Inspect the dry-run output** + +Review `/tmp/backfill-dryrun.txt`. For each category, sanity-check the count. For example, if we have 2 in-flight golf windows × 4 events each = 8 golf events, `tournamentsCreated` might be 4–8 depending on overlap. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/backfill-cli.ts package.json +git commit -m "feat(backfill): CLI wrapper with dry-run default" +``` + +--- + +### Task 8: Go/no-go verification (real run on staging) + +**Human in the loop** — this task is NOT automatable. + +- [ ] **Step 1: Deploy all preceding commits to staging** + +Via the project's normal deploy flow. + +- [ ] **Step 2: Capture a pre-backfill snapshot of the in-flight windows** + +On staging, run: + +```bash +npx tsx scripts/capture-baseline.ts --out=/tmp/pre-backfill-staging +``` + +- [ ] **Step 3: Run dry-run on staging** + +```bash +npm run backfill:canonical -- --dry-run +``` + +Expected: zero errors; counts match expectations. + +- [ ] **Step 4: Human review of dry-run report** + +Requires a human. Verify: +- Tournament names match real-world identity (no "Masters Tournament 2026" with year=null). +- Participant counts per sport are sensible. +- No conflict errors. + +If anything looks wrong, fix in code (not in DB), redeploy, re-run dry-run. + +- [ ] **Step 5: Run real backfill on staging** + +```bash +npm run backfill:canonical -- --apply +``` + +Expected: Same counts as dry-run, zero errors. + +- [ ] **Step 6: Verify no drift on QP totals** + +```bash +npx tsx scripts/capture-baseline.ts --out=/tmp/post-backfill-staging +diff /tmp/pre-backfill-staging/ /tmp/post-backfill-staging/ +``` + +Expected: **Zero differences in `qp-totals-*.json` and `event-results-*.json`**. Backfill must not touch these. + +The `surface-elos-*.json` files may differ because this captures per-window Elo before Phase 2; after Phase 2 it still captures the same per-window table (`season_participant_surface_elos`), which is untouched by backfill. So diff should still be empty. If not, stop and investigate. + +- [ ] **Step 7: Spot-check Masters 2026 round-trip on staging** + +```bash +psql $STAGING_DB -c " +SELECT er.placement AS ev_placement, er.raw_score AS ev_score, + tr.placement AS tr_placement, tr.raw_score AS tr_score, + sp.name +FROM event_results er +JOIN scoring_events se ON se.id = er.scoring_event_id +JOIN tournaments t ON t.id = se.tournament_id +JOIN tournament_results tr ON tr.tournament_id = t.id + AND tr.participant_id = (SELECT participant_id FROM season_participants WHERE id = er.season_participant_id) +JOIN season_participants sp ON sp.id = er.season_participant_id +WHERE t.name = 'Masters Tournament' AND t.year = 2026; +" +``` + +Expected: Every row has `ev_placement = tr_placement` and `ev_score = tr_score`. + +- [ ] **Step 8: Proceed to production only if all checks pass** + +Same flow on prod: capture baseline → dry-run → human review → apply → diff → Masters spot-check. + +- [ ] **Step 9: Tag** + +```bash +git tag phase-2-complete +``` + +--- + +## Self-Review Checklist + +- [ ] `runBackfill` with `dryRun: true` writes nothing (verify via row-count diff). +- [ ] All tests in `scripts/__tests__/backfill-canonical-layer.test.ts` pass. +- [ ] After real run on prod: every `scoringEvents` row in a `qualifying_points` season has non-null `tournament_id`. +- [ ] Every `seasonParticipants` row in a `qualifying_points` season has non-null `participant_id`. +- [ ] Every completed `eventResults` row (non-null `placement` OR non-null `rawScore`) has a matching `tournamentResults` row. +- [ ] No drift in `participant_qualifying_totals` vs. baseline. +- [ ] Tag `phase-2-complete` on main. + +## Rollback + +Phase 2 is reversible because no production code reads canonical tables yet: + +```sql +BEGIN; +UPDATE season_participants SET participant_id = NULL WHERE participant_id IS NOT NULL; +UPDATE scoring_events SET tournament_id = NULL WHERE tournament_id IS NOT NULL; +DELETE FROM tournament_results; +DELETE FROM participant_surface_elos; +DELETE FROM participants; +DELETE FROM tournaments; +COMMIT; +``` + +Then re-run backfill after fixing root cause. + +Phase 3 begins only after `phase-2-complete` has been green in production for 24 hours. diff --git a/docs/superpowers/plans/2026-05-02-phase3-canonical-cutover.md b/docs/superpowers/plans/2026-05-02-phase3-canonical-cutover.md new file mode 100644 index 0000000..1d39dd9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-phase3-canonical-cutover.md @@ -0,0 +1,1272 @@ +# 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 { + 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 { + 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> { + 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(); + 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(); + return ( +
+

Tournaments

+ {grouped.map((g) => ( +
+

{g.sport.name}

+
    + {g.tournaments.map((t) => ( +
  • + + {t.name} {t.year} + + ({t.status}) +
  • + ))} +
+
+ ))} +
+ ); +} +``` + +- [ ] **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(); + 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(); + const actionData = useActionData(); + + return ( +
+

+ {tournament.name} {tournament.year} +

+

Status: {tournament.status}

+ +
+

Current Results

+ + + {results.map((r) => ( + + + + + ))} + +
{r.placement}{canonicalParticipants.find((p) => p.id === r.participantId)?.name}
+
+ +
+

Enter Results

+ ({ id: p.id, name: p.name }))} + intent="batch-upsert-results" + /> +
+ + {actionData?.syncReport && ( + + )} +
+ ); +} +``` + +**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 { + 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/` 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. diff --git a/docs/superpowers/plans/2026-05-02-phase4-cleanup.md b/docs/superpowers/plans/2026-05-02-phase4-cleanup.md new file mode 100644 index 0000000..afcf694 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-phase4-cleanup.md @@ -0,0 +1,359 @@ +# Phase 4 — Cleanup 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:** Remove the per-window surface-Elo admin UI + backing table, remove the old batch-add-results wrapper route (keeping the canonical route as the only path), and strip compatibility shims. Leaves the canonical layer as the sole source of truth. + +**Architecture:** Pure removal. No new behavior. Each removal is preceded by a verification step proving the removed code is no longer reachable. + +**Tech Stack:** Drizzle migration, Vitest, Cypress. + +**Prerequisite:** Phase 3 complete and green in production for at least 7 days with active draft activity. Tag `phase-3-complete` exists. + +**Reference:** `docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md` — Phase 4 section. + +--- + +### Task 1: Verify `season_participant_surface_elos` is no longer read + +**Why:** Before dropping the table, prove nothing reads from it. + +- [ ] **Step 1: Grep for reads** + +Run: +```bash +grep -rn "seasonParticipantSurfaceElos\|season_participant_surface_elos" app/ server/ scripts/ database/ +``` + +Expected: only hits in `database/schema.ts` (the Drizzle export and relation). No app/service reads, no queries. + +If any live read remains — stop. Phase 3 was incomplete; fix the read path to go canonical, redeploy, wait 24h, then return here. + +- [ ] **Step 2: Production observability check** + +Check production logs and/or `pg_stat_user_tables` for activity on `season_participant_surface_elos`: + +```sql +SELECT seq_scan, seq_tup_read, idx_scan, idx_tup_fetch, n_tup_upd, n_tup_del +FROM pg_stat_user_tables +WHERE relname = 'season_participant_surface_elos'; +``` + +Check `pg_stat_reset()` was not called recently. If the table has seen reads/writes in the past 7 days (compared against a baseline), investigate before dropping. + +- [ ] **Step 3: Commit a note in the plan** + +Paste the output of Steps 1 and 2 into a short PR description for the drop migration, documenting verification. + +--- + +### Task 2: Drop `season_participant_surface_elos` + +**Files:** +- Modify: `database/schema.ts` (remove export + relations) +- Create: `drizzle/XXXX_drop_season_participant_surface_elos.sql` + +- [ ] **Step 1: Remove the table from schema** + +In `database/schema.ts`, delete: +- The `seasonParticipantSurfaceElos` `pgTable(...)` export. +- The `seasonParticipantSurfaceElosRelations` export. +- Any references in other `*Relations` exports (e.g., in `seasonParticipantsRelations` or `sportsSeasonsRelations` that include `seasonParticipantSurfaceElos` via `many()`). + +- [ ] **Step 2: Generate migration** + +Run: `npm run db:generate -- --name=drop_season_participant_surface_elos` +Expected: Migration with a single `DROP TABLE "season_participant_surface_elos"` statement. + +- [ ] **Step 3: Verify snapshot chain** + +Run: `npm run db:generate` +Expected: "No schema changes". + +- [ ] **Step 4: Apply migration on staging and re-run tests** + +Run: `npm run db:migrate` +Run: `npm run test:all` +Expected: Green. + +- [ ] **Step 5: Apply migration on production** + +Follow your normal deploy flow. After deploy, re-verify: + +```sql +SELECT tablename FROM pg_tables WHERE tablename = 'season_participant_surface_elos'; +``` + +Expected: no row returned. + +- [ ] **Step 6: Commit** + +```bash +git add database/schema.ts drizzle/ +git commit -m "migration: drop season_participant_surface_elos table + +Canonical participant_surface_elos is the sole source of surface Elo +since Phase 3. No reads or writes observed in the past 7 days. +Part of Phase 4 canonical tournament layer cleanup." +``` + +--- + +### Task 3: Remove per-window surface-Elo admin UI + +**Files:** +- Remove: `app/routes/admin.sports-seasons.$id.surface-elo.tsx` +- Modify: `app/routes.ts` (remove the route registration) +- Modify: any navigation links pointing at the removed page + +- [ ] **Step 1: Find references to the removed route** + +Run: +```bash +grep -rn "surface-elo" app/ | grep -v "^\.\/app/routes/admin\.sports-seasons\.\\\$id\.surface-elo\.tsx" +``` + +Expected: admin navigation links + possibly dashboard; update them to point at `/admin/participants/:id/surface-elo` or the canonical participant list. + +- [ ] **Step 2: Delete the route file** + +```bash +git rm app/routes/admin.sports-seasons.$id.surface-elo.tsx +``` + +- [ ] **Step 3: Remove route registration** + +In `app/routes.ts`, delete the line registering `admin.sports-seasons.$id.surface-elo.tsx`. + +- [ ] **Step 4: Update any links** + +Edit each file found in Step 1 to redirect or remove the per-window link. + +- [ ] **Step 5: Run full test suite** + +Run: `npm run test:all` +Expected: Green. Any failing tests that specifically asserted on the old route should be updated or removed. + +- [ ] **Step 6: Commit** + +```bash +git add app/routes.ts app/ +git commit -m "remove: per-window surface-elo admin UI + +Canonical participant surface-elo edit page (/admin/participants/:id/surface-elo) +is now the only path. Part of Phase 4 cleanup." +``` + +--- + +### Task 4: Remove the batch-add-results wrapper from per-window route + +**Goal:** The per-window scoring-event admin route still has a `batch-add-results` intent (added in Phase 3 as a wrapper that writes to canonical). Remove the intent so the route no longer accepts per-window result entry. Admins must use `/admin/tournaments/:id` instead. + +**Files:** +- Modify: the per-window route handling qualifying result entry (the one edited in Phase 3 Task 11; find via `grep -l "batch-add-results" app/routes/`) +- Modify: any UI component that still posts to `batch-add-results` on that route + +- [ ] **Step 1: Grep for usages of the wrapper** + +Run: +```bash +grep -rn "batch-add-results" app/ +``` + +Cross-reference with Phase 3 changes — confirm the only live callers are UI components on per-window pages (which should have been deprecated in Phase 3 release notes). + +- [ ] **Step 2: Remove the `batch-add-results` intent branch** + +In the per-window route action handler, delete the `if (intent === "batch-add-results") { ... }` branch. Other intents stay. + +- [ ] **Step 3: Remove the Batch Result Entry UI from the per-window event page** + +Find the component that rendered the "Enter Results" form on per-window pages. Replace with a banner: + +```tsx +
+

Result entry has moved.

+ + Enter results for {event.name} here. + +
+``` + +This assumes `event.tournamentId` is loaded in the route loader (it is, after Phase 1b). + +- [ ] **Step 4: Remove related E2E tests that exercised the old form** + +Search `cypress/e2e/` for tests that used the per-window batch form. Either update to use the new canonical flow or remove if covered by `cypress/e2e/enter-tournament-result.cy.ts`. + +- [ ] **Step 5: Run full suite** + +Run: `npm run test:all` +Expected: Green. + +- [ ] **Step 6: Manual smoke** + +Run dev server, log in as admin, navigate to `/admin/sports-seasons//events/` → verify the banner links to the canonical tournament page. + +- [ ] **Step 7: Commit** + +```bash +git add app/ +git commit -m "remove: per-window batch-add-results intent and UI + +Result entry consolidated on /admin/tournaments/:id. Per-window event page +shows a deep link to the canonical flow. Part of Phase 4 cleanup." +``` + +--- + +### Task 5: Remove unused compatibility shims + +**Goal:** Any helper functions, type aliases, or dead code paths added during Phase 3 to keep old callers working. + +- [ ] **Step 1: Grep for known shim markers** + +If Phase 3 left `@deprecated` JSDoc tags, find them: + +```bash +grep -rn "@deprecated" app/ | grep -iE "canonical|tournament|participant" +``` + +For each, verify no live callers remain (`grep -rn "" app/`), then delete. + +- [ ] **Step 2: Check the `intent` prop default on `BatchResultEntry`** + +If Phase 3 Task 8 added an `intent` prop with default `"batch-add-results"`, change the default to `"batch-upsert-results"` now (the canonical intent) since the old one is gone. Or, if the component is now only used from the canonical route, make the prop required and remove the default. + +- [ ] **Step 3: Run full suite + typecheck** + +Run: `npm run typecheck && npm run test:all` +Expected: Green. + +- [ ] **Step 4: Commit** + +```bash +git add app/ +git commit -m "chore: remove deprecated shims after Phase 3 cutover" +``` + +--- + +### Task 6: Final documentation sweep + +**Files:** +- Modify: `docs/agents/database.md` — document canonical vs per-window split +- Modify: `docs/agents/domain-models.md` +- Create: a short section in `docs/` describing how to create a new rolling window (user-facing operator guide) + +- [ ] **Step 1: Update `docs/agents/database.md`** + +Add a section: + +````markdown +## Canonical vs. Per-Window Tables (Qualifying-Points Sports) + +Golf, tennis, and CS2 use a split model: + +- **Canonical** (real-world identity, shared across seasons): + - `tournaments` — real-world tournaments (Wimbledon 2026) + - `participants` — real-world players/teams (Djokovic, NAVI) + - `tournament_results` — final placements + - `participant_surface_elos` — tennis surface-specific Elo +- **Per-window** (specific to a `sports_seasons` row): + - `season_participants` — draftable roster for this window + - `season_participant_expected_values` — EV under this window's 4-event set + - `season_participant_qualifying_totals` — running QP sum for this window + - `season_participant_results` — final placement for fantasy scoring + - `event_results` — per-scoring-event placements (derived from `tournament_results`) + +Enter tournament results at `/admin/tournaments/:id`. The `syncTournamentResults` +service fans out to every window linked via `scoring_events.tournament_id`. +```` + +- [ ] **Step 2: Update `docs/agents/domain-models.md`** + +Replace references to the old per-window participant model with the split canonical/per-window description. + +- [ ] **Step 3: Create operator guide** + +```markdown +# docs/operations/rolling-windows.md +# Creating a Rolling Qualifying-Points Window + +When a new tennis/golf/CS2 "season" should open (typically every 3 months), follow these steps: + +1. Go to `/admin/tournaments` and ensure the 4 real-world tournaments exist. + If a tournament is missing, click "New Tournament" on its sport page. +2. Go to `/admin/sports-seasons/new` and pick the sport. +3. In the tournament picker, select the 4 tournaments that define this window. +4. In the roster picker, select the canonical participants you want in the draft pool. +5. Configure scoring (QP point values, roster size, etc.). +6. Submit. The new `sports_seasons` row is created with scoring_events linked to + the canonical tournaments and season_participants linked to canonical participants. + +When a real-world tournament completes, enter its results once at +`/admin/tournaments/` — they automatically propagate to every window that +contains it. +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/ +git commit -m "docs: canonical tournament layer — agent guides + operator docs" +``` + +--- + +### Task 7: Final regression + tag + +- [ ] **Step 1: Full test suite** + +Run: `npm run test:all` +Expected: Green. + +- [ ] **Step 2: Baseline diff (sanity)** + +Re-run baseline capture and diff against Phase 1 fixtures: + +```bash +npx tsx scripts/capture-baseline.ts --out=/tmp/post-phase4 +diff test-fixtures/baselines/qp-totals-pre-phase1.json /tmp/post-phase4/qp-totals-*.json +diff test-fixtures/baselines/event-results-pre-phase1.json /tmp/post-phase4/event-results-*.json +``` + +Expected: Empty (no QP math drift across the entire migration). + +- [ ] **Step 3: Tag** + +```bash +git tag phase-4-complete +git tag canonical-tournament-layer-complete +``` + +The second tag marks the full feature complete. + +--- + +## Self-Review Checklist + +- [ ] `season_participant_surface_elos` table does not exist in prod. +- [ ] `/admin/sports-seasons/:id/surface-elo` route returns 404. +- [ ] Per-window result entry form has been replaced with a link to the canonical route. +- [ ] `grep -rn "batch-add-results" app/` returns zero results. +- [ ] `docs/agents/database.md` describes the canonical/per-window split. +- [ ] Operator guide `docs/operations/rolling-windows.md` exists. +- [ ] `npm run test:all` is green. +- [ ] Baseline diff is empty. +- [ ] Tag `canonical-tournament-layer-complete` exists on main. + +## Rollback + +Dropping `season_participant_surface_elos` is reversible if needed (no row is dropped in Phase 4 — the data was never used for anything after Phase 3). To rollback: + +1. `git revert` the drop migration commit. +2. Re-generate the `season_participant_surface_elos` table via Drizzle migration. +3. Re-seed it from `participant_surface_elos` keyed via `season_participants.participantId`. + +Other Phase 4 removals (routes, UI, shims) are pure code and reversible via `git revert`. diff --git a/docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md b/docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md new file mode 100644 index 0000000..d08b90d --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md @@ -0,0 +1,287 @@ +# Canonical Tournament & Participant Layer + +**Date:** 2026-05-01 +**Status:** Proposed + +## Overview + +Qualifying-points sports (golf, tennis, CS2) use a four-event "season" that can roll across calendar boundaries (e.g., a June 2026 tennis window needs Wimbledon 2026, US Open 2026, Australian Open 2027, French Open 2027). Today the data model ties events and participants to a single `sportsSeasons` row, which means two overlapping windows must each have their own copy of Wimbledon 2026, their own Djokovic, their own surface Elo. Any change — results, corrections, Elo adjustments — must be applied to every copy. + +This design introduces a **canonical layer** above the existing per-window layer: + +- **Canonical** tables own real-world identity (tournaments, participants, final results, surface Elo). +- **Per-window** tables keep their role: which tournaments a window uses, which participants its draft pool contains, and window-specific derived data (EV, QP totals, QP config). + +Results entered once on the canonical tournament fan out to every window that contains it. Surface Elo edited on a canonical participant propagates automatically. Creating a new rolling window is a point-and-click composition over the canonical data. + +Per-window data that is inherently window-specific (expected value under a particular 4-event set, QP running totals, QP point configuration) stays per-window — because the math depends on which 4 events you're summing over. + +## Scope + +- **In scope:** All `scoringPattern='qualifying_points'` sports — golf, tennis, CS2. The model is sport-agnostic, but migration covers these three. +- **Out of scope:** Team sports (NHL, MLB, NBA, WNBA, UCL, NCAAM/W), single-event sports (darts and snooker use only the World Championship — no rolling window problem). These keep their existing model. +- **Out of scope:** Historical fully-completed windows. No league currently has a completed qualifying-points sportsSeason, so there's nothing to backfill for reporting. + +## Architecture + +### Canonical tables (new) + +``` +tournaments + id uuid pk + sport_id fk → sports + name varchar -- "Wimbledon" + year integer + starts_at timestamp + ends_at timestamp + surface varchar nullable -- tennis only: hard | clay | grass + location varchar nullable + status enum -- scheduled | in_progress | completed + external_key varchar nullable -- reserved for future ATP/PGA/HLTV IDs + created_at, updated_at + UNIQUE (sport_id, name, year) + +participants -- RENAMED; see migration section + id uuid pk + sport_id fk → sports + name varchar + external_key varchar nullable + metadata jsonb + created_at, updated_at + UNIQUE (sport_id, name) + +tournament_results + id uuid pk + tournament_id fk → tournaments + participant_id fk → participants + placement integer + raw_score decimal nullable + created_at, updated_at + UNIQUE (tournament_id, participant_id) + +participant_surface_elos -- tennis-specific; replaces the existing per-window table of the same name + id uuid pk + participant_id fk → participants (canonical) + world_ranking integer nullable + elo_hard integer nullable + elo_clay integer nullable + elo_grass integer nullable + updated_at + UNIQUE (participant_id) +``` + +**Shape note:** Kept wide format (`elo_hard`/`elo_clay`/`elo_grass`) to match the existing per-window `participant_surface_elos` table, minimizing simulator read-path churn. `world_ranking` moves here because it's a real-world attribute of the participant, not a per-window value. + +**Name collision handling:** The existing per-window `participant_surface_elos` table is renamed to `season_participant_surface_elos` in Phase 1 before the new canonical table of the same name is created. + +### Per-window tables (renamed / extended) + +The existing `participants` table is renamed to `season_participants`. Related tables follow the same renaming pattern for clarity: + +| Old name | New name | +|---|---| +| `participants` | `season_participants` | +| `participant_expected_values` | `season_participant_expected_values` | +| `participant_qualifying_totals` | `season_participant_qualifying_totals` | +| `participant_results` | `season_participant_results` | +| `event_results.participant_id` | `event_results.season_participant_id` | +| `participant_surface_elos` (existing per-window) | `season_participant_surface_elos` (renamed in Phase 1); removed entirely in Phase 4 after canonical values verified | + +FK additions (nullable at Phase 1, NOT NULL after Phase 2 backfill): + +- `scoring_events.tournament_id` → `tournaments.id` +- `season_participants.participant_id` → `participants.id` + +Unchanged: `sports_seasons`, `season_sports`, `qualifying_point_config`, `draft_picks`, `teams`, `draft_slots`, `watchlist`, scoring calculation code in `app/models/scoring-calculator.ts`, simulators' Monte Carlo logic. + +### Source of truth + +| Concept | Table | +|---|---| +| Tournament existence & final placements | `tournament_results` (canonical) | +| Window → tournament mapping | `scoring_events` via `tournament_id` | +| Canonical participant identity | `participants` | +| Window's draftable roster | `season_participants` | +| Per-window derived scoring (QP awarded, totals, EV) | `season_participant_*`, `event_results` | +| Surface Elo & world ranking | `participant_surface_elos` (canonical) | + +## Data Flow + +### Creating a new window + +Admin flow has two new steps during `sports_seasons` creation for qualifying-points sports: + +1. **Pick tournaments** — multi-select from `tournaments` filtered by `sport_id`. Inline create if missing. Writes `scoring_events` rows with `tournament_id` set. +2. **Pick roster** — multi-select from `participants` filtered by `sport_id`. Writes `season_participants` rows with `participant_id` set. Inline-create canonical participants if missing. + +Downstream (EV upload, QP config edit, draft) is unchanged. + +### Entering results (enter-once flow) + +New admin route: `/admin/tournaments/:id`. + +1. Admin enters placements for a canonical tournament. UI supports the same paste-and-parse flow as the existing `2026-04-12-batch-qualifying-results` design, but targets the canonical tournament. +2. Save writes `tournament_results` rows (upsert on `(tournament_id, participant_id)`). +3. `syncTournamentResults(tournamentId)` fans out: + - For each `scoring_events` row with `tournament_id = tournamentId`: + - For each participant in `tournament_results` whose canonical `participant_id` also appears in that window's `season_participants`: upsert a matching `event_results` row with `placement` and `raw_score`. + - Apply existing "fill 0-QP for non-scoring season participants" behavior per the batch qualifying results design. + - Run the existing `processQualifyingEvent` (idempotent — replaces QP, doesn't add) against that window's scoring event. + - Update `season_participant_qualifying_totals` per the existing path. + - Each window's fan-out is its own transaction. Failures are logged per-window with a retry action in the admin UI. +4. Tournament status auto-advances to `completed` on first result insert. + +### Corrections + +Edit `tournament_results` → re-run `syncTournamentResults`. Idempotent updates to every affected window. No stale data unless sync fails (surfaced in UI). + +### Surface Elo + +Read path: `app/models/surface-elo.ts`'s `getSurfaceEloMap(sportsSeasonId)` joins `season_participants → participants → participant_surface_elos` (canonical). Admin UI for surface Elo edits canonical `participant_surface_elos` directly, keyed on canonical participant. Edit affects all current and future windows containing that participant. + +## Migration Plan + +Four phases. Each is reversible until the next begins. + +### Phase 1 — Schema (data untouched) + +Two sequential Drizzle migrations, in order, to avoid name collisions between the old per-window tables and the new canonical tables: + +**Phase 1a — Renames of existing tables (no new tables yet):** +- `participants` → `season_participants` +- `participant_expected_values` → `season_participant_expected_values` +- `participant_qualifying_totals` → `season_participant_qualifying_totals` +- `participant_results` → `season_participant_results` +- `participant_surface_elos` → `season_participant_surface_elos` +- `event_results.participant_id` → `event_results.season_participant_id` + +Update every code reference, Drizzle relations, model file names, and type imports. Existing `app/models/participant.ts` is renamed to `app/models/season-participant.ts`. `npm run typecheck` and `npm run test:run` must pass before proceeding to Phase 1b. + +**Phase 1b — New canonical tables:** +- Create `tournaments`, `participants` (canonical), `tournament_results`, `participant_surface_elos` (canonical). +- Add nullable FKs: `scoring_events.tournament_id`, `season_participants.participant_id`. +- A new `app/models/participant.ts` is created for the canonical table (the old model file was renamed in 1a, freeing the name). + +No logic paths read from canonical tables yet. + +Reversible: run migrations in reverse (drop canonical tables first, then reverse the renames). + +### Phase 2 — Backfill + +One-off script `scripts/backfill-canonical-layer.ts` with `--dry-run` and `--sport=` flags. + +For each `sports_seasons` where `scoring_pattern='qualifying_points'`: + +1. Upsert `tournaments` rows by `(sport_id, name, year)` from the window's `scoring_events`. Set `scoring_events.tournament_id`. +2. Upsert `participants` rows by `(sport_id, name)` from the window's `season_participants`. Set `season_participants.participant_id`. +3. For each `event_results` row with `qualifying_points_awarded IS NOT NULL` (i.e., already-scored events like Masters 2026): upsert a `tournament_results` row with `placement` and `raw_score`. **Do not copy `qualifying_points_awarded`** — QP stays on `event_results` because it's window-specific. +4. For each row in `season_participant_surface_elos` (the renamed per-window table): upsert into canonical `participant_surface_elos` keyed by canonical `participant_id`, copying `elo_hard`, `elo_clay`, `elo_grass`, `world_ranking`. Abort if two windows disagree on a canonical participant's values (not expected today — fail loud if encountered). + +Dry-run writes nothing; emits a CSV + summary to stdout. + +Go/no-go: dry-run reviewed by maintainer, real run matches dry-run counts exactly, spot-check Masters 2026 `tournament_results` row matches its `event_results` row. + +Reversible: truncate canonical tables, null out FK columns. Old code paths still functional. + +### Phase 3 — Cutover + +- `ALTER` `scoring_events.tournament_id` and `season_participants.participant_id` to NOT NULL. +- Implement `app/models/tournament.ts`, `app/models/participant.ts` (canonical), `app/models/tournament-result.ts`. +- Implement `app/services/sync-tournament-results.ts`. +- Add admin route `app/routes/admin.tournaments.$id.tsx` with paste-and-parse result entry. +- Add admin "create window" flow additions for tournament + roster picking. +- Switch surface Elo admin UI and `getSurfaceEloMap` to canonical read/write. +- Switch tennis simulator's surface Elo source to canonical. +- Existing per-window "Add Result" / batch entry routes become thin wrappers that write canonical + call sync. (They are removed in Phase 4.) + +Feature-flag the new admin entry route; cut over during a no-draft window. + +Go/no-go: new window created end-to-end in staging via canonical flow; all pre-Phase-1 baselines match post-Phase-3 values. + +### Phase 4 — Cleanup + +- Remove per-window result entry wrappers. +- Drop `season_participant_surface_elos` table via migration after verifying canonical `participant_surface_elos` is fully populated and all code paths read from canonical. +- Remove any compatibility shims. + +## Testing Matrix + +Required types: **U** unit, **M** model integration (real DB), **E** end-to-end (Cypress). + +### Baselines to capture before Phase 1 + +1. Monte Carlo simulator output JSON for each in-flight qualifying-points window, committed as a test fixture. +2. Snapshot of `participant_qualifying_totals` (pre-rename; post-Phase-1a this is `season_participant_qualifying_totals`) per window. +3. Snapshot of each window's `event_results` for completed events (Masters 2026, any completed CS2 majors). + +These are used to verify no scoring drift across phases. + +### Phase 1 — Schema + +| What | Where | Type | Scenarios | +|---|---|---|---| +| Drizzle migration applies cleanly | staging run + `npm run db:migrate` | — | Forward run; re-run `db:generate` and confirm no drift | +| Renames propagate in code | `npm run typecheck`, `npm run test:run` | — | Pass | +| Relations load | existing model tests (updated for renames) | M | All pass | + +### Phase 2 — Backfill script + +| What | Where | Type | Scenarios | +|---|---|---|---| +| Backfill dry-run | `scripts/__tests__/backfill-canonical-layer.test.ts` (new) | M | (a) Empty DB → no-op. (b) One in-flight golf window with 4 events incl. Masters 2026 → 4 tournaments, N canonical participants, N `tournament_results` rows. (c) Two tennis windows with overlapping participants → one canonical participant per (sport, name). (d) Conflicting canonical rows pre-existing → script aborts with clear error. (e) Dry-run writes nothing; real run matches dry-run CSV exactly. | +| Surface Elo backfill | same test file | M | All existing surface Elo rows translate to `participant_surface_elos (canonical)`; counts match; conflicts abort. | +| Masters 2026 round-trip | same test file | M | Post-backfill: `tournament_results` for Masters 2026 matches `event_results` placement/raw_score; `qualifying_points_awarded` untouched on `event_results`; `season_participant_qualifying_totals` untouched. | +| Baseline verification | `scripts/__tests__/verify-baselines.test.ts` (new) | M | `season_participant_qualifying_totals` post-backfill matches pre-Phase-1 baseline snapshot of `participant_qualifying_totals` (they should be identical — backfill doesn't touch QP). | + +### Phase 3 — New code paths + +| What | Where | Type | Scenarios | +|---|---|---|---| +| `app/models/tournament.ts` | `app/models/__tests__/tournament.test.ts` | M | CRUD, unique `(sport_id, name, year)`, `findBySport`, status auto-advance. | +| `app/models/participant.ts` (canonical) | `app/models/__tests__/participant.test.ts` | M | CRUD, unique `(sport_id, name)`, find-or-create. | +| `app/models/tournament-result.ts` | `app/models/__tests__/tournament-result.test.ts` | M | Upsert semantics, placement updates, idempotency. | +| `app/services/sync-tournament-results.ts` | `app/services/__tests__/sync-tournament-results.test.ts` | M | (a) Single window, roster matches all result participants → `event_results` created, QP processed. (b) Two windows share a tournament with overlapping but different rosters → only matching participants get `event_results` in each window; QP processed independently per window. (c) Participant in roster but not in `tournament_results` → 0-QP row (matches existing batch finalize). (d) Re-run with same data → idempotent, no duplicates. (e) Re-run after placement correction → all windows updated, QP re-processed. (f) One window's sync fails → others still succeed; failure logged and retryable. | +| Admin `/admin/tournaments/:id` route | `app/routes/__tests__/admin.tournaments.$id.test.tsx` (new) | M | Loader returns tournament + results; action upserts + triggers sync; non-admin → 403. | +| Admin create-window flow | `cypress/e2e/admin-create-qualifying-window.cy.ts` (new) | E | Admin picks 4 tournaments + roster → window created with FKs populated; draft room loads. | +| Result entry fan-out | `cypress/e2e/enter-tournament-result.cy.ts` (new) | E | Two windows share Wimbledon 2026; admin enters Wimbledon results once; both windows' QP standings update. | +| Surface Elo canonical | `app/models/__tests__/surface-elo.test.ts` (update) | M | Reads and writes go to canonical `participant_surface_elos`; tennis simulator receives correct values; world_ranking reads correctly. | +| Tennis simulator surface Elo | `app/services/simulations/__tests__/tennis-simulator.test.ts` (update) | U | Fed canonical surface Elo; Monte Carlo output matches baseline fixture within tolerance. | +| Back-compat wrappers for old per-window routes | existing route tests (update) | M | Writes via old routes still succeed and produce identical `event_results`. | +| Masters 2026 regression | `scripts/__tests__/verify-baselines.test.ts` | M | Post-Phase-3 QP standings for existing golf window match pre-Phase-1 baseline exactly. | + +### Phase 4 — Cleanup + +| What | Where | Type | Scenarios | +|---|---|---|---| +| Old result-entry routes removed | removed test files | — | Routes return 404. | +| Drop `season_participant_surface_elos` | staging verification | — | Canonical `participant_surface_elos` fully populated; `season_participant_surface_elos` no longer read by any code path; drop migration applies cleanly. | +| Full regression | `npm run test:all` | — | Green. | + +### Cross-cutting smoke tests (after every phase) + +1. Load an in-flight golf draft room → Masters 2026 results render, QP standings match baseline. +2. Run tennis simulator for an in-flight window → Monte Carlo output matches baseline fixture within tolerance. +3. Create a fresh draft for an existing window → draft picks save (no NULL FK errors). + +## Risks & Mitigations + +**R1. Name collision for canonical participants.** `UNIQUE (sport_id, name)` is correct for today's roster sizes. `external_key` reserved for future disambiguation via ATP/PGA/HLTV IDs. + +**R2. CS2 team-name churn.** Rebranded team is a new canonical participant. Intentional — matches current fantasy semantics. + +**R3. Masters 2026 double-scoring.** `processQualifyingEvent` is already idempotent (replaces QP). Explicit test in Phase 2 + Phase 3 matrix. + +**R4. Sync fan-out partial failure.** Per-window transactions. Each window logs success/failure independently. Admin UI shows "synced to X of N windows" with per-window retry. + +**R5. Simulator output drift.** Baseline Monte Carlo output captured before Phase 1 and compared after each subsequent phase. + +**R6. Draft-in-progress during cutover.** Phase 3 deployed behind a feature flag, enabled during a no-draft window. + +**R7. Admin muscle memory.** Old routes stay as wrappers through Phase 3; removed in Phase 4. Release notes accompany Phase 3 cutover. + +## Out of Scope / Flagged for Later + +- Backfill of fully-completed historical windows: no such windows exist today. +- Automated import of tournament results from external feeds (ATP/PGA APIs). `external_key` column reserves space for this. +- Cross-sport disambiguation for identical names (extremely rare; punted until real). +- Merge tool for canonical participants if a duplicate is created accidentally (can be added later; not a v1 blocker given admin-only data entry).