import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types"; const WNBA_STANDINGS_URL = "https://site.api.espn.com/apis/v2/sports/basketball/wnba/standings"; /** * Returns all registered WNBA teams (including pre-season expansion teams not yet * in standings). Used to supplement the standings response so every team gets a record. */ const WNBA_TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/teams"; interface EspnStat { name: string; displayName?: string; shortDisplayName?: string; description?: string; abbreviation?: string; type?: string; value?: number; displayValue?: string; } interface EspnTeam { id: string; displayName: string; shortDisplayName?: string; abbreviation?: string; location?: string; name?: string; } interface EspnStandingsEntry { team: EspnTeam; stats: EspnStat[]; } interface EspnStandingsGroup { name?: string; standings?: { entries?: EspnStandingsEntry[] }; children?: EspnStandingsGroup[]; } interface EspnStandingsResponse { children?: EspnStandingsGroup[]; } interface EspnTeamItem { team: { id: string; displayName: string; isActive?: boolean; }; } interface EspnTeamsResponse { sports?: Array<{ leagues?: Array<{ teams?: EspnTeamItem[]; }>; }>; } function statsMap(stats: EspnStat[]): Map { const map = new Map(); for (const stat of stats) { map.set(stat.name, stat); } return map; } /** * Flatten the ESPN standings response. * WNBA has conferences (East/West) but no divisions — entries are either directly * under the conference group or nested one level deeper (ESPN sometimes wraps * conferences in a single top-level group). Handles both layouts. */ function flattenEspnStandings( response: EspnStandingsResponse ): Array<{ entry: EspnStandingsEntry; conference: string }> { const results: Array<{ entry: EspnStandingsEntry; conference: string }> = []; for (const topGroup of response.children ?? []) { if (topGroup.children && topGroup.children.length > 0) { // Top-level group has sub-groups — each sub-group is a conference (no divisions). for (const confGroup of topGroup.children) { const conferenceName = confGroup.name ?? topGroup.name ?? ""; for (const entry of confGroup.standings?.entries ?? []) { results.push({ entry, conference: conferenceName }); } } } else { // Top-level group is the conference itself. const conferenceName = topGroup.name ?? ""; for (const entry of topGroup.standings?.entries ?? []) { results.push({ entry, conference: conferenceName }); } } } return results; } /** Parse a standings entry into a FetchedStandingsRecord (rank assigned by caller). */ function parseEntry( entry: EspnStandingsEntry, conference: string, leagueRank: number ): FetchedStandingsRecord { const sm = statsMap(entry.stats); const wins = sm.get("wins")?.value ?? 0; const losses = sm.get("losses")?.value ?? 0; const winPercent = sm.get("winPercent")?.value ?? sm.get("winPct")?.value ?? 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 playoffSeedStat = sm.get("playoffSeed"); const conferenceRank = playoffSeedStat?.value !== undefined && playoffSeedStat.value !== null ? Math.round(playoffSeedStat.value) : playoffSeedStat?.displayValue ? parseInt(playoffSeedStat.displayValue, 10) || undefined : undefined; // SRS proxy: average point differential per game. // ESPN exposes "avgPointsFor" and "avgPointsAgainst" (or "pointsFor"/"pointsAgainst"). // If unavailable, fall back to null so the simulator uses the league-average Elo (1500). const ptFor = sm.get("avgPointsFor")?.value ?? sm.get("pointsFor")?.value ?? null; const ptAgainst = sm.get("avgPointsAgainst")?.value ?? sm.get("pointsAgainst")?.value ?? null; const srs = ptFor !== null && ptFor !== undefined && ptAgainst !== null && ptAgainst !== undefined ? Math.round((ptFor - ptAgainst) * 100) / 100 : null; return { teamName: entry.team.displayName, externalTeamId: entry.team.id, wins: Math.round(wins), losses: Math.round(losses), winPct: winPercent, gamesPlayed: Math.round(wins) + Math.round(losses), gamesBack: gamesBehind, conference: conference || undefined, conferenceRank, leagueRank, streak, lastTen, homeRecord, awayRecord, srs, }; } export class WnbaStandingsAdapter implements StandingsSyncAdapter { async fetchStandings(): Promise { // Fetch both endpoints in parallel. const [standingsResponse, teamsResponse] = await Promise.all([ fetch(WNBA_STANDINGS_URL), fetch(WNBA_TEAMS_URL), ]); if (!standingsResponse.ok) { throw new Error( `WNBA standings API returned ${standingsResponse.status}: ${standingsResponse.statusText}` ); } if (!teamsResponse.ok) { throw new Error( `WNBA teams API returned ${teamsResponse.status}: ${teamsResponse.statusText}` ); } const [standingsJson, teamsJson] = await Promise.all([ standingsResponse.json() as Promise, teamsResponse.json() as Promise, ]); // Build standings records from the standings endpoint, sorted by wins desc. // Pre-parse each entry once so statsMap isn't rebuilt multiple times per entry. const flattened = flattenEspnStandings(standingsJson).map(({ entry, conference }) => ({ entry, conference, sm: statsMap(entry.stats), })); const sortedEntries = [...flattened].toSorted((a, b) => { const winsA = a.sm.get("wins")?.value ?? 0; const winsB = b.sm.get("wins")?.value ?? 0; if (winsB !== winsA) return winsB - winsA; const lossA = a.sm.get("losses")?.value ?? 0; const lossB = b.sm.get("losses")?.value ?? 0; return lossA - lossB; }); // Map externalTeamId → parsed record (so we can detect which teams are missing). const recordsByTeamId = new Map(); sortedEntries.forEach(({ entry, conference }, idx) => { recordsByTeamId.set( entry.team.id, parseEntry(entry, conference, idx + 1) ); }); // Extract the full team list from the teams endpoint. const allTeams: Array<{ id: string; displayName: string }> = ( teamsJson.sports?.[0]?.leagues?.[0]?.teams ?? [] ) .filter((t) => t.team.isActive !== false) .map((t) => ({ id: t.team.id, displayName: t.team.displayName })); if (allTeams.length === 0 && recordsByTeamId.size === 0) { throw new Error( "WNBA standings API returned no entries — response shape may have changed" ); } // Merge: use standing records where available; fill in zero-records for // expansion teams not yet in the standings (pre-season or mid-season addition). const results: FetchedStandingsRecord[] = []; const seenIds = new Set(); // First, add all teams that have standings data (preserving their rank). for (const record of recordsByTeamId.values()) { results.push(record); seenIds.add(record.externalTeamId); } // Then, append zero-records for any team in the teams list but not in standings. let expansionRank = results.length + 1; for (const team of allTeams) { if (!seenIds.has(team.id)) { results.push({ teamName: team.displayName, externalTeamId: team.id, wins: 0, losses: 0, winPct: 0, gamesPlayed: 0, leagueRank: expansionRank++, srs: null, }); } } // If the teams endpoint returned nothing (API change), fall back to standings only. if (results.length === 0) { throw new Error( "WNBA standings API returned no entries — response shape may have changed" ); } return results; } }