▍ humdrum codex / soft
5.5 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
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<Config, Payload> = {
  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<Game[]> {
      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<string, Game>();
    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<string, Game>();
        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 };
  },
};