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>
287 lines
19 KiB
Markdown
287 lines
19 KiB
Markdown
# 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=<id>` 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).
|