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
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:
parent
03726f1a66
commit
e353c17a0b
6 changed files with 382 additions and 518 deletions
|
|
@ -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();
|
||||
|
|
|
|||
107
app/services/standings-sync/espn.ts
Normal file
107
app/services/standings-sync/espn.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 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")
|
||||
);
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue