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,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npx tsc:*)",
5
+ "Bash(git add:*)",
6
+ "Bash(git commit:*)",
7
+ "Bash(git push:*)",
8
+ "Bash(gh pr:*)"
9
+ ]
10
+ }
11
+ }
@@ -0,0 +1,37 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*.*.*'
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ release:
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - name: Check out repository
17
+ uses: actions/checkout@v4
18
+
19
+ - name: Set up Node.js
20
+ uses: actions/setup-node@v4
21
+ with:
22
+ node-version: 22
23
+ cache: npm
24
+
25
+ - name: Install dependencies
26
+ run: npm ci
27
+
28
+ - name: Typecheck
29
+ run: npm run check
30
+
31
+ - name: Build
32
+ run: npm run build
33
+
34
+ - name: Create GitHub release
35
+ uses: softprops/action-gh-release@v2
36
+ with:
37
+ generate_release_notes: true
@@ -0,0 +1,38 @@
1
+ # Contributing
2
+
3
+ Thanks for contributing to `nhl-tui`.
4
+
5
+ ## Before You Open A PR
6
+
7
+ Run:
8
+
9
+ ```bash
10
+ npm run check
11
+ npm run build
12
+ ```
13
+
14
+ ## Project Structure
15
+
16
+ Keep the layering intact:
17
+
18
+ - `src/api`: endpoint access only
19
+ - `src/domain`: normalization, diffing, events, reducer logic
20
+ - `src/ui`: Ink rendering and input dispatch only
21
+
22
+ ## Trademark And Content Guidance
23
+
24
+ Please do not add:
25
+
26
+ - NHL logos
27
+ - team logos
28
+ - league or club branding assets
29
+ - broadcast audio or video
30
+ - copyrighted media or artwork sourced from NHL properties
31
+
32
+ The UI should use team abbreviations and text-based presentation only.
33
+
34
+ ## Design Constraints
35
+
36
+ - keep the UI terminal-native and keyboard-first
37
+ - prefer dense, stable layouts over web-style components
38
+ - avoid leaking raw upstream payloads into the UI layer
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sean McCann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # nhl-tui
2
+
3
+ A terminal UI for following NHL games, built with TypeScript, Node.js, React, and Ink.
4
+
5
+ `nhl-tui` is designed to feel like a real TUI, not a web dashboard squeezed into a terminal. It opens on a compact scoreboard, supports keyboard-first navigation, drills into live game detail, and keeps NHL-specific logic out of the rendering layer.
6
+
7
+ ## Status
8
+
9
+ This is an early but usable v1.
10
+
11
+ Current capabilities:
12
+
13
+ - date-based scoreboard browsing
14
+ - stable row selection
15
+ - game detail view with summary, play-by-play, and box score tabs
16
+ - adaptive polling for scoreboard and game detail
17
+ - standings screen with conference playoff-cut grouping
18
+ - leaders screen with top-10 skater and goalie stat tables
19
+ - diff-based domain events for goals, game starts, period changes, and finals
20
+ - queued goal banners
21
+ - reducer-based state management with separate API, domain, and UI layers
22
+
23
+ ## Screens
24
+
25
+ Scoreboard:
26
+
27
+ - grouped by `LIVE`, `UPCOMING`, and `FINAL`
28
+ - away and home team abbreviations
29
+ - score and game state
30
+ - live period and clock
31
+ - scheduled puck drop time for upcoming games
32
+
33
+ Game detail:
34
+
35
+ - summary
36
+ - play-by-play
37
+ - box score
38
+
39
+ Standings:
40
+
41
+ - Eastern and Western conference sections
42
+ - top three teams in each division
43
+ - two conference wild card teams
44
+ - teams below the wild card line
45
+
46
+ Leaders:
47
+
48
+ - skater leaders for points, goals, and assists
49
+ - goalie leaders for goals against average, save percentage, and shutouts
50
+ - top 10 rows per table
51
+
52
+ The summary view includes scoring, penalties, and three stars. Where the upstream payload exposes enough information, player labels are rendered as sweater number plus short name, for example `97 C. McDavid`.
53
+
54
+ ## Quick Start
55
+
56
+ ### Requirements
57
+
58
+ - Node.js 22+
59
+ - network access to `https://api-web.nhle.com`
60
+
61
+ ### Run locally
62
+
63
+ ```bash
64
+ npm install
65
+ npm start
66
+ ```
67
+
68
+ ### Build the CLI
69
+
70
+ ```bash
71
+ npm run build
72
+ node dist/index.js
73
+ ```
74
+
75
+ The package is configured with a `nhl-tui` bin entry for future npm publishing.
76
+
77
+ ## Releases
78
+
79
+ GitHub Releases are automated with Actions on semantic-version tags. Pushing a tag such as `v0.1.0` runs typecheck and build, then creates a GitHub Release with generated notes.
80
+
81
+ Example:
82
+
83
+ ```bash
84
+ git checkout main
85
+ git pull
86
+ git tag v0.1.0
87
+ git push origin main --tags
88
+ ```
89
+
90
+ Version selection is still manual. The workflow only builds and publishes the GitHub Release after the tag exists.
91
+
92
+ ## Controls
93
+
94
+ ### Scoreboard
95
+
96
+ - `up` / `down`: move selection
97
+ - `left` / `right`: previous or next date
98
+ - `g`: jump to top
99
+ - `G`: jump to bottom
100
+ - `enter`: open selected game
101
+ - `s`: open standings
102
+ - `l`: open leaders
103
+ - `r`: manual refresh
104
+ - `esc` or `q`: quit
105
+
106
+ ### Standings view
107
+
108
+ - `esc`: back to scoreboard
109
+
110
+ ### Leaders view
111
+
112
+ - `esc`: back to scoreboard
113
+
114
+ ### Game view
115
+
116
+ - `left` / `right`: cycle tabs
117
+ - `1`: summary
118
+ - `2`: play-by-play
119
+ - `3`: box score
120
+ - `r`: manual refresh
121
+ - `esc`: back to scoreboard
122
+ - `q`: quit
123
+
124
+ ## Architecture
125
+
126
+ The app is split into three layers:
127
+
128
+ - `src/api`
129
+ Public endpoint access only.
130
+ - `src/domain`
131
+ Stable types, normalization, diffing, event emission, and reducer logic.
132
+ - `src/ui`
133
+ Ink components that render already-processed state and dispatch actions.
134
+
135
+ Runtime flow:
136
+
137
+ `fetch -> normalize -> diff -> emit events -> reducer -> Ink render`
138
+
139
+ Ink is only the renderer. Components do not interpret raw upstream payloads.
140
+
141
+ The data-fetching layer is intentionally isolated in `src/api` so endpoints can be swapped or replaced if upstream services change.
142
+
143
+ ## Data Sources And API Usage
144
+
145
+ Game data is retrieved from publicly accessible endpoints used by NHL web applications.
146
+
147
+ Current endpoint usage is isolated in `src/api/nhl.ts`:
148
+
149
+ - `/v1/score/YYYY-MM-DD`
150
+ - `/v1/standings/YYYY-MM-DD`
151
+ - `/v1/skater-stats-leaders/current?categories=points,goals,assists&limit=10`
152
+ - `/v1/goalie-stats-leaders/current?categories=goalsAgainstAverage,savePctg,shutouts&limit=10`
153
+ - `/v1/gamecenter/{gameId}/landing`
154
+ - `/v1/gamecenter/{gameId}/play-by-play`
155
+ - `/v1/gamecenter/{gameId}/boxscore`
156
+
157
+ No API key is currently required for these endpoints, but their availability and permitted use can change over time.
158
+
159
+ The application uses adaptive polling to minimize load on upstream services. It limits requests to the current scoreboard date or the currently selected game, slows down aggressively when possible, and is intended for personal and educational use. The standings screen is fetched once per selected date and then served from in-memory state. The leaders screen is also fetched once and then served from in-memory state.
160
+
161
+ ## Legal And Trademark Considerations
162
+
163
+ This project is not affiliated with, endorsed by, or sponsored by the National Hockey League.
164
+
165
+ Additional safeguards and project conventions:
166
+
167
+ - The UI uses team abbreviations only, not team logos or NHL branding assets.
168
+ - The repository does not bundle NHL logos, team logos, broadcast assets, or other league branding materials.
169
+ - This project does not stream NHL video, bundle broadcast media, or distribute copyrighted game footage or other copyrighted media.
170
+ - `NHL`, team names, club names, logos, shields, and other league or team branding remain the property of their respective owners.
171
+ - References to the league, clubs, players, and game data are for identification and informational use only.
172
+ - This project is intended for personal and educational use. The MIT license applies to this source code, but it does not grant any rights to NHL trademarks, logos, or media.
173
+ - Public NHL web endpoints and related terms may change or be restricted at any time, and this project should not be presented as an official NHL product or service.
174
+
175
+ ## Events
176
+
177
+ Meaningful state transitions are detected in the domain layer by diffing normalized snapshots.
178
+
179
+ Current event types:
180
+
181
+ - `goal_scored`
182
+ - `game_started`
183
+ - `period_changed`
184
+ - `game_ended`
185
+
186
+ Goal events are surfaced through a queued banner system so multiple notifications do not overlap.
187
+
188
+ ## Development
189
+
190
+ Useful commands:
191
+
192
+ ```bash
193
+ npm start
194
+ npm run check
195
+ npm run build
196
+ ```
197
+
198
+ ## Contributing
199
+
200
+ Issues and pull requests are welcome.
201
+
202
+ Before opening a PR, run:
203
+
204
+ ```bash
205
+ npm run check
206
+ npm run build
207
+ ```
208
+
209
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for contributor expectations, including trademark and branding restrictions.
210
+
211
+ ## Roadmap
212
+
213
+ Likely next improvements:
214
+
215
+ - play-by-play-id-based event detection instead of score-only goal diffs
216
+ - additional domain events such as penalties, lead changes, and goalie pulls
217
+ - optional sound hooks driven from emitted domain events
218
+ - richer box score and play-by-play layouts
219
+
220
+ ## License
221
+
222
+ This project is licensed under the MIT License. See [LICENSE](./LICENSE).
@@ -0,0 +1,37 @@
1
+ const BASE_URL = "https://api-web.nhle.com/v1";
2
+ async function requestJson(path) {
3
+ const response = await fetch(`${BASE_URL}${path}`, {
4
+ headers: {
5
+ "user-agent": "nhl-tui/0.1.0",
6
+ accept: "application/json",
7
+ },
8
+ });
9
+ if (!response.ok) {
10
+ const text = await response.text();
11
+ throw new Error(`NHL API request failed: ${response.status} ${response.statusText} ${text}`.trim());
12
+ }
13
+ return response.json();
14
+ }
15
+ export class NhlApi {
16
+ async fetchScoreboard(scoreboardDate) {
17
+ return requestJson(`/score/${scoreboardDate}`);
18
+ }
19
+ async fetchStandings(scoreboardDate) {
20
+ return requestJson(`/standings/${scoreboardDate}`);
21
+ }
22
+ async fetchSkaterLeaders(limit = 10) {
23
+ return requestJson(`/skater-stats-leaders/current?categories=points,goals,assists&limit=${limit}`);
24
+ }
25
+ async fetchGoalieLeaders(limit = 10) {
26
+ return requestJson(`/goalie-stats-leaders/current?categories=goalsAgainstAverage,savePctg,shutouts&limit=${limit}`);
27
+ }
28
+ async fetchSummary(gameId) {
29
+ return requestJson(`/gamecenter/${gameId}/landing`);
30
+ }
31
+ async fetchPlayByPlay(gameId) {
32
+ return requestJson(`/gamecenter/${gameId}/play-by-play`);
33
+ }
34
+ async fetchBoxScore(gameId) {
35
+ return requestJson(`/gamecenter/${gameId}/boxscore`);
36
+ }
37
+ }
@@ -0,0 +1,36 @@
1
+ function pad(value) {
2
+ return String(value).padStart(2, "0");
3
+ }
4
+ function parseScoreboardDate(scoreboardDate) {
5
+ const [year, month, day] = scoreboardDate.split("-").map(Number);
6
+ return new Date(year, (month ?? 1) - 1, day ?? 1);
7
+ }
8
+ export function todayScoreboardDate(now = new Date()) {
9
+ return [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join("-");
10
+ }
11
+ export function shiftScoreboardDate(scoreboardDate, deltaDays) {
12
+ const nextDate = parseScoreboardDate(scoreboardDate);
13
+ nextDate.setDate(nextDate.getDate() + deltaDays);
14
+ return todayScoreboardDate(nextDate);
15
+ }
16
+ export function compareScoreboardDateToToday(scoreboardDate, now = new Date()) {
17
+ const today = todayScoreboardDate(now);
18
+ if (scoreboardDate < today) {
19
+ return -1;
20
+ }
21
+ if (scoreboardDate > today) {
22
+ return 1;
23
+ }
24
+ return 0;
25
+ }
26
+ export function formatScoreboardDateLabel(scoreboardDate, now = new Date()) {
27
+ const date = parseScoreboardDate(scoreboardDate);
28
+ const isToday = compareScoreboardDateToToday(scoreboardDate, now) === 0;
29
+ const parts = new Intl.DateTimeFormat(undefined, {
30
+ weekday: "short",
31
+ month: "short",
32
+ day: "numeric",
33
+ year: date.getFullYear() === now.getFullYear() ? undefined : "numeric",
34
+ }).format(date);
35
+ return isToday ? `Today ${parts}` : parts;
36
+ }
@@ -0,0 +1,97 @@
1
+ const gameTabs = ["summary", "pbp", "box"];
2
+ function cycleGameTab(currentTab, direction) {
3
+ const currentIndex = gameTabs.indexOf(currentTab);
4
+ const nextIndex = (currentIndex + direction + gameTabs.length) % gameTabs.length;
5
+ return gameTabs[nextIndex] ?? currentTab;
6
+ }
7
+ export function handleAppInput(input, key, state, dispatch, quit) {
8
+ if (state.screen.type === "standings" || state.screen.type === "leaders") {
9
+ if (key.escape) {
10
+ dispatch({ type: "go_back" });
11
+ return;
12
+ }
13
+ }
14
+ if (input === "q") {
15
+ quit();
16
+ return;
17
+ }
18
+ if (state.screen.type === "scoreboard" && key.escape) {
19
+ quit();
20
+ return;
21
+ }
22
+ if (input === "r") {
23
+ if (state.screen.type !== "standings" && state.screen.type !== "leaders") {
24
+ dispatch({ type: "manual_refresh_requested" });
25
+ }
26
+ return;
27
+ }
28
+ if (input === "g") {
29
+ dispatch({ type: "jump_selection", target: "top" });
30
+ return;
31
+ }
32
+ if (input === "G") {
33
+ dispatch({ type: "jump_selection", target: "bottom" });
34
+ return;
35
+ }
36
+ if (state.screen.type === "scoreboard" && input === "s") {
37
+ dispatch({ type: "open_standings" });
38
+ return;
39
+ }
40
+ if (state.screen.type === "scoreboard" && input === "l") {
41
+ dispatch({ type: "open_leaders" });
42
+ return;
43
+ }
44
+ if (state.screen.type === "scoreboard") {
45
+ if (key.leftArrow) {
46
+ dispatch({ type: "change_scoreboard_date", delta: -1 });
47
+ return;
48
+ }
49
+ if (key.rightArrow) {
50
+ dispatch({ type: "change_scoreboard_date", delta: 1 });
51
+ return;
52
+ }
53
+ }
54
+ if (state.screen.type === "scoreboard" && key.upArrow) {
55
+ dispatch({ type: "move_selection", delta: -1 });
56
+ return;
57
+ }
58
+ if (state.screen.type === "scoreboard" && key.downArrow) {
59
+ dispatch({ type: "move_selection", delta: 1 });
60
+ return;
61
+ }
62
+ if (key.return && state.screen.type === "scoreboard") {
63
+ dispatch({ type: "open_selected_game" });
64
+ return;
65
+ }
66
+ if (state.screen.type === "game") {
67
+ if (key.escape) {
68
+ dispatch({ type: "go_back" });
69
+ return;
70
+ }
71
+ if (key.leftArrow) {
72
+ dispatch({
73
+ type: "set_tab",
74
+ tab: cycleGameTab(state.screen.tab, -1),
75
+ });
76
+ return;
77
+ }
78
+ if (key.rightArrow) {
79
+ dispatch({
80
+ type: "set_tab",
81
+ tab: cycleGameTab(state.screen.tab, 1),
82
+ });
83
+ return;
84
+ }
85
+ if (input === "1") {
86
+ dispatch({ type: "set_tab", tab: "summary" });
87
+ return;
88
+ }
89
+ if (input === "2") {
90
+ dispatch({ type: "set_tab", tab: "pbp" });
91
+ return;
92
+ }
93
+ if (input === "3") {
94
+ dispatch({ type: "set_tab", tab: "box" });
95
+ }
96
+ }
97
+ }