brackt/app/services/standings-sync/nba.ts
Chris Parsons bcca8b76fa
Add regular season standings for NBA/NHL (fixes #89) (#192)
Adds live standings sync and display for bracket-based sports (NBA/NHL),
so league members can see W/L tables and which teams their opponents drafted
during the regular season — not just after the playoff bracket is set.

- New `regular_season_standings` table with upsert-on-conflict sync
- Standings sync service with NHL (api-web.nhle.com) and NBA (ESPN) adapters,
  externalId write-back for future syncs, and unmatched-team resolution UI
- `RegularSeasonStandings` component: flat (NBA) + division/wild-card (NHL) modes,
  playoff line, TeamOwnerBadge, projected Brackt points (EV), mobile horizontal scroll
- Admin "Sync Standings" card + "Resolve Unmatched" UI on sports season page
- Admin manual standings edit hatch at /admin/sports-seasons/:id/regular-standings
- Show standings above bracket until matches exist; below once bracket is set
- `normalize-team-name` utility extracted to shared lib

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 00:12:01 -07:00

156 lines
4.9 KiB
TypeScript

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
const sorted = [...flattened].sort((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;
});
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 =
playoffSeedStat?.value != null
? 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,
};
});
}
}