From 766ba948e16cf658c3d432c9035f79bebcb12c73 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Thu, 14 May 2026 15:39:40 -0700 Subject: [PATCH] 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 --- ...eagueId.sports-seasons.$sportsSeasonId.tsx | 6 +- .../__tests__/mls-simulator.test.ts | 31 ++ app/services/simulations/mls-simulator.ts | 30 +- .../standings-sync/__tests__/mls.test.ts | 286 ++++++++++++++++++ app/services/standings-sync/index.ts | 5 +- app/services/standings-sync/mls.ts | 163 ++++++++++ 6 files changed, 515 insertions(+), 6 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/__tests__/mls-simulator.test.ts b/app/services/simulations/__tests__/mls-simulator.test.ts index 16df30d..4712515 100644 --- a/app/services/simulations/__tests__/mls-simulator.test.ts +++ b/app/services/simulations/__tests__/mls-simulator.test.ts @@ -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(); + }); +}); diff --git a/app/services/simulations/mls-simulator.ts b/app/services/simulations/mls-simulator.ts index b159855..19f5fee 100644 --- a/app/services/simulations/mls-simulator.ts +++ b/app/services/simulations/mls-simulator.ts @@ -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 | 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(); 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.` ); } 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..d0f9965 --- /dev/null +++ b/app/services/standings-sync/__tests__/mls.test.ts @@ -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); + }); +}); 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..e5d91f8 --- /dev/null +++ b/app/services/standings-sync/mls.ts @@ -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 { + 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 === "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 { + 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, + }; + }); + } +}