nhl-tui 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.
@@ -1,458 +0,0 @@
1
- import { shiftScoreboardDate } from "../app/dates.js";
2
- import { createGoalBanners } from "./events.js";
3
- import type {
4
- AppEvent,
5
- AppScreen,
6
- AppState,
7
- Banner,
8
- DetailTab,
9
- NormalizedGame,
10
- NormalizedGameDetail,
11
- NormalizedLeaders,
12
- NormalizedStandings,
13
- } from "./types.js";
14
-
15
- export type Action =
16
- | {
17
- type: "scoreboard_loaded";
18
- scoreboardDate: string;
19
- games: NormalizedGame[];
20
- receivedAt: number;
21
- events: AppEvent[];
22
- }
23
- | {
24
- type: "standings_loaded";
25
- scoreboardDate: string;
26
- standings: NormalizedStandings;
27
- receivedAt: number;
28
- }
29
- | {
30
- type: "leaders_loaded";
31
- leaders: NormalizedLeaders;
32
- receivedAt: number;
33
- }
34
- | {
35
- type: "game_detail_loaded";
36
- gameId: number;
37
- detail: NormalizedGameDetail;
38
- receivedAt: number;
39
- events: AppEvent[];
40
- }
41
- | {
42
- type: "poll_failed";
43
- resource: "scoreboard" | "standings" | "leaders" | "game";
44
- error: string;
45
- scoreboardDate?: string;
46
- gameId?: number;
47
- }
48
- | { type: "change_scoreboard_date"; delta: -1 | 1 }
49
- | { type: "move_selection"; delta: -1 | 1 }
50
- | { type: "jump_selection"; target: "top" | "bottom" }
51
- | { type: "open_selected_game" }
52
- | { type: "open_standings" }
53
- | { type: "open_leaders" }
54
- | { type: "go_back" }
55
- | { type: "set_tab"; tab: DetailTab }
56
- | { type: "manual_refresh_requested" }
57
- | { type: "dismiss_banner" };
58
-
59
- function isScoreboardScreen(
60
- screen: AppScreen,
61
- ): screen is Extract<AppScreen, { type: "scoreboard" }> {
62
- return screen.type === "scoreboard";
63
- }
64
-
65
- function resolveSelection(
66
- games: NormalizedGame[],
67
- preferredId: number | undefined,
68
- ): number | undefined {
69
- if (!games.length) {
70
- return undefined;
71
- }
72
-
73
- if (preferredId && games.some((game) => game.id === preferredId)) {
74
- return preferredId;
75
- }
76
-
77
- return games[0]?.id;
78
- }
79
-
80
- function enqueueBanners(
81
- activeBanner: Banner | undefined,
82
- bannerQueue: Banner[],
83
- incoming: Banner[],
84
- ): Pick<AppState, "activeBanner" | "bannerQueue"> {
85
- let nextActiveBanner = activeBanner;
86
- const nextQueue = [...bannerQueue];
87
-
88
- for (const banner of incoming) {
89
- if (!nextActiveBanner) {
90
- nextActiveBanner = banner;
91
- } else {
92
- nextQueue.push(banner);
93
- }
94
- }
95
-
96
- return {
97
- activeBanner: nextActiveBanner,
98
- bannerQueue: nextQueue,
99
- };
100
- }
101
-
102
- function mergeDetail(
103
- previousDetail: NormalizedGameDetail | undefined,
104
- nextDetail: NormalizedGameDetail,
105
- ): NormalizedGameDetail {
106
- return {
107
- game: nextDetail.game,
108
- summary: nextDetail.summary ?? previousDetail?.summary,
109
- pbp: nextDetail.pbp ?? previousDetail?.pbp,
110
- box: nextDetail.box ?? previousDetail?.box,
111
- lastUpdatedAt: nextDetail.lastUpdatedAt,
112
- };
113
- }
114
-
115
- function updateGamesWithDetail(
116
- games: NormalizedGame[],
117
- detailGame: NormalizedGame,
118
- ): NormalizedGame[] {
119
- const existing = games.some((game) => game.id === detailGame.id);
120
- const nextGames = existing
121
- ? games.map((game) => (game.id === detailGame.id ? detailGame : game))
122
- : [...games, detailGame];
123
-
124
- return nextGames.slice().sort((left, right) => {
125
- const sectionOrder = {
126
- LIVE: 0,
127
- UPCOMING: 1,
128
- FINAL: 2,
129
- };
130
-
131
- return (
132
- sectionOrder[left.section] - sectionOrder[right.section] ||
133
- left.startTimeEpochMs - right.startTimeEpochMs ||
134
- left.id - right.id
135
- );
136
- });
137
- }
138
-
139
- function withEvents(
140
- state: AppState,
141
- games: NormalizedGame[],
142
- events: AppEvent[],
143
- receivedAt: number,
144
- ): Pick<AppState, "activeBanner" | "bannerQueue" | "recentEvents"> {
145
- const banners = createGoalBanners(events, games, receivedAt);
146
- const bannerState = enqueueBanners(state.activeBanner, state.bannerQueue, banners);
147
-
148
- return {
149
- ...bannerState,
150
- recentEvents: [...state.recentEvents, ...events].slice(-40),
151
- };
152
- }
153
-
154
- function moveSelection(state: AppState, delta: -1 | 1): AppState {
155
- if (!isScoreboardScreen(state.screen) || !state.games.length) {
156
- return state;
157
- }
158
-
159
- const currentIndex = state.games.findIndex(
160
- (game) => game.id === state.selectedGameId,
161
- );
162
- const nextIndex =
163
- currentIndex === -1
164
- ? delta > 0
165
- ? 0
166
- : state.games.length - 1
167
- : Math.max(0, Math.min(state.games.length - 1, currentIndex + delta));
168
-
169
- return {
170
- ...state,
171
- selectedGameId: state.games[nextIndex]?.id,
172
- };
173
- }
174
-
175
- function jumpSelection(state: AppState, target: "top" | "bottom"): AppState {
176
- if (!isScoreboardScreen(state.screen) || !state.games.length) {
177
- return state;
178
- }
179
-
180
- return {
181
- ...state,
182
- selectedGameId:
183
- target === "top"
184
- ? state.games[0]?.id
185
- : state.games[state.games.length - 1]?.id,
186
- };
187
- }
188
-
189
- export function appReducer(state: AppState, action: Action): AppState {
190
- switch (action.type) {
191
- case "scoreboard_loaded": {
192
- if (action.scoreboardDate !== state.scoreboardDate) {
193
- return state;
194
- }
195
-
196
- const nextSelectedGameId = isScoreboardScreen(state.screen)
197
- ? resolveSelection(action.games, state.selectedGameId)
198
- : state.selectedGameId;
199
-
200
- return {
201
- ...state,
202
- games: action.games,
203
- scoreboardLoadedDate: action.scoreboardDate,
204
- scoreboardUpdatedAt: action.receivedAt,
205
- scoreboardErrorMessage: undefined,
206
- selectedGameId: nextSelectedGameId,
207
- ...withEvents(state, action.games, action.events, action.receivedAt),
208
- };
209
- }
210
-
211
- case "standings_loaded": {
212
- if (action.scoreboardDate !== state.scoreboardDate) {
213
- return state;
214
- }
215
-
216
- const nextStandingsByDate = {
217
- ...state.standingsByDate,
218
- [action.scoreboardDate]: action.standings,
219
- };
220
-
221
- const cacheKeys = Object.keys(nextStandingsByDate);
222
- if (cacheKeys.length > 5) {
223
- const oldest = cacheKeys.reduce((a, b) =>
224
- nextStandingsByDate[a]!.lastUpdatedAt < nextStandingsByDate[b]!.lastUpdatedAt ? a : b,
225
- );
226
- delete nextStandingsByDate[oldest];
227
- }
228
-
229
- return {
230
- ...state,
231
- standingsByDate: nextStandingsByDate,
232
- standingsErrorByDate: {
233
- ...state.standingsErrorByDate,
234
- [action.scoreboardDate]: undefined,
235
- },
236
- };
237
- }
238
-
239
- case "leaders_loaded": {
240
- return {
241
- ...state,
242
- leaders: action.leaders,
243
- leadersErrorMessage: undefined,
244
- };
245
- }
246
-
247
- case "game_detail_loaded": {
248
- const mergedDetail = mergeDetail(
249
- state.gameDetails[action.gameId],
250
- action.detail,
251
- );
252
- const nextGames = updateGamesWithDetail(state.games, action.detail.game);
253
-
254
- return {
255
- ...state,
256
- games: nextGames,
257
- gameDetails: {
258
- ...state.gameDetails,
259
- [action.gameId]: mergedDetail,
260
- },
261
- gameDetailErrors: {
262
- ...state.gameDetailErrors,
263
- [action.gameId]: undefined,
264
- },
265
- ...withEvents(state, nextGames, action.events, action.receivedAt),
266
- };
267
- }
268
-
269
- case "poll_failed":
270
- if (action.scoreboardDate && action.scoreboardDate !== state.scoreboardDate) {
271
- return state;
272
- }
273
-
274
- if (
275
- action.gameId &&
276
- (state.screen.type !== "game" || state.screen.gameId !== action.gameId)
277
- ) {
278
- return state;
279
- }
280
-
281
- if (action.resource === "scoreboard") {
282
- return {
283
- ...state,
284
- scoreboardErrorMessage: action.error,
285
- };
286
- }
287
-
288
- if (action.resource === "standings") {
289
- return {
290
- ...state,
291
- standingsErrorByDate: {
292
- ...state.standingsErrorByDate,
293
- [state.scoreboardDate]: action.error,
294
- },
295
- };
296
- }
297
-
298
- if (action.resource === "leaders") {
299
- return {
300
- ...state,
301
- leadersErrorMessage: action.error,
302
- };
303
- }
304
-
305
- if (!action.gameId) {
306
- return state;
307
- }
308
-
309
- return {
310
- ...state,
311
- gameDetailErrors: {
312
- ...state.gameDetailErrors,
313
- [action.gameId]: action.error,
314
- },
315
- };
316
-
317
- case "change_scoreboard_date":
318
- if (!isScoreboardScreen(state.screen)) {
319
- return state;
320
- }
321
-
322
- return {
323
- ...state,
324
- scoreboardDate: shiftScoreboardDate(state.scoreboardDate, action.delta),
325
- games: [],
326
- scoreboardLoadedDate: undefined,
327
- scoreboardUpdatedAt: undefined,
328
- scoreboardErrorMessage: undefined,
329
- selectedGameId: undefined,
330
- activeBanner: undefined,
331
- bannerQueue: [],
332
- };
333
-
334
- case "move_selection":
335
- return moveSelection(state, action.delta);
336
-
337
- case "jump_selection":
338
- return jumpSelection(state, action.target);
339
-
340
- case "open_selected_game":
341
- if (
342
- !isScoreboardScreen(state.screen) ||
343
- !state.selectedGameId ||
344
- !state.games.some((game) => game.id === state.selectedGameId)
345
- ) {
346
- return state;
347
- }
348
-
349
- return {
350
- ...state,
351
- screen: {
352
- type: "game",
353
- gameId: state.selectedGameId,
354
- tab: "summary",
355
- },
356
- };
357
-
358
- case "open_standings":
359
- if (!isScoreboardScreen(state.screen)) {
360
- return state;
361
- }
362
-
363
- return {
364
- ...state,
365
- screen: {
366
- type: "standings",
367
- },
368
- standingsErrorByDate: {
369
- ...state.standingsErrorByDate,
370
- [state.scoreboardDate]: undefined,
371
- },
372
- };
373
-
374
- case "open_leaders":
375
- if (!isScoreboardScreen(state.screen)) {
376
- return state;
377
- }
378
-
379
- return {
380
- ...state,
381
- screen: {
382
- type: "leaders",
383
- },
384
- leadersErrorMessage: undefined,
385
- };
386
-
387
- case "go_back":
388
- if (state.screen.type === "game") {
389
- return {
390
- ...state,
391
- screen: {
392
- type: "scoreboard",
393
- },
394
- selectedGameId: resolveSelection(state.games, state.screen.gameId),
395
- };
396
- }
397
-
398
- if (state.screen.type === "standings" || state.screen.type === "leaders") {
399
- return {
400
- ...state,
401
- screen: {
402
- type: "scoreboard",
403
- },
404
- };
405
- }
406
-
407
- return state;
408
-
409
- case "set_tab":
410
- if (state.screen.type !== "game") {
411
- return state;
412
- }
413
-
414
- return {
415
- ...state,
416
- screen: {
417
- ...state.screen,
418
- tab: action.tab,
419
- },
420
- };
421
-
422
- case "manual_refresh_requested": {
423
- const nextState: AppState = {
424
- ...state,
425
- manualRefreshToken: state.manualRefreshToken + 1,
426
- };
427
-
428
- if (state.screen.type === "scoreboard") {
429
- return {
430
- ...nextState,
431
- scoreboardErrorMessage: undefined,
432
- };
433
- }
434
-
435
- if (state.screen.type === "game") {
436
- return {
437
- ...nextState,
438
- gameDetailErrors: {
439
- ...state.gameDetailErrors,
440
- [state.screen.gameId]: undefined,
441
- },
442
- };
443
- }
444
-
445
- return nextState;
446
- }
447
-
448
- case "dismiss_banner":
449
- return {
450
- ...state,
451
- activeBanner: state.bannerQueue[0],
452
- bannerQueue: state.bannerQueue.slice(1),
453
- };
454
-
455
- default:
456
- return state;
457
- }
458
- }
@@ -1,270 +0,0 @@
1
- export type DetailTab = "summary" | "pbp" | "box";
2
-
3
- export type GamePhase = "live" | "upcoming" | "final";
4
-
5
- export type AppScreen =
6
- | {
7
- type: "scoreboard";
8
- }
9
- | {
10
- type: "standings";
11
- }
12
- | {
13
- type: "leaders";
14
- }
15
- | {
16
- type: "game";
17
- gameId: number;
18
- tab: DetailTab;
19
- };
20
-
21
- export type Banner = {
22
- id: string;
23
- type: "goal";
24
- gameId: number;
25
- team: string;
26
- title: string;
27
- subtitle?: string;
28
- createdAt: number;
29
- };
30
-
31
- export type AppEvent =
32
- | { type: "goal_scored"; gameId: number; team: string; timestamp: number }
33
- | { type: "game_started"; gameId: number; timestamp: number }
34
- | { type: "period_changed"; gameId: number; period: string; timestamp: number }
35
- | { type: "game_ended"; gameId: number; timestamp: number };
36
-
37
- export type NormalizedTeam = {
38
- id: number;
39
- abbrev: string;
40
- location: string;
41
- shortName: string;
42
- score: number;
43
- shotsOnGoal?: number;
44
- record?: string;
45
- };
46
-
47
- export type GameClock = {
48
- periodNumber: number;
49
- periodType: string;
50
- periodLabel: string;
51
- timeRemaining: string;
52
- running: boolean;
53
- inIntermission: boolean;
54
- };
55
-
56
- export type NormalizedGame = {
57
- id: number;
58
- season: number;
59
- state: string;
60
- phase: GamePhase;
61
- section: "LIVE" | "UPCOMING" | "FINAL";
62
- startTimeUtc: string;
63
- startTimeEpochMs: number;
64
- startTimeLabel: string;
65
- statusLabel: string;
66
- contextLabel: string;
67
- periodLabel?: string;
68
- venue: string;
69
- away: NormalizedTeam;
70
- home: NormalizedTeam;
71
- clock?: GameClock;
72
- };
73
-
74
- export type SummaryGoal = {
75
- eventId: number;
76
- team: string;
77
- scorer: string;
78
- strength: string;
79
- timeInPeriod: string;
80
- periodLabel: string;
81
- scoreAfter: string;
82
- assists: string[];
83
- };
84
-
85
- export type SummaryPenalty = {
86
- team: string;
87
- player: string;
88
- drawnBy?: string;
89
- kind: string;
90
- duration: number;
91
- timeInPeriod: string;
92
- periodLabel: string;
93
- };
94
-
95
- export type SummaryScoringPeriod = {
96
- periodLabel: string;
97
- goals: SummaryGoal[];
98
- };
99
-
100
- export type SummaryPenaltyPeriod = {
101
- periodLabel: string;
102
- penalties: SummaryPenalty[];
103
- };
104
-
105
- export type ThreeStar = {
106
- star: number;
107
- player: string;
108
- team: string;
109
- position: string;
110
- statLine: string;
111
- };
112
-
113
- export type GameSummary = {
114
- scoring: SummaryScoringPeriod[];
115
- penalties: SummaryPenaltyPeriod[];
116
- threeStars: ThreeStar[];
117
- };
118
-
119
- export type NormalizedPlay = {
120
- id: number;
121
- sortOrder: number;
122
- type: string;
123
- team?: string;
124
- title: string;
125
- detail?: string;
126
- score?: string;
127
- isGoal: boolean;
128
- periodLabel: string;
129
- timeInPeriod: string;
130
- timeRemaining: string;
131
- };
132
-
133
- export type PlayByPlay = {
134
- plays: NormalizedPlay[];
135
- lastEventId?: number;
136
- };
137
-
138
- export type SkaterStatLine = {
139
- playerId: number;
140
- sweaterNumber?: number;
141
- name: string;
142
- position: string;
143
- goals: number;
144
- assists: number;
145
- points: number;
146
- plusMinus?: number;
147
- shots: number;
148
- hits: number;
149
- pim: number;
150
- toi: string;
151
- };
152
-
153
- export type GoalieStatLine = {
154
- playerId: number;
155
- sweaterNumber?: number;
156
- name: string;
157
- saves: number;
158
- shotsAgainst: number;
159
- goalsAgainst: number;
160
- savePct: number;
161
- toi: string;
162
- };
163
-
164
- export type TeamBoxScore = {
165
- team: NormalizedTeam;
166
- skaters: SkaterStatLine[];
167
- goalies: GoalieStatLine[];
168
- };
169
-
170
- export type BoxScore = {
171
- away: TeamBoxScore;
172
- home: TeamBoxScore;
173
- };
174
-
175
- export type StandingsEntry = {
176
- teamAbbrev: string;
177
- teamName: string;
178
- conferenceAbbrev: string;
179
- conferenceName: string;
180
- divisionAbbrev: string;
181
- divisionName: string;
182
- divisionRank: number;
183
- conferenceRank: number;
184
- wildcardRank: number;
185
- leagueRank: number;
186
- points: number;
187
- gamesPlayed: number;
188
- wins: number;
189
- losses: number;
190
- otLosses: number;
191
- row: number;
192
- streak: string;
193
- clinchIndicator?: string;
194
- };
195
-
196
- export type StandingsSection = {
197
- title: string;
198
- entries: StandingsEntry[];
199
- };
200
-
201
- export type ConferenceStandings = {
202
- conferenceAbbrev: string;
203
- conferenceName: string;
204
- sections: StandingsSection[];
205
- };
206
-
207
- export type NormalizedStandings = {
208
- date: string;
209
- conferences: ConferenceStandings[];
210
- lastUpdatedAt: number;
211
- };
212
-
213
- export type LeaderTableKey =
214
- | "points"
215
- | "goals"
216
- | "assists"
217
- | "goalsAgainstAverage"
218
- | "savePctg"
219
- | "shutouts";
220
-
221
- export type LeaderEntry = {
222
- rank: number;
223
- playerId: number;
224
- player: string;
225
- teamAbbrev: string;
226
- position: string;
227
- value: number;
228
- displayValue: string;
229
- };
230
-
231
- export type LeaderTable = {
232
- key: LeaderTableKey;
233
- title: string;
234
- valueLabel: string;
235
- entries: LeaderEntry[];
236
- };
237
-
238
- export type NormalizedLeaders = {
239
- skaterTables: LeaderTable[];
240
- goalieTables: LeaderTable[];
241
- lastUpdatedAt: number;
242
- };
243
-
244
- export type NormalizedGameDetail = {
245
- game: NormalizedGame;
246
- summary?: GameSummary;
247
- pbp?: PlayByPlay;
248
- box?: BoxScore;
249
- lastUpdatedAt: number;
250
- };
251
-
252
- export type AppState = {
253
- screen: AppScreen;
254
- scoreboardDate: string;
255
- games: NormalizedGame[];
256
- standingsByDate: Record<string, NormalizedStandings>;
257
- standingsErrorByDate: Record<string, string | undefined>;
258
- leaders?: NormalizedLeaders;
259
- leadersErrorMessage?: string;
260
- gameDetails: Record<number, NormalizedGameDetail>;
261
- gameDetailErrors: Record<number, string | undefined>;
262
- scoreboardLoadedDate?: string;
263
- scoreboardUpdatedAt?: number;
264
- scoreboardErrorMessage?: string;
265
- selectedGameId?: number;
266
- bannerQueue: Banner[];
267
- activeBanner?: Banner;
268
- recentEvents: AppEvent[];
269
- manualRefreshToken: number;
270
- };