import type { SourceModule } from "../registry"; import { SportsConfig, SportsPayload, type SportsConfig as Config, type SportsPayload as Payload, } from "@/lib/schemas/sources/sports"; type Game = Payload["games"][number]; interface ESPNCompetitor { homeAway: "home" | "away"; team: { shortDisplayName?: string; displayName?: string; abbreviation?: string }; score?: string; } interface ESPNEvent { date?: string; status?: { type?: { shortDetail?: string; description?: string; state?: string } }; competitions?: Array<{ competitors?: ESPNCompetitor[] }>; } const gameKey = (g: Game) => `${g.league}|${g.away}|${g.home}|${g.startTime ?? ""}`; // A dashboard is about *now*: yesterday's results + today's slate. Leagues stay // strictly yesterday+today (no future dump). Favorites look a week ahead too, but // only so we can surface a team's NEXT game when it isn't playing today. const LEAGUE_BACK = 1; const LEAGUE_FORWARD = 1; const FAV_BACK = 1; const FAV_FORWARD = 7; // Local civil day as YYYY-MM-DD (host timezone — matches the TUI's to_local and // the paper's new Date(...).toDateString(), which run on the same machine). function dayStr(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function offsetDay(offset: number): Date { const d = new Date(); d.setDate(d.getDate() + offset); return d; } // YYYYMMDD strings over [-back, +forward] days, oldest first. function fetchDates(back: number, forward: number): string[] { const out: string[] = []; for (let off = -back; off <= forward; off++) { out.push(dayStr(offsetDay(off)).replace(/-/g, "")); } return out; } export const sportsModule: SourceModule = { kind: "sports", label: "Scores", keyless: true, // ESPN public endpoints need no key defaultRefreshSeconds: 600, configSchema: SportsConfig, payloadSchema: SportsPayload, isConfigured: (config) => config.leagues.length > 0 || config.teams.length > 0, async fetch({ config, signal }) { // Fetch one league's scoreboard for a date and return its games. When // teamFilter is given, keep only games whose home/away matches one of the // team substrings. async function scoreboard( league: string, date: string, teamFilter?: string[], ): Promise { const res = await fetch( `https://site.api.espn.com/apis/site/v2/sports/${league}/scoreboard?dates=${date}`, { signal }, ); if (!res.ok) return []; const j = (await res.json()) as { events?: ESPNEvent[] }; const wanted = teamFilter?.map((t) => t.toLowerCase().trim()).filter(Boolean); const out: Game[] = []; for (const ev of j.events ?? []) { const comp = ev.competitions?.[0]; const home = comp?.competitors?.find((c) => c.homeAway === "home"); const away = comp?.competitors?.find((c) => c.homeAway === "away"); const name = (t?: ESPNCompetitor) => t?.team.shortDisplayName ?? t?.team.abbreviation ?? t?.team.displayName ?? "—"; const blob = `${home?.team.displayName ?? ""} ${home?.team.shortDisplayName ?? ""} ${home?.team.abbreviation ?? ""} ${away?.team.displayName ?? ""} ${away?.team.shortDisplayName ?? ""} ${away?.team.abbreviation ?? ""}`.toLowerCase(); if (wanted && wanted.length && !wanted.some((w) => blob.includes(w))) continue; out.push({ league, status: ev.status?.type?.shortDetail ?? ev.status?.type?.description ?? "", state: ev.status?.type?.state ?? null, home: name(home), away: name(away), homeScore: home?.score ?? null, awayScore: away?.score ?? null, startTime: ev.date ?? null, favorite: false, }); } return out; } const today = dayStr(new Date()); const yest = dayStr(offsetDay(-1)); const dayOf = (g: Game) => (g.startTime ? dayStr(new Date(g.startTime)) : null); const byTime = (a: Game, b: Game) => (a.startTime ? Date.parse(a.startTime) : 0) - (b.startTime ? Date.parse(b.startTime) : 0); // --- Leagues: full slate, yesterday + today only --- const leagueDates = fetchDates(LEAGUE_BACK, LEAGUE_FORWARD); const leagueRaw = ( await Promise.all(config.leagues.flatMap((l) => leagueDates.map((d) => scoreboard(l, d)))) ).flat(); const merged = new Map(); for (const g of leagueRaw) { const d = dayOf(g); if (d === yest || d === today) merged.set(gameKey(g), g); } // --- Favorites: per team, yesterday + today, else the next game --- const favDates = fetchDates(FAV_BACK, FAV_FORWARD); await Promise.all( config.teams.map(async ({ league, team }) => { const raw = (await Promise.all(favDates.map((d) => scoreboard(league, d, [team])))).flat(); const seen = new Map(); for (const g of raw) seen.set(gameKey(g), g); const games = [...seen.values()].sort(byTime); const playsToday = games.some((g) => dayOf(g) === today); for (const g of games) { const d = dayOf(g); if (d === yest || d === today) merged.set(gameKey(g), { ...g, favorite: true }); } if (!playsToday) { const next = games.find((g) => { const d = dayOf(g); return d !== null && d > today; }); if (next) merged.set(gameKey(next), { ...next, favorite: true }); } }), ); const games = [...merged.values()].sort(byTime); return { games }; }, };