From 3474288fc376040182b787464f1c218c660a1d5a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 22:00:48 +0000 Subject: [PATCH] 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 --- ...eagueId.sports-seasons.$sportsSeasonId.tsx | 6 +- app/services/simulations/mls-simulator.ts | 21 +- .../standings-sync/__tests__/mls.test.ts | 217 ++++++++++++++++++ app/services/standings-sync/index.ts | 5 +- app/services/standings-sync/mls.ts | 157 +++++++++++++ 5 files changed, 401 insertions(+), 5 deletions(-) create mode 100644 app/services/standings-sync/__tests__/mls.test.ts create mode 100644 app/services/standings-sync/mls.ts diff --git a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx index 93a32dd..053d64b 100644 --- a/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx +++ b/app/routes/leagues/$leagueId.sports-seasons.$sportsSeasonId.tsx @@ -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} /> diff --git a/app/services/simulations/mls-simulator.ts b/app/services/simulations/mls-simulator.ts index b159855..2e395ba 100644 --- a/app/services/simulations/mls-simulator.ts +++ b/app/services/simulations/mls-simulator.ts @@ -66,6 +66,19 @@ function configNumber(config: Record | 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. + */ +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 +355,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(); for (const p of participants) { @@ -350,6 +365,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 +377,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.` ); } diff --git a/app/services/standings-sync/__tests__/mls.test.ts b/app/services/standings-sync/__tests__/mls.test.ts new file mode 100644 index 0000000..b6a220c --- /dev/null +++ b/app/services/standings-sync/__tests__/mls.test.ts @@ -0,0 +1,217 @@ +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"); + }); +}); diff --git a/app/services/standings-sync/index.ts b/app/services/standings-sync/index.ts index e451314..425220f 100644 --- a/app/services/standings-sync/index.ts +++ b/app/services/standings-sync/index.ts @@ -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." ); } } diff --git a/app/services/standings-sync/mls.ts b/app/services/standings-sync/mls.ts new file mode 100644 index 0000000..8d87bec --- /dev/null +++ b/app/services/standings-sync/mls.ts @@ -0,0 +1,157 @@ +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 { + const map = new Map(); + 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.includes("EASTERN") || upper.includes("EAST")) return "Eastern"; + if (upper.includes("WESTERN") || upper.includes("WEST")) 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 { + 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" + ); + } + + // Sort by points desc for overall league rank + const sorted = [...flattened].toSorted((a, b) => { + const ptsA = statsMap(a.entry.stats).get("points")?.value ?? 0; + const ptsB = statsMap(b.entry.stats).get("points")?.value ?? 0; + if (ptsB !== ptsA) return ptsB - ptsA; + const gdA = statsMap(a.entry.stats).get("pointDifferential")?.value ?? 0; + const gdB = statsMap(b.entry.stats).get("pointDifferential")?.value ?? 0; + return gdB - gdA; + }); + + return sorted.map(({ entry, conference }, leagueIdx): FetchedStandingsRecord => { + const sm = statsMap(entry.stats); + + 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(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 + ? 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 ? Math.round(tablePoints) : undefined, + goalsFor: goalsFor != null ? Math.round(goalsFor) : undefined, + goalsAgainst: goalsAgainst != null ? Math.round(goalsAgainst) : undefined, + goalDifference: goalDifference != null ? Math.round(goalDifference) : undefined, + winPct: gamesPlayed > 0 ? wins / gamesPlayed : 0, + gamesPlayed, + conference: normalizeConference(conference), + conferenceRank, + leagueRank: leagueIdx + 1, + homeRecord, + awayRecord, + }; + }); + } +}