import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types"; import { flattenEspnStandings, parseConferenceRank, sortByWinLoss, statsMap, type EspnStandingsResponse, } from "./espn"; const MLB_STANDINGS_URL = "https://site.api.espn.com/apis/v2/sports/baseball/mlb/standings"; // "American League" → "AL", "National League" → "NL" function abbreviateLeague(name: string): string { if (name.startsWith("American")) return "AL"; if (name.startsWith("National")) return "NL"; return name; } // "American League East" → "AL East", "National League West" → "NL West" function abbreviateDivision(name: string): string { return name .replace(/^American League /, "AL ") .replace(/^National League /, "NL "); } export class MlbStandingsAdapter implements StandingsSyncAdapter { async fetchStandings(): Promise { const response = await fetch(MLB_STANDINGS_URL); if (!response.ok) { throw new Error(`MLB standings API returned ${response.status}: ${response.statusText}`); } const json = (await response.json()) as EspnStandingsResponse; const flattened = flattenEspnStandings(json); if (flattened.length === 0) { throw new Error("MLB standings API returned no entries — response shape may have changed"); } // Pre-build statsMap once per entry so the sort doesn't rebuild it on every comparison. const withSm = flattened.map(({ entry, conference, division }) => ({ entry, conference, division, sm: statsMap(entry.stats), })); // Compute divisionRank: rank each team within its own division by win/loss. const divisionRankMap = new Map(); const divisionGroups = new Map(); for (const item of withSm) { const key = item.division; if (!divisionGroups.has(key)) divisionGroups.set(key, []); divisionGroups.get(key)?.push(item); } for (const divEntries of divisionGroups.values()) { // Use the ESPN entry order within each division — ESPN already applies correct tiebreakers. divEntries.forEach((e, i) => { divisionRankMap.set(e.entry.team.id, i + 1); }); } const sorted = [...withSm].toSorted(sortByWinLoss); return sorted.map(({ entry, conference, division, sm }, leagueIdx): FetchedStandingsRecord => { const wins = sm.get("wins")?.value ?? 0; const losses = sm.get("losses")?.value ?? 0; // Fall back to computing win% from wins/losses if ESPN omits the stat. const winPctStat = sm.get("winPercent")?.value ?? sm.get("winPct")?.value; const winPct = winPctStat ?? (wins + losses > 0 ? wins / (wins + losses) : 0); const gamesBehind = sm.get("gamesBehind")?.value; const streak = sm.get("streak")?.displayValue ?? sm.get("streakSummary")?.displayValue; const lastTen = sm.get("Last Ten Games")?.displayValue ?? sm.get("L10")?.displayValue ?? undefined; const homeRecord = sm.get("Home")?.displayValue ?? undefined; const awayRecord = sm.get("Road")?.displayValue ?? undefined; const abbreviatedDiv = abbreviateDivision(division); return { teamName: entry.team.displayName, externalTeamId: entry.team.id, wins: Math.round(wins), losses: Math.round(losses), winPct, gamesPlayed: Math.round(wins) + Math.round(losses), gamesBack: gamesBehind, conference: abbreviateLeague(conference), division: abbreviatedDiv || undefined, conferenceRank: parseConferenceRank(sm), divisionRank: divisionRankMap.get(entry.team.id), leagueRank: leagueIdx + 1, streak, lastTen, homeRecord, awayRecord, }; }); } }