## 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
107 lines
3.3 KiB
TypeScript
107 lines
3.3 KiB
TypeScript
// 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);
|
|
}
|