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"; // Fallback division lookup for when ESPN's API doesn't return division groupings. // Keyed by the team's ESPN displayName. Athletics entries cover the Oakland→Las Vegas rename. const MLB_TEAM_DIVISION: Record = { // AL East "Baltimore Orioles": "AL East", "Boston Red Sox": "AL East", "New York Yankees": "AL East", "Tampa Bay Rays": "AL East", "Toronto Blue Jays": "AL East", // AL Central "Chicago White Sox": "AL Central", "Cleveland Guardians": "AL Central", "Detroit Tigers": "AL Central", "Kansas City Royals": "AL Central", "Minnesota Twins": "AL Central", // AL West "Houston Astros": "AL West", "Los Angeles Angels": "AL West", "Athletics": "AL West", "Oakland Athletics": "AL West", "Las Vegas Athletics": "AL West", "Seattle Mariners": "AL West", "Texas Rangers": "AL West", // NL East "Atlanta Braves": "NL East", "Miami Marlins": "NL East", "New York Mets": "NL East", "Philadelphia Phillies": "NL East", "Washington Nationals": "NL East", // NL Central "Chicago Cubs": "NL Central", "Cincinnati Reds": "NL Central", "Milwaukee Brewers": "NL Central", "Pittsburgh Pirates": "NL Central", "St. Louis Cardinals": "NL Central", // NL West "Arizona Diamondbacks": "NL West", "Colorado Rockies": "NL West", "Los Angeles Dodgers": "NL West", "San Diego Padres": "NL West", "San Francisco Giants": "NL West", }; // "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. // Resolve division: use ESPN's value when present; fall back to the hardcoded map. const withSm = flattened.map(({ entry, conference, division }) => { const resolvedDivision = division || MLB_TEAM_DIVISION[entry.team.displayName] || ""; if (!resolvedDivision) { // eslint-disable-next-line no-console console.warn( `[MLB standings] No division found for team "${entry.team.displayName}" (id=${entry.team.id}). ` + "Update MLB_TEAM_DIVISION if this is a renamed or expansion team." ); } return { entry, conference, division: resolvedDivision, sm: statsMap(entry.stats) }; }); // Compute divisionRank: rank each team within its own division by win/loss. // Key includes conference to prevent AL/NL teams from sharing a group when ESPN // returns short names like "East" for both leagues, or no names at all. // Sort by win/loss within each group so rank 1 is always the best record, // regardless of the order ESPN returns teams in the response. const divisionRankMap = new Map(); const divisionGroups = new Map(); for (const item of withSm) { const key = `${item.conference}:${item.division}`; if (!divisionGroups.has(key)) divisionGroups.set(key, []); divisionGroups.get(key)?.push(item); } for (const divEntries of divisionGroups.values()) { divEntries.sort(sortByWinLoss); 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, }; }); } }