sportsing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/espn.ts ADDED
@@ -0,0 +1,307 @@
1
+ // ESPN's free, no-key JSON API — the stats source for the live 2026 World Cup.
2
+ // Used by stats / predict. Returns per-team match statistics (possession,
3
+ // shots, passes, cards…), rosters, and key events for `soccer/fifa.world`.
4
+ //
5
+ // Undocumented/unofficial: the shapes here are observed, not contracted, and
6
+ // could change. All ESPN-specific parsing is contained in this module so a
7
+ // break is a one-file fix. Reuses api.ts's disk cache.
8
+
9
+ import { cached, ApiError } from "./api.ts";
10
+ import { c } from "./ansi.ts";
11
+
12
+ const BASE = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world";
13
+
14
+ /**
15
+ * Detect a *structurally* wrong scoreboard response. ESPN is unofficial and
16
+ * returns HTTP 200 even when its JSON shape drifts, so the parser's `?? ""`
17
+ * fallbacks would silently degrade to blank stats — indistinguishable from
18
+ * "no data yet". This keys on shape, NOT emptiness: a date with no matches
19
+ * (`events: []`) and a pre-kickoff match with no stats are both fine.
20
+ *
21
+ * Off when: the `events` key is missing or not an array, or an *in-play* event
22
+ * has zero competitors or unnamed ("?") teams (a live match always has named
23
+ * competitors — if it doesn't, the nested shape changed).
24
+ */
25
+ export function looksOff(raw: any): boolean {
26
+ if (!raw || typeof raw !== "object" || !Array.isArray(raw.events)) return true;
27
+ for (const e of raw.events) {
28
+ const comp = e?.competitions?.[0];
29
+ // Status lives on the competition, but ESPN sometimes mirrors it on the event
30
+ // itself — check both, same fallback normalizeEvent uses.
31
+ const state = comp?.status?.type?.state ?? e?.status?.type?.state;
32
+ if (state !== "in") continue; // only live matches must have full structure
33
+ const competitors = comp?.competitors ?? [];
34
+ if (competitors.length === 0) return true;
35
+ if (!competitors.every((cc: any) => cc?.team?.displayName ?? cc?.team?.name)) return true;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ let driftWarned = false;
41
+
42
+ /** Emit the ESPN-drift warning to stderr, at most once per process run. */
43
+ function warnDriftOnce(): void {
44
+ if (driftWarned) return;
45
+ driftWarned = true;
46
+ console.error(c.yellow("⚠ ESPN data looks off — its format may have changed; it's an unofficial API."));
47
+ }
48
+
49
+ /** WC2026 scoreboard search window (YYYYMMDD) — opening day → final. */
50
+ export const TOURNAMENT_START = "20260611";
51
+ export const TOURNAMENT_END = "20260719";
52
+
53
+ export interface EspnCompetitor {
54
+ homeAway: "home" | "away";
55
+ name: string;
56
+ abbreviation: string;
57
+ score: string;
58
+ }
59
+
60
+ export interface EspnEvent {
61
+ id: string;
62
+ date: string;
63
+ name: string;
64
+ /** "pre" (scheduled), "in" (live), "post" (finished). */
65
+ state: "pre" | "in" | "post";
66
+ detail: string; // e.g. "FT", "45'", "1:00 - 1st Half"
67
+ competitors: EspnCompetitor[];
68
+ }
69
+
70
+ /** One team's stat block for a match: a flat list of named stat rows. */
71
+ export interface EspnTeamStats {
72
+ team: string;
73
+ abbreviation: string;
74
+ stats: { name: string; label: string; value: string }[];
75
+ }
76
+
77
+ function normalizeEvent(e: any): EspnEvent {
78
+ const comp = e.competitions?.[0] ?? {};
79
+ return {
80
+ id: String(e.id),
81
+ date: e.date,
82
+ name: e.name ?? e.shortName ?? "",
83
+ state: comp.status?.type?.state ?? e.status?.type?.state ?? "pre",
84
+ detail: comp.status?.type?.shortDetail ?? e.status?.type?.shortDetail ?? "",
85
+ competitors: (comp.competitors ?? []).map((c: any) => ({
86
+ homeAway: c.homeAway,
87
+ name: c.team?.displayName ?? c.team?.name ?? "?",
88
+ abbreviation: c.team?.abbreviation ?? "",
89
+ score: c.score ?? "",
90
+ })),
91
+ };
92
+ }
93
+
94
+ /** Scoreboard events for a date or `YYYYMMDD-YYYYMMDD` range. */
95
+ export async function getScoreboard(dates: string, ttlMs = 60_000): Promise<EspnEvent[]> {
96
+ const raw = await cached<any>(`espn_sb_${dates}`, ttlMs, async () => {
97
+ const res = await fetch(`${BASE}/scoreboard?dates=${dates}`);
98
+ if (!res.ok) throw new ApiError(res.status, `ESPN scoreboard request failed (HTTP ${res.status}).`);
99
+ return res.json();
100
+ });
101
+ if (looksOff(raw)) warnDriftOnce();
102
+ return (raw.events ?? []).map(normalizeEvent);
103
+ }
104
+
105
+ /** Every tournament event — played and upcoming (opening day → final). The full
106
+ * window so `predict` can see matches days out, not just the next day. */
107
+ export async function getEvents(ttlMs = 60_000): Promise<EspnEvent[]> {
108
+ return getScoreboard(`${TOURNAMENT_START}-${TOURNAMENT_END}`, ttlMs);
109
+ }
110
+
111
+ function eventHasTeam(e: EspnEvent, term: string): boolean {
112
+ return e.competitors.some(
113
+ (c) => c.name.toLowerCase().includes(term) || c.abbreviation.toLowerCase() === term,
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Resolve free-text terms to a single event. Every term must match a team
119
+ * (so "USA" → any USA game; "USA Paraguay" → that specific game). With
120
+ * `playedOnly`, ignores not-yet-started games. Returns the most recent match.
121
+ */
122
+ export async function findEvent(terms: string[], opts: { playedOnly?: boolean } = {}): Promise<EspnEvent | null> {
123
+ const t = terms.map((s) => s.toLowerCase());
124
+ let events = (await getEvents()).filter((e) => t.every((term) => eventHasTeam(e, term)));
125
+ if (opts.playedOnly) events = events.filter((e) => e.state !== "pre");
126
+ events.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
127
+ return events[0] ?? null;
128
+ }
129
+
130
+ export interface H2HGame {
131
+ date: string;
132
+ score: string;
133
+ result: "W" | "D" | "L" | "?";
134
+ }
135
+
136
+ /** Prior meetings between the two sides of an event (summary headToHeadGames),
137
+ * results from the first listed team's perspective. */
138
+ export async function getHeadToHead(
139
+ eventId: string,
140
+ ttlMs = 60 * 60_000,
141
+ ): Promise<{ team: string; games: H2HGame[] }> {
142
+ // Separate cache key from getMatchStats (which also hits /summary on a short
143
+ // TTL) — a shared key would let the shorter TTL win and refetch H2H needlessly.
144
+ const raw = await cached<any>(`espn_h2h_${eventId}`, ttlMs, async () => {
145
+ const res = await fetch(`${BASE}/summary?event=${eventId}`);
146
+ if (!res.ok) throw new ApiError(res.status, `ESPN summary request failed (HTTP ${res.status}).`);
147
+ return res.json();
148
+ });
149
+ const block = (raw.headToHeadGames ?? [])[0];
150
+ if (!block) return { team: "", games: [] };
151
+ const games: H2HGame[] = (block.events ?? []).map((e: any) => {
152
+ const [a, b] = String(e.score ?? "").split("-").map(Number);
153
+ const result: H2HGame["result"] =
154
+ a === undefined || b === undefined || Number.isNaN(a) || Number.isNaN(b) ? "?" : a > b ? "W" : a < b ? "L" : "D";
155
+ return { date: String(e.gameDate ?? e.date ?? "").slice(0, 10), score: String(e.score ?? ""), result };
156
+ });
157
+ return { team: block.team?.displayName ?? "", games };
158
+ }
159
+
160
+ export interface LiveMatch {
161
+ detail: string; // clock/status, e.g. "66'", "HT", "FT"
162
+ state: "pre" | "in" | "post";
163
+ kickoff: string; // ISO kickoff time (for the pre-game countdown)
164
+ homeAbbr: string;
165
+ awayAbbr: string;
166
+ homeScore: string;
167
+ awayScore: string;
168
+ possession?: [string, string];
169
+ shots?: [string, string];
170
+ onTarget?: [string, string];
171
+ /** Market-implied win % (de-vigged) from ESPN odds: [home, draw, away], 0–100. */
172
+ winProb?: [number, number, number];
173
+ /** Raw 3-way odds line, e.g. "QAT +1500 / X +600 / SUI -525". */
174
+ oddsLine?: string;
175
+ /** Notable in-match events (goals, cards, subs…), most recent first. */
176
+ events?: { clock: string; type: string; team: string; text: string }[];
177
+ }
178
+
179
+ /** American moneyline → implied probability (0–1). */
180
+ function mlToProb(ml: number): number {
181
+ return ml >= 0 ? 100 / (ml + 100) : -ml / (-ml + 100);
182
+ }
183
+
184
+ /** Fresh live state for one match in a single call: clock + score (summary
185
+ * header) and stats (boxscore). Short TTL — this drives the live overlay. */
186
+ export async function getLiveMatch(eventId: string, ttlMs = 5_000): Promise<LiveMatch | null> {
187
+ const raw = await cached<any>(`espn_live_${eventId}`, ttlMs, async () => {
188
+ const res = await fetch(`${BASE}/summary?event=${eventId}`);
189
+ if (!res.ok) throw new ApiError(res.status, `ESPN summary request failed (HTTP ${res.status}).`);
190
+ return res.json();
191
+ });
192
+ const comp = raw.header?.competitions?.[0];
193
+ if (!comp) return null;
194
+ const hc = (comp.competitors ?? []).find((c: any) => c.homeAway === "home");
195
+ const ac = (comp.competitors ?? []).find((c: any) => c.homeAway === "away");
196
+ const teams = raw.boxscore?.teams ?? [];
197
+ const byAbbr = (a?: string) => teams.find((t: any) => t.team?.abbreviation === a);
198
+ const ht = byAbbr(hc?.team?.abbreviation) ?? teams[0];
199
+ const at = byAbbr(ac?.team?.abbreviation) ?? teams[1];
200
+ const stat = (t: any, n: string): string | undefined => {
201
+ const s = (t?.statistics ?? []).find((x: any) => x.name === n);
202
+ return s ? String(s.displayValue) : undefined;
203
+ };
204
+ const pair = (n: string): [string, string] | undefined => {
205
+ const h = stat(ht, n);
206
+ const a = stat(at, n);
207
+ return h === undefined && a === undefined ? undefined : [h ?? "—", a ?? "—"];
208
+ };
209
+ const homeAbbr = hc?.team?.abbreviation ?? "?";
210
+ const awayAbbr = ac?.team?.abbreviation ?? "?";
211
+
212
+ // Notable in-match events (goals, cards, subs, VAR…) from the summary's
213
+ // keyEvents feed — most recent first, capped so the overlay panel stays small.
214
+ const events = (raw.keyEvents ?? [])
215
+ .map((k: any) => ({
216
+ clock: k.clock?.displayValue ?? "",
217
+ type: k.type?.text ?? "",
218
+ team: k.team?.abbreviation ?? k.team?.displayName ?? "",
219
+ text: k.text ?? k.shortText ?? "",
220
+ }))
221
+ .filter((e: { type: string; text: string }) => e.type || e.text)
222
+ .reverse()
223
+ .slice(0, 8);
224
+
225
+ // Win probability + odds line, derived from the 3-way moneyline.
226
+ let winProb: [number, number, number] | undefined;
227
+ let oddsLine: string | undefined;
228
+ const o = raw.pickcenter?.[0] ?? raw.odds?.[0];
229
+ const hml = o?.homeTeamOdds?.moneyLine;
230
+ const aml = o?.awayTeamOdds?.moneyLine;
231
+ const dml = o?.drawOdds?.moneyLine;
232
+ if (typeof hml === "number" && typeof aml === "number" && typeof dml === "number") {
233
+ const ph = mlToProb(hml);
234
+ const pd = mlToProb(dml);
235
+ const pa = mlToProb(aml);
236
+ const sum = ph + pd + pa;
237
+ winProb = sum > 0 ? [Math.round((ph / sum) * 100), Math.round((pd / sum) * 100), Math.round((pa / sum) * 100)] : undefined;
238
+ const fmt = (n: number) => (n >= 0 ? "+" + n : String(n));
239
+ oddsLine = `${homeAbbr} ${fmt(hml)} / X ${fmt(dml)} / ${awayAbbr} ${fmt(aml)}`;
240
+ }
241
+
242
+ return {
243
+ detail: comp.status?.type?.shortDetail ?? "",
244
+ state: comp.status?.type?.state ?? "pre",
245
+ kickoff: comp.date ?? "",
246
+ homeAbbr,
247
+ awayAbbr,
248
+ homeScore: String(hc?.score ?? "0"),
249
+ awayScore: String(ac?.score ?? "0"),
250
+ possession: pair("possessionPct"),
251
+ shots: pair("totalShots"),
252
+ onTarget: pair("shotsOnTarget"),
253
+ winProb,
254
+ oddsLine,
255
+ events,
256
+ };
257
+ }
258
+
259
+ /** Resolve terms to the *currently relevant* match: live now → today → next
260
+ * upcoming → most recent. (findEvent returns the latest-scheduled, which is
261
+ * wrong for "watch <team>" when a team has several fixtures.) */
262
+ export async function findCurrentMatch(terms: string[]): Promise<EspnEvent | null> {
263
+ const t = terms.map((s) => s.toLowerCase());
264
+ const events = (await getEvents()).filter((e) => t.every((term) => eventHasTeam(e, term)));
265
+ if (!events.length) return null;
266
+ const live = events.find((e) => e.state === "in");
267
+ if (live) return live;
268
+ const today = new Date().toLocaleDateString();
269
+ const todayGame = events.find((e) => new Date(e.date).toLocaleDateString() === today);
270
+ if (todayGame) return todayGame;
271
+ const upcoming = events.filter((e) => e.state === "pre").sort((a, b) => +new Date(a.date) - +new Date(b.date))[0];
272
+ if (upcoming) return upcoming;
273
+ return events.sort((a, b) => +new Date(b.date) - +new Date(a.date))[0] ?? null;
274
+ }
275
+
276
+ /** The match `watch --wait` should poll toward: a currently-live one matching
277
+ * `terms` (or any live match if `terms` is empty), else the soonest upcoming.
278
+ * Returns null when nothing matching is live or scheduled. Short default TTL so
279
+ * the kickoff→in-play transition is seen promptly. ESPN's `state` is the live
280
+ * signal (no key, same source the overlay follows — unlike the lagging
281
+ * football-data feed behind `live`). */
282
+ export async function resolveWatchTarget(terms: string[], ttlMs = 15_000): Promise<EspnEvent | null> {
283
+ const t = terms.map((s) => s.toLowerCase());
284
+ const events = (await getEvents(ttlMs)).filter((e) => t.every((term) => eventHasTeam(e, term)));
285
+ const live = events.find((e) => e.state === "in");
286
+ if (live) return live;
287
+ return events.filter((e) => e.state === "pre").sort((a, b) => +new Date(a.date) - +new Date(b.date))[0] ?? null;
288
+ }
289
+
290
+ /** Per-team statistics for one event (from the summary boxscore). */
291
+ export async function getMatchStats(eventId: string, ttlMs = 60_000): Promise<EspnTeamStats[]> {
292
+ const raw = await cached<any>(`espn_sum_${eventId}`, ttlMs, async () => {
293
+ const res = await fetch(`${BASE}/summary?event=${eventId}`);
294
+ if (!res.ok) throw new ApiError(res.status, `ESPN summary request failed (HTTP ${res.status}).`);
295
+ return res.json();
296
+ });
297
+ const teams = raw.boxscore?.teams ?? [];
298
+ return teams.map((t: any) => ({
299
+ team: t.team?.displayName ?? t.team?.name ?? "?",
300
+ abbreviation: t.team?.abbreviation ?? "",
301
+ stats: (t.statistics ?? []).map((s: any) => ({
302
+ name: s.name,
303
+ label: s.label ?? s.name,
304
+ value: String(s.displayValue ?? s.value ?? ""),
305
+ })),
306
+ }));
307
+ }
package/src/events.ts ADDED
@@ -0,0 +1,85 @@
1
+ // Pure match-snapshot diffing: turn two successive score polls into discrete
2
+ // favourite-team events (kickoff / goal / full-time). No I/O — the live tick
3
+ // (AGT-508) holds the previous snapshot and feeds successive polls in here, then
4
+ // routes the returned events to notify(). Idempotent: diffing a snapshot against
5
+ // itself yields nothing, so the same transition never alerts twice.
6
+
7
+ import { teamLabel } from "./format.ts";
8
+ import { matchHasTeam } from "./match-util.ts";
9
+ import type { Match, MatchStatus } from "./types.ts";
10
+
11
+ export type MatchEventKind = "kickoff" | "goal" | "full-time";
12
+
13
+ export interface MatchEvent {
14
+ kind: MatchEventKind;
15
+ matchId: number;
16
+ /** Short fixture label, e.g. "USA vs ENG". */
17
+ fixture: string;
18
+ home: string;
19
+ away: string;
20
+ /** Scoreline at the moment of the event (0–0 at kickoff, final at full-time). */
21
+ score: { home: number; away: number };
22
+ /** For goals only: which side scored. */
23
+ scoringSide?: "home" | "away";
24
+ }
25
+
26
+ const PRE: ReadonlySet<MatchStatus> = new Set(["SCHEDULED", "TIMED"]);
27
+ const LIVE: ReadonlySet<MatchStatus> = new Set(["IN_PLAY", "PAUSED"]);
28
+ const POST: ReadonlySet<MatchStatus> = new Set(["FINISHED", "AWARDED"]);
29
+
30
+ /** Current goals for each side, treating a not-yet-reported score as 0. */
31
+ function goalsOf(m: Match): { home: number; away: number } {
32
+ return { home: m.score.fullTime.home ?? 0, away: m.score.fullTime.away ?? 0 };
33
+ }
34
+
35
+ /**
36
+ * Diff two match-list snapshots into favourite-team events.
37
+ *
38
+ * - `kickoff` — status moved pre (SCHEDULED/TIMED) → live (IN_PLAY/PAUSED)
39
+ * - `goal` — a side's goal count increased (one event per side that scored)
40
+ * - `full-time` — status moved live → post (FINISHED/AWARDED)
41
+ *
42
+ * Only matches involving a favourite team are considered. A match must appear in
43
+ * both snapshots (keyed by id) to diff a transition; brand-new entries are skipped
44
+ * until there is a prior state to compare against. Pure and order-stable.
45
+ *
46
+ * Polling-model caveat: at most one `goal` event is emitted per side per tick.
47
+ * If a side's count jumps by more than one between polls (a multi-goal burst in
48
+ * one interval), it collapses to a single event carrying the resulting scoreline —
49
+ * the intermediate goals are not reconstructable from two snapshots.
50
+ */
51
+ export function diffEvents(prev: Match[], cur: Match[], favorites: string[]): MatchEvent[] {
52
+ const favs = favorites.map((f) => f.trim().toLowerCase()).filter(Boolean);
53
+ if (favs.length === 0) return [];
54
+
55
+ const prevById = new Map(prev.map((m) => [m.id, m]));
56
+ const events: MatchEvent[] = [];
57
+
58
+ for (const m of cur) {
59
+ if (!favs.some((n) => matchHasTeam(m, n))) continue;
60
+ const before = prevById.get(m.id);
61
+ if (!before) continue; // need a prior state to diff a transition
62
+
63
+ const base = {
64
+ matchId: m.id,
65
+ fixture: `${teamLabel(m.homeTeam)} vs ${teamLabel(m.awayTeam)}`,
66
+ home: teamLabel(m.homeTeam),
67
+ away: teamLabel(m.awayTeam),
68
+ };
69
+ const score = goalsOf(m);
70
+
71
+ if (PRE.has(before.status) && LIVE.has(m.status)) {
72
+ events.push({ kind: "kickoff", ...base, score });
73
+ }
74
+
75
+ const wasScore = goalsOf(before);
76
+ if (score.home > wasScore.home) events.push({ kind: "goal", ...base, score, scoringSide: "home" });
77
+ if (score.away > wasScore.away) events.push({ kind: "goal", ...base, score, scoringSide: "away" });
78
+
79
+ if (LIVE.has(before.status) && POST.has(m.status)) {
80
+ events.push({ kind: "full-time", ...base, score });
81
+ }
82
+ }
83
+
84
+ return events;
85
+ }
package/src/format.ts ADDED
@@ -0,0 +1,180 @@
1
+ import { c, pad, visibleLen } from "./ansi.ts";
2
+ import type { Match, StandingRow, Stage } from "./types.ts";
3
+
4
+ export function teamLabel(t: { name: string | null; tla?: string | null }): string {
5
+ return t.tla || t.name || "TBD";
6
+ }
7
+
8
+ export function fmtDate(iso: string): string {
9
+ const d = new Date(iso);
10
+ return d.toLocaleString(undefined, {
11
+ weekday: "short",
12
+ month: "short",
13
+ day: "numeric",
14
+ hour: "numeric",
15
+ minute: "2-digit",
16
+ });
17
+ }
18
+
19
+ export function fmtTimeOnly(iso: string): string {
20
+ return new Date(iso).toLocaleString(undefined, { hour: "numeric", minute: "2-digit" });
21
+ }
22
+
23
+ /** Date-only header, e.g. "Sat, Jun 13" — for grouping a list by day (local). */
24
+ export function fmtDayHeader(iso: string): string {
25
+ return new Date(iso).toLocaleDateString(undefined, {
26
+ weekday: "short",
27
+ month: "short",
28
+ day: "numeric",
29
+ });
30
+ }
31
+
32
+ function statusBadge(m: Match): string {
33
+ switch (m.status) {
34
+ case "IN_PLAY":
35
+ return c.bgGreen(c.bold(" LIVE ")) + (m.minute ? c.green(` ${m.minute}'`) : "");
36
+ case "PAUSED":
37
+ return c.yellow("HT");
38
+ case "FINISHED":
39
+ return c.dim("FT");
40
+ case "POSTPONED":
41
+ return c.red("PPD");
42
+ case "CANCELLED":
43
+ return c.red("CANC");
44
+ case "SUSPENDED":
45
+ return c.red("SUSP");
46
+ default:
47
+ return c.cyan(fmtTimeOnly(m.utcDate));
48
+ }
49
+ }
50
+
51
+ /** One match as a single aligned line: "ENG 2 - 1 USA FT". */
52
+ export function matchLine(m: Match): string {
53
+ const home = teamLabel(m.homeTeam);
54
+ const away = teamLabel(m.awayTeam);
55
+ const hs = m.score.fullTime.home;
56
+ const as = m.score.fullTime.away;
57
+ const played = hs != null && as != null;
58
+
59
+ const scoreCol = played
60
+ ? scoreStr(hs, as, m)
61
+ : c.dim(" v ");
62
+
63
+ const homeStr = winnerBold(home, m, "HOME_TEAM");
64
+ const awayStr = winnerBold(away, m, "AWAY_TEAM");
65
+
66
+ return (
67
+ pad(homeStr, 18, "right") +
68
+ " " +
69
+ pad(scoreCol, 9) +
70
+ " " +
71
+ pad(awayStr, 18, "left") +
72
+ " " +
73
+ statusBadge(m)
74
+ );
75
+ }
76
+
77
+ function scoreStr(hs: number, as: number, m: Match): string {
78
+ const live = m.status === "IN_PLAY" || m.status === "PAUSED";
79
+ const core = `${hs} - ${as}`;
80
+ return live ? c.green(c.bold(core)) : c.bold(core);
81
+ }
82
+
83
+ function winnerBold(name: string, m: Match, side: "HOME_TEAM" | "AWAY_TEAM"): string {
84
+ return m.score.winner === side ? c.bold(c.white(name)) : name;
85
+ }
86
+
87
+ export function groupName(g: string | null): string {
88
+ if (!g) return "";
89
+ return g.replace("GROUP_", "Group ");
90
+ }
91
+
92
+ export const STAGE_LABELS: Record<Stage, string> = {
93
+ GROUP_STAGE: "Group Stage",
94
+ LAST_32: "Round of 32",
95
+ LAST_16: "Round of 16",
96
+ QUARTER_FINALS: "Quarter-finals",
97
+ SEMI_FINALS: "Semi-finals",
98
+ THIRD_PLACE: "Third-place Play-off",
99
+ FINAL: "Final",
100
+ };
101
+
102
+ /** Canonical stage label for a match: group name (e.g. "Group B") for the group
103
+ * stage, or the knockout-round label. Falls back to "Group Stage" when the
104
+ * group letter is unknown. */
105
+ export function stageLabel(m: Match): string {
106
+ return m.stage === "GROUP_STAGE" ? groupName(m.group) || "Group Stage" : STAGE_LABELS[m.stage];
107
+ }
108
+
109
+ export const KNOCKOUT_ORDER: Stage[] = [
110
+ "LAST_32",
111
+ "LAST_16",
112
+ "QUARTER_FINALS",
113
+ "SEMI_FINALS",
114
+ "THIRD_PLACE",
115
+ "FINAL",
116
+ ];
117
+
118
+ /** Render a standings table for one group. */
119
+ export function standingsTable(group: string | null, rows: StandingRow[]): string {
120
+ const header =
121
+ c.dim(
122
+ pad("#", 2) +
123
+ " " +
124
+ pad("Team", 18) +
125
+ pad("P", 4, "right") +
126
+ pad("W", 3, "right") +
127
+ pad("D", 3, "right") +
128
+ pad("L", 3, "right") +
129
+ pad("GF", 4, "right") +
130
+ pad("GA", 4, "right") +
131
+ pad("GD", 4, "right") +
132
+ pad("Pts", 5, "right"),
133
+ );
134
+ const body = rows
135
+ .map((r) => {
136
+ const qualifies = r.position <= 2;
137
+ const posMark = qualifies ? c.green(String(r.position)) : c.dim(String(r.position));
138
+ const name = qualifies ? c.bold(teamLabel(r.team)) : teamLabel(r.team);
139
+ return (
140
+ pad(posMark, 2) +
141
+ " " +
142
+ pad(name, 18) +
143
+ pad(String(r.playedGames), 4, "right") +
144
+ pad(String(r.won), 3, "right") +
145
+ pad(String(r.draw), 3, "right") +
146
+ pad(String(r.lost), 3, "right") +
147
+ pad(String(r.goalsFor), 4, "right") +
148
+ pad(String(r.goalsAgainst), 4, "right") +
149
+ pad(signed(r.goalDifference), 4, "right") +
150
+ pad(c.bold(String(r.points)), 5, "right")
151
+ );
152
+ })
153
+ .join("\n");
154
+ const title = c.bold(c.cyan(groupName(group)));
155
+ return `${title}\n${header}\n${body}`;
156
+ }
157
+
158
+ function signed(n: number): string {
159
+ return n > 0 ? `+${n}` : String(n);
160
+ }
161
+
162
+ export function heading(text: string): string {
163
+ const line = "─".repeat(Math.max(visibleLen(text), 8));
164
+ return `\n${c.bold(c.magenta(text))}\n${c.dim(line)}`;
165
+ }
166
+
167
+ export function relativeTime(iso: string): string {
168
+ const diff = new Date(iso).getTime() - Date.now();
169
+ const abs = Math.abs(diff);
170
+ const mins = Math.round(abs / 60000);
171
+ const hours = Math.floor(mins / 60);
172
+ const days = Math.floor(hours / 24);
173
+ let s: string;
174
+ if (days >= 1) s = `${days}d ${hours % 24}h`;
175
+ else if (hours >= 1) s = `${hours}h ${mins % 60}m`;
176
+ else s = `${mins}m`;
177
+ return diff >= 0 ? `in ${s}` : `${s} ago`;
178
+ }
179
+
180
+ export { visibleLen };
package/src/index.ts ADDED
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bun
2
+ import { c } from "./ansi.ts";
3
+ import { ApiError } from "./api.ts";
4
+ import { fifa } from "./sports/fifa.ts";
5
+
6
+ const VERSION = "0.1.0";
7
+
8
+ // Sport namespaces. Add a new sport by writing src/sports/<sport>.ts with a
9
+ // dispatcher `(args: string[]) => unknown` and registering it here.
10
+ const SPORTS: Record<string, (args: string[]) => unknown | Promise<unknown>> = {
11
+ fifa,
12
+ };
13
+
14
+ function help() {
15
+ const b = c.bold;
16
+ console.log(`${b(c.cyan("⚽ sportsing"))} — sports in your terminal ${c.dim("v" + VERSION)}
17
+
18
+ ${b("USAGE")}
19
+ sportsing <sport> <command> [options]
20
+
21
+ ${b("SPORTS")}
22
+ ${c.green("fifa")} FIFA World Cup 2026 ${c.dim("— sportsing fifa help")}
23
+
24
+ ${b("NOTE")}
25
+ During the World Cup, the ${b("fifa")} prefix is optional —
26
+ ${c.dim("sportsing today")} is shorthand for ${c.dim("sportsing fifa today")}.
27
+
28
+ ${b("EXAMPLES")}
29
+ sportsing fifa today
30
+ sportsing fifa next --team USA
31
+ sportsing today ${c.dim("(= sportsing fifa today)")}
32
+ `);
33
+ }
34
+
35
+ async function dispatch(): Promise<void> {
36
+ const [, , first, ...rest] = process.argv;
37
+
38
+ if (!first || first === "help" || first === "--help" || first === "-h") return help();
39
+ if (first === "--version" || first === "-v") {
40
+ console.log("sportsing " + VERSION);
41
+ return;
42
+ }
43
+
44
+ // Explicit sport namespace: `sportsing fifa <command>`.
45
+ const sport = SPORTS[first];
46
+ if (sport) {
47
+ await sport(rest);
48
+ return;
49
+ }
50
+
51
+ // Back-compat: while FIFA is the only sport, a bare `sportsing <command>`
52
+ // runs as a FIFA command (`sportsing today` == `sportsing fifa today`).
53
+ // An unknown token surfaces as "Unknown fifa command". Delete this line when
54
+ // a second sport lands so bare commands require an explicit sport prefix.
55
+ await fifa([first, ...rest]);
56
+ }
57
+
58
+ async function main() {
59
+ try {
60
+ await dispatch();
61
+ } catch (e) {
62
+ if (e instanceof ApiError) {
63
+ console.error(c.red(`API error (${e.status}): ${e.message}`));
64
+ } else {
65
+ console.error(c.red("Error: " + (e instanceof Error ? e.message : String(e))));
66
+ }
67
+ process.exitCode = 1;
68
+ }
69
+ }
70
+
71
+ main();