brackt/app/services/standings-sync/nhl.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

78 lines
2.3 KiB
TypeScript

import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
const NHL_STANDINGS_URL = "https://api-web.nhle.com/v1/standings/now";
interface NhlTeamRecord {
teamName: { default: string };
teamAbbrev: { default: string };
teamLogo?: string;
conferenceName: string;
divisionName: string;
gamesPlayed: number;
wins: number;
losses: number;
otLosses: number;
points: number;
pointPctg: number;
winPctg: number;
l10Wins: number;
l10Losses: number;
l10OtLosses: number;
streakCode: string; // "W" or "L"
streakCount: number;
homeWins: number;
homeLosses: number;
homeOtLosses: number;
roadWins: number;
roadLosses: number;
roadOtLosses: number;
conferenceSequence: number;
divisionSequence: number;
leagueSequence: number;
teamId?: number;
}
interface NhlStandingsResponse {
standings: NhlTeamRecord[];
}
export class NhlStandingsAdapter implements StandingsSyncAdapter {
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
const response = await fetch(NHL_STANDINGS_URL);
if (!response.ok) {
throw new Error(`NHL standings API returned ${response.status}: ${response.statusText}`);
}
const json = (await response.json()) as NhlStandingsResponse;
if (!json.standings || !Array.isArray(json.standings)) {
throw new Error("Unexpected NHL standings API response shape");
}
return json.standings.map((team, index): FetchedStandingsRecord => {
const homeRecord = `${team.homeWins}-${team.homeLosses}-${team.homeOtLosses}`;
const awayRecord = `${team.roadWins}-${team.roadLosses}-${team.roadOtLosses}`;
const streak = `${team.streakCode}${team.streakCount}`;
const lastTen = `${team.l10Wins}-${team.l10Losses}-${team.l10OtLosses}`;
return {
teamName: team.teamName.default,
externalTeamId: team.teamAbbrev?.default ?? String(index),
wins: team.wins,
losses: team.losses,
otLosses: team.otLosses,
winPct: team.winPctg ?? 0,
gamesPlayed: team.gamesPlayed,
conference: team.conferenceName,
division: team.divisionName,
conferenceRank: team.conferenceSequence,
divisionRank: team.divisionSequence,
leagueRank: team.leagueSequence,
streak,
lastTen,
homeRecord,
awayRecord,
};
});
}
}