diff --git a/app/models/__tests__/simulator-inputs.test.ts b/app/models/__tests__/simulator-inputs.test.ts index ca3b5fd..e333d1f 100644 --- a/app/models/__tests__/simulator-inputs.test.ts +++ b/app/models/__tests__/simulator-inputs.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; +import { getParticipantSimulatorInputs } from "../simulator"; const mockDb = vi.hoisted(() => ({ query: { @@ -18,8 +19,6 @@ describe("simulator input model", () => { }); it("does not treat previously generated ratings as direct rating inputs", async () => { - const { getParticipantSimulatorInputs } = await import("../simulator"); - mockDb.query.seasonParticipants.findMany.mockResolvedValue([ { id: "direct-rating" }, { id: "generated-rating" }, diff --git a/app/services/simulations/__tests__/llws-simulator.test.ts b/app/services/simulations/__tests__/llws-simulator.test.ts index e6ccb94..15c236a 100644 --- a/app/services/simulations/__tests__/llws-simulator.test.ts +++ b/app/services/simulations/__tests__/llws-simulator.test.ts @@ -70,13 +70,13 @@ describe("LLWSSimulator", () => { describe("output structure", () => { it("returns one result per participant (20 total)", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); expect(results).toHaveLength(20); }); it("every result has source 'llws_monte_carlo'", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); for (const r of results) { expect(r.source).toBe("llws_monte_carlo"); } @@ -84,7 +84,7 @@ describe("LLWSSimulator", () => { it("all probability values are between 0 and 1", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); for (const r of results) { const p = r.probabilities; for (const v of Object.values(p)) { @@ -100,35 +100,35 @@ describe("LLWSSimulator", () => { describe("probability conservation (one winner per sim)", () => { it("probFirst sums to ~1.0 across all participants", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 1); }); it("probSecond sums to ~1.0 across all participants", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); const total = results.reduce((s, r) => s + r.probabilities.probSecond, 0); expect(total).toBeCloseTo(1.0, 1); }); it("probThird sums to ~1.0 across all participants", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); const total = results.reduce((s, r) => s + r.probabilities.probThird, 0); expect(total).toBeCloseTo(1.0, 1); }); it("probFourth sums to ~1.0 across all participants", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); const total = results.reduce((s, r) => s + r.probabilities.probFourth, 0); expect(total).toBeCloseTo(1.0, 1); }); it("sum of probFifth across participants equals ~1.0 (4 bracket losers, split evenly)", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); // 4 bracket losers per sim, each assigned bracketLoser/(4*N) → sum = 1.0 const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0); expect(total).toBeCloseTo(1.0, 1); @@ -136,7 +136,7 @@ describe("LLWSSimulator", () => { it("probFifth through probEighth are equal for every participant (even bracket-loser split)", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); for (const r of results) { const p = r.probabilities; expect(p.probFifth).toBeCloseTo(p.probSixth, 10); @@ -151,7 +151,7 @@ describe("LLWSSimulator", () => { describe("odds-driven win probability", () => { it("strong favourite (us-1) has higher probFirst than a weak team", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS, { includeOdds: true })); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); const byId = new Map(results.map((r) => [r.participantId, r])); const us1prob = byId.get("us-1")?.probabilities.probFirst ?? 0; const us10prob = byId.get("us-10")?.probabilities.probFirst ?? 0; @@ -160,7 +160,7 @@ describe("LLWSSimulator", () => { it("works when no odds are entered (all 50/50 fallback)", async () => { setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); // no odds - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 1); }); @@ -169,7 +169,7 @@ describe("LLWSSimulator", () => { // Equal positive odds (+5000 for every team) → vig-removed prob ≈ 1/20 each. const eqOddsRows = ALL_IDS.map((id) => ({ participantId: id, sourceOdds: 5000 })); setupMockDb(defaultParticipants(), eqOddsRows); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); for (const r of results) { // With equal odds and random pools, each team should win ~5% of the time. // Allow a generous band given Monte Carlo variance. @@ -184,7 +184,7 @@ describe("LLWSSimulator", () => { describe("pool assignment modes", () => { it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => { setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); expect(results).toHaveLength(20); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 1); @@ -197,7 +197,7 @@ describe("LLWSSimulator", () => { ...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })), ]; setupMockDb(participants, makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); expect(results).toHaveLength(20); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 1); @@ -215,7 +215,7 @@ describe("LLWSSimulator", () => { externalId: i < 10 ? "US" : "Intl", })); setupMockDb(participants, makeEvRows(nineteen)); - await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/exactly 20/); + await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/exactly 20/); }); it("infers US side from name prefix when externalId is null", async () => { @@ -224,7 +224,7 @@ describe("LLWSSimulator", () => { ...INTL_IDS.map((id) => ({ id, name: `Japan ${id}`, externalId: null })), ]; setupMockDb(participants, makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); expect(results).toHaveLength(20); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 1); @@ -236,7 +236,7 @@ describe("LLWSSimulator", () => { ...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: null })), ]; setupMockDb(participants, makeEvRows(ALL_IDS)); - const results = await new LLWSSimulator().simulate("season-1"); + const results = await new LLWSSimulator(1_000).simulate("season-1"); expect(results).toHaveLength(20); const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0); expect(total).toBeCloseTo(1.0, 1); @@ -249,7 +249,7 @@ describe("LLWSSimulator", () => { { id: "intl-10", name: "Team intl-10", externalId: "CANADA" }, // unrecognized ]; setupMockDb(participants, makeEvRows(ALL_IDS)); - await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/invalid externalId/); + await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/invalid externalId/); }); it("throws when US team count is not 10", async () => { @@ -259,7 +259,7 @@ describe("LLWSSimulator", () => { ...Array.from({ length: 9 }, (_, i) => ({ id: `intl-${i + 1}`, name: `Team ${i + 1}`, externalId: "Intl" })), ]; setupMockDb(participants, makeEvRows(ALL_IDS)); - await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/10 US teams/); + await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/); }); it("throws when fixed pools have unequal A/B split", async () => { @@ -270,7 +270,7 @@ describe("LLWSSimulator", () => { ...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })), ]; setupMockDb(participants, makeEvRows(ALL_IDS)); - await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/exactly 5 teams each/); + await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/exactly 5 teams each/); }); it("throws when US externalIds mix pool suffixes and bare side", async () => { @@ -281,7 +281,7 @@ describe("LLWSSimulator", () => { ...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })), ]; setupMockDb(participants, makeEvRows(ALL_IDS)); - await expect(new LLWSSimulator().simulate("season-1")).rejects.toThrow(/mixed externalId formats/); + await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/mixed externalId formats/); }); }); }); diff --git a/app/services/simulations/llws-simulator.ts b/app/services/simulations/llws-simulator.ts index b6dfcb3..c633b08 100644 --- a/app/services/simulations/llws-simulator.ts +++ b/app/services/simulations/llws-simulator.ts @@ -259,6 +259,8 @@ function determineRandomized(sideTeams: Team[], sideName: string): boolean { // ─── Simulator ──────────────────────────────────────────────────────────────── export class LLWSSimulator implements Simulator { + constructor(private numSimulations = NUM_SIMULATIONS) {} + async simulate(sportsSeasonId: string): Promise { const db = database(); @@ -345,7 +347,7 @@ export class LLWSSimulator implements Simulator { }; // 6. Run Monte Carlo simulations. - for (let s = 0; s < NUM_SIMULATIONS; s++) { + for (let s = 0; s < this.numSimulations; s++) { // Assign pools for this simulation. const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized); const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized); @@ -376,7 +378,7 @@ export class LLWSSimulator implements Simulator { // 7. Convert counts to probability distributions. // bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly. const bracketLosersPerSim = 4; - const bracketDivisor = bracketLosersPerSim * NUM_SIMULATIONS; + const bracketDivisor = bracketLosersPerSim * this.numSimulations; const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }; return allIds.map((id) => { @@ -385,10 +387,10 @@ export class LLWSSimulator implements Simulator { return { participantId: id, probabilities: { - probFirst: c.champion / NUM_SIMULATIONS, - probSecond: c.finalist / NUM_SIMULATIONS, - probThird: c.thirdPlace / NUM_SIMULATIONS, - probFourth: c.fourthPlace / NUM_SIMULATIONS, + probFirst: c.champion / this.numSimulations, + probSecond: c.finalist / this.numSimulations, + probThird: c.thirdPlace / this.numSimulations, + probFourth: c.fourthPlace / this.numSimulations, probFifth: bracketProb, probSixth: bracketProb, probSeventh: bracketProb, diff --git a/app/services/standings-sync/__tests__/mlb.test.ts b/app/services/standings-sync/__tests__/mlb.test.ts index 0968412..da3e545 100644 --- a/app/services/standings-sync/__tests__/mlb.test.ts +++ b/app/services/standings-sync/__tests__/mlb.test.ts @@ -1,82 +1,70 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { MlbStandingsAdapter } from "../mlb"; -const SAMPLE_MLB_RESPONSE = { - records: [ +// Minimal ESPN-shaped response: AL East + NL East with a few teams +const SAMPLE_ESPN_RESPONSE = { + children: [ { - // AL East division record - teamRecords: [ + name: "American League", + children: [ { - team: { - id: 110, - name: "Baltimore Orioles", - league: { name: "American League" }, - division: { name: "AL East" }, - }, - wins: 47, - losses: 34, - gamesPlayed: 81, - winningPercentage: ".580", - divisionRank: "1", - leagueRank: "1", - gamesBack: "-", - streak: { streakCode: "W3", streakNumber: 3 }, - records: { - splitRecords: [ - { type: "home", wins: 24, losses: 15 }, - { type: "away", wins: 23, losses: 19 }, - { type: "lastTen", wins: 7, losses: 3 }, - ], - }, - }, - { - team: { - id: 111, - name: "Boston Red Sox", - league: { name: "American League" }, - division: { name: "AL East" }, - }, - wins: 42, - losses: 39, - gamesPlayed: 81, - winningPercentage: ".519", - divisionRank: "2", - leagueRank: "4", - gamesBack: "5.0", - streak: { streakCode: "L2", streakNumber: 2 }, - records: { - splitRecords: [ - { type: "home", wins: 22, losses: 18 }, - { type: "away", wins: 20, losses: 21 }, - { type: "lastTen", wins: 4, losses: 6 }, + name: "American League East", + standings: { + entries: [ + { + team: { id: "2", displayName: "Baltimore Orioles" }, + stats: [ + { name: "wins", value: 47 }, + { name: "losses", value: 34 }, + { name: "winPercent", value: 0.58 }, + { name: "gamesBehind", value: 0 }, + { name: "streak", displayValue: "W3" }, + { name: "Home", displayValue: "24-15" }, + { name: "Road", displayValue: "23-19" }, + { name: "Last Ten Games", displayValue: "7-3" }, + { name: "playoffSeed", value: 1 }, + ], + }, + { + team: { id: "3", displayName: "Boston Red Sox" }, + stats: [ + { name: "wins", value: 42 }, + { name: "losses", value: 39 }, + { name: "winPercent", value: 0.519 }, + { name: "gamesBehind", value: 5.0 }, + { name: "streak", displayValue: "L2" }, + { name: "Home", displayValue: "22-18" }, + { name: "Road", displayValue: "20-21" }, + { name: "Last Ten Games", displayValue: "4-6" }, + { name: "playoffSeed", value: 2 }, + ], + }, ], }, }, ], }, { - // NL East division record - teamRecords: [ + name: "National League", + children: [ { - team: { - id: 121, - name: "New York Mets", - league: { name: "National League" }, - division: { name: "NL East" }, - }, - wins: 43, - losses: 38, - gamesPlayed: 81, - winningPercentage: ".531", - divisionRank: "1", - leagueRank: "1", - gamesBack: "-", - streak: { streakCode: "W1", streakNumber: 1 }, - records: { - splitRecords: [ - { type: "home", wins: 22, losses: 17 }, - { type: "away", wins: 21, losses: 21 }, - { type: "lastTen", wins: 6, losses: 4 }, + name: "National League East", + standings: { + entries: [ + { + team: { id: "21", displayName: "New York Mets" }, + stats: [ + { name: "wins", value: 43 }, + { name: "losses", value: 38 }, + { name: "winPercent", value: 0.531 }, + { name: "gamesBehind", value: 0 }, + { name: "streak", displayValue: "W1" }, + { name: "Home", displayValue: "22-17" }, + { name: "Road", displayValue: "21-21" }, + { name: "Last Ten Games", displayValue: "6-4" }, + { name: "playoffSeed", value: 1 }, + ], + }, ], }, }, @@ -93,20 +81,20 @@ describe("MlbStandingsAdapter", () => { it("maps AL teams to conference 'AL'", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, - json: async () => SAMPLE_MLB_RESPONSE, + json: async () => SAMPLE_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); const records = await adapter.fetchStandings(); - const bos = records.find((r) => r.teamName === "Baltimore Orioles"); - expect(bos?.conference).toBe("AL"); + const ori = records.find((r) => r.teamName === "Baltimore Orioles"); + expect(ori?.conference).toBe("AL"); }); it("maps NL teams to conference 'NL'", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, - json: async () => SAMPLE_MLB_RESPONSE, + json: async () => SAMPLE_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); @@ -119,22 +107,22 @@ describe("MlbStandingsAdapter", () => { it("maps division names correctly", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, - json: async () => SAMPLE_MLB_RESPONSE, + json: async () => SAMPLE_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); const records = await adapter.fetchStandings(); - const bos = records.find((r) => r.teamName === "Baltimore Orioles"); + const ori = records.find((r) => r.teamName === "Baltimore Orioles"); const mets = records.find((r) => r.teamName === "New York Mets"); - expect(bos?.division).toBe("AL East"); + expect(ori?.division).toBe("AL East"); expect(mets?.division).toBe("NL East"); }); it("maps wins, losses, gamesPlayed, and winPct", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, - json: async () => SAMPLE_MLB_RESPONSE, + json: async () => SAMPLE_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); @@ -150,7 +138,7 @@ describe("MlbStandingsAdapter", () => { it("formats streak as 'W3' or 'L2'", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, - json: async () => SAMPLE_MLB_RESPONSE, + json: async () => SAMPLE_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); @@ -162,10 +150,10 @@ describe("MlbStandingsAdapter", () => { expect(bos?.streak).toBe("L2"); }); - it("extracts lastTen from splitRecords", async () => { + it("extracts lastTen from Last Ten Games stat", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, - json: async () => SAMPLE_MLB_RESPONSE, + json: async () => SAMPLE_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); @@ -175,10 +163,36 @@ describe("MlbStandingsAdapter", () => { expect(ori?.lastTen).toBe("7-3"); }); - it("extracts homeRecord and awayRecord from splitRecords", async () => { + it("gamesBack is 0 for the division leader (ESPN returns numeric 0, not '-')", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, - json: async () => SAMPLE_MLB_RESPONSE, + json: async () => SAMPLE_ESPN_RESPONSE, + } as Response); + + const adapter = new MlbStandingsAdapter(); + const records = await adapter.fetchStandings(); + + const ori = records.find((r) => r.teamName === "Baltimore Orioles"); + expect(ori?.gamesBack).toBe(0); + }); + + it("gamesBack is a positive number for non-leaders", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_ESPN_RESPONSE, + } as Response); + + const adapter = new MlbStandingsAdapter(); + const records = await adapter.fetchStandings(); + + const bos = records.find((r) => r.teamName === "Boston Red Sox"); + expect(bos?.gamesBack).toBeCloseTo(5.0, 1); + }); + + it("extracts homeRecord and awayRecord", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); @@ -189,36 +203,10 @@ describe("MlbStandingsAdapter", () => { expect(ori?.awayRecord).toBe("23-19"); }); - it("parses gamesBack as null for the division leader ('-')", async () => { + it("computes leagueRank sorted by wins desc across all teams", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, - json: async () => SAMPLE_MLB_RESPONSE, - } as Response); - - const adapter = new MlbStandingsAdapter(); - const records = await adapter.fetchStandings(); - - const ori = records.find((r) => r.teamName === "Baltimore Orioles"); - expect(ori?.gamesBack).toBeUndefined(); - }); - - it("parses gamesBack as a number for non-leaders", async () => { - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => SAMPLE_MLB_RESPONSE, - } as Response); - - const adapter = new MlbStandingsAdapter(); - const records = await adapter.fetchStandings(); - - const bos = records.find((r) => r.teamName === "Boston Red Sox"); - expect(bos?.gamesBack).toBeCloseTo(5.0, 1); - }); - - it("computes leagueRank sorted by wins desc across all 30 teams", async () => { - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => SAMPLE_MLB_RESPONSE, + json: async () => SAMPLE_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); @@ -231,10 +219,23 @@ describe("MlbStandingsAdapter", () => { expect(bos?.leagueRank).toBeGreaterThan(1); }); + it("uses ESPN string team id as externalTeamId", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_ESPN_RESPONSE, + } as Response); + + const adapter = new MlbStandingsAdapter(); + const records = await adapter.fetchStandings(); + + const ori = records.find((r) => r.teamName === "Baltimore Orioles"); + expect(ori?.externalTeamId).toBe("2"); + }); + it("never sets otLosses (MLB has no OT losses)", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, - json: async () => SAMPLE_MLB_RESPONSE, + json: async () => SAMPLE_ESPN_RESPONSE, } as Response); const adapter = new MlbStandingsAdapter(); @@ -245,34 +246,70 @@ describe("MlbStandingsAdapter", () => { } }); - it("uses team numeric id as externalTeamId", async () => { + it("throws on non-ok HTTP response", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 406, + statusText: "Not Acceptable", + } as Response); + + const adapter = new MlbStandingsAdapter(); + await expect(adapter.fetchStandings()).rejects.toThrow("406"); + }); + + it("produces a stable leagueRank when two teams are tied on wins and losses", async () => { + const tiedResponse = { + children: [ + { + name: "American League", + children: [ + { + name: "American League East", + standings: { + entries: [ + { + team: { id: "99", displayName: "Z Team" }, + stats: [ + { name: "wins", value: 40 }, + { name: "losses", value: 40 }, + { name: "winPercent", value: 0.5 }, + ], + }, + { + team: { id: "98", displayName: "A Team" }, + stats: [ + { name: "wins", value: 40 }, + { name: "losses", value: 40 }, + { name: "winPercent", value: 0.5 }, + ], + }, + ], + }, + }, + ], + }, + ], + }; + vi.mocked(fetch).mockResolvedValueOnce({ ok: true, - json: async () => SAMPLE_MLB_RESPONSE, + json: async () => tiedResponse, } as Response); const adapter = new MlbStandingsAdapter(); const records = await adapter.fetchStandings(); - const ori = records.find((r) => r.teamName === "Baltimore Orioles"); - expect(ori?.externalTeamId).toBe("110"); + // Tiebreaker is alphabetical — "A Team" should always rank above "Z Team" + const aTeam = records.find((r) => r.teamName === "A Team"); + const zTeam = records.find((r) => r.teamName === "Z Team"); + expect(aTeam?.leagueRank).toBe(1); + expect(zTeam?.leagueRank).toBe(2); }); - it("throws on non-ok HTTP response", async () => { - vi.mocked(fetch).mockResolvedValueOnce({ - ok: false, - status: 503, - statusText: "Service Unavailable", - } as Response); - - const adapter = new MlbStandingsAdapter(); - await expect(adapter.fetchStandings()).rejects.toThrow("503"); - }); - - it("throws on unexpected response shape", async () => { + it("throws when response has no entries", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, - json: async () => ({ unexpected: true }), + json: async () => ({ children: [] }), } as Response); const adapter = new MlbStandingsAdapter(); diff --git a/app/services/standings-sync/espn.ts b/app/services/standings-sync/espn.ts new file mode 100644 index 0000000..cd17279 --- /dev/null +++ b/app/services/standings-sync/espn.ts @@ -0,0 +1,107 @@ +// Shared interfaces and utilities for ESPN standings API adapters. +// Used by MLB, NBA, WNBA, and MLS adapters. + +export interface EspnStat { + name: string; + displayName?: string; + shortDisplayName?: string; + description?: string; + abbreviation?: string; + type?: string; + value?: number; + displayValue?: string; +} + +export interface EspnTeam { + id: string; + displayName: string; + shortDisplayName?: string; + abbreviation?: string; + location?: string; + name?: string; +} + +export interface EspnStandingsEntry { + team: EspnTeam; + stats: EspnStat[]; +} + +export interface EspnStandingsGroup { + name?: string; + standings?: { entries?: EspnStandingsEntry[] }; + children?: EspnStandingsGroup[]; +} + +export interface EspnStandingsResponse { + children?: EspnStandingsGroup[]; +} + +export function statsMap(stats: EspnStat[]): Map { + const map = new Map(); + for (const stat of stats) map.set(stat.name, stat); + return map; +} + +/** + * Flatten an ESPN standings response into individual entries with their + * conference and division context. + * + * Handles two layouts ESPN uses: + * - conf → div → entries (NBA, MLB): division name is populated + * - conf → entries (flat conferences): division is "" + */ +export function flattenEspnStandings( + response: EspnStandingsResponse +): Array<{ entry: EspnStandingsEntry; conference: string; division: string }> { + const results: Array<{ entry: EspnStandingsEntry; conference: string; division: string }> = []; + + for (const confGroup of response.children ?? []) { + const conferenceName = confGroup.name ?? ""; + + if (confGroup.children && confGroup.children.length > 0) { + for (const divGroup of confGroup.children) { + const divisionName = divGroup.name ?? ""; + for (const entry of divGroup.standings?.entries ?? []) { + results.push({ entry, conference: conferenceName, division: divisionName }); + } + } + } else { + for (const entry of confGroup.standings?.entries ?? []) { + results.push({ entry, conference: conferenceName, division: "" }); + } + } + } + + return results; +} + +/** + * Extract the playoffSeed stat as a conference rank integer, or undefined if absent. + * Only meaningful for team sports that use playoff seeding (MLB, NBA, WNBA). + */ +export function parseConferenceRank(sm: Map): number | undefined { + const stat = sm.get("playoffSeed"); + if (stat?.value !== undefined && stat.value !== null) return Math.round(stat.value); + if (stat?.displayValue) { + const parsed = parseInt(stat.displayValue, 10); + return isNaN(parsed) ? undefined : parsed; + } + return undefined; +} + +/** + * Sort comparator for ESPN win-loss standings: wins descending, losses ascending, + * alphabetical by team name as a stable tiebreaker. + */ +export function sortByWinLoss( + a: { sm: Map; entry: EspnStandingsEntry }, + b: { sm: Map; entry: EspnStandingsEntry } +): number { + const winsA = a.sm.get("wins")?.value ?? 0; + const winsB = b.sm.get("wins")?.value ?? 0; + if (winsB !== winsA) return winsB - winsA; + const lossA = a.sm.get("losses")?.value ?? 0; + const lossB = b.sm.get("losses")?.value ?? 0; + if (lossA !== lossB) return lossA - lossB; + return a.entry.team.displayName.localeCompare(b.entry.team.displayName); +} diff --git a/app/services/standings-sync/mlb.ts b/app/services/standings-sync/mlb.ts index c84711e..b1b6d3f 100644 --- a/app/services/standings-sync/mlb.ts +++ b/app/services/standings-sync/mlb.ts @@ -1,156 +1,82 @@ import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types"; +import { + flattenEspnStandings, + parseConferenceRank, + sortByWinLoss, + statsMap, + type EspnStandingsResponse, +} from "./espn"; -// MLB Stats API — free, no key required. -// leagueId 103 = American League, 104 = National League const MLB_STANDINGS_URL = - "https://statsapi.mlb.com/api/v1/standings?leagueId=103,104&standingsTypes=regularSeason&hydrate=team(division,league)"; + "https://site.api.espn.com/apis/v2/sports/baseball/mlb/standings"; -interface MlbSplitRecord { - type: string; // "home", "away", "lastTen", etc. - wins: number; - losses: number; +// "American League" → "AL", "National League" → "NL" +function abbreviateLeague(name: string): string { + if (name.startsWith("American")) return "AL"; + if (name.startsWith("National")) return "NL"; + return name; } -interface MlbTeamRecord { - team: { - id: number; - name: string; - league?: { name?: string }; - division?: { name?: string }; - }; - wins: number; - losses: number; - gamesPlayed: number; - winningPercentage: string; // e.g. ".617" - divisionRank?: string; // e.g. "1"–"5" — rank within the 5-team division (all teams) - wildCardRank?: string; // e.g. "1" — only set for WC-ranked teams - leagueRank?: string; // rank within the 15-team AL or NL - gamesBack: string; // "-" for leader, "2.0" otherwise - wildCardGamesBack?: string; - streak?: { - streakCode: string; // full streak string e.g. "W3" or "L5" — NOT just the letter - streakNumber: number; // numeric part; redundant with streakCode, not used - }; - records?: { splitRecords?: MlbSplitRecord[] }; -} - -interface MlbDivisionRecord { - teamRecords: MlbTeamRecord[]; -} - -interface MlbStandingsResponse { - records: MlbDivisionRecord[]; -} - -function sortByRecord(a: MlbTeamRecord, b: MlbTeamRecord): number { - return b.wins !== a.wins ? b.wins - a.wins : a.losses - b.losses; +// "American League East" → "AL East", "National League West" → "NL West" +function abbreviateDivision(name: string): string { + return name + .replace(/^American League /, "AL ") + .replace(/^National League /, "NL "); } export class MlbStandingsAdapter implements StandingsSyncAdapter { - constructor(private season: number = new Date().getFullYear()) {} - async fetchStandings(): Promise { - const url = `${MLB_STANDINGS_URL}&season=${this.season}`; - const response = await fetch(url); + const response = await fetch(MLB_STANDINGS_URL); if (!response.ok) { throw new Error(`MLB standings API returned ${response.status}: ${response.statusText}`); } - const json = (await response.json()) as MlbStandingsResponse; + const json = (await response.json()) as EspnStandingsResponse; + const flattened = flattenEspnStandings(json); - if (!json.records || !Array.isArray(json.records)) { - throw new Error("Unexpected MLB standings API response shape"); + if (flattened.length === 0) { + throw new Error("MLB standings API returned no entries — response shape may have changed"); } - // Collect all team records from all division groups - const allTeamRecords: MlbTeamRecord[] = json.records.flatMap( - (divRecord) => divRecord.teamRecords ?? [] - ); + // Pre-build statsMap once per entry so the sort doesn't rebuild it on every comparison. + const withSm = flattened.map(({ entry, conference, division }) => ({ + entry, + conference, + division, + sm: statsMap(entry.stats), + })); - if (allTeamRecords.length === 0) { - throw new Error("MLB standings API returned no team records"); - } + const sorted = [...withSm].toSorted(sortByWinLoss); - // Compute leagueRank by sorting all teams by wins desc, losses asc within each league. - // We'll assign ranks per league (AL rank 1–15, NL rank 1–15) separately since - // the API's leagueRank field may reflect conference-level rank. - const alTeams = allTeamRecords.filter( - (t) => t.team.league?.name?.includes("American") - ); - const nlTeams = allTeamRecords.filter( - (t) => t.team.league?.name?.includes("National") - ); + return sorted.map(({ entry, conference, division, sm }, leagueIdx): FetchedStandingsRecord => { + const wins = sm.get("wins")?.value ?? 0; + const losses = sm.get("losses")?.value ?? 0; - const alSorted = [...alTeams].toSorted(sortByRecord); - const nlSorted = [...nlTeams].toSorted(sortByRecord); + // Fall back to computing win% from wins/losses if ESPN omits the stat. + const winPctStat = sm.get("winPercent")?.value ?? sm.get("winPct")?.value; + const winPct = winPctStat ?? (wins + losses > 0 ? wins / (wins + losses) : 0); - const conferenceRankMap = new Map(); - for (const [i, t] of alSorted.entries()) conferenceRankMap.set(t.team.id, i + 1); - for (const [i, t] of nlSorted.entries()) conferenceRankMap.set(t.team.id, i + 1); + const gamesBehind = sm.get("gamesBehind")?.value; + const streak = sm.get("streak")?.displayValue ?? sm.get("streakSummary")?.displayValue; + const lastTen = + sm.get("Last Ten Games")?.displayValue ?? sm.get("L10")?.displayValue ?? undefined; + const homeRecord = sm.get("Home")?.displayValue ?? undefined; + const awayRecord = sm.get("Road")?.displayValue ?? undefined; - // Compute overall league rank across all 30 teams for leagueRank field - const allSorted = [...allTeamRecords].toSorted(sortByRecord); - const overallRankMap = new Map(); - for (const [i, t] of allSorted.entries()) overallRankMap.set(t.team.id, i + 1); - - return allTeamRecords.map((team): FetchedStandingsRecord => { - // Conference: "American League" → "AL", "National League" → "NL" - const leagueName = team.team.league?.name ?? ""; - const conference = leagueName.startsWith("American") - ? "AL" - : leagueName.startsWith("National") - ? "NL" - : leagueName; - - // Division: "AL East", "AL Central", etc. - const division = team.team.division?.name ?? null; - - // Win percentage - const winPct = parseFloat(team.winningPercentage) || 0; - - // Games back — "-" means leader; use null. Use isNaN guard so GB=0 (tied for first) is kept. - const gbParsed = parseFloat(team.gamesBack); - const gbStr = (team.gamesBack === "-" || isNaN(gbParsed)) ? null : gbParsed; - - // Division rank and conference rank - const divisionRank = team.divisionRank ? parseInt(team.divisionRank, 10) : null; - - // Conference rank: use the per-league rank computed above - const conferenceRank = conferenceRankMap.get(team.team.id) ?? null; - - // streakCode is the full string e.g. "W3"; streakNumber is the numeric part embedded within it - const streak = team.streak?.streakCode; - - // Split records - const splits = team.records?.splitRecords ?? []; - const homeSplit = splits.find((s) => s.type === "home"); - const awaySplit = splits.find((s) => s.type === "away"); - const lastTenSplit = splits.find((s) => s.type === "lastTen"); - - const homeRecord = homeSplit - ? `${homeSplit.wins}-${homeSplit.losses}` - : undefined; - const awayRecord = awaySplit - ? `${awaySplit.wins}-${awaySplit.losses}` - : undefined; - const lastTen = lastTenSplit - ? `${lastTenSplit.wins}-${lastTenSplit.losses}` - : undefined; + const abbreviatedDiv = abbreviateDivision(division); return { - teamName: team.team.name, - externalTeamId: String(team.team.id), - wins: team.wins, - losses: team.losses, - gamesPlayed: team.gamesPlayed, + teamName: entry.team.displayName, + externalTeamId: entry.team.id, + wins: Math.round(wins), + losses: Math.round(losses), winPct, - // MLB has no OT losses - conference, - division: division ?? undefined, - divisionRank: divisionRank ?? undefined, - conferenceRank: conferenceRank ?? undefined, - leagueRank: overallRankMap.get(team.team.id) ?? 1, - gamesBack: gbStr ?? undefined, + gamesPlayed: Math.round(wins) + Math.round(losses), + gamesBack: gamesBehind, + conference: abbreviateLeague(conference), + division: abbreviatedDiv || undefined, + conferenceRank: parseConferenceRank(sm), + leagueRank: leagueIdx + 1, streak, lastTen, homeRecord, diff --git a/app/services/standings-sync/mls.ts b/app/services/standings-sync/mls.ts index e5d91f8..709d010 100644 --- a/app/services/standings-sync/mls.ts +++ b/app/services/standings-sync/mls.ts @@ -1,70 +1,14 @@ import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types"; +import { + flattenEspnStandings, + parseConferenceRank, + statsMap, + type EspnStandingsResponse, +} from "./espn"; 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". @@ -76,12 +20,6 @@ function normalizeConference(raw: string): string { 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); @@ -100,14 +38,14 @@ export class MlsStandingsAdapter implements StandingsSyncAdapter { ); } - // Pre-parse each entry's stats once, then sort by points desc for overall league rank - const parsed = flattened.map(({ entry, conference }) => ({ + // Pre-parse each entry's stats once, then sort by points desc for overall league rank. + const withSm = flattened.map(({ entry, conference }) => ({ entry, conference, sm: statsMap(entry.stats), })); - const sorted = [...parsed].toSorted((a, b) => { + const sorted = [...withSm].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; @@ -129,14 +67,6 @@ export class MlsStandingsAdapter implements StandingsSyncAdapter { 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; @@ -153,7 +83,7 @@ export class MlsStandingsAdapter implements StandingsSyncAdapter { winPct: gamesPlayed > 0 ? wins / gamesPlayed : 0, gamesPlayed, conference: normalizeConference(conference), - conferenceRank, + conferenceRank: parseConferenceRank(sm), leagueRank: leagueIdx + 1, homeRecord, awayRecord, diff --git a/app/services/standings-sync/nba.ts b/app/services/standings-sync/nba.ts index 01de825..649610f 100644 --- a/app/services/standings-sync/nba.ts +++ b/app/services/standings-sync/nba.ts @@ -1,86 +1,15 @@ import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types"; +import { + flattenEspnStandings, + parseConferenceRank, + sortByWinLoss, + statsMap, + type EspnStandingsResponse, +} from "./espn"; const NBA_STANDINGS_URL = "https://site.api.espn.com/apis/v2/sports/basketball/nba/standings"; -interface EspnStat { - name: string; - displayName?: string; - shortDisplayName?: string; - description?: string; - abbreviation?: string; - type?: string; - value?: number; - displayValue?: string; -} - -interface EspnTeam { - id: string; - displayName: string; - shortDisplayName?: string; - abbreviation?: string; - location?: string; - name?: 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; -} - -/** - * Flatten the ESPN standings response (conference → division → entries). - * Returns enriched entries with conference and division names attached. - */ -function flattenEspnStandings( - response: EspnStandingsResponse -): Array<{ entry: EspnStandingsEntry; conference: string; division: string }> { - const results: Array<{ - entry: EspnStandingsEntry; - conference: string; - division: string; - }> = []; - - for (const confGroup of response.children ?? []) { - const conferenceName = confGroup.name ?? ""; - - // Some ESPN responses have nested division children - if (confGroup.children && confGroup.children.length > 0) { - for (const divGroup of confGroup.children) { - const divisionName = divGroup.name ?? ""; - for (const entry of divGroup.standings?.entries ?? []) { - results.push({ entry, conference: conferenceName, division: divisionName }); - } - } - } else { - // Flat conference without division sub-groups - for (const entry of confGroup.standings?.entries ?? []) { - results.push({ entry, conference: conferenceName, division: "" }); - } - } - } - - return results; -} - export class NbaStandingsAdapter implements StandingsSyncAdapter { async fetchStandings(): Promise { const response = await fetch(NBA_STANDINGS_URL); @@ -95,42 +24,28 @@ export class NbaStandingsAdapter implements StandingsSyncAdapter { throw new Error("NBA standings API returned no entries — response shape may have changed"); } - // Assign league rank by sorting on wins desc, then losses asc - const sorted = [...flattened].toSorted((a, b) => { - const winsA = statsMap(a.entry.stats).get("wins")?.value ?? 0; - const winsB = statsMap(b.entry.stats).get("wins")?.value ?? 0; - if (winsB !== winsA) return winsB - winsA; - const lossA = statsMap(a.entry.stats).get("losses")?.value ?? 0; - const lossB = statsMap(b.entry.stats).get("losses")?.value ?? 0; - return lossA - lossB; - }); + // Pre-build statsMap once per entry so the sort doesn't rebuild it on every comparison. + const withSm = flattened.map(({ entry, conference, division }) => ({ + entry, + conference, + division, + sm: statsMap(entry.stats), + })); - return sorted.map(({ entry, conference, division }, leagueIdx): FetchedStandingsRecord => { - const sm = statsMap(entry.stats); + const sorted = [...withSm].toSorted(sortByWinLoss); + + return sorted.map(({ entry, conference, division, sm }, leagueIdx): FetchedStandingsRecord => { const wins = sm.get("wins")?.value ?? 0; const losses = sm.get("losses")?.value ?? 0; - const winPercent = sm.get("winPercent")?.value ?? sm.get("winPct")?.value ?? 0; + + // Fall back to computing win% from wins/losses if ESPN omits the stat. + const winPctStat = sm.get("winPercent")?.value ?? sm.get("winPct")?.value; + const winPct = winPctStat ?? (wins + losses > 0 ? wins / (wins + losses) : 0); + const gamesBehind = sm.get("gamesBehind")?.value; const streak = sm.get("streak")?.displayValue ?? sm.get("streakSummary")?.displayValue; - - // ESPN returns last-10 as "Last Ten Games" with a displayValue like "7-3" const lastTen = - sm.get("Last Ten Games")?.displayValue ?? - sm.get("L10")?.displayValue ?? - undefined; - - // Seed within conference — ESPN key is "playoffSeed", value is a float (1.0, 2.0 ...) - const playoffSeedStat = sm.get("playoffSeed"); - const conferenceRank = - playoffSeedStat !== undefined && playoffSeedStat.value !== null && playoffSeedStat.value !== undefined - ? Math.round(playoffSeedStat.value) - : playoffSeedStat?.displayValue - ? parseInt(playoffSeedStat.displayValue, 10) || undefined - : undefined; - - const gamesPlayed = wins + losses; - - // Home/road records — ESPN returns these as displayValue strings (e.g. "24-17") + sm.get("Last Ten Games")?.displayValue ?? sm.get("L10")?.displayValue ?? undefined; const homeRecord = sm.get("Home")?.displayValue ?? undefined; const awayRecord = sm.get("Road")?.displayValue ?? undefined; @@ -139,12 +54,12 @@ export class NbaStandingsAdapter implements StandingsSyncAdapter { externalTeamId: entry.team.id, wins: Math.round(wins), losses: Math.round(losses), - winPct: winPercent, - gamesPlayed, + winPct, + gamesPlayed: Math.round(wins) + Math.round(losses), gamesBack: gamesBehind, conference, division: division || undefined, - conferenceRank, + conferenceRank: parseConferenceRank(sm), leagueRank: leagueIdx + 1, streak, lastTen, diff --git a/app/services/standings-sync/wnba.ts b/app/services/standings-sync/wnba.ts index 10b6a35..c6931ba 100644 --- a/app/services/standings-sync/wnba.ts +++ b/app/services/standings-sync/wnba.ts @@ -1,4 +1,12 @@ import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types"; +import { + parseConferenceRank, + sortByWinLoss, + statsMap, + type EspnStat, + type EspnStandingsEntry, + type EspnStandingsResponse, +} from "./espn"; const WNBA_STANDINGS_URL = "https://site.api.espn.com/apis/v2/sports/basketball/wnba/standings"; @@ -10,41 +18,6 @@ const WNBA_STANDINGS_URL = const WNBA_TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/teams"; -interface EspnStat { - name: string; - displayName?: string; - shortDisplayName?: string; - description?: string; - abbreviation?: string; - type?: string; - value?: number; - displayValue?: string; -} - -interface EspnTeam { - id: string; - displayName: string; - shortDisplayName?: string; - abbreviation?: string; - location?: string; - name?: string; -} - -interface EspnStandingsEntry { - team: EspnTeam; - stats: EspnStat[]; -} - -interface EspnStandingsGroup { - name?: string; - standings?: { entries?: EspnStandingsEntry[] }; - children?: EspnStandingsGroup[]; -} - -interface EspnStandingsResponse { - children?: EspnStandingsGroup[]; -} - interface EspnTeamItem { team: { id: string; @@ -61,21 +34,11 @@ interface EspnTeamsResponse { }>; } -function statsMap(stats: EspnStat[]): Map { - const map = new Map(); - for (const stat of stats) { - map.set(stat.name, stat); - } - return map; -} - /** - * Flatten the ESPN standings response. - * WNBA has conferences (East/West) but no divisions — entries are either directly - * under the conference group or nested one level deeper (ESPN sometimes wraps - * conferences in a single top-level group). Handles both layouts. + * WNBA-specific flatten: conferences may be directly under root or wrapped in a + * single top-level group. ESPN sometimes wraps them; handle both layouts. */ -function flattenEspnStandings( +function flattenWnbaStandings( response: EspnStandingsResponse ): Array<{ entry: EspnStandingsEntry; conference: string }> { const results: Array<{ entry: EspnStandingsEntry; conference: string }> = []; @@ -104,14 +67,18 @@ function flattenEspnStandings( /** Parse a standings entry into a FetchedStandingsRecord (rank assigned by caller). */ function parseEntry( entry: EspnStandingsEntry, + sm: Map, conference: string, leagueRank: number ): FetchedStandingsRecord { - const sm = statsMap(entry.stats); const wins = sm.get("wins")?.value ?? 0; const losses = sm.get("losses")?.value ?? 0; - const winPercent = sm.get("winPercent")?.value ?? sm.get("winPct")?.value ?? 0; + + // Fall back to computing win% from wins/losses if ESPN omits the stat. + const winPctStat = sm.get("winPercent")?.value ?? sm.get("winPct")?.value; + const winPct = winPctStat ?? (wins + losses > 0 ? wins / (wins + losses) : 0); + const gamesBehind = sm.get("gamesBehind")?.value; const streak = sm.get("streak")?.displayValue ?? @@ -123,14 +90,6 @@ function parseEntry( const homeRecord = sm.get("Home")?.displayValue ?? undefined; const awayRecord = sm.get("Road")?.displayValue ?? undefined; - const playoffSeedStat = sm.get("playoffSeed"); - const conferenceRank = - playoffSeedStat?.value !== undefined && playoffSeedStat.value !== null - ? Math.round(playoffSeedStat.value) - : playoffSeedStat?.displayValue - ? parseInt(playoffSeedStat.displayValue, 10) || undefined - : undefined; - // SRS proxy: average point differential per game. // ESPN exposes "avgPointsFor" and "avgPointsAgainst" (or "pointsFor"/"pointsAgainst"). // If unavailable, fall back to null so the simulator uses the league-average Elo (1500). @@ -153,11 +112,11 @@ function parseEntry( externalTeamId: entry.team.id, wins: Math.round(wins), losses: Math.round(losses), - winPct: winPercent, + winPct, gamesPlayed: Math.round(wins) + Math.round(losses), gamesBack: gamesBehind, conference: conference || undefined, - conferenceRank, + conferenceRank: parseConferenceRank(sm), leagueRank, streak, lastTen, @@ -193,26 +152,19 @@ export class WnbaStandingsAdapter implements StandingsSyncAdapter { // Build standings records from the standings endpoint, sorted by wins desc. // Pre-parse each entry once so statsMap isn't rebuilt multiple times per entry. - const flattened = flattenEspnStandings(standingsJson).map(({ entry, conference }) => ({ + const flattened = flattenWnbaStandings(standingsJson).map(({ entry, conference }) => ({ entry, conference, sm: statsMap(entry.stats), })); - const sortedEntries = [...flattened].toSorted((a, b) => { - const winsA = a.sm.get("wins")?.value ?? 0; - const winsB = b.sm.get("wins")?.value ?? 0; - if (winsB !== winsA) return winsB - winsA; - const lossA = a.sm.get("losses")?.value ?? 0; - const lossB = b.sm.get("losses")?.value ?? 0; - return lossA - lossB; - }); + const sortedEntries = [...flattened].toSorted(sortByWinLoss); // Map externalTeamId → parsed record (so we can detect which teams are missing). const recordsByTeamId = new Map(); - sortedEntries.forEach(({ entry, conference }, idx) => { + sortedEntries.forEach(({ entry, sm, conference }, idx) => { recordsByTeamId.set( entry.team.id, - parseEntry(entry, conference, idx + 1) + parseEntry(entry, sm, conference, idx + 1) ); });