Fix MLB standings 406 error and refactor ESPN adapter shared code (#62)
## 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:
parent
03726f1a66
commit
add196902b
9 changed files with 413 additions and 545 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { getParticipantSimulatorInputs } from "../simulator";
|
||||||
|
|
||||||
const mockDb = vi.hoisted(() => ({
|
const mockDb = vi.hoisted(() => ({
|
||||||
query: {
|
query: {
|
||||||
|
|
@ -18,8 +19,6 @@ describe("simulator input model", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not treat previously generated ratings as direct rating inputs", async () => {
|
it("does not treat previously generated ratings as direct rating inputs", async () => {
|
||||||
const { getParticipantSimulatorInputs } = await import("../simulator");
|
|
||||||
|
|
||||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
|
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
|
||||||
{ id: "direct-rating" },
|
{ id: "direct-rating" },
|
||||||
{ id: "generated-rating" },
|
{ id: "generated-rating" },
|
||||||
|
|
|
||||||
|
|
@ -70,13 +70,13 @@ describe("LLWSSimulator", () => {
|
||||||
describe("output structure", () => {
|
describe("output structure", () => {
|
||||||
it("returns one result per participant (20 total)", async () => {
|
it("returns one result per participant (20 total)", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
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);
|
expect(results).toHaveLength(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("every result has source 'llws_monte_carlo'", async () => {
|
it("every result has source 'llws_monte_carlo'", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
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) {
|
for (const r of results) {
|
||||||
expect(r.source).toBe("llws_monte_carlo");
|
expect(r.source).toBe("llws_monte_carlo");
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +84,7 @@ describe("LLWSSimulator", () => {
|
||||||
|
|
||||||
it("all probability values are between 0 and 1", async () => {
|
it("all probability values are between 0 and 1", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
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) {
|
for (const r of results) {
|
||||||
const p = r.probabilities;
|
const p = r.probabilities;
|
||||||
for (const v of Object.values(p)) {
|
for (const v of Object.values(p)) {
|
||||||
|
|
@ -100,35 +100,35 @@ describe("LLWSSimulator", () => {
|
||||||
describe("probability conservation (one winner per sim)", () => {
|
describe("probability conservation (one winner per sim)", () => {
|
||||||
it("probFirst sums to ~1.0 across all participants", async () => {
|
it("probFirst sums to ~1.0 across all participants", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
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);
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("probSecond sums to ~1.0 across all participants", async () => {
|
it("probSecond sums to ~1.0 across all participants", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
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);
|
const total = results.reduce((s, r) => s + r.probabilities.probSecond, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("probThird sums to ~1.0 across all participants", async () => {
|
it("probThird sums to ~1.0 across all participants", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
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);
|
const total = results.reduce((s, r) => s + r.probabilities.probThird, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("probFourth sums to ~1.0 across all participants", async () => {
|
it("probFourth sums to ~1.0 across all participants", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
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);
|
const total = results.reduce((s, r) => s + r.probabilities.probFourth, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sum of probFifth across participants equals ~1.0 (4 bracket losers, split evenly)", async () => {
|
it("sum of probFifth across participants equals ~1.0 (4 bracket losers, split evenly)", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
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
|
// 4 bracket losers per sim, each assigned bracketLoser/(4*N) → sum = 1.0
|
||||||
const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
|
const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
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 () => {
|
it("probFifth through probEighth are equal for every participant (even bracket-loser split)", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
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) {
|
for (const r of results) {
|
||||||
const p = r.probabilities;
|
const p = r.probabilities;
|
||||||
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
|
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
|
||||||
|
|
@ -151,7 +151,7 @@ describe("LLWSSimulator", () => {
|
||||||
describe("odds-driven win probability", () => {
|
describe("odds-driven win probability", () => {
|
||||||
it("strong favourite (us-1) has higher probFirst than a weak team", async () => {
|
it("strong favourite (us-1) has higher probFirst than a weak team", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS, { includeOdds: true }));
|
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 byId = new Map(results.map((r) => [r.participantId, r]));
|
||||||
const us1prob = byId.get("us-1")?.probabilities.probFirst ?? 0;
|
const us1prob = byId.get("us-1")?.probabilities.probFirst ?? 0;
|
||||||
const us10prob = byId.get("us-10")?.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 () => {
|
it("works when no odds are entered (all 50/50 fallback)", async () => {
|
||||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS)); // no odds
|
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);
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
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.
|
// Equal positive odds (+5000 for every team) → vig-removed prob ≈ 1/20 each.
|
||||||
const eqOddsRows = ALL_IDS.map((id) => ({ participantId: id, sourceOdds: 5000 }));
|
const eqOddsRows = ALL_IDS.map((id) => ({ participantId: id, sourceOdds: 5000 }));
|
||||||
setupMockDb(defaultParticipants(), eqOddsRows);
|
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) {
|
for (const r of results) {
|
||||||
// With equal odds and random pools, each team should win ~5% of the time.
|
// With equal odds and random pools, each team should win ~5% of the time.
|
||||||
// Allow a generous band given Monte Carlo variance.
|
// Allow a generous band given Monte Carlo variance.
|
||||||
|
|
@ -184,7 +184,7 @@ describe("LLWSSimulator", () => {
|
||||||
describe("pool assignment modes", () => {
|
describe("pool assignment modes", () => {
|
||||||
it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => {
|
it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => {
|
||||||
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
|
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);
|
expect(results).toHaveLength(20);
|
||||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
|
|
@ -197,7 +197,7 @@ describe("LLWSSimulator", () => {
|
||||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||||
];
|
];
|
||||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
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);
|
expect(results).toHaveLength(20);
|
||||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
|
|
@ -215,7 +215,7 @@ describe("LLWSSimulator", () => {
|
||||||
externalId: i < 10 ? "US" : "Intl",
|
externalId: i < 10 ? "US" : "Intl",
|
||||||
}));
|
}));
|
||||||
setupMockDb(participants, makeEvRows(nineteen));
|
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 () => {
|
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 })),
|
...INTL_IDS.map((id) => ({ id, name: `Japan ${id}`, externalId: null })),
|
||||||
];
|
];
|
||||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
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);
|
expect(results).toHaveLength(20);
|
||||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
|
|
@ -236,7 +236,7 @@ describe("LLWSSimulator", () => {
|
||||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: null })),
|
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: null })),
|
||||||
];
|
];
|
||||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
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);
|
expect(results).toHaveLength(20);
|
||||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
expect(total).toBeCloseTo(1.0, 1);
|
||||||
|
|
@ -249,7 +249,7 @@ describe("LLWSSimulator", () => {
|
||||||
{ id: "intl-10", name: "Team intl-10", externalId: "CANADA" }, // unrecognized
|
{ id: "intl-10", name: "Team intl-10", externalId: "CANADA" }, // unrecognized
|
||||||
];
|
];
|
||||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
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 () => {
|
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" })),
|
...Array.from({ length: 9 }, (_, i) => ({ id: `intl-${i + 1}`, name: `Team ${i + 1}`, externalId: "Intl" })),
|
||||||
];
|
];
|
||||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
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 () => {
|
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" })),
|
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||||
];
|
];
|
||||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
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 () => {
|
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" })),
|
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||||
];
|
];
|
||||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,8 @@ function determineRandomized(sideTeams: Team[], sideName: string): boolean {
|
||||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class LLWSSimulator implements Simulator {
|
export class LLWSSimulator implements Simulator {
|
||||||
|
constructor(private numSimulations = NUM_SIMULATIONS) {}
|
||||||
|
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
|
|
@ -345,7 +347,7 @@ export class LLWSSimulator implements Simulator {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 6. Run Monte Carlo simulations.
|
// 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.
|
// Assign pools for this simulation.
|
||||||
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
|
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
|
||||||
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
|
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
|
||||||
|
|
@ -376,7 +378,7 @@ export class LLWSSimulator implements Simulator {
|
||||||
// 7. Convert counts to probability distributions.
|
// 7. Convert counts to probability distributions.
|
||||||
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
|
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
|
||||||
const bracketLosersPerSim = 4;
|
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 };
|
const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 };
|
||||||
return allIds.map((id) => {
|
return allIds.map((id) => {
|
||||||
|
|
@ -385,10 +387,10 @@ export class LLWSSimulator implements Simulator {
|
||||||
return {
|
return {
|
||||||
participantId: id,
|
participantId: id,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
probFirst: c.champion / NUM_SIMULATIONS,
|
probFirst: c.champion / this.numSimulations,
|
||||||
probSecond: c.finalist / NUM_SIMULATIONS,
|
probSecond: c.finalist / this.numSimulations,
|
||||||
probThird: c.thirdPlace / NUM_SIMULATIONS,
|
probThird: c.thirdPlace / this.numSimulations,
|
||||||
probFourth: c.fourthPlace / NUM_SIMULATIONS,
|
probFourth: c.fourthPlace / this.numSimulations,
|
||||||
probFifth: bracketProb,
|
probFifth: bracketProb,
|
||||||
probSixth: bracketProb,
|
probSixth: bracketProb,
|
||||||
probSeventh: bracketProb,
|
probSeventh: bracketProb,
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,70 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { MlbStandingsAdapter } from "../mlb";
|
import { MlbStandingsAdapter } from "../mlb";
|
||||||
|
|
||||||
const SAMPLE_MLB_RESPONSE = {
|
// Minimal ESPN-shaped response: AL East + NL East with a few teams
|
||||||
records: [
|
const SAMPLE_ESPN_RESPONSE = {
|
||||||
|
children: [
|
||||||
{
|
{
|
||||||
// AL East division record
|
name: "American League",
|
||||||
teamRecords: [
|
children: [
|
||||||
{
|
{
|
||||||
team: {
|
name: "American League East",
|
||||||
id: 110,
|
standings: {
|
||||||
name: "Baltimore Orioles",
|
entries: [
|
||||||
league: { name: "American League" },
|
{
|
||||||
division: { name: "AL East" },
|
team: { id: "2", displayName: "Baltimore Orioles" },
|
||||||
},
|
stats: [
|
||||||
wins: 47,
|
{ name: "wins", value: 47 },
|
||||||
losses: 34,
|
{ name: "losses", value: 34 },
|
||||||
gamesPlayed: 81,
|
{ name: "winPercent", value: 0.58 },
|
||||||
winningPercentage: ".580",
|
{ name: "gamesBehind", value: 0 },
|
||||||
divisionRank: "1",
|
{ name: "streak", displayValue: "W3" },
|
||||||
leagueRank: "1",
|
{ name: "Home", displayValue: "24-15" },
|
||||||
gamesBack: "-",
|
{ name: "Road", displayValue: "23-19" },
|
||||||
streak: { streakCode: "W3", streakNumber: 3 },
|
{ name: "Last Ten Games", displayValue: "7-3" },
|
||||||
records: {
|
{ name: "playoffSeed", value: 1 },
|
||||||
splitRecords: [
|
],
|
||||||
{ type: "home", wins: 24, losses: 15 },
|
},
|
||||||
{ type: "away", wins: 23, losses: 19 },
|
{
|
||||||
{ type: "lastTen", wins: 7, losses: 3 },
|
team: { id: "3", displayName: "Boston Red Sox" },
|
||||||
],
|
stats: [
|
||||||
},
|
{ name: "wins", value: 42 },
|
||||||
},
|
{ name: "losses", value: 39 },
|
||||||
{
|
{ name: "winPercent", value: 0.519 },
|
||||||
team: {
|
{ name: "gamesBehind", value: 5.0 },
|
||||||
id: 111,
|
{ name: "streak", displayValue: "L2" },
|
||||||
name: "Boston Red Sox",
|
{ name: "Home", displayValue: "22-18" },
|
||||||
league: { name: "American League" },
|
{ name: "Road", displayValue: "20-21" },
|
||||||
division: { name: "AL East" },
|
{ name: "Last Ten Games", displayValue: "4-6" },
|
||||||
},
|
{ name: "playoffSeed", value: 2 },
|
||||||
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 },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// NL East division record
|
name: "National League",
|
||||||
teamRecords: [
|
children: [
|
||||||
{
|
{
|
||||||
team: {
|
name: "National League East",
|
||||||
id: 121,
|
standings: {
|
||||||
name: "New York Mets",
|
entries: [
|
||||||
league: { name: "National League" },
|
{
|
||||||
division: { name: "NL East" },
|
team: { id: "21", displayName: "New York Mets" },
|
||||||
},
|
stats: [
|
||||||
wins: 43,
|
{ name: "wins", value: 43 },
|
||||||
losses: 38,
|
{ name: "losses", value: 38 },
|
||||||
gamesPlayed: 81,
|
{ name: "winPercent", value: 0.531 },
|
||||||
winningPercentage: ".531",
|
{ name: "gamesBehind", value: 0 },
|
||||||
divisionRank: "1",
|
{ name: "streak", displayValue: "W1" },
|
||||||
leagueRank: "1",
|
{ name: "Home", displayValue: "22-17" },
|
||||||
gamesBack: "-",
|
{ name: "Road", displayValue: "21-21" },
|
||||||
streak: { streakCode: "W1", streakNumber: 1 },
|
{ name: "Last Ten Games", displayValue: "6-4" },
|
||||||
records: {
|
{ name: "playoffSeed", value: 1 },
|
||||||
splitRecords: [
|
],
|
||||||
{ type: "home", wins: 22, losses: 17 },
|
},
|
||||||
{ type: "away", wins: 21, losses: 21 },
|
|
||||||
{ type: "lastTen", wins: 6, losses: 4 },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -93,20 +81,20 @@ describe("MlbStandingsAdapter", () => {
|
||||||
it("maps AL teams to conference 'AL'", async () => {
|
it("maps AL teams to conference 'AL'", async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => SAMPLE_MLB_RESPONSE,
|
json: async () => SAMPLE_ESPN_RESPONSE,
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const adapter = new MlbStandingsAdapter();
|
const adapter = new MlbStandingsAdapter();
|
||||||
const records = await adapter.fetchStandings();
|
const records = await adapter.fetchStandings();
|
||||||
|
|
||||||
const bos = records.find((r) => r.teamName === "Baltimore Orioles");
|
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
|
||||||
expect(bos?.conference).toBe("AL");
|
expect(ori?.conference).toBe("AL");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps NL teams to conference 'NL'", async () => {
|
it("maps NL teams to conference 'NL'", async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => SAMPLE_MLB_RESPONSE,
|
json: async () => SAMPLE_ESPN_RESPONSE,
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const adapter = new MlbStandingsAdapter();
|
const adapter = new MlbStandingsAdapter();
|
||||||
|
|
@ -119,22 +107,22 @@ describe("MlbStandingsAdapter", () => {
|
||||||
it("maps division names correctly", async () => {
|
it("maps division names correctly", async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => SAMPLE_MLB_RESPONSE,
|
json: async () => SAMPLE_ESPN_RESPONSE,
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const adapter = new MlbStandingsAdapter();
|
const adapter = new MlbStandingsAdapter();
|
||||||
const records = await adapter.fetchStandings();
|
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");
|
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");
|
expect(mets?.division).toBe("NL East");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps wins, losses, gamesPlayed, and winPct", async () => {
|
it("maps wins, losses, gamesPlayed, and winPct", async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => SAMPLE_MLB_RESPONSE,
|
json: async () => SAMPLE_ESPN_RESPONSE,
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const adapter = new MlbStandingsAdapter();
|
const adapter = new MlbStandingsAdapter();
|
||||||
|
|
@ -150,7 +138,7 @@ describe("MlbStandingsAdapter", () => {
|
||||||
it("formats streak as 'W3' or 'L2'", async () => {
|
it("formats streak as 'W3' or 'L2'", async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => SAMPLE_MLB_RESPONSE,
|
json: async () => SAMPLE_ESPN_RESPONSE,
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const adapter = new MlbStandingsAdapter();
|
const adapter = new MlbStandingsAdapter();
|
||||||
|
|
@ -162,10 +150,10 @@ describe("MlbStandingsAdapter", () => {
|
||||||
expect(bos?.streak).toBe("L2");
|
expect(bos?.streak).toBe("L2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts lastTen from splitRecords", async () => {
|
it("extracts lastTen from Last Ten Games stat", async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => SAMPLE_MLB_RESPONSE,
|
json: async () => SAMPLE_ESPN_RESPONSE,
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const adapter = new MlbStandingsAdapter();
|
const adapter = new MlbStandingsAdapter();
|
||||||
|
|
@ -175,10 +163,36 @@ describe("MlbStandingsAdapter", () => {
|
||||||
expect(ori?.lastTen).toBe("7-3");
|
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({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
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);
|
} as Response);
|
||||||
|
|
||||||
const adapter = new MlbStandingsAdapter();
|
const adapter = new MlbStandingsAdapter();
|
||||||
|
|
@ -189,36 +203,10 @@ describe("MlbStandingsAdapter", () => {
|
||||||
expect(ori?.awayRecord).toBe("23-19");
|
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({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
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).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,
|
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const adapter = new MlbStandingsAdapter();
|
const adapter = new MlbStandingsAdapter();
|
||||||
|
|
@ -231,10 +219,23 @@ describe("MlbStandingsAdapter", () => {
|
||||||
expect(bos?.leagueRank).toBeGreaterThan(1);
|
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 () => {
|
it("never sets otLosses (MLB has no OT losses)", async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => SAMPLE_MLB_RESPONSE,
|
json: async () => SAMPLE_ESPN_RESPONSE,
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const adapter = new MlbStandingsAdapter();
|
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({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => SAMPLE_MLB_RESPONSE,
|
json: async () => tiedResponse,
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const adapter = new MlbStandingsAdapter();
|
const adapter = new MlbStandingsAdapter();
|
||||||
const records = await adapter.fetchStandings();
|
const records = await adapter.fetchStandings();
|
||||||
|
|
||||||
const ori = records.find((r) => r.teamName === "Baltimore Orioles");
|
// Tiebreaker is alphabetical — "A Team" should always rank above "Z Team"
|
||||||
expect(ori?.externalTeamId).toBe("110");
|
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 () => {
|
it("throws when response has no entries", 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 () => {
|
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ unexpected: true }),
|
json: async () => ({ children: [] }),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const adapter = new MlbStandingsAdapter();
|
const adapter = new MlbStandingsAdapter();
|
||||||
|
|
|
||||||
107
app/services/standings-sync/espn.ts
Normal file
107
app/services/standings-sync/espn.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -1,156 +1,82 @@
|
||||||
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
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 =
|
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 {
|
// "American League" → "AL", "National League" → "NL"
|
||||||
type: string; // "home", "away", "lastTen", etc.
|
function abbreviateLeague(name: string): string {
|
||||||
wins: number;
|
if (name.startsWith("American")) return "AL";
|
||||||
losses: number;
|
if (name.startsWith("National")) return "NL";
|
||||||
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MlbTeamRecord {
|
// "American League East" → "AL East", "National League West" → "NL West"
|
||||||
team: {
|
function abbreviateDivision(name: string): string {
|
||||||
id: number;
|
return name
|
||||||
name: string;
|
.replace(/^American League /, "AL ")
|
||||||
league?: { name?: string };
|
.replace(/^National League /, "NL ");
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MlbStandingsAdapter implements StandingsSyncAdapter {
|
export class MlbStandingsAdapter implements StandingsSyncAdapter {
|
||||||
constructor(private season: number = new Date().getFullYear()) {}
|
|
||||||
|
|
||||||
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
||||||
const url = `${MLB_STANDINGS_URL}&season=${this.season}`;
|
const response = await fetch(MLB_STANDINGS_URL);
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`MLB standings API returned ${response.status}: ${response.statusText}`);
|
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)) {
|
if (flattened.length === 0) {
|
||||||
throw new Error("Unexpected MLB standings API response shape");
|
throw new Error("MLB standings API returned no entries — response shape may have changed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all team records from all division groups
|
// Pre-build statsMap once per entry so the sort doesn't rebuild it on every comparison.
|
||||||
const allTeamRecords: MlbTeamRecord[] = json.records.flatMap(
|
const withSm = flattened.map(({ entry, conference, division }) => ({
|
||||||
(divRecord) => divRecord.teamRecords ?? []
|
entry,
|
||||||
);
|
conference,
|
||||||
|
division,
|
||||||
|
sm: statsMap(entry.stats),
|
||||||
|
}));
|
||||||
|
|
||||||
if (allTeamRecords.length === 0) {
|
const sorted = [...withSm].toSorted(sortByWinLoss);
|
||||||
throw new Error("MLB standings API returned no team records");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute leagueRank by sorting all teams by wins desc, losses asc within each league.
|
return sorted.map(({ entry, conference, division, sm }, leagueIdx): FetchedStandingsRecord => {
|
||||||
// We'll assign ranks per league (AL rank 1–15, NL rank 1–15) separately since
|
const wins = sm.get("wins")?.value ?? 0;
|
||||||
// the API's leagueRank field may reflect conference-level rank.
|
const losses = sm.get("losses")?.value ?? 0;
|
||||||
const alTeams = allTeamRecords.filter(
|
|
||||||
(t) => t.team.league?.name?.includes("American")
|
|
||||||
);
|
|
||||||
const nlTeams = allTeamRecords.filter(
|
|
||||||
(t) => t.team.league?.name?.includes("National")
|
|
||||||
);
|
|
||||||
|
|
||||||
const alSorted = [...alTeams].toSorted(sortByRecord);
|
// Fall back to computing win% from wins/losses if ESPN omits the stat.
|
||||||
const nlSorted = [...nlTeams].toSorted(sortByRecord);
|
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>();
|
const gamesBehind = sm.get("gamesBehind")?.value;
|
||||||
for (const [i, t] of alSorted.entries()) conferenceRankMap.set(t.team.id, i + 1);
|
const streak = sm.get("streak")?.displayValue ?? sm.get("streakSummary")?.displayValue;
|
||||||
for (const [i, t] of nlSorted.entries()) conferenceRankMap.set(t.team.id, i + 1);
|
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 abbreviatedDiv = abbreviateDivision(division);
|
||||||
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;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
teamName: team.team.name,
|
teamName: entry.team.displayName,
|
||||||
externalTeamId: String(team.team.id),
|
externalTeamId: entry.team.id,
|
||||||
wins: team.wins,
|
wins: Math.round(wins),
|
||||||
losses: team.losses,
|
losses: Math.round(losses),
|
||||||
gamesPlayed: team.gamesPlayed,
|
|
||||||
winPct,
|
winPct,
|
||||||
// MLB has no OT losses
|
gamesPlayed: Math.round(wins) + Math.round(losses),
|
||||||
conference,
|
gamesBack: gamesBehind,
|
||||||
division: division ?? undefined,
|
conference: abbreviateLeague(conference),
|
||||||
divisionRank: divisionRank ?? undefined,
|
division: abbreviatedDiv || undefined,
|
||||||
conferenceRank: conferenceRank ?? undefined,
|
conferenceRank: parseConferenceRank(sm),
|
||||||
leagueRank: overallRankMap.get(team.team.id) ?? 1,
|
leagueRank: leagueIdx + 1,
|
||||||
gamesBack: gbStr ?? undefined,
|
|
||||||
streak,
|
streak,
|
||||||
lastTen,
|
lastTen,
|
||||||
homeRecord,
|
homeRecord,
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,14 @@
|
||||||
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
||||||
|
import {
|
||||||
|
flattenEspnStandings,
|
||||||
|
parseConferenceRank,
|
||||||
|
statsMap,
|
||||||
|
type EspnStandingsResponse,
|
||||||
|
} from "./espn";
|
||||||
|
|
||||||
const MLS_STANDINGS_URL =
|
const MLS_STANDINGS_URL =
|
||||||
"https://site.api.espn.com/apis/v2/sports/soccer/usa.1/standings";
|
"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".
|
* Normalize ESPN conference group names to "Eastern" or "Western".
|
||||||
* ESPN returns names like "Eastern Conference" or "Western Conference".
|
* ESPN returns names like "Eastern Conference" or "Western Conference".
|
||||||
|
|
@ -76,12 +20,6 @@ function normalizeConference(raw: string): string {
|
||||||
return raw;
|
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 {
|
export class MlsStandingsAdapter implements StandingsSyncAdapter {
|
||||||
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
||||||
const response = await fetch(MLS_STANDINGS_URL);
|
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
|
// Pre-parse each entry's stats once, then sort by points desc for overall league rank.
|
||||||
const parsed = flattened.map(({ entry, conference }) => ({
|
const withSm = flattened.map(({ entry, conference }) => ({
|
||||||
entry,
|
entry,
|
||||||
conference,
|
conference,
|
||||||
sm: statsMap(entry.stats),
|
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 ptsA = a.sm.get("points")?.value ?? 0;
|
||||||
const ptsB = b.sm.get("points")?.value ?? 0;
|
const ptsB = b.sm.get("points")?.value ?? 0;
|
||||||
if (ptsB !== ptsA) return ptsB - ptsA;
|
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 goalsAgainst = sm.get("pointsAgainst")?.value ?? sm.get("ga")?.value;
|
||||||
const goalDifference = sm.get("pointDifferential")?.value ?? sm.get("gd")?.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 homeRecord = sm.get("Home")?.displayValue ?? undefined;
|
||||||
const awayRecord = sm.get("Road")?.displayValue ?? undefined;
|
const awayRecord = sm.get("Road")?.displayValue ?? undefined;
|
||||||
|
|
||||||
|
|
@ -153,7 +83,7 @@ export class MlsStandingsAdapter implements StandingsSyncAdapter {
|
||||||
winPct: gamesPlayed > 0 ? wins / gamesPlayed : 0,
|
winPct: gamesPlayed > 0 ? wins / gamesPlayed : 0,
|
||||||
gamesPlayed,
|
gamesPlayed,
|
||||||
conference: normalizeConference(conference),
|
conference: normalizeConference(conference),
|
||||||
conferenceRank,
|
conferenceRank: parseConferenceRank(sm),
|
||||||
leagueRank: leagueIdx + 1,
|
leagueRank: leagueIdx + 1,
|
||||||
homeRecord,
|
homeRecord,
|
||||||
awayRecord,
|
awayRecord,
|
||||||
|
|
|
||||||
|
|
@ -1,86 +1,15 @@
|
||||||
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
||||||
|
import {
|
||||||
|
flattenEspnStandings,
|
||||||
|
parseConferenceRank,
|
||||||
|
sortByWinLoss,
|
||||||
|
statsMap,
|
||||||
|
type EspnStandingsResponse,
|
||||||
|
} from "./espn";
|
||||||
|
|
||||||
const NBA_STANDINGS_URL =
|
const NBA_STANDINGS_URL =
|
||||||
"https://site.api.espn.com/apis/v2/sports/basketball/nba/standings";
|
"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 {
|
export class NbaStandingsAdapter implements StandingsSyncAdapter {
|
||||||
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
|
||||||
const response = await fetch(NBA_STANDINGS_URL);
|
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");
|
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
|
// Pre-build statsMap once per entry so the sort doesn't rebuild it on every comparison.
|
||||||
const sorted = [...flattened].toSorted((a, b) => {
|
const withSm = flattened.map(({ entry, conference, division }) => ({
|
||||||
const winsA = statsMap(a.entry.stats).get("wins")?.value ?? 0;
|
entry,
|
||||||
const winsB = statsMap(b.entry.stats).get("wins")?.value ?? 0;
|
conference,
|
||||||
if (winsB !== winsA) return winsB - winsA;
|
division,
|
||||||
const lossA = statsMap(a.entry.stats).get("losses")?.value ?? 0;
|
sm: statsMap(entry.stats),
|
||||||
const lossB = statsMap(b.entry.stats).get("losses")?.value ?? 0;
|
}));
|
||||||
return lossA - lossB;
|
|
||||||
});
|
|
||||||
|
|
||||||
return sorted.map(({ entry, conference, division }, leagueIdx): FetchedStandingsRecord => {
|
const sorted = [...withSm].toSorted(sortByWinLoss);
|
||||||
const sm = statsMap(entry.stats);
|
|
||||||
|
return sorted.map(({ entry, conference, division, sm }, leagueIdx): FetchedStandingsRecord => {
|
||||||
const wins = sm.get("wins")?.value ?? 0;
|
const wins = sm.get("wins")?.value ?? 0;
|
||||||
const losses = sm.get("losses")?.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 gamesBehind = sm.get("gamesBehind")?.value;
|
||||||
const streak = sm.get("streak")?.displayValue ?? sm.get("streakSummary")?.displayValue;
|
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 =
|
const lastTen =
|
||||||
sm.get("Last Ten Games")?.displayValue ??
|
sm.get("Last Ten Games")?.displayValue ?? sm.get("L10")?.displayValue ?? undefined;
|
||||||
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")
|
|
||||||
const homeRecord = sm.get("Home")?.displayValue ?? undefined;
|
const homeRecord = sm.get("Home")?.displayValue ?? undefined;
|
||||||
const awayRecord = sm.get("Road")?.displayValue ?? undefined;
|
const awayRecord = sm.get("Road")?.displayValue ?? undefined;
|
||||||
|
|
||||||
|
|
@ -139,12 +54,12 @@ export class NbaStandingsAdapter implements StandingsSyncAdapter {
|
||||||
externalTeamId: entry.team.id,
|
externalTeamId: entry.team.id,
|
||||||
wins: Math.round(wins),
|
wins: Math.round(wins),
|
||||||
losses: Math.round(losses),
|
losses: Math.round(losses),
|
||||||
winPct: winPercent,
|
winPct,
|
||||||
gamesPlayed,
|
gamesPlayed: Math.round(wins) + Math.round(losses),
|
||||||
gamesBack: gamesBehind,
|
gamesBack: gamesBehind,
|
||||||
conference,
|
conference,
|
||||||
division: division || undefined,
|
division: division || undefined,
|
||||||
conferenceRank,
|
conferenceRank: parseConferenceRank(sm),
|
||||||
leagueRank: leagueIdx + 1,
|
leagueRank: leagueIdx + 1,
|
||||||
streak,
|
streak,
|
||||||
lastTen,
|
lastTen,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
||||||
|
import {
|
||||||
|
parseConferenceRank,
|
||||||
|
sortByWinLoss,
|
||||||
|
statsMap,
|
||||||
|
type EspnStat,
|
||||||
|
type EspnStandingsEntry,
|
||||||
|
type EspnStandingsResponse,
|
||||||
|
} from "./espn";
|
||||||
|
|
||||||
const WNBA_STANDINGS_URL =
|
const WNBA_STANDINGS_URL =
|
||||||
"https://site.api.espn.com/apis/v2/sports/basketball/wnba/standings";
|
"https://site.api.espn.com/apis/v2/sports/basketball/wnba/standings";
|
||||||
|
|
@ -10,41 +18,6 @@ const WNBA_STANDINGS_URL =
|
||||||
const WNBA_TEAMS_URL =
|
const WNBA_TEAMS_URL =
|
||||||
"https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/teams";
|
"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 {
|
interface EspnTeamItem {
|
||||||
team: {
|
team: {
|
||||||
id: string;
|
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-specific flatten: conferences may be directly under root or wrapped in a
|
||||||
* WNBA has conferences (East/West) but no divisions — entries are either directly
|
* single top-level group. ESPN sometimes wraps them; handle both layouts.
|
||||||
* under the conference group or nested one level deeper (ESPN sometimes wraps
|
|
||||||
* conferences in a single top-level group). Handles both layouts.
|
|
||||||
*/
|
*/
|
||||||
function flattenEspnStandings(
|
function flattenWnbaStandings(
|
||||||
response: EspnStandingsResponse
|
response: EspnStandingsResponse
|
||||||
): Array<{ entry: EspnStandingsEntry; conference: string }> {
|
): Array<{ entry: EspnStandingsEntry; conference: string }> {
|
||||||
const results: 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). */
|
/** Parse a standings entry into a FetchedStandingsRecord (rank assigned by caller). */
|
||||||
function parseEntry(
|
function parseEntry(
|
||||||
entry: EspnStandingsEntry,
|
entry: EspnStandingsEntry,
|
||||||
|
sm: Map<string, EspnStat>,
|
||||||
conference: string,
|
conference: string,
|
||||||
leagueRank: number
|
leagueRank: number
|
||||||
): FetchedStandingsRecord {
|
): FetchedStandingsRecord {
|
||||||
const sm = statsMap(entry.stats);
|
|
||||||
|
|
||||||
const wins = sm.get("wins")?.value ?? 0;
|
const wins = sm.get("wins")?.value ?? 0;
|
||||||
const losses = sm.get("losses")?.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 gamesBehind = sm.get("gamesBehind")?.value;
|
||||||
const streak =
|
const streak =
|
||||||
sm.get("streak")?.displayValue ??
|
sm.get("streak")?.displayValue ??
|
||||||
|
|
@ -123,14 +90,6 @@ function parseEntry(
|
||||||
const homeRecord = sm.get("Home")?.displayValue ?? undefined;
|
const homeRecord = sm.get("Home")?.displayValue ?? undefined;
|
||||||
const awayRecord = sm.get("Road")?.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.
|
// SRS proxy: average point differential per game.
|
||||||
// ESPN exposes "avgPointsFor" and "avgPointsAgainst" (or "pointsFor"/"pointsAgainst").
|
// ESPN exposes "avgPointsFor" and "avgPointsAgainst" (or "pointsFor"/"pointsAgainst").
|
||||||
// If unavailable, fall back to null so the simulator uses the league-average Elo (1500).
|
// 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,
|
externalTeamId: entry.team.id,
|
||||||
wins: Math.round(wins),
|
wins: Math.round(wins),
|
||||||
losses: Math.round(losses),
|
losses: Math.round(losses),
|
||||||
winPct: winPercent,
|
winPct,
|
||||||
gamesPlayed: Math.round(wins) + Math.round(losses),
|
gamesPlayed: Math.round(wins) + Math.round(losses),
|
||||||
gamesBack: gamesBehind,
|
gamesBack: gamesBehind,
|
||||||
conference: conference || undefined,
|
conference: conference || undefined,
|
||||||
conferenceRank,
|
conferenceRank: parseConferenceRank(sm),
|
||||||
leagueRank,
|
leagueRank,
|
||||||
streak,
|
streak,
|
||||||
lastTen,
|
lastTen,
|
||||||
|
|
@ -193,26 +152,19 @@ export class WnbaStandingsAdapter implements StandingsSyncAdapter {
|
||||||
|
|
||||||
// Build standings records from the standings endpoint, sorted by wins desc.
|
// 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.
|
// 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,
|
entry,
|
||||||
conference,
|
conference,
|
||||||
sm: statsMap(entry.stats),
|
sm: statsMap(entry.stats),
|
||||||
}));
|
}));
|
||||||
const sortedEntries = [...flattened].toSorted((a, b) => {
|
const sortedEntries = [...flattened].toSorted(sortByWinLoss);
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Map externalTeamId → parsed record (so we can detect which teams are missing).
|
// Map externalTeamId → parsed record (so we can detect which teams are missing).
|
||||||
const recordsByTeamId = new Map<string, FetchedStandingsRecord>();
|
const recordsByTeamId = new Map<string, FetchedStandingsRecord>();
|
||||||
sortedEntries.forEach(({ entry, conference }, idx) => {
|
sortedEntries.forEach(({ entry, sm, conference }, idx) => {
|
||||||
recordsByTeamId.set(
|
recordsByTeamId.set(
|
||||||
entry.team.id,
|
entry.team.id,
|
||||||
parseEntry(entry, conference, idx + 1)
|
parseEntry(entry, sm, conference, idx + 1)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue