brackt/app/services/match-sync/espn-schedule.ts
Claude ac33e9e223
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m50s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 51s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
fix: address 10 code review issues in match sync system
- Fix 1 (CRITICAL): CS2 "Sync from API" button was always 401 because it
  POSTed to /admin/jobs/sync-matches which requires a cron secret header
  that browsers can't send. Added sync-matches intent to cs2-setup action.

- Fix 2 (CRITICAL): loserAdvances was hardcoded false in match-sync/index.ts,
  breaking NBA Play-In scoring. Now calls doesLoserAdvance().

- Fix 3: ESPN score "0" was falsy → stored as null. Non-numeric strings
  like "F/OT" produced NaN. Added parseScore() helper.

- Fix 4: ESPN limit=1000 silently truncated MLB/NBA playoff games. Added
  seasonType param to EspnScheduleAdapter; all *_bracket types pass 3
  (postseason only).

- Fix 5: Cron job was syncing completed seasons unnecessarily. Added
  active status filter.

- Fix 6: hasLiveMatches triggered perpetual 30s polling for unseeded
  brackets. Added participant presence check.

- Fix 7: autoCompleteRoundIfDone was duplicated between bracket.server.ts
  and scoring-calculator.ts. Removed inline copy, imported shared version.

- Fix 8: MatchSchedule date grouping never ran for ESPN sports (no matchday
  field). Removed hasMatchdays gate, always call groupByMatchday with
  stable UTC date key.

- Fix 9: Silent failure when bracket matches exist but no scoring events.
  Added warning log.

- Fix 10: Added 3 tests — zero score preservation, NaN from non-numeric
  score, seasonType in URL.

https://claude.ai/code/session_01WUUM7uWzFoSkGcZRhnEKG6
2026-06-12 21:53:06 +00:00

96 lines
3.5 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";
}
function parseScore(s: string | undefined): number | null {
if (s == null || s === "") return null;
const n = parseInt(s, 10);
return Number.isFinite(n) ? n : null;
}
export class EspnScheduleAdapter implements MatchSyncAdapter {
readonly supportsLiveScores = false;
// sport: e.g. "baseball/mlb", "basketball/nba"
// seasonType: ESPN season type (1=preseason, 2=regular, 3=postseason). Pass 3 for bracket sports
// to avoid the scoreboard limit silently truncating regular-season games.
constructor(private readonly sport: string, private readonly seasonType?: number) {}
// externalSeasonId is "YYYY" year string for ESPN sports
async fetchMatches(externalSeasonId: string): Promise<MatchRecord[]> {
const seasonTypeParam = this.seasonType != null ? `&seasontype=${this.seasonType}` : "";
const url = `https://site.api.espn.com/apis/site/v2/sports/${this.sport}/scoreboard?limit=1000&dates=${externalSeasonId}${seasonTypeParam}`;
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 = parseScore(home.score);
const awayScore = parseScore(away.score);
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;
}
}