brackt/docs/plans/match-sync-display.md

256 lines
13 KiB
Markdown
Raw Normal View 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:
```typescript
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:
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`
```typescript
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