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:
parent
c097e4827c
commit
3474288fc3
5 changed files with 401 additions and 5 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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
217
app/services/standings-sync/__tests__/mls.test.ts
Normal file
217
app/services/standings-sync/__tests__/mls.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
157
app/services/standings-sync/mls.ts
Normal file
157
app/services/standings-sync/mls.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue