Add MLS standings sync and fix simulator conference resolution (#423)
* Add MLS standings sync and fix simulator conference resolution - New MlsStandingsAdapter using ESPN's free soccer API (no key required), returning Eastern/Western conference data, W/D/L/GF/GA/GD/PTS, conference rank, and home/away records; registered under mls_bracket - Display route now shows soccer table columns and a 9-team playoff cutoff line for mls_bracket (top 9 per conference qualify for MLS Cup Playoffs) - MLS simulator gains a third conference fallback: reads externalId="Eastern" or "Western" on the participant, mirroring the LLWS pool-assignment pattern so admins can bootstrap conference data before the first standings sync - 9 unit tests covering stat mapping, conference normalization, rank ordering, and error paths https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Address code review feedback on MLS standings + simulator 1. Update mls-simulator.ts module comment to document the new step-3 externalId fallback in the conference resolution order 2. Tighten normalizeConference to exact-match "Eastern"/"Western Conference" instead of broad substring, preventing false matches on names like "Northeast" 3. Pre-parse statsMap for each entry before the sort so it isn't rebuilt O(n log n) times during comparison 4. Document the externalId name-matching tradeoff on parseConferenceFromExternalId 5. Try ESPN's gamesPlayed stat before falling back to wins+losses+ties sum; export parseConferenceFromExternalId for direct testing 6. Add tests: normalizeConference passthrough, winPct=0 at preseason, and parseConferenceFromExternalId (case-insensitivity, null/undefined, numeric ESPN IDs, unrecognized strings) https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u * Fix lint: replace != null with !== null && !== undefined oxlint enforces eqeqeq; the five != null checks in mls.ts were flagged. https://claude.ai/code/session_01WhzXHpv6taXdHzhgvnv83u --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c097e4827c
commit
766ba948e1
6 changed files with 515 additions and 6 deletions
|
|
@ -55,7 +55,9 @@ export default function SportSeasonDetail({
|
|||
simulatorType === "mlb_bracket" ? "mlb-divisions" :
|
||||
"flat";
|
||||
const playoffSpots =
|
||||
simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 : 8;
|
||||
simulatorType === "nba_bracket" || simulatorType === "afl_bracket" ? 10 :
|
||||
simulatorType === "mls_bracket" ? 9 :
|
||||
8;
|
||||
|
||||
const ownershipMap = Object.fromEntries(
|
||||
teamOwnerships.map((o) => [
|
||||
|
|
@ -115,7 +117,7 @@ export default function SportSeasonDetail({
|
|||
teamOwnerships={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
showOtLosses={showOtLosses}
|
||||
showSoccerTable={simulatorType === "epl_standings"}
|
||||
showSoccerTable={simulatorType === "epl_standings" || simulatorType === "mls_bracket"}
|
||||
displayMode={standingsDisplayMode as "flat" | "nhl-divisions" | "mlb-divisions"}
|
||||
playoffSpots={playoffSpots}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
simulateMlsBestOfThree,
|
||||
simulateMlsRegularSeason,
|
||||
simulateMlsPlayoffs,
|
||||
parseConferenceFromExternalId,
|
||||
type MlsConferenceSeed,
|
||||
type MlsTeamEntry,
|
||||
} from "../mls-simulator";
|
||||
|
|
@ -361,3 +362,33 @@ describe("mls_bracket simulator sync", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseConferenceFromExternalId ────────────────────────────────────────────
|
||||
|
||||
describe("parseConferenceFromExternalId", () => {
|
||||
it('returns "Eastern" for exact match', () => {
|
||||
expect(parseConferenceFromExternalId("Eastern")).toBe("Eastern");
|
||||
});
|
||||
|
||||
it('returns "Western" for exact match', () => {
|
||||
expect(parseConferenceFromExternalId("Western")).toBe("Western");
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(parseConferenceFromExternalId("eastern")).toBe("Eastern");
|
||||
expect(parseConferenceFromExternalId("WESTERN")).toBe("Western");
|
||||
});
|
||||
|
||||
it("returns null for a numeric ESPN team id", () => {
|
||||
expect(parseConferenceFromExternalId("339")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for null/undefined", () => {
|
||||
expect(parseConferenceFromExternalId(null)).toBeNull();
|
||||
expect(parseConferenceFromExternalId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an unrecognized string", () => {
|
||||
expect(parseConferenceFromExternalId("Northeast")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@
|
|||
* Conference assignment resolution order:
|
||||
* 1. regularSeasonStandings.conference (populated by standings sync)
|
||||
* 2. seasonParticipantSimulatorInputs.region ("Eastern" or "Western")
|
||||
* 3. Error — conference must be known for all teams
|
||||
* 3. seasonParticipant.externalId set to "Eastern" or "Western" (pre-sync bootstrap;
|
||||
* note: this prevents externalId-based matching in future syncs for that participant,
|
||||
* which falls back to slower name-matching — acceptable tradeoff for bootstrapping)
|
||||
* 4. Error — conference must be known for all teams
|
||||
*
|
||||
* MLS Cup Playoffs format (18 teams: 9 East + 9 West):
|
||||
* Wild Card : E8 vs E9, W8 vs W9 — single game (no draws; PKs if tied)
|
||||
|
|
@ -66,6 +69,23 @@ function configNumber(config: Record<string, unknown> | undefined, key: string,
|
|||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer MLS conference from seasonParticipant.externalId.
|
||||
* Admins can set externalId to "Eastern" or "Western" before the first standings
|
||||
* sync, mirroring how LLWS uses externalId to encode pool assignment.
|
||||
*
|
||||
* Side-effect: once externalId holds a conference name instead of an ESPN team ID,
|
||||
* the standings-sync write-back won't overwrite it (write-back only fires when
|
||||
* externalId is null). That participant will always match by name, never by ID.
|
||||
*/
|
||||
export function parseConferenceFromExternalId(externalId: string | null | undefined): MlsConference | null {
|
||||
if (!externalId) return null;
|
||||
const upper = externalId.toUpperCase();
|
||||
if (upper === "EASTERN") return "Eastern";
|
||||
if (upper === "WESTERN") return "Western";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Public types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type MlsConference = "Eastern" | "Western";
|
||||
|
|
@ -342,7 +362,9 @@ export class MLSSimulator implements Simulator {
|
|||
}
|
||||
}
|
||||
|
||||
// Resolve conference for every participant
|
||||
// Resolve conference for every participant.
|
||||
// Priority: (1) regularSeasonStandings.conference, (2) simulatorInputs.region,
|
||||
// (3) seasonParticipant.externalId set to "Eastern"/"Western" (pre-sync bootstrap).
|
||||
const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r]));
|
||||
const conferenceMap = new Map<string, MlsConference>();
|
||||
for (const p of participants) {
|
||||
|
|
@ -350,6 +372,7 @@ export class MLSSimulator implements Simulator {
|
|||
const raw =
|
||||
(standing?.conference as string | null | undefined) ??
|
||||
regionMap.get(p.id) ??
|
||||
parseConferenceFromExternalId(p.externalId) ??
|
||||
null;
|
||||
if (raw === "Eastern" || raw === "Western") {
|
||||
conferenceMap.set(p.id, raw);
|
||||
|
|
@ -361,7 +384,8 @@ export class MLSSimulator implements Simulator {
|
|||
throw new Error(
|
||||
`MLSSimulator: Conference assignment missing for ${missingConf.length} team(s): ` +
|
||||
`${missingConf.map((p) => p.name).join(", ")}. ` +
|
||||
`Set region to "Eastern" or "Western" via Admin → Simulator Inputs.`
|
||||
`Options: sync standings (populates conference automatically), set region to "Eastern"/"Western" ` +
|
||||
`via Admin → Simulator Inputs, or set externalId to "Eastern"/"Western" on the participant.`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
286
app/services/standings-sync/__tests__/mls.test.ts
Normal file
286
app/services/standings-sync/__tests__/mls.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { MlsStandingsAdapter } from "../mls";
|
||||
|
||||
function makeStat(name: string, value: number, displayValue?: string) {
|
||||
return { name, value, displayValue: displayValue ?? String(value) };
|
||||
}
|
||||
|
||||
const SAMPLE_MLS_RESPONSE = {
|
||||
children: [
|
||||
{
|
||||
name: "Eastern Conference",
|
||||
standings: {
|
||||
entries: [
|
||||
{
|
||||
team: { id: "371", displayName: "Inter Miami CF", shortDisplayName: "Inter Miami", abbreviation: "MIA" },
|
||||
stats: [
|
||||
makeStat("wins", 18),
|
||||
makeStat("losses", 8),
|
||||
makeStat("ties", 8),
|
||||
makeStat("points", 62),
|
||||
makeStat("pointsFor", 55),
|
||||
makeStat("pointsAgainst", 38),
|
||||
makeStat("pointDifferential", 17),
|
||||
makeStat("playoffSeed", 1),
|
||||
{ name: "Home", value: 0, displayValue: "10-3-3" },
|
||||
{ name: "Road", value: 0, displayValue: "8-5-5" },
|
||||
],
|
||||
},
|
||||
{
|
||||
team: { id: "928", displayName: "Columbus Crew", shortDisplayName: "Columbus", abbreviation: "CLB" },
|
||||
stats: [
|
||||
makeStat("wins", 15),
|
||||
makeStat("losses", 10),
|
||||
makeStat("ties", 9),
|
||||
makeStat("points", 54),
|
||||
makeStat("pointsFor", 48),
|
||||
makeStat("pointsAgainst", 42),
|
||||
makeStat("pointDifferential", 6),
|
||||
makeStat("playoffSeed", 2),
|
||||
{ name: "Home", value: 0, displayValue: "9-4-4" },
|
||||
{ name: "Road", value: 0, displayValue: "6-6-5" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Western Conference",
|
||||
standings: {
|
||||
entries: [
|
||||
{
|
||||
team: { id: "339", displayName: "Los Angeles FC", shortDisplayName: "LAFC", abbreviation: "LAFC" },
|
||||
stats: [
|
||||
makeStat("wins", 20),
|
||||
makeStat("losses", 6),
|
||||
makeStat("ties", 8),
|
||||
makeStat("points", 68),
|
||||
makeStat("pointsFor", 62),
|
||||
makeStat("pointsAgainst", 30),
|
||||
makeStat("pointDifferential", 32),
|
||||
makeStat("playoffSeed", 1),
|
||||
{ name: "Home", value: 0, displayValue: "12-2-3" },
|
||||
{ name: "Road", value: 0, displayValue: "8-4-5" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("MlsStandingsAdapter", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("maps ESPN API response to FetchedStandingsRecord[]", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => SAMPLE_MLS_RESPONSE,
|
||||
} as Response);
|
||||
|
||||
const adapter = new MlsStandingsAdapter();
|
||||
const records = await adapter.fetchStandings();
|
||||
|
||||
expect(records).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("maps soccer-specific fields (W/D/L/GF/GA/GD/PTS)", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => SAMPLE_MLS_RESPONSE,
|
||||
} as Response);
|
||||
|
||||
const adapter = new MlsStandingsAdapter();
|
||||
const records = await adapter.fetchStandings();
|
||||
|
||||
const miami = records.find((r) => r.teamName === "Inter Miami CF");
|
||||
if (!miami) throw new Error("Inter Miami CF not found");
|
||||
expect(miami.wins).toBe(18);
|
||||
expect(miami.losses).toBe(8);
|
||||
expect(miami.ties).toBe(8);
|
||||
expect(miami.tablePoints).toBe(62);
|
||||
expect(miami.goalsFor).toBe(55);
|
||||
expect(miami.goalsAgainst).toBe(38);
|
||||
expect(miami.goalDifference).toBe(17);
|
||||
expect(miami.gamesPlayed).toBe(34); // 18+8+8
|
||||
});
|
||||
|
||||
it("normalizes conference names to Eastern/Western", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => SAMPLE_MLS_RESPONSE,
|
||||
} as Response);
|
||||
|
||||
const adapter = new MlsStandingsAdapter();
|
||||
const records = await adapter.fetchStandings();
|
||||
|
||||
const miami = records.find((r) => r.teamName === "Inter Miami CF");
|
||||
const lafc = records.find((r) => r.teamName === "Los Angeles FC");
|
||||
if (!miami || !lafc) throw new Error("Teams not found");
|
||||
|
||||
expect(miami.conference).toBe("Eastern");
|
||||
expect(lafc.conference).toBe("Western");
|
||||
});
|
||||
|
||||
it("assigns conferenceRank from playoffSeed stat", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => SAMPLE_MLS_RESPONSE,
|
||||
} as Response);
|
||||
|
||||
const adapter = new MlsStandingsAdapter();
|
||||
const records = await adapter.fetchStandings();
|
||||
|
||||
const miami = records.find((r) => r.teamName === "Inter Miami CF");
|
||||
const columbus = records.find((r) => r.teamName === "Columbus Crew");
|
||||
if (!miami || !columbus) throw new Error("Teams not found");
|
||||
|
||||
expect(miami.conferenceRank).toBe(1);
|
||||
expect(columbus.conferenceRank).toBe(2);
|
||||
});
|
||||
|
||||
it("assigns leagueRank by points descending", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => SAMPLE_MLS_RESPONSE,
|
||||
} as Response);
|
||||
|
||||
const adapter = new MlsStandingsAdapter();
|
||||
const records = await adapter.fetchStandings();
|
||||
|
||||
// LAFC has 68 pts, Miami 62, Columbus 54 → ranks 1, 2, 3
|
||||
const lafc = records.find((r) => r.teamName === "Los Angeles FC");
|
||||
const miami = records.find((r) => r.teamName === "Inter Miami CF");
|
||||
const columbus = records.find((r) => r.teamName === "Columbus Crew");
|
||||
if (!lafc || !miami || !columbus) throw new Error("Teams not found");
|
||||
|
||||
expect(lafc.leagueRank).toBe(1);
|
||||
expect(miami.leagueRank).toBe(2);
|
||||
expect(columbus.leagueRank).toBe(3);
|
||||
});
|
||||
|
||||
it("uses externalTeamId from ESPN team id", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => SAMPLE_MLS_RESPONSE,
|
||||
} as Response);
|
||||
|
||||
const adapter = new MlsStandingsAdapter();
|
||||
const records = await adapter.fetchStandings();
|
||||
|
||||
const lafc = records.find((r) => r.teamName === "Los Angeles FC");
|
||||
if (!lafc) throw new Error("LAFC not found");
|
||||
expect(lafc.externalTeamId).toBe("339");
|
||||
});
|
||||
|
||||
it("maps home and away records", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => SAMPLE_MLS_RESPONSE,
|
||||
} as Response);
|
||||
|
||||
const adapter = new MlsStandingsAdapter();
|
||||
const records = await adapter.fetchStandings();
|
||||
|
||||
const miami = records.find((r) => r.teamName === "Inter Miami CF");
|
||||
if (!miami) throw new Error("Inter Miami CF not found");
|
||||
expect(miami.homeRecord).toBe("10-3-3");
|
||||
expect(miami.awayRecord).toBe("8-5-5");
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: "Too Many Requests",
|
||||
} as Response);
|
||||
|
||||
const adapter = new MlsStandingsAdapter();
|
||||
await expect(adapter.fetchStandings()).rejects.toThrow("429");
|
||||
});
|
||||
|
||||
it("throws when no entries returned", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ children: [] }),
|
||||
} as Response);
|
||||
|
||||
const adapter = new MlsStandingsAdapter();
|
||||
await expect(adapter.fetchStandings()).rejects.toThrow("no entries");
|
||||
});
|
||||
|
||||
it("passes through unrecognized conference names unchanged", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
children: [
|
||||
{
|
||||
name: "Southern Division",
|
||||
standings: {
|
||||
entries: [
|
||||
{
|
||||
team: { id: "1", displayName: "Test FC" },
|
||||
stats: [
|
||||
makeStat("wins", 5),
|
||||
makeStat("losses", 3),
|
||||
makeStat("ties", 2),
|
||||
makeStat("points", 17),
|
||||
makeStat("pointsFor", 12),
|
||||
makeStat("pointsAgainst", 9),
|
||||
makeStat("pointDifferential", 3),
|
||||
makeStat("playoffSeed", 1),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const adapter = new MlsStandingsAdapter();
|
||||
const records = await adapter.fetchStandings();
|
||||
expect(records[0].conference).toBe("Southern Division");
|
||||
});
|
||||
|
||||
it("returns winPct of 0 when gamesPlayed is 0", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
children: [
|
||||
{
|
||||
name: "Eastern Conference",
|
||||
standings: {
|
||||
entries: [
|
||||
{
|
||||
team: { id: "1", displayName: "Preseason FC" },
|
||||
stats: [
|
||||
makeStat("wins", 0),
|
||||
makeStat("losses", 0),
|
||||
makeStat("ties", 0),
|
||||
makeStat("points", 0),
|
||||
makeStat("pointsFor", 0),
|
||||
makeStat("pointsAgainst", 0),
|
||||
makeStat("pointDifferential", 0),
|
||||
makeStat("playoffSeed", 1),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const adapter = new MlsStandingsAdapter();
|
||||
const records = await adapter.fetchStandings();
|
||||
expect(records[0].winPct).toBe(0);
|
||||
expect(records[0].gamesPlayed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ import { AflStandingsAdapter } from "./afl";
|
|||
import { MlbStandingsAdapter } from "./mlb";
|
||||
import { WnbaStandingsAdapter } from "./wnba";
|
||||
import { EplStandingsAdapter } from "./epl";
|
||||
import { MlsStandingsAdapter } from "./mls";
|
||||
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
|
||||
|
||||
/**
|
||||
|
|
@ -28,6 +29,8 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
|
|||
return new AflStandingsAdapter();
|
||||
case "epl_standings":
|
||||
return new EplStandingsAdapter();
|
||||
case "mls_bracket":
|
||||
return new MlsStandingsAdapter();
|
||||
case "mlb_bracket":
|
||||
return new MlbStandingsAdapter();
|
||||
case "wnba_bracket":
|
||||
|
|
@ -43,7 +46,7 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
|
|||
default:
|
||||
throw new Error(
|
||||
`No standings sync adapter available for simulator type "${simulatorType}". ` +
|
||||
"NBA, NHL, AFL, MLB, WNBA, and EPL are currently supported."
|
||||
"NBA, NHL, AFL, MLB, WNBA, EPL, and MLS are currently supported."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
163
app/services/standings-sync/mls.ts
Normal file
163
app/services/standings-sync/mls.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
||||
|
||||
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".
|
||||
*/
|
||||
function normalizeConference(raw: string): string {
|
||||
const upper = raw.toUpperCase();
|
||||
if (upper === "EASTERN" || upper === "EASTERN CONFERENCE") return "Eastern";
|
||||
if (upper === "WESTERN" || upper === "WESTERN CONFERENCE") return "Western";
|
||||
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);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`MLS standings API returned ${response.status}: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as EspnStandingsResponse;
|
||||
const flattened = flattenEspnStandings(json);
|
||||
|
||||
if (flattened.length === 0) {
|
||||
throw new Error(
|
||||
"MLS standings API returned no entries — response shape may have changed"
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-parse each entry's stats once, then sort by points desc for overall league rank
|
||||
const parsed = flattened.map(({ entry, conference }) => ({
|
||||
entry,
|
||||
conference,
|
||||
sm: statsMap(entry.stats),
|
||||
}));
|
||||
|
||||
const sorted = [...parsed].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;
|
||||
const gdA = a.sm.get("pointDifferential")?.value ?? 0;
|
||||
const gdB = b.sm.get("pointDifferential")?.value ?? 0;
|
||||
return gdB - gdA;
|
||||
});
|
||||
|
||||
return sorted.map(({ entry, conference, sm }, leagueIdx): FetchedStandingsRecord => {
|
||||
const wins = sm.get("wins")?.value ?? 0;
|
||||
const losses = sm.get("losses")?.value ?? 0;
|
||||
const ties = sm.get("ties")?.value ?? sm.get("draws")?.value ?? 0;
|
||||
const gamesPlayed = Math.round(
|
||||
sm.get("gamesPlayed")?.value ?? wins + losses + ties
|
||||
);
|
||||
|
||||
const tablePoints = sm.get("points")?.value;
|
||||
const goalsFor = sm.get("pointsFor")?.value ?? sm.get("gf")?.value;
|
||||
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;
|
||||
|
||||
return {
|
||||
teamName: entry.team.displayName,
|
||||
externalTeamId: entry.team.id,
|
||||
wins: Math.round(wins),
|
||||
losses: Math.round(losses),
|
||||
ties: Math.round(ties),
|
||||
tablePoints: tablePoints !== null && tablePoints !== undefined ? Math.round(tablePoints) : undefined,
|
||||
goalsFor: goalsFor !== null && goalsFor !== undefined ? Math.round(goalsFor) : undefined,
|
||||
goalsAgainst: goalsAgainst !== null && goalsAgainst !== undefined ? Math.round(goalsAgainst) : undefined,
|
||||
goalDifference: goalDifference !== null && goalDifference !== undefined ? Math.round(goalDifference) : undefined,
|
||||
winPct: gamesPlayed > 0 ? wins / gamesPlayed : 0,
|
||||
gamesPlayed,
|
||||
conference: normalizeConference(conference),
|
||||
conferenceRank,
|
||||
leagueRank: leagueIdx + 1,
|
||||
homeRecord,
|
||||
awayRecord,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue