brackt/app/services/match-sync/espn-schedule.ts
Claude 999f70c4eb
feat: add match sync service, cron job, public tournament display (Phases 2-4)
Phase 2 — Match Sync Adapters + Cron Job:
- PandaScoreMatchSyncAdapter (CS2): fetches matches with map-level sub-games, stage detection
- EspnScheduleAdapter (MLB/NBA/MLS/WNBA/NHL): fetches scoreboard with live scores
- syncMatches() orchestrator: resolves participants by externalId+name, bulk-upserts season_matches, syncs playoff bracket results through existing setMatchWinner/processMatchResult pipeline
- POST /admin/jobs/sync-matches cron endpoint (mirrors sync-and-simulate pattern)
- External Season ID field added to sports season create/edit admin forms
- Sync from API button wired in CS2 setup page (enabled when externalSeasonId set)

Phase 3 — Public Tournament & Schedule Display:
- MatchSchedule component: generic match list with live/scheduled/complete status badges, matchday grouping
- Cs2TournamentBracket component: tab layout (Opening/Challengers/Legends/Champions Stage), map scores per match
- /sports-seasons/:sportsSeasonId/tournament public route with 30-second live polling

Phase 4 — Playoff Bracket Auto-Sync:
- externalMatchId column added to playoff_matches table (migration 0121)
- Bracket matches (matchStage=null) auto-synced: matches existing playoff_match rows by externalMatchId then participant IDs, calls full scoring pipeline
- autoCompleteRoundIfDone extracted to scoring-calculator.ts for shared use

All 2365 tests pass; typecheck clean.

https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
2026-06-12 20:46:30 +00:00

89 lines
3 KiB
TypeScript

import type { MatchRecord, MatchSyncAdapter, MatchStatusValue } from "./types";
interface EspnCompetitor {
id: string;
team: { id: string; displayName: string; abbreviation?: string };
score?: string;
homeAway?: "home" | "away";
winner?: boolean;
}
interface EspnEvent {
id: string;
date: string;
competitions?: Array<{
competitors?: EspnCompetitor[];
status?: { type?: { name?: string; completed?: boolean; description?: string } };
matchday?: number;
}>;
}
interface EspnScheduleResponse {
events?: EspnEvent[];
}
function mapStatus(name: string | undefined, completed: boolean | undefined): MatchStatusValue {
if (completed) return "complete";
if (name === "STATUS_IN_PROGRESS" || name === "in_progress") return "in_progress";
if (name === "STATUS_CANCELED" || name === "canceled") return "canceled";
if (name === "STATUS_POSTPONED" || name === "postponed") return "postponed";
return "scheduled";
}
export class EspnScheduleAdapter implements MatchSyncAdapter {
readonly supportsLiveScores = false;
private readonly sport: string; // e.g. "baseball/mlb", "basketball/nba"
constructor(sport: string) {
this.sport = sport;
}
// externalSeasonId is "YYYY" year string for ESPN sports
async fetchMatches(externalSeasonId: string): Promise<MatchRecord[]> {
const url = `https://site.api.espn.com/apis/site/v2/sports/${this.sport}/scoreboard?limit=1000&dates=${externalSeasonId}`;
const resp = await fetch(url);
if (!resp.ok) {
const body = await resp.text();
throw new Error(`ESPN API error ${resp.status}: ${body}`);
}
const data = (await resp.json()) as EspnScheduleResponse;
const matches: MatchRecord[] = [];
for (const event of data.events ?? []) {
const comp = event.competitions?.[0];
if (!comp) continue;
const competitors = comp.competitors ?? [];
const home = competitors.find((c) => c.homeAway === "home") ?? competitors[0];
const away = competitors.find((c) => c.homeAway === "away") ?? competitors[1];
if (!home || !away) continue;
const statusType = comp.status?.type;
const status = mapStatus(statusType?.name, statusType?.completed);
const homeScore = home.score ? parseInt(home.score, 10) : null;
const awayScore = away.score ? parseInt(away.score, 10) : null;
const winnerExternalId = home.winner ? home.team.id : away.winner ? away.team.id : null;
matches.push({
externalMatchId: event.id,
team1ExternalId: home.team.id,
team1Name: home.team.displayName,
team2ExternalId: away.team.id,
team2Name: away.team.displayName,
team1Score: homeScore,
team2Score: awayScore,
winnerExternalId,
status,
scheduledAt: new Date(event.date),
startedAt: status !== "scheduled" ? new Date(event.date) : null,
completedAt: status === "complete" ? new Date(event.date) : null,
matchStage: null,
matchRound: null,
matchday: comp.matchday ?? null,
isSeries: false,
});
}
return matches;
}
}