Fix MLB standings 406 error and refactor ESPN adapter shared code (#62)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m21s
🚀 Deploy / ʦ TypeScript (push) Successful in 1m18s
🚀 Deploy / 🔍 Lint (push) Successful in 49s
🚀 Deploy / 🐳 Build (push) Successful in 12m18s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s

## Summary

- **Root cause**: `statsapi.mlb.com` returns 406 (deprecated). Switched MLB to ESPN's free standings API (`site.api.espn.com/apis/v2/sports/baseball/mlb/standings`), consistent with NBA, WNBA, and MLS.
- **Refactor**: Extracted a shared `espn.ts` utility module, eliminating 4× duplication of `statsMap()`, `flattenEspnStandings()`, ESPN interfaces, and the `playoffSeed` → conference rank logic across adapters.
- **Bug fixes** found during review and applied across all affected adapters:
  - `parseConferenceRank`: `|| undefined` falsy-zero bug replaced with `isNaN` guard
  - Sort comparators: stable alphabetical tiebreaker added to MLB, NBA, WNBA
  - `winPct`: falls back to `wins/(wins+losses)` if ESPN omits the stat (was silently 0)
  - statsMap pre-built once per entry before sorting in all adapters (was rebuilt per comparison)
  - WNBA `parseEntry`: accepts pre-computed `sm` instead of rebuilding it internally
  - MLB `gamesBack`: tests updated to reflect ESPN returns numeric `0` for division leaders (old API used `"-"` → `undefined`)

## Test plan

- [ ] All 60 standings-sync unit tests pass (`npm run test:run -- app/services/standings-sync`)
- [ ] Trigger MLB standings sync from admin panel and confirm it returns data without a 406
- [ ] Confirm NBA, WNBA, MLS syncs still work (adapters touched but behaviour unchanged)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #62
This commit is contained in:
chrisp 2026-06-01 03:31:18 +00:00
parent 03726f1a66
commit add196902b
9 changed files with 413 additions and 545 deletions

View file

@ -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" },

View file

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

View file

@ -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<SimulationResult[]> {
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,

View file

@ -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();

View file

@ -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<string, EspnStat> {
const map = new Map<string, EspnStat>();
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<string, EspnStat>): 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<string, EspnStat>; entry: EspnStandingsEntry },
b: { sm: Map<string, EspnStat>; 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);
}

View file

@ -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<FetchedStandingsRecord[]> {
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 115, NL rank 115) 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<number, number>();
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<number, number>();
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,

View file

@ -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<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".
@ -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<FetchedStandingsRecord[]> {
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,

View file

@ -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<string, EspnStat> {
const map = new Map<string, EspnStat>();
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<FetchedStandingsRecord[]> {
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,

View file

@ -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<string, EspnStat> {
const map = new Map<string, EspnStat>();
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<string, EspnStat>,
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<string, FetchedStandingsRecord>();
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)
);
});