nhl-tui 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.
Files changed (51) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/.github/workflows/release.yml +37 -0
  3. package/CONTRIBUTING.md +38 -0
  4. package/LICENSE +21 -0
  5. package/README.md +222 -0
  6. package/dist/api/nhl.js +37 -0
  7. package/dist/app/dates.js +36 -0
  8. package/dist/app/input.js +97 -0
  9. package/dist/app/polling.js +241 -0
  10. package/dist/app/store.js +39 -0
  11. package/dist/app/timers.js +15 -0
  12. package/dist/domain/diff.js +57 -0
  13. package/dist/domain/events.js +22 -0
  14. package/dist/domain/normalize.js +677 -0
  15. package/dist/domain/reducer.js +313 -0
  16. package/dist/domain/types.js +1 -0
  17. package/dist/index.js +14 -0
  18. package/dist/ui/App.js +77 -0
  19. package/dist/ui/components/Banner.js +8 -0
  20. package/dist/ui/components/Footer.js +10 -0
  21. package/dist/ui/components/GameList.js +20 -0
  22. package/dist/ui/components/GameRow.js +25 -0
  23. package/dist/ui/components/StatusLine.js +49 -0
  24. package/dist/ui/screens/GameDetailScreen.js +37 -0
  25. package/dist/ui/screens/LeadersScreen.js +36 -0
  26. package/dist/ui/screens/ScoreboardScreen.js +6 -0
  27. package/dist/ui/screens/StandingsScreen.js +27 -0
  28. package/package.json +28 -0
  29. package/src/api/nhl.ts +53 -0
  30. package/src/app/dates.ts +54 -0
  31. package/src/app/input.ts +130 -0
  32. package/src/app/polling.ts +333 -0
  33. package/src/app/store.ts +55 -0
  34. package/src/app/timers.ts +23 -0
  35. package/src/domain/diff.ts +107 -0
  36. package/src/domain/events.ts +31 -0
  37. package/src/domain/normalize.ts +966 -0
  38. package/src/domain/reducer.ts +458 -0
  39. package/src/domain/types.ts +270 -0
  40. package/src/index.tsx +15 -0
  41. package/src/ui/App.tsx +151 -0
  42. package/src/ui/components/Banner.tsx +23 -0
  43. package/src/ui/components/Footer.tsx +17 -0
  44. package/src/ui/components/GameList.tsx +45 -0
  45. package/src/ui/components/GameRow.tsx +60 -0
  46. package/src/ui/components/StatusLine.tsx +83 -0
  47. package/src/ui/screens/GameDetailScreen.tsx +199 -0
  48. package/src/ui/screens/LeadersScreen.tsx +92 -0
  49. package/src/ui/screens/ScoreboardScreen.tsx +36 -0
  50. package/src/ui/screens/StandingsScreen.tsx +95 -0
  51. package/tsconfig.json +18 -0
@@ -0,0 +1,241 @@
1
+ import { useEffect, useEffectEvent } from "react";
2
+ import { compareScoreboardDateToToday } from "./dates.js";
3
+ import { diffGame, diffGames } from "../domain/diff.js";
4
+ import { normalizeDetail, normalizeLeaders, normalizeScoreboard, normalizeStandings, } from "../domain/normalize.js";
5
+ function getSoonestUpcomingDelta(games, now) {
6
+ const deltas = games
7
+ .filter((game) => game.phase === "upcoming")
8
+ .map((game) => game.startTimeEpochMs - now)
9
+ .sort((left, right) => left - right);
10
+ return deltas[0];
11
+ }
12
+ export function getScoreboardPollDelayMs(games, scoreboardDate, now = Date.now()) {
13
+ const dateRelation = compareScoreboardDateToToday(scoreboardDate, new Date(now));
14
+ if (dateRelation < 0) {
15
+ return 300000;
16
+ }
17
+ if (dateRelation > 0) {
18
+ return 120000;
19
+ }
20
+ if (!games.length) {
21
+ return 15000;
22
+ }
23
+ if (games.some((game) => game.phase === "live")) {
24
+ return 5000;
25
+ }
26
+ const soonestUpcoming = getSoonestUpcomingDelta(games, now);
27
+ if (soonestUpcoming !== undefined) {
28
+ if (soonestUpcoming <= 5 * 60_000 && soonestUpcoming >= -30 * 60_000) {
29
+ return 3000;
30
+ }
31
+ if (soonestUpcoming <= 30 * 60_000 && soonestUpcoming > 5 * 60_000) {
32
+ return 10000;
33
+ }
34
+ }
35
+ if (games.every((game) => game.phase === "final")) {
36
+ return 180000;
37
+ }
38
+ return 60000;
39
+ }
40
+ export function getDetailPollDelayMs(tab, game, now = Date.now()) {
41
+ if (!game) {
42
+ return 4000;
43
+ }
44
+ if (game.phase === "live") {
45
+ if (tab === "pbp") {
46
+ return 2500;
47
+ }
48
+ if (tab === "box") {
49
+ return 7000;
50
+ }
51
+ return 5000;
52
+ }
53
+ if (game.phase === "upcoming") {
54
+ return Math.min(getScoreboardPollDelayMs([game], game.startTimeUtc.slice(0, 10), now), tab === "pbp" ? 5000 : 8000);
55
+ }
56
+ return undefined;
57
+ }
58
+ function formatError(error) {
59
+ if (error instanceof Error) {
60
+ return error.message;
61
+ }
62
+ return String(error);
63
+ }
64
+ export function useAppPolling({ client, dispatch, stateRef, scoreboardDate, screenType, screenGameId, screenTab, manualRefreshToken, }) {
65
+ const fetchScoreboard = useEffectEvent(async () => {
66
+ const currentScoreboardDate = stateRef.current.scoreboardDate;
67
+ try {
68
+ const receivedAt = Date.now();
69
+ const payload = await client.fetchScoreboard(currentScoreboardDate);
70
+ const games = normalizeScoreboard(payload);
71
+ const previousGames = stateRef.current.games;
72
+ const events = diffGames(previousGames, games, receivedAt);
73
+ dispatch({
74
+ type: "scoreboard_loaded",
75
+ scoreboardDate: currentScoreboardDate,
76
+ games,
77
+ receivedAt,
78
+ events,
79
+ });
80
+ }
81
+ catch (error) {
82
+ dispatch({
83
+ type: "poll_failed",
84
+ resource: "scoreboard",
85
+ error: formatError(error),
86
+ scoreboardDate: currentScoreboardDate,
87
+ });
88
+ }
89
+ });
90
+ const fetchStandings = useEffectEvent(async () => {
91
+ const currentScoreboardDate = stateRef.current.scoreboardDate;
92
+ if (stateRef.current.standingsByDate[currentScoreboardDate]) {
93
+ return;
94
+ }
95
+ try {
96
+ const receivedAt = Date.now();
97
+ const payload = await client.fetchStandings(currentScoreboardDate);
98
+ const standings = normalizeStandings(payload, currentScoreboardDate, receivedAt);
99
+ dispatch({
100
+ type: "standings_loaded",
101
+ scoreboardDate: currentScoreboardDate,
102
+ standings,
103
+ receivedAt,
104
+ });
105
+ }
106
+ catch (error) {
107
+ dispatch({
108
+ type: "poll_failed",
109
+ resource: "standings",
110
+ error: formatError(error),
111
+ scoreboardDate: currentScoreboardDate,
112
+ });
113
+ }
114
+ });
115
+ const fetchLeaders = useEffectEvent(async () => {
116
+ if (stateRef.current.leaders) {
117
+ return;
118
+ }
119
+ try {
120
+ const receivedAt = Date.now();
121
+ const [skaterPayload, goaliePayload] = await Promise.all([
122
+ client.fetchSkaterLeaders(10),
123
+ client.fetchGoalieLeaders(10),
124
+ ]);
125
+ const leaders = normalizeLeaders(skaterPayload, goaliePayload, receivedAt);
126
+ dispatch({
127
+ type: "leaders_loaded",
128
+ leaders,
129
+ receivedAt,
130
+ });
131
+ }
132
+ catch (error) {
133
+ if (stateRef.current.screen.type === "leaders") {
134
+ dispatch({
135
+ type: "poll_failed",
136
+ resource: "leaders",
137
+ error: formatError(error),
138
+ });
139
+ }
140
+ }
141
+ });
142
+ const fetchDetail = useEffectEvent(async () => {
143
+ const screen = stateRef.current.screen;
144
+ if (screen.type !== "game") {
145
+ return;
146
+ }
147
+ try {
148
+ const receivedAt = Date.now();
149
+ let payload;
150
+ if (screen.tab === "pbp") {
151
+ payload = await client.fetchPlayByPlay(screen.gameId);
152
+ }
153
+ else if (screen.tab === "box") {
154
+ payload = await client.fetchBoxScore(screen.gameId);
155
+ }
156
+ else {
157
+ const landing = await client.fetchSummary(screen.gameId);
158
+ let box;
159
+ try {
160
+ box = await client.fetchBoxScore(screen.gameId);
161
+ }
162
+ catch {
163
+ box = undefined;
164
+ }
165
+ payload = { landing, box };
166
+ }
167
+ const detail = normalizeDetail(screen.tab, payload, receivedAt);
168
+ const previousGame = stateRef.current.gameDetails[screen.gameId]?.game ??
169
+ stateRef.current.games.find((game) => game.id === screen.gameId);
170
+ const events = diffGame(previousGame, detail.game, receivedAt);
171
+ dispatch({
172
+ type: "game_detail_loaded",
173
+ gameId: screen.gameId,
174
+ detail,
175
+ receivedAt,
176
+ events,
177
+ });
178
+ }
179
+ catch (error) {
180
+ dispatch({
181
+ type: "poll_failed",
182
+ resource: "game",
183
+ error: formatError(error),
184
+ gameId: screen.gameId,
185
+ });
186
+ }
187
+ });
188
+ const fetchCurrentView = useEffectEvent(async () => {
189
+ if (stateRef.current.screen.type === "scoreboard") {
190
+ await fetchScoreboard();
191
+ return;
192
+ }
193
+ if (stateRef.current.screen.type === "standings") {
194
+ await fetchStandings();
195
+ return;
196
+ }
197
+ if (stateRef.current.screen.type === "leaders") {
198
+ await fetchLeaders();
199
+ return;
200
+ }
201
+ await fetchDetail();
202
+ });
203
+ useEffect(() => {
204
+ let disposed = false;
205
+ let timer;
206
+ const loop = async () => {
207
+ await fetchCurrentView();
208
+ if (disposed) {
209
+ return;
210
+ }
211
+ const state = stateRef.current;
212
+ let delay = getScoreboardPollDelayMs(state.games, state.scoreboardDate);
213
+ const screen = state.screen;
214
+ if (screen.type === "standings" || screen.type === "leaders") {
215
+ delay = undefined;
216
+ }
217
+ if (screen.type === "game") {
218
+ const currentGame = state.gameDetails[screen.gameId]?.game ??
219
+ state.games.find((game) => game.id === screen.gameId);
220
+ delay = getDetailPollDelayMs(screen.tab, currentGame);
221
+ }
222
+ if (delay === undefined) {
223
+ return;
224
+ }
225
+ timer = setTimeout(loop, delay);
226
+ };
227
+ timer = setTimeout(loop, 0);
228
+ return () => {
229
+ disposed = true;
230
+ if (timer) {
231
+ clearTimeout(timer);
232
+ }
233
+ };
234
+ }, [fetchCurrentView, scoreboardDate, screenType, screenGameId, screenTab]);
235
+ useEffect(() => {
236
+ if (manualRefreshToken === 0) {
237
+ return;
238
+ }
239
+ void fetchCurrentView();
240
+ }, [fetchCurrentView, manualRefreshToken]);
241
+ }
@@ -0,0 +1,39 @@
1
+ import { useReducer } from "react";
2
+ import { todayScoreboardDate } from "./dates.js";
3
+ import { appReducer } from "../domain/reducer.js";
4
+ export const initialState = {
5
+ screen: {
6
+ type: "scoreboard",
7
+ },
8
+ scoreboardDate: todayScoreboardDate(),
9
+ games: [],
10
+ standingsByDate: {},
11
+ standingsErrorByDate: {},
12
+ leaders: undefined,
13
+ leadersErrorMessage: undefined,
14
+ gameDetails: {},
15
+ gameDetailErrors: {},
16
+ scoreboardLoadedDate: undefined,
17
+ scoreboardUpdatedAt: undefined,
18
+ scoreboardErrorMessage: undefined,
19
+ selectedGameId: undefined,
20
+ bannerQueue: [],
21
+ activeBanner: undefined,
22
+ recentEvents: [],
23
+ manualRefreshToken: 0,
24
+ };
25
+ export function useAppStore() {
26
+ return useReducer(appReducer, initialState);
27
+ }
28
+ export function selectVisibleGames(state) {
29
+ return state.games;
30
+ }
31
+ export function selectSelectedGame(state) {
32
+ return state.games.find((game) => game.id === state.selectedGameId);
33
+ }
34
+ export function selectCurrentStandings(state) {
35
+ return state.standingsByDate[state.scoreboardDate];
36
+ }
37
+ export function selectCurrentLeaders(state) {
38
+ return state.leaders;
39
+ }
@@ -0,0 +1,15 @@
1
+ import { useEffect, useEffectEvent } from "react";
2
+ export function useBannerTimer(activeBanner, onExpire) {
3
+ const handleExpire = useEffectEvent(onExpire);
4
+ useEffect(() => {
5
+ if (!activeBanner) {
6
+ return undefined;
7
+ }
8
+ const timer = setTimeout(() => {
9
+ handleExpire();
10
+ }, 3200);
11
+ return () => {
12
+ clearTimeout(timer);
13
+ };
14
+ }, [activeBanner?.id, handleExpire]);
15
+ }
@@ -0,0 +1,57 @@
1
+ function buildGoalEvents(gameId, team, delta, timestamp) {
2
+ if (delta <= 0) {
3
+ return [];
4
+ }
5
+ return Array.from({ length: delta }, () => ({
6
+ type: "goal_scored",
7
+ gameId,
8
+ team,
9
+ timestamp,
10
+ }));
11
+ }
12
+ function diffSingleGame(previousGame, nextGame, timestamp) {
13
+ if (!previousGame) {
14
+ return [];
15
+ }
16
+ const events = [];
17
+ if (previousGame.phase !== "live" && nextGame.phase === "live") {
18
+ events.push({
19
+ type: "game_started",
20
+ gameId: nextGame.id,
21
+ timestamp,
22
+ });
23
+ }
24
+ if (previousGame.phase === "live" && nextGame.phase === "final") {
25
+ events.push({
26
+ type: "game_ended",
27
+ gameId: nextGame.id,
28
+ timestamp,
29
+ });
30
+ }
31
+ if (previousGame.phase === "live" &&
32
+ nextGame.phase === "live" &&
33
+ previousGame.periodLabel &&
34
+ nextGame.periodLabel &&
35
+ previousGame.periodLabel !== nextGame.periodLabel) {
36
+ events.push({
37
+ type: "period_changed",
38
+ gameId: nextGame.id,
39
+ period: nextGame.periodLabel,
40
+ timestamp,
41
+ });
42
+ }
43
+ events.push(...buildGoalEvents(nextGame.id, nextGame.away.abbrev, nextGame.away.score - previousGame.away.score, timestamp));
44
+ events.push(...buildGoalEvents(nextGame.id, nextGame.home.abbrev, nextGame.home.score - previousGame.home.score, timestamp));
45
+ return events;
46
+ }
47
+ export function diffGames(previousGames, nextGames, timestamp) {
48
+ const previousById = new Map(previousGames.map((game) => [game.id, game]));
49
+ const events = [];
50
+ for (const nextGame of nextGames) {
51
+ events.push(...diffSingleGame(previousById.get(nextGame.id), nextGame, timestamp));
52
+ }
53
+ return events;
54
+ }
55
+ export function diffGame(previousGame, nextGame, timestamp) {
56
+ return diffSingleGame(previousGame, nextGame, timestamp);
57
+ }
@@ -0,0 +1,22 @@
1
+ export function createGoalBanners(events, games, createdAt) {
2
+ const gamesById = new Map(games.map((game) => [game.id, game]));
3
+ return events.flatMap((event, index) => {
4
+ if (event.type !== "goal_scored") {
5
+ return [];
6
+ }
7
+ const game = gamesById.get(event.gameId);
8
+ return [
9
+ {
10
+ id: `${event.gameId}:${event.timestamp}:${index}`,
11
+ type: "goal",
12
+ gameId: event.gameId,
13
+ team: event.team,
14
+ title: `${event.team} GOAL`,
15
+ subtitle: game
16
+ ? `${game.away.abbrev} ${game.away.score} - ${game.home.score} ${game.home.abbrev}`
17
+ : undefined,
18
+ createdAt,
19
+ },
20
+ ];
21
+ });
22
+ }