import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types"; // MLB Stats API — free, no key required. // leagueId 103 = American League, 104 = National League const MLB_STANDINGS_URL = "https://statsapi.mlb.com/api/v1/standings?leagueId=103,104&standingsTypes=regularSeason&hydrate=team(division,league)"; interface MlbSplitRecord { type: string; // "home", "away", "lastTen", etc. wins: number; losses: number; } interface MlbTeamRecord { team: { id: number; name: string; league?: { name?: string }; division?: { name?: string }; }; wins: number; losses: number; gamesPlayed: number; winningPercentage: string; // e.g. ".617" divisionRank?: string; // e.g. "1"–"5" — rank within the 5-team division (all teams) wildCardRank?: string; // e.g. "1" — only set for WC-ranked teams leagueRank?: string; // rank within the 15-team AL or NL gamesBack: string; // "-" for leader, "2.0" otherwise wildCardGamesBack?: string; streak?: { streakCode: string; streakNumber: number }; records?: { splitRecords?: MlbSplitRecord[] }; } interface MlbDivisionRecord { teamRecords: MlbTeamRecord[]; } interface MlbStandingsResponse { records: MlbDivisionRecord[]; } function sortByRecord(a: MlbTeamRecord, b: MlbTeamRecord): number { return b.wins !== a.wins ? b.wins - a.wins : a.losses - b.losses; } export class MlbStandingsAdapter implements StandingsSyncAdapter { constructor(private season: number = new Date().getFullYear()) {} async fetchStandings(): Promise { const url = `${MLB_STANDINGS_URL}&season=${this.season}`; const response = await fetch(url); if (!response.ok) { throw new Error(`MLB standings API returned ${response.status}: ${response.statusText}`); } const json = (await response.json()) as MlbStandingsResponse; if (!json.records || !Array.isArray(json.records)) { throw new Error("Unexpected MLB standings API response shape"); } // Collect all team records from all division groups const allTeamRecords: MlbTeamRecord[] = json.records.flatMap( (divRecord) => divRecord.teamRecords ?? [] ); if (allTeamRecords.length === 0) { throw new Error("MLB standings API returned no team records"); } // Compute leagueRank by sorting all teams by wins desc, losses asc within each league. // We'll assign ranks per league (AL rank 1–15, NL rank 1–15) separately since // the API's leagueRank field may reflect conference-level rank. const alTeams = allTeamRecords.filter( (t) => t.team.league?.name?.includes("American") ); const nlTeams = allTeamRecords.filter( (t) => t.team.league?.name?.includes("National") ); const alSorted = [...alTeams].toSorted(sortByRecord); const nlSorted = [...nlTeams].toSorted(sortByRecord); const conferenceRankMap = new Map(); for (const [i, t] of alSorted.entries()) conferenceRankMap.set(t.team.id, i + 1); for (const [i, t] of nlSorted.entries()) conferenceRankMap.set(t.team.id, i + 1); // Compute overall league rank across all 30 teams for leagueRank field const allSorted = [...allTeamRecords].toSorted(sortByRecord); const overallRankMap = new Map(); for (const [i, t] of allSorted.entries()) overallRankMap.set(t.team.id, i + 1); return allTeamRecords.map((team): FetchedStandingsRecord => { // Conference: "American League" → "AL", "National League" → "NL" const leagueName = team.team.league?.name ?? ""; const conference = leagueName.startsWith("American") ? "AL" : leagueName.startsWith("National") ? "NL" : leagueName; // Division: "AL East", "AL Central", etc. const division = team.team.division?.name ?? null; // Win percentage const winPct = parseFloat(team.winningPercentage) || 0; // Games back — "-" means leader; use null. Use isNaN guard so GB=0 (tied for first) is kept. const gbParsed = parseFloat(team.gamesBack); const gbStr = (team.gamesBack === "-" || isNaN(gbParsed)) ? null : gbParsed; // Division rank and conference rank const divisionRank = team.divisionRank ? parseInt(team.divisionRank, 10) : null; // Conference rank: use the per-league rank computed above const conferenceRank = conferenceRankMap.get(team.team.id) ?? null; // Streak: e.g. "W3" or "L2" const streak = team.streak ? `${team.streak.streakCode}${team.streak.streakNumber}` : undefined; // Split records const splits = team.records?.splitRecords ?? []; const homeSplit = splits.find((s) => s.type === "home"); const awaySplit = splits.find((s) => s.type === "away"); const lastTenSplit = splits.find((s) => s.type === "lastTen"); const homeRecord = homeSplit ? `${homeSplit.wins}-${homeSplit.losses}` : undefined; const awayRecord = awaySplit ? `${awaySplit.wins}-${awaySplit.losses}` : undefined; const lastTen = lastTenSplit ? `${lastTenSplit.wins}-${lastTenSplit.losses}` : undefined; return { teamName: team.team.name, externalTeamId: String(team.team.id), wins: team.wins, losses: team.losses, gamesPlayed: team.gamesPlayed, winPct, // MLB has no OT losses conference, division: division ?? undefined, divisionRank: divisionRank ?? undefined, conferenceRank: conferenceRank ?? undefined, leagueRank: overallRankMap.get(team.team.id) ?? 1, gamesBack: gbStr ?? undefined, streak, lastTen, homeRecord, awayRecord, }; }); } }