brackt/docs/plans/match-sync-display.md
Claude 0dca36dd5c
feat: add generic season_matches + match_sub_games tables with CS2 admin UI (Phase 1)
Adds sport-agnostic match data infrastructure:
- matchStatusEnum + season_matches + match_sub_games tables in schema
- externalSeasonId column on sports_seasons for API linkage
- Drizzle migration 0120_tidy_invaders.sql
- season-match model (upsert w/ onConflictDoUpdate, queries by event/season/stage)
- CS2 admin: Swiss rounds read-only section in cs2-setup + new manual swiss-matches route
- 7 unit tests for model (upsert conflict, null stage/round for non-CS2 sports, bulk, sub-games)
- Plan doc committed at docs/plans/match-sync-display.md for cross-session reference

https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
2026-06-12 20:13:27 +00:00

13 KiB
Raw Blame History

Plan: Generalized Match Import, Swiss Stage Tracking, Live Scores, and Display System

Context

The current system tracks tournament stage entry/exit manually with no match-level data, no automated schedule/result imports, and no public tournament display. The goal is a sport-agnostic match sync infrastructure (usable for MLB schedules, CS2 Swiss rounds, EPL fixtures, etc.) with CS2 as the first implementation target.

Scope decision: The new season_matches table is generic from day one. CS2 Swiss uses nullable match_stage/match_round columns; MLB just leaves those NULL. Playoff bracket matches remain in the existing playoff_matches table (already works well with complex progression logic). This avoids proliferating per-sport match tables.


Status

  • Phase 1 — Generic Data Model + CS2 Admin UI
  • Phase 2 — Generic Match Sync Adapter + Cron Job
  • Phase 3 — Public Tournament & Schedule Display + Live Scores
  • Phase 4 — Automated Playoff Bracket Updates (future)

Phase 1 — Generic Data Model + CS2 Admin UI (no external API)

1a. Schema additions (database/schema.ts)

New enum: matchStatusEnum = ["scheduled", "in_progress", "complete", "canceled", "postponed"]

New table: season_matches — generic match record for regular season, Swiss rounds, group stages, fixtures

id (uuid PK)
sports_season_id (uuid FK → sportsSeasons, onDelete: cascade)
scoring_event_id (uuid FK → scoringEvents, nullable — set for CS2 stage matches, null for MLB regular season)
participant1_id (uuid FK → seasonParticipants, nullable)
participant2_id (uuid FK → seasonParticipants, nullable)
winner_id (uuid FK → seasonParticipants, nullable)
participant1_score (integer nullable)
participant2_score (integer nullable)
match_stage (integer nullable) — CS2: 1/2/3 for Opening/Challengers/Legends; NULL for MLB
match_round (integer nullable) — CS2: round within stage (1-5); MLB: series game number or NULL
matchday (integer nullable) — EPL/MLS fixture week; NULL for CS2
is_series (boolean default false) — true for Bo3/Bo5 CS2 matches
status (matchStatusEnum default 'scheduled')
scheduled_at (timestamp nullable)
started_at (timestamp nullable)
completed_at (timestamp nullable)
external_match_id (varchar 255 nullable) — API ID for upsert conflict key
created_at, updated_at (timestamps)

Indexes: unique on external_match_id (partial, WHERE NOT NULL), index on (sports_season_id, status), index on (sports_season_id, match_stage, match_round), index on (scoring_event_id).

New table: match_sub_games — per-sub-game scores (CS2 maps, MLB innings if desired, etc.)

id (uuid PK)
season_match_id (uuid FK → seasonMatches, onDelete: cascade)
game_number (integer) — map 1/2/3 for CS2, game 1-7 for series, inning for baseball
game_label (varchar 100 nullable) — CS2: "Mirage", "Inferno"; others: NULL or "Game 1"
participant1_score (integer nullable)
participant2_score (integer nullable)
winner_id (uuid FK → seasonParticipants, nullable)
status (matchStatusEnum default 'scheduled')
started_at, completed_at (timestamps nullable)
external_game_id (varchar 255 nullable)

Unique index on (season_match_id, game_number).

New column on sportsSeasons: external_season_id varchar(255) nullable — links to the API's identifier for this season/tournament (PandaScore tournament ID for CS2, ESPN league season ID for MLB, etc.)

Add Drizzle relations() for both new tables following the existing patterns.

Run npm run db:generate after changes.

1b. New model: app/models/season-match.ts

  • upsertSeasonMatch(data) — conflict on external_match_id, update scores/status/timestamps
  • findSeasonMatchesBySportsSeasonId(sportsSeasonId, filters?) — joined with participant names
  • findSeasonMatchesByScoringEventId(scoringEventId) — for CS2 stage view, ordered stage → round
  • findSeasonMatchesByStage(sportsSeasonId, stage) — CS2 per-stage query
  • upsertMatchSubGame(data)
  • findMatchSubGamesByMatchId(matchId)

1c. Extend cs2-setup admin route

File: app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx

  • Add loader query calling findSeasonMatchesByScoringEventId
  • Add read-only Swiss rounds section below existing stage assignment UI: per round shows Team A (W-L) vs Team B (W-L) → maps [16-14, 14-16, 16-12]
  • "Sync from API" button (fetcher POST to cron endpoint, wired in Phase 2)

1d. New admin route: manual match entry

File: app/routes/admin.sports-seasons.$id.events.$eventId.swiss-matches.tsx Actions: update-match-result, create-manual-match, delete-match — manual fallback when API unavailable Register in app/routes.ts under admin layout.

1e. Tests

  • app/models/__tests__/season-match.test.ts — upsert conflict resolution, ordering, null stage/round handling

Phase 2 — Generic Match Sync Adapter + Cron Job

External Data Sources

  • CS2 (and future Dota2/LoL): PandaScore — free tier, 1,000 req/hour, no credit card needed. PANDASCORE_API_KEY env var.
  • MLB/NBA/NFL/MLS/NHL etc.: ESPN public API (already used for standings), or sport-specific APIs already in standings-sync/. Same free, no-auth pattern.

2a. New service directory: app/services/match-sync/

types.ts — generic adapter interface:

interface MatchRecord {
  externalMatchId: string;
  team1ExternalId: string;
  team2ExternalId: string;
  team1Score: number | null;
  team2Score: number | null;
  winnerExternalId: string | null;
  status: "scheduled" | "in_progress" | "complete" | "canceled" | "postponed";
  scheduledAt: Date | null;
  startedAt: Date | null;
  completedAt: Date | null;
  matchStage?: number | null;   // CS2: 1/2/3; omit for MLB
  matchRound?: number | null;   // CS2: round within stage; omit for MLB
  matchday?: number | null;     // EPL/MLS fixture week
  isSeries?: boolean;
  subGames?: SubGameRecord[];
}
interface SubGameRecord {
  externalGameId?: string;
  gameNumber: number;
  gameLabel?: string;     // CS2 map name, etc.
  team1Score: number | null;
  team2Score: number | null;
  winnerExternalId?: string | null;
  status: "scheduled" | "in_progress" | "complete";
}
interface MatchSyncAdapter {
  fetchMatches(externalSeasonId: string): Promise<MatchRecord[]>;
  supportsLiveScores: boolean;
}
interface MatchSyncResult {
  created: number;
  updated: number;
  unchanged: number;
  unmatchedTeams: {externalId: string; name: string}[];
  errors: {externalMatchId: string; error: string}[];
}

pandascore.tsPandaScoreMatchSyncAdapter implements MatchSyncAdapter. Calls PandaScore CS2 matches endpoint. Maps stage/round/game (map) fields.

espn-schedule.tsEspnScheduleAdapter implements MatchSyncAdapter (Phase 2b). Calls ESPN schedule endpoints for MLB, NBA, MLS, etc. team1Score/team2Score from ESPN scores.

index.tssyncMatches(sportsSeasonId) orchestrator, mirrors syncStandings() exactly:

  1. Load sportsSeason + sport, extract externalSeasonId
  2. getMatchSyncAdapter(simulatorType) → picks adapter
  3. Fetch matches, resolve participants via externalId → name fallback
  4. Bulk upsert into season_matches + match_sub_games
  5. Return MatchSyncResult
function getMatchSyncAdapter(simulatorType: string): MatchSyncAdapter {
  switch (simulatorType) {
    case "cs2_major_qualifying_points": return new PandaScoreMatchSyncAdapter();
    case "mlb_bracket": return new EspnScheduleAdapter("baseball/mlb");
    case "nba_bracket": return new EspnScheduleAdapter("basketball/nba");
    case "mls_bracket": return new EspnScheduleAdapter("soccer/mls");
    // ... expand as needed
    default: throw new Error(`No match sync adapter for "${simulatorType}"`);
  }
}

2b. New cron job: app/routes/admin/jobs.sync-matches.ts

Exact same pattern as jobs.sync-and-simulate.ts:

  • POST /admin/jobs/sync-matches with requireCronSecret auth
  • Finds active sports seasons where externalSeasonId IS NOT NULL
  • Calls syncMatches(season.id) for each; triggers simulation if results changed
  • Returns { synced, unchanged, errors }

Register in app/routes.ts.

2c. Admin form: add external_season_id field to sports season admin form

2d. Tests

  • app/services/match-sync/__tests__/pandascore.test.ts
  • app/services/match-sync/__tests__/espn-schedule.test.ts
  • app/services/match-sync/__tests__/sync.test.ts — idempotency, name matching, null stage/round handling

Phase 3 — Public Tournament & Schedule Display + Live Scores

3a. CS2 Tournament component: app/components/scoring/Cs2TournamentBracket.tsx

Tab layout: Swiss Stages | Champions Stage

  • Swiss Stages tab: sub-tabs Stage 1 / 2 / 3. Each shows W-L standings table + rounds accordion. Each round: pairing, map scores, status badge.
  • Champions Stage tab: reuses existing <PlayoffBracket /> with bracketTemplateId="simple_8".
  • Status badges: scheduled (gray), in_progress (amber pulse), complete (green).

3b. Generic schedule component: app/components/scoring/MatchSchedule.tsx

Reusable across all sports. Shows upcoming + completed matches with scores. Used for MLB schedule view, EPL fixtures, etc. Props: matches: SeasonMatch[], sport.

3c. New public route: app/routes/sports-seasons.$sportsSeasonId.tournament.tsx

URL: /sports-seasons/:sportsSeasonId/tournament — league-independent. Loader: fetches season_matches + playoff matches (Champions Stage) + cs2 stage results. CS2 renders <Cs2TournamentBracket>, other sports render <MatchSchedule> based on simulatorType. Live polling: if any match in_progress, useRevalidator() re-fetches every 30 seconds.

Register in app/routes.ts outside admin layout.

3d. Optional: Socket.IO push

Emit season-match-updated from syncMatches when a match transitions to in_progress or complete. Phase 3 enhancement, deferred until polling is stable.


Phase 4 — Automated Playoff Bracket Updates (future)

Playoff bracket matches (playoff_matches) can also be auto-synced using the same MatchSyncAdapter interface. This is deferred because playoff sync is more complex than schedule sync — it must trigger processMatchResult() and bracket progression logic correctly, not just upsert data.

What's needed to enable this:

  1. Add external_match_id varchar(255) nullable column to playoff_matches (one migration)
  2. Add fetchPlayoffMatches(externalSeasonId) to the adapter (or reuse fetchMatches with a matchType flag)
  3. Add a syncPlayoffResults(sportsSeasonId) path in match-sync/index.ts that writes to playoff_matches and calls the existing setMatchWinner() / processMatchResult() pipeline
  4. Handle idempotency — don't re-trigger scoring if winner is already set

The same PandaScoreMatchSyncAdapter and EspnScheduleAdapter used for schedule sync will handle playoff data too, since they already return match results regardless of format.


What Does NOT Change (Phases 13)

  • cs2_major_stage_results — still the source of truth for CS2 stage entry/exit/placement
  • Fantasy scoring pipeline — fully untouched
  • playoff_matches / playoff_match_games — unchanged, still used for all bracket progression
  • Existing CS2 admin setup UI — works as before, just gains a new read-only section

Key Files

File Action
database/schema.ts Add matchStatusEnum, season_matches, match_sub_games tables, externalSeasonId on sportsSeasons
app/models/season-match.ts New — all DB queries for new tables
app/services/match-sync/types.ts New — generic adapter interface
app/services/match-sync/pandascore.ts New — PandaScore CS2 implementation
app/services/match-sync/espn-schedule.ts New — ESPN schedule implementation (MLB etc.)
app/services/match-sync/index.ts New — orchestrator (mirrors standings-sync/index.ts)
app/routes/admin/jobs.sync-matches.ts New — cron job (mirrors jobs.sync-and-simulate.ts)
app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx Extend loader + Swiss rounds display section
app/routes/admin.sports-seasons.$id.events.$eventId.swiss-matches.tsx New — manual match entry admin route
app/components/scoring/Cs2TournamentBracket.tsx New — CS2 tournament display
app/components/scoring/MatchSchedule.tsx New — generic match schedule display
app/routes/sports-seasons.$sportsSeasonId.tournament.tsx New — public tournament/schedule route
app/routes.ts Register 3 new routes

Verification

  1. npm run db:generate produces migration for 2 new tables + column
  2. npm run db:migrate applies cleanly
  3. npm run typecheck passes
  4. npm run test:run passes including new model + adapter tests
  5. Manual: set externalSeasonId on a CS2 sports season, call /admin/jobs/sync-matches, verify season_matches rows created with stage/round populated
  6. Manual: navigate to /sports-seasons/:id/tournament, confirm CS2 Swiss stage display renders with rounds and map scores