2026-03-21 00:12:01 -07:00
|
|
|
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`NBA standings API returned ${response.status}: ${response.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const json = (await response.json()) as EspnStandingsResponse;
|
|
|
|
|
const flattened = flattenEspnStandings(json);
|
|
|
|
|
|
|
|
|
|
if (flattened.length === 0) {
|
|
|
|
|
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
|
2026-03-21 09:44:05 -07:00
|
|
|
const sorted = [...flattened].toSorted((a, b) => {
|
2026-03-21 00:12:01 -07:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return sorted.map(({ entry, conference, division }, leagueIdx): 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;
|
|
|
|
|
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 =
|
2026-03-21 09:44:05 -07:00
|
|
|
playoffSeedStat !== undefined && playoffSeedStat.value !== null && playoffSeedStat.value !== undefined
|
2026-03-21 00:12:01 -07:00
|
|
|
? 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 awayRecord = sm.get("Road")?.displayValue ?? undefined;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
teamName: entry.team.displayName,
|
|
|
|
|
externalTeamId: entry.team.id,
|
|
|
|
|
wins: Math.round(wins),
|
|
|
|
|
losses: Math.round(losses),
|
|
|
|
|
winPct: winPercent,
|
|
|
|
|
gamesPlayed,
|
|
|
|
|
gamesBack: gamesBehind,
|
|
|
|
|
conference,
|
|
|
|
|
division: division || undefined,
|
|
|
|
|
conferenceRank,
|
|
|
|
|
leagueRank: leagueIdx + 1,
|
|
|
|
|
streak,
|
|
|
|
|
lastTen,
|
|
|
|
|
homeRecord,
|
|
|
|
|
awayRecord,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|