# 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.
**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)`.
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
- **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/`
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`
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.
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 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
| `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