brackt/app/services/standings-sync/espn.ts
Chris Parsons e353c17a0b
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m28s
🚀 Deploy / ʦ TypeScript (pull_request) Failing after 1m9s
🚀 Deploy / 🔍 Lint (pull_request) Successful in 46s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix MLB standings 406 error and refactor ESPN adapter shared code
- Switch MLB standings from deprecated statsapi.mlb.com to ESPN API
  (same source as NBA, WNBA, MLS — no auth required)
- Extract shared espn.ts utility: EspnStat/Team/Entry/Group/Response
  interfaces, statsMap(), flattenEspnStandings(), parseConferenceRank(),
  sortByWinLoss(); removes 4× duplication across adapters
- Fix parseConferenceRank falsy-zero bug (|| undefined → isNaN guard)
- Add stable alphabetical tiebreaker to MLB, NBA, WNBA sort comparators
- Fix winPct silent-zero: fall back to wins/(wins+losses) if ESPN omits stat
- Pre-build statsMap once per entry before sorting in all adapters
- Fix WNBA parseEntry to accept pre-computed sm instead of rebuilding it
- Restore and update gamesBack tests (ESPN returns numeric 0 for leaders,
  not the old API's "-" string)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:18:15 -07:00

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