Spec and phased implementation plans for eliminating data duplication across overlapping qualifying-points windows (golf, tennis, CS2) by introducing canonical tournaments, participants, tournament_results, and surface-Elo tables above the existing per-window layer. - Phase 1a: rename existing per-window tables to season_* prefix - Phase 1b: create canonical tables + nullable FKs - Phase 2: backfill canonical from existing in-flight windows - Phase 3: cutover - sync service, admin UI, simulator read switch - Phase 4: cleanup - drop deprecated tables and routes Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
19 KiB
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.idseason_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:
- Pick tournaments — multi-select from
tournamentsfiltered bysport_id. Inline create if missing. Writesscoring_eventsrows withtournament_idset. - Pick roster — multi-select from
participantsfiltered bysport_id. Writesseason_participantsrows withparticipant_idset. 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.
- Admin enters placements for a canonical tournament. UI supports the same paste-and-parse flow as the existing
2026-04-12-batch-qualifying-resultsdesign, but targets the canonical tournament. - Save writes
tournament_resultsrows (upsert on(tournament_id, participant_id)). syncTournamentResults(tournamentId)fans out:- For each
scoring_eventsrow withtournament_id = tournamentId:- For each participant in
tournament_resultswhose canonicalparticipant_idalso appears in that window'sseason_participants: upsert a matchingevent_resultsrow withplacementandraw_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_totalsper the existing path.
- For each participant in
- Each window's fan-out is its own transaction. Failures are logged per-window with a retry action in the admin UI.
- For each
- Tournament status auto-advances to
completedon 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_participantsparticipant_expected_values→season_participant_expected_valuesparticipant_qualifying_totals→season_participant_qualifying_totalsparticipant_results→season_participant_resultsparticipant_surface_elos→season_participant_surface_elosevent_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.tsis 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=<id> flags.
For each sports_seasons where scoring_pattern='qualifying_points':
- Upsert
tournamentsrows by(sport_id, name, year)from the window'sscoring_events. Setscoring_events.tournament_id. - Upsert
participantsrows by(sport_id, name)from the window'sseason_participants. Setseason_participants.participant_id. - For each
event_resultsrow withqualifying_points_awarded IS NOT NULL(i.e., already-scored events like Masters 2026): upsert atournament_resultsrow withplacementandraw_score. Do not copyqualifying_points_awarded— QP stays onevent_resultsbecause it's window-specific. - For each row in
season_participant_surface_elos(the renamed per-window table): upsert into canonicalparticipant_surface_eloskeyed by canonicalparticipant_id, copyingelo_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
ALTERscoring_events.tournament_idandseason_participants.participant_idto 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.tsxwith paste-and-parse result entry. - Add admin "create window" flow additions for tournament + roster picking.
- Switch surface Elo admin UI and
getSurfaceEloMapto 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_elostable via migration after verifying canonicalparticipant_surface_elosis 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
- Monte Carlo simulator output JSON for each in-flight qualifying-points window, committed as a test fixture.
- Snapshot of
participant_qualifying_totals(pre-rename; post-Phase-1a this isseason_participant_qualifying_totals) per window. - Snapshot of each window's
event_resultsfor 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)
- Load an in-flight golf draft room → Masters 2026 results render, QP standings match baseline.
- Run tennis simulator for an in-flight window → Monte Carlo output matches baseline fixture within tolerance.
- 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_keycolumn 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).