13 KiB
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 ✓
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 onexternal_match_id, update scores/status/timestampsfindSeasonMatchesBySportsSeasonId(sportsSeasonId, filters?)— joined with participant namesfindSeasonMatchesByScoringEventId(scoringEventId)— for CS2 stage view, ordered stage → roundfindSeasonMatchesByStage(sportsSeasonId, stage)— CS2 per-stage queryupsertMatchSubGame(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_KEYenv 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.ts — PandaScoreMatchSyncAdapter implements MatchSyncAdapter. Calls PandaScore CS2 matches endpoint. Maps stage/round/game (map) fields.
espn-schedule.ts — EspnScheduleAdapter implements MatchSyncAdapter (Phase 2b). Calls ESPN schedule endpoints for MLB, NBA, MLS, etc. team1Score/team2Score from ESPN scores.
index.ts — syncMatches(sportsSeasonId) orchestrator, mirrors syncStandings() exactly:
- Load sportsSeason + sport, extract
externalSeasonId getMatchSyncAdapter(simulatorType)→ picks adapter- Fetch matches, resolve participants via
externalId→ name fallback - Bulk upsert into
season_matches+match_sub_games - 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-matcheswithrequireCronSecretauth- 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.tsapp/services/match-sync/__tests__/espn-schedule.test.tsapp/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 />withbracketTemplateId="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:
- Add
external_match_id varchar(255) nullablecolumn toplayoff_matches(one migration) - Add
fetchPlayoffMatches(externalSeasonId)to the adapter (or reusefetchMatcheswith amatchTypeflag) - Add a
syncPlayoffResults(sportsSeasonId)path inmatch-sync/index.tsthat writes toplayoff_matchesand calls the existingsetMatchWinner()/processMatchResult()pipeline - 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 1–3)
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
npm run db:generateproduces migration for 2 new tables + columnnpm run db:migrateapplies cleanlynpm run typecheckpassesnpm run test:runpasses including new model + adapter tests- Manual: set
externalSeasonIdon a CS2 sports season, call/admin/jobs/sync-matches, verifyseason_matchesrows created with stage/round populated - Manual: navigate to
/sports-seasons/:id/tournament, confirm CS2 Swiss stage display renders with rounds and map scores