sportsing 0.1.2 → 0.2.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/README.md +160 -12
- package/package.json +6 -2
- package/src/alerts.ts +47 -0
- package/src/api.ts +1 -1
- package/src/click-to-watch.ts +39 -0
- package/src/commands/_lib.ts +28 -12
- package/src/commands/analyze.ts +2 -2
- package/src/commands/ask.ts +1 -1
- package/src/commands/cross.ts +354 -0
- package/src/commands/fav.ts +19 -16
- package/src/commands/league-ai.ts +201 -0
- package/src/commands/league-bracket.ts +150 -0
- package/src/commands/league.ts +839 -0
- package/src/commands/live.ts +57 -42
- package/src/commands/me.ts +1 -1
- package/src/commands/predict.ts +2 -2
- package/src/commands/scorers.ts +1 -1
- package/src/commands/setup.ts +3 -3
- package/src/commands/subscriptions.ts +66 -0
- package/src/commands/table.ts +1 -1
- package/src/commands/watch.ts +19 -11
- package/src/config.ts +166 -28
- package/src/espn.ts +606 -33
- package/src/format.ts +87 -0
- package/src/game-events.ts +239 -0
- package/src/game.ts +51 -0
- package/src/index.ts +35 -16
- package/src/league-ai.ts +359 -0
- package/src/league-alerts.ts +139 -0
- package/src/league-detect.ts +104 -0
- package/src/league-overlay.ts +351 -0
- package/src/league-panels.ts +159 -0
- package/src/overlay.ts +96 -51
- package/src/playoff-bracket.ts +384 -0
- package/src/recap.ts +37 -8
- package/src/season.ts +182 -0
- package/src/sports/fifa.ts +9 -4
- package/src/sports/nba.ts +20 -0
- package/src/sports/nhl.ts +20 -0
- package/src/standings.ts +130 -0
- package/src/stream.ts +49 -8
- package/src/watch-route.ts +95 -0
- package/src/watchability.ts +183 -0
package/src/format.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { c, pad, visibleLen } from "./ansi.ts";
|
|
2
2
|
import type { Match, StandingRow, Stage } from "./types.ts";
|
|
3
|
+
import type { Game, GameCompetitor } from "./game.ts";
|
|
3
4
|
|
|
4
5
|
export function teamLabel(t: { name: string | null; tla?: string | null }): string {
|
|
5
6
|
return t.tla || t.name || "TBD";
|
|
@@ -177,4 +178,90 @@ export function relativeTime(iso: string): string {
|
|
|
177
178
|
return diff >= 0 ? `in ${s}` : `${s} ago`;
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
// --- Sport-neutral Game lines (NBA/NHL) ---
|
|
182
|
+
|
|
183
|
+
/** Season-phase tag: "PRE" for preseason, "POST" for postseason (incl. the
|
|
184
|
+
* NBA play-in), "" for the regular season. Plain text, for tests/alignment. */
|
|
185
|
+
export function phaseTag(g: Game): string {
|
|
186
|
+
return g.seasonType === "preseason" ? "PRE" : g.seasonType === "postseason" ? "POST" : "";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** How a league names its periods. Leagues that set this get live/final
|
|
190
|
+
* status rendered from `period` + `clock` in their own terms (hockey:
|
|
191
|
+
* 1st/2nd/3rd/OT/SO) instead of the source's status text. */
|
|
192
|
+
export interface PeriodNaming {
|
|
193
|
+
/** Periods in regulation (NHL: 3). Later periods are overtime. */
|
|
194
|
+
regulation: number;
|
|
195
|
+
/** True if a regular-season/preseason tie after one OT goes to a shootout
|
|
196
|
+
* (NHL). Postseason overtime never ends in one. */
|
|
197
|
+
shootout: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const ordinal = (n: number): string => {
|
|
201
|
+
const tens = n % 100;
|
|
202
|
+
const suffix = tens >= 11 && tens <= 13 ? "th" : ["th", "st", "nd", "rd"][n % 10] ?? "th";
|
|
203
|
+
return n + suffix;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** Name of period `g.period` under `naming`: "1st".."3rd", then "OT" (playoff
|
|
207
|
+
* multi-OT: "2OT", "3OT", …), or "SO" for a shootout. "" before the game. */
|
|
208
|
+
export function periodLabel(g: Pick<Game, "period" | "seasonType">, naming: PeriodNaming): string {
|
|
209
|
+
const p = g.period;
|
|
210
|
+
if (p <= 0) return "";
|
|
211
|
+
if (p <= naming.regulation) return ordinal(p);
|
|
212
|
+
const ot = p - naming.regulation;
|
|
213
|
+
if (naming.shootout && g.seasonType !== "postseason" && ot > 1) return "SO";
|
|
214
|
+
return ot === 1 ? "OT" : `${ot}OT`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Plain status text from period + clock: "12:34 - 2nd", "End of 2nd", "SO",
|
|
218
|
+
* "Final", "Final/OT", "Final/SO". Only for live and finished games. A live
|
|
219
|
+
* game with no clock at all falls back to the source's text — only an
|
|
220
|
+
* explicit "0:00" means the period has ended. */
|
|
221
|
+
export function periodStatus(g: Game, naming: PeriodNaming): string {
|
|
222
|
+
const label = periodLabel(g, naming);
|
|
223
|
+
if (g.state === "post") return g.period > naming.regulation ? `Final/${label}` : "Final";
|
|
224
|
+
if (!label) return g.detail;
|
|
225
|
+
if (label === "SO") return "SO";
|
|
226
|
+
if (!g.clock) return g.detail || label;
|
|
227
|
+
return g.clock !== "0:00" ? `${g.clock} - ${label}` : `End of ${label}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Status column: local start time before the game, a LIVE badge + the clock
|
|
231
|
+
* text during it, the final text after it. Clock/final text comes from the
|
|
232
|
+
* league's period naming when given, else the source's own status text. */
|
|
233
|
+
function gameStatus(g: Game, naming?: PeriodNaming): string {
|
|
234
|
+
if (g.state === "pre") return c.cyan(fmtTimeOnly(g.date));
|
|
235
|
+
const text = naming ? periodStatus(g, naming) : g.detail;
|
|
236
|
+
if (g.state === "in") return c.bgGreen(c.bold(" LIVE ")) + (text ? c.green(" " + text) : "");
|
|
237
|
+
return c.dim(text || "Final");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Score as a number for comparison; NaN when not yet scored. */
|
|
241
|
+
const scoreNum = (s: string) => (s === "" ? NaN : Number(s));
|
|
242
|
+
|
|
243
|
+
function side(t: GameCompetitor, other: GameCompetitor, g: Game): string {
|
|
244
|
+
const label = t.abbreviation || t.name;
|
|
245
|
+
if (g.state === "pre") return pad(label, 5) + " ";
|
|
246
|
+
const won = g.state === "post" && scoreNum(t.score) > scoreNum(other.score);
|
|
247
|
+
const text = pad(label, 5) + pad(t.score, 4, "right");
|
|
248
|
+
return won ? c.bold(c.white(text)) : text;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** One game as an aligned line, US style (away @ home):
|
|
252
|
+
* "UTAH 109 @ DEN 97 Final PRE". `naming` renders the status in the
|
|
253
|
+
* league's period terms (see `PeriodNaming`). */
|
|
254
|
+
export function gameLine(g: Game, naming?: PeriodNaming): string {
|
|
255
|
+
const tag = phaseTag(g);
|
|
256
|
+
const tagStr = tag === "PRE" ? c.yellow(tag) : tag === "POST" ? c.magenta(tag) : "";
|
|
257
|
+
return (
|
|
258
|
+
side(g.away, g.home, g) +
|
|
259
|
+
c.dim(" @ ") +
|
|
260
|
+
side(g.home, g.away, g) +
|
|
261
|
+
" " +
|
|
262
|
+
pad(gameStatus(g, naming), 12) +
|
|
263
|
+
(tagStr ? " " + tagStr : "")
|
|
264
|
+
).trimEnd();
|
|
265
|
+
}
|
|
266
|
+
|
|
180
267
|
export { visibleLen };
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Per-sport live event diffing over sport-neutral `Game` snapshots (NBA, NHL).
|
|
2
|
+
//
|
|
3
|
+
// The World Cup path keeps its own football-data `Match` differ (events.ts);
|
|
4
|
+
// this is the ESPN-backed sibling. Each sport gets a rule set, because what is
|
|
5
|
+
// alert-worthy differs: every NHL goal matters, every NBA basket would be noise
|
|
6
|
+
// (so NBA alerts on tip-off, lead changes, a close finish, and the final).
|
|
7
|
+
//
|
|
8
|
+
// Pure apart from the caller-owned `GameMemory`: the live tick holds the
|
|
9
|
+
// previous snapshot plus one memory map per sport, and feeds successive polls
|
|
10
|
+
// in here. Two NBA rules need more than two snapshots — a lead change bridged
|
|
11
|
+
// by a tie (home leads → tied → away leads) and "close-late fires once per
|
|
12
|
+
// game" — so they remember per-game facts in that map. Diffing a snapshot
|
|
13
|
+
// against itself yields nothing, with or without memory.
|
|
14
|
+
|
|
15
|
+
import type { Game } from "./game.ts";
|
|
16
|
+
|
|
17
|
+
export type GameEventKind =
|
|
18
|
+
| "puck-drop"
|
|
19
|
+
| "tip-off"
|
|
20
|
+
| "goal"
|
|
21
|
+
| "period-end"
|
|
22
|
+
| "overtime"
|
|
23
|
+
| "shootout"
|
|
24
|
+
| "lead-change"
|
|
25
|
+
| "close-late"
|
|
26
|
+
| "final";
|
|
27
|
+
|
|
28
|
+
export type Side = "home" | "away";
|
|
29
|
+
|
|
30
|
+
export interface GameEvent {
|
|
31
|
+
kind: GameEventKind;
|
|
32
|
+
gameId: string;
|
|
33
|
+
/** Short fixture label, away @ home, e.g. "UTAH @ DEN". */
|
|
34
|
+
fixture: string;
|
|
35
|
+
home: string;
|
|
36
|
+
away: string;
|
|
37
|
+
/** Score at the moment of the event. */
|
|
38
|
+
score: { home: number; away: number };
|
|
39
|
+
/** Period the event belongs to — for `period-end`, the period that ended. */
|
|
40
|
+
period: number;
|
|
41
|
+
/** Source status text of the newer snapshot, e.g. "End of 1st", "Final/SO". */
|
|
42
|
+
detail: string;
|
|
43
|
+
/** `goal`: which side scored. */
|
|
44
|
+
scoringSide?: Side;
|
|
45
|
+
/** `lead-change`: which side now leads. */
|
|
46
|
+
leader?: Side;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Per-game facts a rule needs beyond the two snapshots being diffed. */
|
|
50
|
+
export interface GameMemo {
|
|
51
|
+
/** NBA: the last side seen leading (ties don't clear it). */
|
|
52
|
+
leader?: Side;
|
|
53
|
+
/** NBA: close-late already fired (or was already true at baseline). */
|
|
54
|
+
closeLate?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Caller-owned, keyed by game id; one map per sport. Entries are tiny and
|
|
58
|
+
* are never pruned here — a game can drop out of one poll and come back. */
|
|
59
|
+
export type GameMemory = Map<string, GameMemo>;
|
|
60
|
+
|
|
61
|
+
/** What a rule emits; `diffGames` stamps on the shared game fields. */
|
|
62
|
+
type Emit = Pick<GameEvent, "kind" | "period"> & Partial<Pick<GameEvent, "scoringSide" | "leader">>;
|
|
63
|
+
|
|
64
|
+
/** One alert rule: the events for a single game moving `before` → `after`. */
|
|
65
|
+
type Rule = (before: Game, after: Game, memo: GameMemo) => Emit[];
|
|
66
|
+
|
|
67
|
+
// ── shared helpers ──────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** Numeric score, treating "" (not started) or junk as 0. */
|
|
70
|
+
const pts = (score: string): number => Number(score) || 0;
|
|
71
|
+
|
|
72
|
+
function scoreOf(g: Game): { home: number; away: number } {
|
|
73
|
+
return { home: pts(g.home.score), away: pts(g.away.score) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function leaderOf(g: Game): Side | undefined {
|
|
77
|
+
const s = scoreOf(g);
|
|
78
|
+
return s.home > s.away ? "home" : s.away > s.home ? "away" : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Seconds left on a game clock ("4:21", "0:00", NBA's sub-minute "45.3"),
|
|
82
|
+
* or undefined when there is no parseable clock. */
|
|
83
|
+
export function clockSeconds(clock: string): number | undefined {
|
|
84
|
+
const m = /^\s*(?:(\d+):)?(\d+(?:\.\d+)?)\s*$/.exec(clock);
|
|
85
|
+
if (!m) return undefined;
|
|
86
|
+
return Number(m[1] ?? 0) * 60 + Number(m[2]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Live with the clock run out — between periods (or at the final buzzer,
|
|
90
|
+
* before the source flips the game to "post"). */
|
|
91
|
+
const atPeriodEnd = (g: Game): boolean => g.state === "in" && clockSeconds(g.clock) === 0;
|
|
92
|
+
|
|
93
|
+
// Shared rules.
|
|
94
|
+
|
|
95
|
+
const start =
|
|
96
|
+
(kind: "puck-drop" | "tip-off"): Rule =>
|
|
97
|
+
(b, a) =>
|
|
98
|
+
b.state === "pre" && a.state === "in" ? [{ kind, period: a.period }] : [];
|
|
99
|
+
|
|
100
|
+
const final: Rule = (b, a) => (b.state === "in" && a.state === "post" ? [{ kind: "final", period: a.period }] : []);
|
|
101
|
+
|
|
102
|
+
// ── NHL ─────────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
const NHL_REGULATION_PERIODS = 3;
|
|
105
|
+
|
|
106
|
+
/** A shootout is "period 5" outside the playoffs; playoff OT never ends in one. */
|
|
107
|
+
const inShootout = (g: Game): boolean => g.seasonType !== "postseason" && g.period > NHL_REGULATION_PERIODS + 1;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One goal event per side whose score rose (a multi-goal burst between polls
|
|
111
|
+
* collapses into one event with the resulting score). Skipped around a
|
|
112
|
+
* shootout: ESPN credits the shootout winner's +1 only when the game goes
|
|
113
|
+
* final, and that isn't a goal.
|
|
114
|
+
*/
|
|
115
|
+
const nhlGoals: Rule = (b, a) => {
|
|
116
|
+
if (inShootout(b) || inShootout(a)) return [];
|
|
117
|
+
const was = scoreOf(b);
|
|
118
|
+
const now = scoreOf(a);
|
|
119
|
+
const out: Emit[] = [];
|
|
120
|
+
if (now.home > was.home) out.push({ kind: "goal", period: a.period, scoringSide: "home" });
|
|
121
|
+
if (now.away > was.away) out.push({ kind: "goal", period: a.period, scoringSide: "away" });
|
|
122
|
+
return out;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* End of a period: the clock hit 0:00 in a new snapshot, or the period number
|
|
127
|
+
* advanced without us ever seeing the 0:00 snapshot (a poll gap). The
|
|
128
|
+
* game-ending period is covered by `final`, and a shootout has no period end.
|
|
129
|
+
*/
|
|
130
|
+
const nhlPeriodEnd: Rule = (b, a) => {
|
|
131
|
+
if (b.state !== "in" || a.state !== "in") return [];
|
|
132
|
+
const out: Emit[] = [];
|
|
133
|
+
if (a.period > b.period && !atPeriodEnd(b) && !inShootout(b)) out.push({ kind: "period-end", period: b.period });
|
|
134
|
+
if (atPeriodEnd(a) && !inShootout(a) && !(atPeriodEnd(b) && b.period === a.period)) {
|
|
135
|
+
out.push({ kind: "period-end", period: a.period });
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Entering overtime (each playoff OT period) or the shootout. */
|
|
141
|
+
const nhlOvertime: Rule = (b, a) => {
|
|
142
|
+
if (b.state !== "in" || a.state !== "in" || a.period <= b.period || a.period <= NHL_REGULATION_PERIODS) return [];
|
|
143
|
+
return [{ kind: inShootout(a) ? "shootout" : "overtime", period: a.period }];
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// ── NBA ─────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
const NBA_FOURTH = 4;
|
|
149
|
+
const CLOSE_LATE_MARGIN = 5;
|
|
150
|
+
const CLOSE_LATE_SECONDS = 5 * 60;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The lead switched sides. Ties don't count as a change, but they don't reset
|
|
154
|
+
* the memory either, so home leads → tied → away leads (across polls) is one
|
|
155
|
+
* lead change. Only while live; the final covers a last-gasp swing.
|
|
156
|
+
*/
|
|
157
|
+
const nbaLeadChange: Rule = (b, a, memo) => {
|
|
158
|
+
const was = leaderOf(b);
|
|
159
|
+
const now = leaderOf(a);
|
|
160
|
+
memo.leader ??= was;
|
|
161
|
+
const changed = a.state === "in" && now !== undefined && now !== was && memo.leader !== undefined && now !== memo.leader;
|
|
162
|
+
if (now) memo.leader = now;
|
|
163
|
+
return changed ? [{ kind: "lead-change", period: a.period, leader: now }] : [];
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Live, in the 4th or any OT, ≤ 5:00 left, margin ≤ 5. */
|
|
167
|
+
function isCloseLate(g: Game): boolean {
|
|
168
|
+
if (g.state !== "in" || g.period < NBA_FOURTH) return false;
|
|
169
|
+
const left = clockSeconds(g.clock);
|
|
170
|
+
if (left === undefined || left > CLOSE_LATE_SECONDS) return false;
|
|
171
|
+
const s = scoreOf(g);
|
|
172
|
+
return Math.abs(s.home - s.away) <= CLOSE_LATE_MARGIN;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Close game late — once per game. If the earlier snapshot already qualified
|
|
177
|
+
* (we started watching mid-crunch), that's the baseline: remember it, no alert.
|
|
178
|
+
*/
|
|
179
|
+
const nbaCloseLate: Rule = (b, a, memo) => {
|
|
180
|
+
if (memo.closeLate) return [];
|
|
181
|
+
if (isCloseLate(b)) {
|
|
182
|
+
memo.closeLate = true;
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
if (!isCloseLate(a)) return [];
|
|
186
|
+
memo.closeLate = true;
|
|
187
|
+
return [{ kind: "close-late", period: a.period }];
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// ── rule sets + differ ──────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/** Alert rules per ESPN-backed sport, in emission order. */
|
|
193
|
+
export const GAME_RULES = {
|
|
194
|
+
nhl: [start("puck-drop"), nhlGoals, nhlPeriodEnd, nhlOvertime, final],
|
|
195
|
+
nba: [start("tip-off"), nbaLeadChange, nbaCloseLate, final],
|
|
196
|
+
} satisfies Record<string, Rule[]>;
|
|
197
|
+
|
|
198
|
+
export type GameSport = keyof typeof GAME_RULES;
|
|
199
|
+
|
|
200
|
+
/** True if `sport` has an alert rule set. */
|
|
201
|
+
export function isGameSport(sport: string): sport is GameSport {
|
|
202
|
+
return Object.hasOwn(GAME_RULES, sport);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Diff two `Game` snapshots into events using `sport`'s rule set.
|
|
207
|
+
*
|
|
208
|
+
* A game must appear in both snapshots (keyed by id) to diff a transition;
|
|
209
|
+
* brand-new entries are skipped until there is a prior state. Narrowing to
|
|
210
|
+
* favourite teams is the caller's job (filter `cur` first). Pass the same
|
|
211
|
+
* `memory` map on every tick for a sport so once-per-game rules hold across
|
|
212
|
+
* polls; omitted, each call starts fresh.
|
|
213
|
+
*/
|
|
214
|
+
export function diffGames(prev: Game[], cur: Game[], sport: GameSport, memory: GameMemory = new Map()): GameEvent[] {
|
|
215
|
+
const rules: Rule[] = GAME_RULES[sport];
|
|
216
|
+
const prevById = new Map(prev.map((g) => [g.id, g]));
|
|
217
|
+
const events: GameEvent[] = [];
|
|
218
|
+
|
|
219
|
+
for (const g of cur) {
|
|
220
|
+
const before = prevById.get(g.id);
|
|
221
|
+
if (!before) continue;
|
|
222
|
+
let memo = memory.get(g.id);
|
|
223
|
+
if (!memo) memory.set(g.id, (memo = {}));
|
|
224
|
+
|
|
225
|
+
const base = {
|
|
226
|
+
gameId: g.id,
|
|
227
|
+
fixture: `${g.away.abbreviation} @ ${g.home.abbreviation}`,
|
|
228
|
+
home: g.home.abbreviation,
|
|
229
|
+
away: g.away.abbreviation,
|
|
230
|
+
score: scoreOf(g),
|
|
231
|
+
detail: g.detail,
|
|
232
|
+
};
|
|
233
|
+
for (const rule of rules) {
|
|
234
|
+
for (const e of rule(before, g, memo)) events.push({ ...base, ...e });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return events;
|
|
239
|
+
}
|
package/src/game.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Sport-neutral game shape for the ESPN-backed leagues (NBA, NHL, …).
|
|
2
|
+
//
|
|
3
|
+
// `types.ts` is football-data.org's soccer shape (goals, groups, stages) and
|
|
4
|
+
// stays the World Cup path's model. NBA/NHL commands consume `Game` instead:
|
|
5
|
+
// periods + a clock, a season phase (preseason through postseason), and the
|
|
6
|
+
// per-game broadcast list that drives "can I watch it". Source-specific
|
|
7
|
+
// parsing lives with its source (ESPN → `toGame` in espn.ts); nothing here
|
|
8
|
+
// knows about any API.
|
|
9
|
+
|
|
10
|
+
/** "pre" (scheduled), "in" (live), "post" (finished). */
|
|
11
|
+
export type GameState = "pre" | "in" | "post";
|
|
12
|
+
|
|
13
|
+
export type SeasonPhase = "preseason" | "regular" | "postseason";
|
|
14
|
+
|
|
15
|
+
/** Who a broadcast is aimed at: everywhere, or one team's home market. */
|
|
16
|
+
export type BroadcastMarket = "national" | "home" | "away";
|
|
17
|
+
|
|
18
|
+
export interface Broadcast {
|
|
19
|
+
/** Network / service name as listed, e.g. "Utah 16", "ESPN+", "NBA TV". */
|
|
20
|
+
name: string;
|
|
21
|
+
market: BroadcastMarket;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GameCompetitor {
|
|
25
|
+
/** Source team id (ESPN: Jazz "26", Mammoth "129764"); "" when unknown. */
|
|
26
|
+
id: string;
|
|
27
|
+
/** Full name, e.g. "Utah Jazz". */
|
|
28
|
+
name: string;
|
|
29
|
+
abbreviation: string;
|
|
30
|
+
/** Score as displayed; "" before the game starts. */
|
|
31
|
+
score: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Game {
|
|
35
|
+
id: string;
|
|
36
|
+
/** ISO start time. */
|
|
37
|
+
date: string;
|
|
38
|
+
/** e.g. "Utah Jazz at Denver Nuggets". */
|
|
39
|
+
name: string;
|
|
40
|
+
state: GameState;
|
|
41
|
+
/** Short status text from the source, e.g. "Final/OT", "10/6 - 9:00 PM EDT". */
|
|
42
|
+
detail: string;
|
|
43
|
+
/** Current (live) or last (final) period; 0 before the game starts. */
|
|
44
|
+
period: number;
|
|
45
|
+
/** Game clock while live (e.g. "4:21"); "" when not in progress. */
|
|
46
|
+
clock: string;
|
|
47
|
+
seasonType: SeasonPhase;
|
|
48
|
+
home: GameCompetitor;
|
|
49
|
+
away: GameCompetitor;
|
|
50
|
+
broadcasts: Broadcast[];
|
|
51
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { c } from "./ansi.ts";
|
|
3
3
|
import { ApiError } from "./api.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { SPORTS as REGISTRY, bare } from "./commands/cross.ts";
|
|
5
|
+
import { subscriptions } from "./commands/subscriptions.ts";
|
|
6
|
+
import pkg from "../package.json" with { type: "json" };
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
// Single source of truth: package.json (a hardcoded copy drifted to a stale
|
|
9
|
+
// 0.1.0 while the package shipped 0.1.2). The JSON import is inlined by
|
|
10
|
+
// `bun build --compile`, and npm always ships package.json.
|
|
11
|
+
const VERSION: string = pkg.version;
|
|
7
12
|
|
|
8
|
-
// Sport namespaces. Add a new sport by writing src/sports/<sport>.ts
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
};
|
|
13
|
+
// Sport namespaces. Add a new sport by writing src/sports/<sport>.ts and
|
|
14
|
+
// registering it in SPORTS in src/commands/cross.ts (which also makes the bare
|
|
15
|
+
// cross-sport commands include it).
|
|
16
|
+
const SPORTS = Object.fromEntries(REGISTRY.map((s) => [s.key, s.run]));
|
|
13
17
|
|
|
14
18
|
function help() {
|
|
15
19
|
const b = c.bold;
|
|
@@ -17,18 +21,30 @@ function help() {
|
|
|
17
21
|
|
|
18
22
|
${b("USAGE")}
|
|
19
23
|
sportsing <sport> <command> [options]
|
|
24
|
+
sportsing <today|next|me> ${c.dim("your favorite teams, every sport")}
|
|
25
|
+
sportsing live --notify [--quiet] ${c.dim("alerts for your teams, every sport")}
|
|
20
26
|
|
|
21
27
|
${b("SPORTS")}
|
|
22
28
|
${c.green("fifa")} FIFA World Cup 2026 ${c.dim("— sportsing fifa help")}
|
|
29
|
+
${c.green("nba")} NBA, preseason through playoffs ${c.dim("— sportsing nba help")}
|
|
30
|
+
${c.green("nhl")} NHL, preseason through playoffs ${c.dim("— sportsing nhl help")}
|
|
23
31
|
|
|
24
|
-
${b("
|
|
25
|
-
|
|
26
|
-
${c.
|
|
32
|
+
${b("YOUR TEAMS")} ${c.dim("(favorites across every sport, each row tagged with its sport)")}
|
|
33
|
+
${c.green("today")} Your teams' games today ${c.dim("(--tomorrow, --yesterday, --offset N)")}
|
|
34
|
+
${c.green("next")} Each team's next game + countdown
|
|
35
|
+
${c.green("me")} Dashboard: last result + next game per team
|
|
36
|
+
${c.green("live --notify")} OS alerts for every sport with a favorite ${c.dim("(--quiet: no log; run with &)")}
|
|
37
|
+
${c.dim("Add favorites per sport: sportsing nba fav add UTAH")}
|
|
38
|
+
|
|
39
|
+
${b("SETTINGS")}
|
|
40
|
+
${c.green("subscriptions")} What you can watch with + home market ${c.dim("— alias: subs")}
|
|
27
41
|
|
|
28
42
|
${b("EXAMPLES")}
|
|
29
43
|
sportsing fifa today
|
|
30
44
|
sportsing fifa next --team USA
|
|
31
|
-
sportsing
|
|
45
|
+
sportsing nba schedule --team UTAH
|
|
46
|
+
sportsing nhl next --team UTAH
|
|
47
|
+
sportsing today ${c.dim("(your teams, every sport)")}
|
|
32
48
|
`);
|
|
33
49
|
}
|
|
34
50
|
|
|
@@ -48,11 +64,14 @@ async function dispatch(): Promise<void> {
|
|
|
48
64
|
return;
|
|
49
65
|
}
|
|
50
66
|
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
// Cross-sport settings (not tied to any one sport's namespace).
|
|
68
|
+
if (first === "subscriptions" || first === "subs") {
|
|
69
|
+
await subscriptions(rest);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Bare `today`/`next`/`me`/`live` run across every sport; anything else gets a hint.
|
|
74
|
+
await bare(first, rest);
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
async function main() {
|