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
This commit is contained in:
Claude 2026-05-14 22:00:48 +00:00
parent c097e4827c
commit 3474288fc3
No known key found for this signature in database
5 changed files with 401 additions and 5 deletions

View file

@ -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}
/>

View file

@ -66,6 +66,19 @@ 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.
*/
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<string, MlsConference>();
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.`
);
}

View file

@ -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");
});
});

View file

@ -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."
);
}
}

View file

@ -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<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.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<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"
);
}
// 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,
};
});
}
}