Fix MLB standings 406 error and refactor ESPN adapter shared code
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m28s
🚀 Deploy / ʦ TypeScript (pull_request) Failing after 1m9s
🚀 Deploy / 🔍 Lint (pull_request) Successful in 46s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

- Switch MLB standings from deprecated statsapi.mlb.com to ESPN API
  (same source as NBA, WNBA, MLS — no auth required)
- Extract shared espn.ts utility: EspnStat/Team/Entry/Group/Response
  interfaces, statsMap(), flattenEspnStandings(), parseConferenceRank(),
  sortByWinLoss(); removes 4× duplication across adapters
- Fix parseConferenceRank falsy-zero bug (|| undefined → isNaN guard)
- Add stable alphabetical tiebreaker to MLB, NBA, WNBA sort comparators
- Fix winPct silent-zero: fall back to wins/(wins+losses) if ESPN omits stat
- Pre-build statsMap once per entry before sorting in all adapters
- Fix WNBA parseEntry to accept pre-computed sm instead of rebuilding it
- Restore and update gamesBack tests (ESPN returns numeric 0 for leaders,
  not the old API's "-" string)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-31 18:18:15 -07:00
parent 03726f1a66
commit e353c17a0b
6 changed files with 382 additions and 518 deletions

View file

@ -1,82 +1,70 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MlbStandingsAdapter } from "../mlb";
const SAMPLE_MLB_RESPONSE = {
records: [
// Minimal ESPN-shaped response: AL East + NL East with a few teams
const SAMPLE_ESPN_RESPONSE = {
children: [
{
// AL East division record
teamRecords: [
name: "American League",
children: [
{
team: {
id: 110,
name: "Baltimore Orioles",
league: { name: "American League" },
division: { name: "AL East" },
},
wins: 47,
losses: 34,
gamesPlayed: 81,
winningPercentage: ".580",
divisionRank: "1",
leagueRank: "1",
gamesBack: "-",
streak: { streakCode: "W3", streakNumber: 3 },
records: {
splitRecords: [
{ type: "home", wins: 24, losses: 15 },
{ type: "away", wins: 23, losses: 19 },
{ type: "lastTen", wins: 7, losses: 3 },
],
},
},
{
team: {
id: 111,
name: "Boston Red Sox",
league: { name: "American League" },
division: { name: "AL East" },
},
wins: 42,
losses: 39,
gamesPlayed: 81,
winningPercentage: ".519",
divisionRank: "2",
leagueRank: "4",
gamesBack: "5.0",
streak: { streakCode: "L2", streakNumber: 2 },
records: {
splitRecords: [
{ type: "home", wins: 22, losses: 18 },
{ type: "away", wins: 20, losses: 21 },
{ type: "lastTen", wins: 4, losses: 6 },
name: "American League East",
standings: {
entries: [
{
team: { id: "2", displayName: "Baltimore Orioles" },
stats: [
{ name: "wins", value: 47 },
{ name: "losses", value: 34 },
{ name: "winPercent", value: 0.58 },
{ name: "gamesBehind", value: 0 },
{ name: "streak", displayValue: "W3" },
{ name: "Home", displayValue: "24-15" },
{ name: "Road", displayValue: "23-19" },
{ name: "Last Ten Games", displayValue: "7-3" },
{ name: "playoffSeed", value: 1 },
],
},
{
team: { id: "3", displayName: "Boston Red Sox" },
stats: [
{ name: "wins", value: 42 },
{ name: "losses", value: 39 },
{ name: "winPercent", value: 0.519 },
{ name: "gamesBehind", value: 5.0 },
{ name: "streak", displayValue: "L2" },
{ name: "Home", displayValue: "22-18" },
{ name: "Road", displayValue: "20-21" },
{ name: "Last Ten Games", displayValue: "4-6" },
{ name: "playoffSeed", value: 2 },
],
},
],
},
},
],
},
{
// NL East division record
teamRecords: [
name: "National League",
children: [
{
team: {
id: 121,
name: "New York Mets",
league: { name: "National League" },
division: { name: "NL East" },
},
wins: 43,
losses: 38,
gamesPlayed: 81,
winningPercentage: ".531",
divisionRank: "1",
leagueRank: "1",
gamesBack: "-",
streak: { streakCode: "W1", streakNumber: 1 },
records: {
splitRecords: [
{ type: "home", wins: 22, losses: 17 },
{ type: "away", wins: 21, losses: 21 },
{ type: "lastTen", wins: 6, losses: 4 },
name: "National League East",
standings: {
entries: [
{
team: { id: "21", displayName: "New York Mets" },
stats: [
{ name: "wins", value: 43 },
{ name: "losses", value: 38 },
{ name: "winPercent", value: 0.531 },
{ name: "gamesBehind", value: 0 },
{ name: "streak", displayValue: "W1" },
{ name: "Home", displayValue: "22-17" },
{ name: "Road", displayValue: "21-21" },
{ name: "Last Ten Games", displayValue: "6-4" },
{ name: "playoffSeed", value: 1 },
],
},
],
},
},
@ -93,20 +81,20 @@ describe("MlbStandingsAdapter", () => {
it("maps AL teams to conference 'AL'", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
json: async () => SAMPLE_ESPN_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const bos = records.find((r) => r.teamName === "Baltimore Orioles");
expect(bos?.conference).toBe("AL");
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.conference).toBe("AL");
});
it("maps NL teams to conference 'NL'", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
json: async () => SAMPLE_ESPN_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
@ -119,22 +107,22 @@ describe("MlbStandingsAdapter", () => {
it("maps division names correctly", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
json: async () => SAMPLE_ESPN_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const bos = records.find((r) => r.teamName === "Baltimore Orioles");
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
const mets = records.find((r) => r.teamName === "New York Mets");
expect(bos?.division).toBe("AL East");
expect(ori?.division).toBe("AL East");
expect(mets?.division).toBe("NL East");
});
it("maps wins, losses, gamesPlayed, and winPct", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
json: async () => SAMPLE_ESPN_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
@ -150,7 +138,7 @@ describe("MlbStandingsAdapter", () => {
it("formats streak as 'W3' or 'L2'", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
json: async () => SAMPLE_ESPN_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
@ -162,10 +150,10 @@ describe("MlbStandingsAdapter", () => {
expect(bos?.streak).toBe("L2");
});
it("extracts lastTen from splitRecords", async () => {
it("extracts lastTen from Last Ten Games stat", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
json: async () => SAMPLE_ESPN_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
@ -175,10 +163,36 @@ describe("MlbStandingsAdapter", () => {
expect(ori?.lastTen).toBe("7-3");
});
it("extracts homeRecord and awayRecord from splitRecords", async () => {
it("gamesBack is 0 for the division leader (ESPN returns numeric 0, not '-')", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
json: async () => SAMPLE_ESPN_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.gamesBack).toBe(0);
});
it("gamesBack is a positive number for non-leaders", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_ESPN_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const bos = records.find((r) => r.teamName === "Boston Red Sox");
expect(bos?.gamesBack).toBeCloseTo(5.0, 1);
});
it("extracts homeRecord and awayRecord", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_ESPN_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
@ -189,36 +203,10 @@ describe("MlbStandingsAdapter", () => {
expect(ori?.awayRecord).toBe("23-19");
});
it("parses gamesBack as null for the division leader ('-')", async () => {
it("computes leagueRank sorted by wins desc across all teams", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.gamesBack).toBeUndefined();
});
it("parses gamesBack as a number for non-leaders", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const bos = records.find((r) => r.teamName === "Boston Red Sox");
expect(bos?.gamesBack).toBeCloseTo(5.0, 1);
});
it("computes leagueRank sorted by wins desc across all 30 teams", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
json: async () => SAMPLE_ESPN_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
@ -231,10 +219,23 @@ describe("MlbStandingsAdapter", () => {
expect(bos?.leagueRank).toBeGreaterThan(1);
});
it("uses ESPN string team id as externalTeamId", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_ESPN_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.externalTeamId).toBe("2");
});
it("never sets otLosses (MLB has no OT losses)", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
json: async () => SAMPLE_ESPN_RESPONSE,
} as Response);
const adapter = new MlbStandingsAdapter();
@ -245,34 +246,70 @@ describe("MlbStandingsAdapter", () => {
}
});
it("uses team numeric id as externalTeamId", async () => {
it("throws on non-ok HTTP response", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 406,
statusText: "Not Acceptable",
} as Response);
const adapter = new MlbStandingsAdapter();
await expect(adapter.fetchStandings()).rejects.toThrow("406");
});
it("produces a stable leagueRank when two teams are tied on wins and losses", async () => {
const tiedResponse = {
children: [
{
name: "American League",
children: [
{
name: "American League East",
standings: {
entries: [
{
team: { id: "99", displayName: "Z Team" },
stats: [
{ name: "wins", value: 40 },
{ name: "losses", value: 40 },
{ name: "winPercent", value: 0.5 },
],
},
{
team: { id: "98", displayName: "A Team" },
stats: [
{ name: "wins", value: 40 },
{ name: "losses", value: 40 },
{ name: "winPercent", value: 0.5 },
],
},
],
},
},
],
},
],
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_MLB_RESPONSE,
json: async () => tiedResponse,
} as Response);
const adapter = new MlbStandingsAdapter();
const records = await adapter.fetchStandings();
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
expect(ori?.externalTeamId).toBe("110");
// Tiebreaker is alphabetical — "A Team" should always rank above "Z Team"
const aTeam = records.find((r) => r.teamName === "A Team");
const zTeam = records.find((r) => r.teamName === "Z Team");
expect(aTeam?.leagueRank).toBe(1);
expect(zTeam?.leagueRank).toBe(2);
});
it("throws on non-ok HTTP response", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 503,
statusText: "Service Unavailable",
} as Response);
const adapter = new MlbStandingsAdapter();
await expect(adapter.fetchStandings()).rejects.toThrow("503");
});
it("throws on unexpected response shape", async () => {
it("throws when response has no entries", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ unexpected: true }),
json: async () => ({ children: [] }),
} as Response);
const adapter = new MlbStandingsAdapter();

View file

@ -0,0 +1,107 @@
// Shared interfaces and utilities for ESPN standings API adapters.
// Used by MLB, NBA, WNBA, and MLS adapters.
export interface EspnStat {
name: string;
displayName?: string;
shortDisplayName?: string;
description?: string;
abbreviation?: string;
type?: string;
value?: number;
displayValue?: string;
}
export interface EspnTeam {
id: string;
displayName: string;
shortDisplayName?: string;
abbreviation?: string;
location?: string;
name?: string;
}
export interface EspnStandingsEntry {
team: EspnTeam;
stats: EspnStat[];
}
export interface EspnStandingsGroup {
name?: string;
standings?: { entries?: EspnStandingsEntry[] };
children?: EspnStandingsGroup[];
}
export interface EspnStandingsResponse {
children?: EspnStandingsGroup[];
}
export function statsMap(stats: EspnStat[]): Map<string, EspnStat> {
const map = new Map<string, EspnStat>();
for (const stat of stats) map.set(stat.name, stat);
return map;
}
/**
* Flatten an ESPN standings response into individual entries with their
* conference and division context.
*
* Handles two layouts ESPN uses:
* - conf div entries (NBA, MLB): division name is populated
* - conf entries (flat conferences): division is ""
*/
export function flattenEspnStandings(
response: EspnStandingsResponse
): Array<{ entry: EspnStandingsEntry; conference: string; division: string }> {
const results: Array<{ entry: EspnStandingsEntry; conference: string; division: string }> = [];
for (const confGroup of response.children ?? []) {
const conferenceName = confGroup.name ?? "";
if (confGroup.children && confGroup.children.length > 0) {
for (const divGroup of confGroup.children) {
const divisionName = divGroup.name ?? "";
for (const entry of divGroup.standings?.entries ?? []) {
results.push({ entry, conference: conferenceName, division: divisionName });
}
}
} else {
for (const entry of confGroup.standings?.entries ?? []) {
results.push({ entry, conference: conferenceName, division: "" });
}
}
}
return results;
}
/**
* Extract the playoffSeed stat as a conference rank integer, or undefined if absent.
* Only meaningful for team sports that use playoff seeding (MLB, NBA, WNBA).
*/
export function parseConferenceRank(sm: Map<string, EspnStat>): number | undefined {
const stat = sm.get("playoffSeed");
if (stat?.value !== undefined && stat.value !== null) return Math.round(stat.value);
if (stat?.displayValue) {
const parsed = parseInt(stat.displayValue, 10);
return isNaN(parsed) ? undefined : parsed;
}
return undefined;
}
/**
* Sort comparator for ESPN win-loss standings: wins descending, losses ascending,
* alphabetical by team name as a stable tiebreaker.
*/
export function sortByWinLoss(
a: { sm: Map<string, EspnStat>; entry: EspnStandingsEntry },
b: { sm: Map<string, EspnStat>; entry: EspnStandingsEntry }
): number {
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;
if (lossA !== lossB) return lossA - lossB;
return a.entry.team.displayName.localeCompare(b.entry.team.displayName);
}

View file

@ -1,156 +1,81 @@
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
import {
flattenEspnStandings,
parseConferenceRank,
sortByWinLoss,
statsMap,
} from "./espn";
// 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)";
"https://site.api.espn.com/apis/v2/sports/baseball/mlb/standings";
interface MlbSplitRecord {
type: string; // "home", "away", "lastTen", etc.
wins: number;
losses: number;
// "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;
}
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; // full streak string e.g. "W3" or "L5" — NOT just the letter
streakNumber: number; // numeric part; redundant with streakCode, not used
};
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;
// "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 {
constructor(private season: number = new Date().getFullYear()) {}
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
const url = `${MLB_STANDINGS_URL}&season=${this.season}`;
const response = await fetch(url);
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 MlbStandingsResponse;
const json = await response.json();
const flattened = flattenEspnStandings(json);
if (!json.records || !Array.isArray(json.records)) {
throw new Error("Unexpected MLB standings API response shape");
if (flattened.length === 0) {
throw new Error("MLB standings API returned no entries — response shape may have changed");
}
// Collect all team records from all division groups
const allTeamRecords: MlbTeamRecord[] = json.records.flatMap(
(divRecord) => divRecord.teamRecords ?? []
);
// 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),
}));
if (allTeamRecords.length === 0) {
throw new Error("MLB standings API returned no team records");
}
const sorted = [...withSm].toSorted(sortByWinLoss);
// Compute leagueRank by sorting all teams by wins desc, losses asc within each league.
// We'll assign ranks per league (AL rank 115, NL rank 115) 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")
);
return sorted.map(({ entry, conference, division, sm }, leagueIdx): FetchedStandingsRecord => {
const wins = sm.get("wins")?.value ?? 0;
const losses = sm.get("losses")?.value ?? 0;
const alSorted = [...alTeams].toSorted(sortByRecord);
const nlSorted = [...nlTeams].toSorted(sortByRecord);
// 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 conferenceRankMap = new Map<number, number>();
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);
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;
// Compute overall league rank across all 30 teams for leagueRank field
const allSorted = [...allTeamRecords].toSorted(sortByRecord);
const overallRankMap = new Map<number, number>();
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;
// streakCode is the full string e.g. "W3"; streakNumber is the numeric part embedded within it
const streak = team.streak?.streakCode;
// 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;
const abbreviatedDiv = abbreviateDivision(division);
return {
teamName: team.team.name,
externalTeamId: String(team.team.id),
wins: team.wins,
losses: team.losses,
gamesPlayed: team.gamesPlayed,
teamName: entry.team.displayName,
externalTeamId: entry.team.id,
wins: Math.round(wins),
losses: Math.round(losses),
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,
gamesPlayed: Math.round(wins) + Math.round(losses),
gamesBack: gamesBehind,
conference: abbreviateLeague(conference),
division: abbreviatedDiv || undefined,
conferenceRank: parseConferenceRank(sm),
leagueRank: leagueIdx + 1,
streak,
lastTen,
homeRecord,

View file

@ -1,70 +1,13 @@
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
import {
flattenEspnStandings,
parseConferenceRank,
statsMap,
} from "./espn";
const MLS_STANDINGS_URL =
"https://site.api.espn.com/apis/v2/sports/soccer/usa.1/standings";
interface EspnStat {
name: string;
displayName?: string;
abbreviation?: string;
value?: number;
displayValue?: string;
}
interface EspnTeam {
id: string;
displayName: string;
shortDisplayName?: string;
abbreviation?: string;
}
interface EspnStandingsEntry {
team: EspnTeam;
stats: EspnStat[];
}
interface EspnStandingsGroup {
name?: string;
standings?: { entries?: EspnStandingsEntry[] };
children?: EspnStandingsGroup[];
}
interface EspnStandingsResponse {
children?: EspnStandingsGroup[];
}
function statsMap(stats: EspnStat[]): Map<string, EspnStat> {
const map = new Map<string, EspnStat>();
for (const stat of stats) {
map.set(stat.name, stat);
}
return map;
}
function flattenEspnStandings(
response: EspnStandingsResponse
): Array<{ entry: EspnStandingsEntry; conference: string }> {
const results: Array<{ entry: EspnStandingsEntry; conference: string }> = [];
for (const confGroup of response.children ?? []) {
const conferenceName = confGroup.name ?? "";
if (confGroup.children && confGroup.children.length > 0) {
for (const subGroup of confGroup.children) {
for (const entry of subGroup.standings?.entries ?? []) {
results.push({ entry, conference: conferenceName });
}
}
} else {
for (const entry of confGroup.standings?.entries ?? []) {
results.push({ entry, conference: conferenceName });
}
}
}
return results;
}
/**
* Normalize ESPN conference group names to "Eastern" or "Western".
* ESPN returns names like "Eastern Conference" or "Western Conference".
@ -76,12 +19,6 @@ function normalizeConference(raw: string): string {
return raw;
}
/**
* MLS standings adapter backed by ESPN's free soccer standings API.
*
* Endpoint: GET /apis/v2/sports/soccer/usa.1/standings
* No API key required.
*/
export class MlsStandingsAdapter implements StandingsSyncAdapter {
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
const response = await fetch(MLS_STANDINGS_URL);
@ -91,7 +28,7 @@ export class MlsStandingsAdapter implements StandingsSyncAdapter {
);
}
const json = (await response.json()) as EspnStandingsResponse;
const json = await response.json();
const flattened = flattenEspnStandings(json);
if (flattened.length === 0) {
@ -100,14 +37,14 @@ export class MlsStandingsAdapter implements StandingsSyncAdapter {
);
}
// Pre-parse each entry's stats once, then sort by points desc for overall league rank
const parsed = flattened.map(({ entry, conference }) => ({
// Pre-parse each entry's stats once, then sort by points desc for overall league rank.
const withSm = flattened.map(({ entry, conference }) => ({
entry,
conference,
sm: statsMap(entry.stats),
}));
const sorted = [...parsed].toSorted((a, b) => {
const sorted = [...withSm].toSorted((a, b) => {
const ptsA = a.sm.get("points")?.value ?? 0;
const ptsB = b.sm.get("points")?.value ?? 0;
if (ptsB !== ptsA) return ptsB - ptsA;
@ -129,14 +66,6 @@ export class MlsStandingsAdapter implements StandingsSyncAdapter {
const goalsAgainst = sm.get("pointsAgainst")?.value ?? sm.get("ga")?.value;
const goalDifference = sm.get("pointDifferential")?.value ?? sm.get("gd")?.value;
const playoffSeedStat = sm.get("playoffSeed");
const conferenceRank =
playoffSeedStat?.value !== null && playoffSeedStat?.value !== undefined
? Math.round(playoffSeedStat.value)
: playoffSeedStat?.displayValue
? parseInt(playoffSeedStat.displayValue, 10) || undefined
: undefined;
const homeRecord = sm.get("Home")?.displayValue ?? undefined;
const awayRecord = sm.get("Road")?.displayValue ?? undefined;
@ -153,7 +82,7 @@ export class MlsStandingsAdapter implements StandingsSyncAdapter {
winPct: gamesPlayed > 0 ? wins / gamesPlayed : 0,
gamesPlayed,
conference: normalizeConference(conference),
conferenceRank,
conferenceRank: parseConferenceRank(sm),
leagueRank: leagueIdx + 1,
homeRecord,
awayRecord,

View file

@ -1,86 +1,14 @@
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
import {
flattenEspnStandings,
parseConferenceRank,
sortByWinLoss,
statsMap,
} from "./espn";
const NBA_STANDINGS_URL =
"https://site.api.espn.com/apis/v2/sports/basketball/nba/standings";
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[];
}
function statsMap(stats: EspnStat[]): Map<string, EspnStat> {
const map = new Map<string, EspnStat>();
for (const stat of stats) {
map.set(stat.name, stat);
}
return map;
}
/**
* Flatten the ESPN standings response (conference division entries).
* Returns enriched entries with conference and division names attached.
*/
function flattenEspnStandings(
response: EspnStandingsResponse
): Array<{ entry: EspnStandingsEntry; conference: string; division: string }> {
const results: Array<{
entry: EspnStandingsEntry;
conference: string;
division: string;
}> = [];
for (const confGroup of response.children ?? []) {
const conferenceName = confGroup.name ?? "";
// Some ESPN responses have nested division children
if (confGroup.children && confGroup.children.length > 0) {
for (const divGroup of confGroup.children) {
const divisionName = divGroup.name ?? "";
for (const entry of divGroup.standings?.entries ?? []) {
results.push({ entry, conference: conferenceName, division: divisionName });
}
}
} else {
// Flat conference without division sub-groups
for (const entry of confGroup.standings?.entries ?? []) {
results.push({ entry, conference: conferenceName, division: "" });
}
}
}
return results;
}
export class NbaStandingsAdapter implements StandingsSyncAdapter {
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
const response = await fetch(NBA_STANDINGS_URL);
@ -88,49 +16,35 @@ export class NbaStandingsAdapter implements StandingsSyncAdapter {
throw new Error(`NBA standings API returned ${response.status}: ${response.statusText}`);
}
const json = (await response.json()) as EspnStandingsResponse;
const json = await response.json();
const flattened = flattenEspnStandings(json);
if (flattened.length === 0) {
throw new Error("NBA standings API returned no entries — response shape may have changed");
}
// Assign league rank by sorting on wins desc, then losses asc
const sorted = [...flattened].toSorted((a, b) => {
const winsA = statsMap(a.entry.stats).get("wins")?.value ?? 0;
const winsB = statsMap(b.entry.stats).get("wins")?.value ?? 0;
if (winsB !== winsA) return winsB - winsA;
const lossA = statsMap(a.entry.stats).get("losses")?.value ?? 0;
const lossB = statsMap(b.entry.stats).get("losses")?.value ?? 0;
return lossA - lossB;
});
// 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),
}));
return sorted.map(({ entry, conference, division }, leagueIdx): FetchedStandingsRecord => {
const sm = statsMap(entry.stats);
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;
const winPercent = sm.get("winPercent")?.value ?? sm.get("winPct")?.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;
// ESPN returns last-10 as "Last Ten Games" with a displayValue like "7-3"
const lastTen =
sm.get("Last Ten Games")?.displayValue ??
sm.get("L10")?.displayValue ??
undefined;
// Seed within conference — ESPN key is "playoffSeed", value is a float (1.0, 2.0 ...)
const playoffSeedStat = sm.get("playoffSeed");
const conferenceRank =
playoffSeedStat !== undefined && playoffSeedStat.value !== null && playoffSeedStat.value !== undefined
? Math.round(playoffSeedStat.value)
: playoffSeedStat?.displayValue
? parseInt(playoffSeedStat.displayValue, 10) || undefined
: undefined;
const gamesPlayed = wins + losses;
// Home/road records — ESPN returns these as displayValue strings (e.g. "24-17")
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;
@ -139,12 +53,12 @@ export class NbaStandingsAdapter implements StandingsSyncAdapter {
externalTeamId: entry.team.id,
wins: Math.round(wins),
losses: Math.round(losses),
winPct: winPercent,
gamesPlayed,
winPct,
gamesPlayed: Math.round(wins) + Math.round(losses),
gamesBack: gamesBehind,
conference,
division: division || undefined,
conferenceRank,
conferenceRank: parseConferenceRank(sm),
leagueRank: leagueIdx + 1,
streak,
lastTen,

View file

@ -1,4 +1,12 @@
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
import {
parseConferenceRank,
sortByWinLoss,
statsMap,
type EspnStat,
type EspnStandingsEntry,
type EspnStandingsResponse,
} from "./espn";
const WNBA_STANDINGS_URL =
"https://site.api.espn.com/apis/v2/sports/basketball/wnba/standings";
@ -10,41 +18,6 @@ const WNBA_STANDINGS_URL =
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;
@ -61,21 +34,11 @@ interface EspnTeamsResponse {
}>;
}
function statsMap(stats: EspnStat[]): Map<string, EspnStat> {
const map = new Map<string, EspnStat>();
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.
* WNBA-specific flatten: conferences may be directly under root or wrapped in a
* single top-level group. ESPN sometimes wraps them; handle both layouts.
*/
function flattenEspnStandings(
function flattenWnbaStandings(
response: EspnStandingsResponse
): Array<{ entry: EspnStandingsEntry; conference: string }> {
const results: Array<{ entry: EspnStandingsEntry; conference: string }> = [];
@ -104,14 +67,18 @@ function flattenEspnStandings(
/** Parse a standings entry into a FetchedStandingsRecord (rank assigned by caller). */
function parseEntry(
entry: EspnStandingsEntry,
sm: Map<string, EspnStat>,
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;
// 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 ??
@ -123,14 +90,6 @@ function parseEntry(
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).
@ -153,11 +112,11 @@ function parseEntry(
externalTeamId: entry.team.id,
wins: Math.round(wins),
losses: Math.round(losses),
winPct: winPercent,
winPct,
gamesPlayed: Math.round(wins) + Math.round(losses),
gamesBack: gamesBehind,
conference: conference || undefined,
conferenceRank,
conferenceRank: parseConferenceRank(sm),
leagueRank,
streak,
lastTen,
@ -193,26 +152,19 @@ export class WnbaStandingsAdapter implements StandingsSyncAdapter {
// 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 }) => ({
const flattened = flattenWnbaStandings(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;
});
const sortedEntries = [...flattened].toSorted(sortByWinLoss);
// Map externalTeamId → parsed record (so we can detect which teams are missing).
const recordsByTeamId = new Map<string, FetchedStandingsRecord>();
sortedEntries.forEach(({ entry, conference }, idx) => {
sortedEntries.forEach(({ entry, sm, conference }, idx) => {
recordsByTeamId.set(
entry.team.id,
parseEntry(entry, conference, idx + 1)
parseEntry(entry, sm, conference, idx + 1)
);
});