nhl-tui 0.1.0 → 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/dist/app/input.js +18 -0
- package/dist/app/polling.js +30 -13
- package/dist/app/store.js +1 -0
- package/dist/domain/normalize.js +53 -4
- package/dist/domain/reducer.js +47 -4
- package/dist/index.js +0 -0
- package/dist/ui/App.js +1 -1
- package/dist/ui/screens/GameDetailScreen.js +13 -8
- package/package.json +4 -1
- package/.claude/settings.local.json +0 -11
- package/.github/workflows/release.yml +0 -37
- package/CONTRIBUTING.md +0 -38
- package/src/api/nhl.ts +0 -53
- package/src/app/dates.ts +0 -54
- package/src/app/input.ts +0 -130
- package/src/app/polling.ts +0 -333
- package/src/app/store.ts +0 -55
- package/src/app/timers.ts +0 -23
- package/src/domain/diff.ts +0 -107
- package/src/domain/events.ts +0 -31
- package/src/domain/normalize.ts +0 -966
- package/src/domain/reducer.ts +0 -458
- package/src/domain/types.ts +0 -270
- package/src/index.tsx +0 -15
- package/src/ui/App.tsx +0 -151
- package/src/ui/components/Banner.tsx +0 -23
- package/src/ui/components/Footer.tsx +0 -17
- package/src/ui/components/GameList.tsx +0 -45
- package/src/ui/components/GameRow.tsx +0 -60
- package/src/ui/components/StatusLine.tsx +0 -83
- package/src/ui/screens/GameDetailScreen.tsx +0 -199
- package/src/ui/screens/LeadersScreen.tsx +0 -92
- package/src/ui/screens/ScoreboardScreen.tsx +0 -36
- package/src/ui/screens/StandingsScreen.tsx +0 -95
- package/tsconfig.json +0 -18
package/dist/app/input.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { compareScoreboardDateToToday, todayScoreboardDate } from "./dates.js";
|
|
1
2
|
const gameTabs = ["summary", "pbp", "box"];
|
|
2
3
|
function cycleGameTab(currentTab, direction) {
|
|
3
4
|
const currentIndex = gameTabs.indexOf(currentTab);
|
|
@@ -20,6 +21,14 @@ export function handleAppInput(input, key, state, dispatch, quit) {
|
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
23
|
if (input === "r") {
|
|
24
|
+
if (state.screen.type === "scoreboard") {
|
|
25
|
+
const today = todayScoreboardDate();
|
|
26
|
+
if (state.scoreboardDate !== today &&
|
|
27
|
+
compareScoreboardDateToToday(state.scoreboardDate) < 0) {
|
|
28
|
+
dispatch({ type: "advance_to_today", today });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
23
32
|
if (state.screen.type !== "standings" && state.screen.type !== "leaders") {
|
|
24
33
|
dispatch({ type: "manual_refresh_requested" });
|
|
25
34
|
}
|
|
@@ -92,6 +101,15 @@ export function handleAppInput(input, key, state, dispatch, quit) {
|
|
|
92
101
|
}
|
|
93
102
|
if (input === "3") {
|
|
94
103
|
dispatch({ type: "set_tab", tab: "box" });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (input === "j" || key.downArrow) {
|
|
107
|
+
dispatch({ type: "pbp_page", delta: 1 });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (input === "k" || key.upArrow) {
|
|
111
|
+
dispatch({ type: "pbp_page", delta: -1 });
|
|
112
|
+
return;
|
|
95
113
|
}
|
|
96
114
|
}
|
|
97
115
|
}
|
package/dist/app/polling.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useEffectEvent } from "react";
|
|
2
|
-
import { compareScoreboardDateToToday } from "./dates.js";
|
|
2
|
+
import { compareScoreboardDateToToday, todayScoreboardDate } from "./dates.js";
|
|
3
3
|
import { diffGame, diffGames } from "../domain/diff.js";
|
|
4
4
|
import { normalizeDetail, normalizeLeaders, normalizeScoreboard, normalizeStandings, } from "../domain/normalize.js";
|
|
5
5
|
function getSoonestUpcomingDelta(games, now) {
|
|
@@ -147,24 +147,33 @@ export function useAppPolling({ client, dispatch, stateRef, scoreboardDate, scre
|
|
|
147
147
|
try {
|
|
148
148
|
const receivedAt = Date.now();
|
|
149
149
|
let payload;
|
|
150
|
+
let gameSource;
|
|
150
151
|
if (screen.tab === "pbp") {
|
|
151
|
-
|
|
152
|
+
const [landing, pbpData] = await Promise.all([
|
|
153
|
+
client.fetchSummary(screen.gameId),
|
|
154
|
+
client.fetchPlayByPlay(screen.gameId),
|
|
155
|
+
]);
|
|
156
|
+
gameSource = landing;
|
|
157
|
+
payload = pbpData;
|
|
152
158
|
}
|
|
153
159
|
else if (screen.tab === "box") {
|
|
154
|
-
|
|
160
|
+
const [landing, boxData] = await Promise.all([
|
|
161
|
+
client.fetchSummary(screen.gameId),
|
|
162
|
+
client.fetchBoxScore(screen.gameId),
|
|
163
|
+
]);
|
|
164
|
+
gameSource = landing;
|
|
165
|
+
payload = boxData;
|
|
155
166
|
}
|
|
156
167
|
else {
|
|
157
|
-
const landing = await
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
payload = { landing, box };
|
|
168
|
+
const [landing, box, pbp] = await Promise.all([
|
|
169
|
+
client.fetchSummary(screen.gameId),
|
|
170
|
+
client.fetchBoxScore(screen.gameId).catch(() => undefined),
|
|
171
|
+
client.fetchPlayByPlay(screen.gameId).catch(() => undefined),
|
|
172
|
+
]);
|
|
173
|
+
gameSource = landing;
|
|
174
|
+
payload = { landing, box, pbp };
|
|
166
175
|
}
|
|
167
|
-
const detail = normalizeDetail(screen.tab, payload, receivedAt);
|
|
176
|
+
const detail = normalizeDetail(screen.tab, payload, receivedAt, gameSource);
|
|
168
177
|
const previousGame = stateRef.current.gameDetails[screen.gameId]?.game ??
|
|
169
178
|
stateRef.current.games.find((game) => game.id === screen.gameId);
|
|
170
179
|
const events = diffGame(previousGame, detail.game, receivedAt);
|
|
@@ -204,6 +213,14 @@ export function useAppPolling({ client, dispatch, stateRef, scoreboardDate, scre
|
|
|
204
213
|
let disposed = false;
|
|
205
214
|
let timer;
|
|
206
215
|
const loop = async () => {
|
|
216
|
+
const preState = stateRef.current;
|
|
217
|
+
const today = todayScoreboardDate();
|
|
218
|
+
if (preState.followingToday &&
|
|
219
|
+
preState.screen.type === "scoreboard" &&
|
|
220
|
+
preState.scoreboardDate !== today) {
|
|
221
|
+
dispatch({ type: "advance_to_today", today });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
207
224
|
await fetchCurrentView();
|
|
208
225
|
if (disposed) {
|
|
209
226
|
return;
|
package/dist/app/store.js
CHANGED
package/dist/domain/normalize.js
CHANGED
|
@@ -252,9 +252,10 @@ function normalizePenalty(rawPenalty, periodLabel) {
|
|
|
252
252
|
}
|
|
253
253
|
function normalizeThreeStar(rawStar, playerNumbers) {
|
|
254
254
|
const savePct = toNumber(rawStar.savePctg);
|
|
255
|
-
const
|
|
255
|
+
const goals = toNumber(rawStar.goals);
|
|
256
|
+
const assists = toNumber(rawStar.assists);
|
|
256
257
|
const goalieStat = savePct ? `SV% ${savePct.toFixed(3)}` : "";
|
|
257
|
-
const skaterStat =
|
|
258
|
+
const skaterStat = goals || assists ? `${goals}G ${assists}A ${goals + assists}P` : "";
|
|
258
259
|
return {
|
|
259
260
|
star: toNumber(rawStar.star),
|
|
260
261
|
player: formatPlayerLabel(readName(rawStar.name), rawStar.sweaterNo ?? playerNumbers?.get(toNumber(rawStar.playerId))),
|
|
@@ -652,12 +653,56 @@ export function normalizeLeaders(rawSkaterPayload, rawGoaliePayload, now = Date.
|
|
|
652
653
|
lastUpdatedAt: now,
|
|
653
654
|
};
|
|
654
655
|
}
|
|
655
|
-
export function
|
|
656
|
+
export function computeShotsByPeriod(rawPbpPayload, awayTeamId, homeTeamId) {
|
|
657
|
+
const payload = rawPbpPayload;
|
|
658
|
+
const plays = Array.isArray(payload.plays) ? payload.plays : [];
|
|
659
|
+
if (!awayTeamId || !homeTeamId) {
|
|
660
|
+
return [];
|
|
661
|
+
}
|
|
662
|
+
const periodMap = new Map();
|
|
663
|
+
for (const play of plays) {
|
|
664
|
+
const p = play;
|
|
665
|
+
const type = p.typeDescKey;
|
|
666
|
+
if (type !== "shot-on-goal" && type !== "goal") {
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
const periodDescriptor = p.periodDescriptor;
|
|
670
|
+
const periodNumber = toNumber(periodDescriptor?.number);
|
|
671
|
+
const periodType = typeof periodDescriptor?.periodType === "string"
|
|
672
|
+
? periodDescriptor.periodType
|
|
673
|
+
: "REG";
|
|
674
|
+
if (periodType === "SO") {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const teamId = toNumber(p.details?.eventOwnerTeamId);
|
|
678
|
+
if (!teamId) {
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (!periodMap.has(periodNumber)) {
|
|
682
|
+
periodMap.set(periodNumber, {
|
|
683
|
+
away: 0,
|
|
684
|
+
home: 0,
|
|
685
|
+
periodLabel: formatPeriodLabel(periodNumber, periodType),
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
const entry = periodMap.get(periodNumber);
|
|
689
|
+
if (teamId === awayTeamId) {
|
|
690
|
+
entry.away++;
|
|
691
|
+
}
|
|
692
|
+
else if (teamId === homeTeamId) {
|
|
693
|
+
entry.home++;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return Array.from(periodMap.entries())
|
|
697
|
+
.sort(([a], [b]) => a - b)
|
|
698
|
+
.map(([, entry]) => entry);
|
|
699
|
+
}
|
|
700
|
+
export function normalizeDetail(tab, rawPayload, now = Date.now(), gameSource) {
|
|
656
701
|
const payload = rawPayload;
|
|
657
702
|
const summaryPayload = tab === "summary" && payload.landing ? payload.landing : payload;
|
|
658
703
|
const boxPayload = tab === "summary" && payload.box ? payload.box : undefined;
|
|
659
704
|
const detail = {
|
|
660
|
-
game: normalizeGame(summaryPayload),
|
|
705
|
+
game: normalizeGame(gameSource ?? summaryPayload),
|
|
661
706
|
lastUpdatedAt: now,
|
|
662
707
|
};
|
|
663
708
|
if (tab === "summary") {
|
|
@@ -666,6 +711,10 @@ export function normalizeDetail(tab, rawPayload, now = Date.now()) {
|
|
|
666
711
|
if (boxPayload) {
|
|
667
712
|
detail.box = normalizeBoxScore(boxPayload);
|
|
668
713
|
}
|
|
714
|
+
if (payload.pbp) {
|
|
715
|
+
const game = detail.game;
|
|
716
|
+
detail.summary.shotsByPeriod = computeShotsByPeriod(payload.pbp, game.away.id, game.home.id);
|
|
717
|
+
}
|
|
669
718
|
}
|
|
670
719
|
if (tab === "pbp") {
|
|
671
720
|
detail.pbp = normalizePlayByPlay(payload);
|
package/dist/domain/reducer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { shiftScoreboardDate } from "../app/dates.js";
|
|
1
|
+
import { shiftScoreboardDate, todayScoreboardDate } from "../app/dates.js";
|
|
2
2
|
import { createGoalBanners } from "./events.js";
|
|
3
3
|
function isScoreboardScreen(screen) {
|
|
4
4
|
return screen.type === "scoreboard";
|
|
@@ -117,7 +117,15 @@ export function appReducer(state, action) {
|
|
|
117
117
|
const cacheKeys = Object.keys(nextStandingsByDate);
|
|
118
118
|
if (cacheKeys.length > 5) {
|
|
119
119
|
const oldest = cacheKeys.reduce((a, b) => nextStandingsByDate[a].lastUpdatedAt < nextStandingsByDate[b].lastUpdatedAt ? a : b);
|
|
120
|
-
|
|
120
|
+
const { [oldest]: _, ...trimmed } = nextStandingsByDate;
|
|
121
|
+
return {
|
|
122
|
+
...state,
|
|
123
|
+
standingsByDate: trimmed,
|
|
124
|
+
standingsErrorByDate: {
|
|
125
|
+
...state.standingsErrorByDate,
|
|
126
|
+
[action.scoreboardDate]: undefined,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
121
129
|
}
|
|
122
130
|
return {
|
|
123
131
|
...state,
|
|
@@ -191,13 +199,32 @@ export function appReducer(state, action) {
|
|
|
191
199
|
[action.gameId]: action.error,
|
|
192
200
|
},
|
|
193
201
|
};
|
|
194
|
-
case "
|
|
202
|
+
case "advance_to_today":
|
|
203
|
+
if (state.scoreboardDate === action.today) {
|
|
204
|
+
return state;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
...state,
|
|
208
|
+
screen: { type: "scoreboard" },
|
|
209
|
+
scoreboardDate: action.today,
|
|
210
|
+
followingToday: true,
|
|
211
|
+
games: [],
|
|
212
|
+
scoreboardLoadedDate: undefined,
|
|
213
|
+
scoreboardUpdatedAt: undefined,
|
|
214
|
+
scoreboardErrorMessage: undefined,
|
|
215
|
+
selectedGameId: undefined,
|
|
216
|
+
activeBanner: undefined,
|
|
217
|
+
bannerQueue: [],
|
|
218
|
+
};
|
|
219
|
+
case "change_scoreboard_date": {
|
|
195
220
|
if (!isScoreboardScreen(state.screen)) {
|
|
196
221
|
return state;
|
|
197
222
|
}
|
|
223
|
+
const newDate = shiftScoreboardDate(state.scoreboardDate, action.delta);
|
|
198
224
|
return {
|
|
199
225
|
...state,
|
|
200
|
-
scoreboardDate:
|
|
226
|
+
scoreboardDate: newDate,
|
|
227
|
+
followingToday: newDate === todayScoreboardDate(),
|
|
201
228
|
games: [],
|
|
202
229
|
scoreboardLoadedDate: undefined,
|
|
203
230
|
scoreboardUpdatedAt: undefined,
|
|
@@ -206,6 +233,7 @@ export function appReducer(state, action) {
|
|
|
206
233
|
activeBanner: undefined,
|
|
207
234
|
bannerQueue: [],
|
|
208
235
|
};
|
|
236
|
+
}
|
|
209
237
|
case "move_selection":
|
|
210
238
|
return moveSelection(state, action.delta);
|
|
211
239
|
case "jump_selection":
|
|
@@ -222,6 +250,7 @@ export function appReducer(state, action) {
|
|
|
222
250
|
type: "game",
|
|
223
251
|
gameId: state.selectedGameId,
|
|
224
252
|
tab: "summary",
|
|
253
|
+
pbpPage: 0,
|
|
225
254
|
},
|
|
226
255
|
};
|
|
227
256
|
case "open_standings":
|
|
@@ -277,8 +306,22 @@ export function appReducer(state, action) {
|
|
|
277
306
|
screen: {
|
|
278
307
|
...state.screen,
|
|
279
308
|
tab: action.tab,
|
|
309
|
+
pbpPage: 0,
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
case "pbp_page": {
|
|
313
|
+
if (state.screen.type !== "game" || state.screen.tab !== "pbp") {
|
|
314
|
+
return state;
|
|
315
|
+
}
|
|
316
|
+
const nextPage = Math.max(0, state.screen.pbpPage + action.delta);
|
|
317
|
+
return {
|
|
318
|
+
...state,
|
|
319
|
+
screen: {
|
|
320
|
+
...state.screen,
|
|
321
|
+
pbpPage: nextPage,
|
|
280
322
|
},
|
|
281
323
|
};
|
|
324
|
+
}
|
|
282
325
|
case "manual_refresh_requested": {
|
|
283
326
|
const nextState = {
|
|
284
327
|
...state,
|
package/dist/index.js
CHANGED
|
File without changes
|
package/dist/ui/App.js
CHANGED
|
@@ -73,5 +73,5 @@ export function App({ client }) {
|
|
|
73
73
|
screenErrorMessage = detailErrorMessage;
|
|
74
74
|
}
|
|
75
75
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyanBright", children: "nhl-tui" }), _jsx(Banner, { banner: state.activeBanner }), _jsx(Box, { marginBottom: 1, children: screen.type === "scoreboard" ? (_jsx(ScoreboardScreen, { dateLabel: formatScoreboardDateLabel(state.scoreboardDate), games: visibleGames, selectedGameId: state.selectedGameId, loading: state.scoreboardLoadedDate !== state.scoreboardDate &&
|
|
76
|
-
!state.scoreboardErrorMessage, errorMessage: state.scoreboardErrorMessage })) : screen.type === "standings" ? (_jsx(StandingsScreen, { dateLabel: formatScoreboardDateLabel(state.scoreboardDate), standings: standings, loading: !standings && !standingsErrorMessage, errorMessage: standingsErrorMessage })) : screen.type === "leaders" ? (_jsx(LeadersScreen, { leaders: leaders, loading: !leaders && !state.leadersErrorMessage, errorMessage: state.leadersErrorMessage })) : (_jsx(GameDetailScreen, { game: detailGame ?? selectedGame, detail: detail, tab: gameScreen?.tab ?? "summary" })) }), _jsx(StatusLine, { updatedAt: statusUpdatedAt, pollDelayMs: pollDelayMs, errorMessage: screenErrorMessage, hideAutoWhenDisabled: screen.type === "standings" || screen.type === "leaders" }), _jsx(Footer, { mode: screen.type })] }));
|
|
76
|
+
!state.scoreboardErrorMessage, errorMessage: state.scoreboardErrorMessage })) : screen.type === "standings" ? (_jsx(StandingsScreen, { dateLabel: formatScoreboardDateLabel(state.scoreboardDate), standings: standings, loading: !standings && !standingsErrorMessage, errorMessage: standingsErrorMessage })) : screen.type === "leaders" ? (_jsx(LeadersScreen, { leaders: leaders, loading: !leaders && !state.leadersErrorMessage, errorMessage: state.leadersErrorMessage })) : (_jsx(GameDetailScreen, { game: detailGame ?? selectedGame, detail: detail, tab: gameScreen?.tab ?? "summary", pbpPage: gameScreen?.pbpPage ?? 0 })) }), _jsx(StatusLine, { updatedAt: statusUpdatedAt, pollDelayMs: pollDelayMs, errorMessage: screenErrorMessage, hideAutoWhenDisabled: screen.type === "standings" || screen.type === "leaders" }), _jsx(Footer, { mode: screen.type })] }));
|
|
77
77
|
}
|
|
@@ -6,21 +6,26 @@ function truncate(value, width) {
|
|
|
6
6
|
}
|
|
7
7
|
return `${value.slice(0, Math.max(0, width - 1))}…`;
|
|
8
8
|
}
|
|
9
|
-
function renderSummary(summary) {
|
|
9
|
+
function renderSummary(summary, awayAbbrev, homeAbbrev) {
|
|
10
10
|
if (!summary) {
|
|
11
11
|
return _jsx(Text, { dimColor: true, children: "Loading summary..." });
|
|
12
12
|
}
|
|
13
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Scoring" }), summary.scoring.length ? (summary.scoring.map((period) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: period.periodLabel || "Game" }), period.goals.length ? (period.goals.map((goal) => (_jsxs(Text, { children: [goal.timeInPeriod.padStart(5), " ", goal.team.padEnd(3), " ", truncate(goal.scorer, 18), " ", goal.strength.padEnd(3), " ", goal.scoreAfter.padEnd(5), goal.assists.length ? ` A: ${goal.assists.join(", ")}` : ""] }, goal.eventId)))) : (_jsx(Text, { dimColor: true, children: "No goals" }))] }, `scoring-${period.periodLabel}`)))) : (_jsx(Text, { dimColor: true, children: "No scoring data yet." })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Penalties" }), summary.penalties.length ? (summary.penalties.map((period) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: period.periodLabel || "Game" }), period.penalties.length ? (period.penalties.map((penalty, index) => (_jsxs(Text, { children: [penalty.timeInPeriod.padStart(5), " ", penalty.team.padEnd(3), " ", truncate(penalty.player, 22), " ", truncate(penalty.kind, 18), " ", `${penalty.duration}m`.padStart(3)] }, `${penalty.player}-${index}`)))) : (_jsx(Text, { dimColor: true, children: "No penalties" }))] }, `penalties-${period.periodLabel}`)))) : (_jsx(Text, { dimColor: true, children: "No penalty summary available." }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Three Stars" }), summary.threeStars.length ? (summary.threeStars.map((star) => (_jsxs(Text, { children: [String(star.star).padStart(2), " ", star.team.padEnd(3), " ", truncate(star.player, 20), " ", star.position.padEnd(2), " ", star.statLine] }, `${star.star}-${star.player}`)))) : (_jsx(Text, { dimColor: true, children: "Three stars not posted yet." }))] })] }));
|
|
13
|
+
return (_jsxs(Box, { flexDirection: "column", children: [summary.shotsByPeriod && summary.shotsByPeriod.length > 0 && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Shots on Goal" }), _jsxs(Text, { dimColor: true, children: ["".padEnd(6), " ", awayAbbrev.padEnd(4), " ", homeAbbrev.padEnd(4)] }), summary.shotsByPeriod.map((period) => (_jsxs(Text, { children: [period.periodLabel.padEnd(6), " ", String(period.away).padStart(3).padEnd(4), " ", String(period.home).padStart(3).padEnd(4)] }, `shots-${period.periodLabel}`))), _jsxs(Text, { children: ["Total".padEnd(6), " ", String(summary.shotsByPeriod.reduce((sum, p) => sum + p.away, 0)).padStart(3).padEnd(4), " ", String(summary.shotsByPeriod.reduce((sum, p) => sum + p.home, 0)).padStart(3).padEnd(4)] })] })), _jsx(Text, { bold: true, children: "Scoring" }), summary.scoring.length ? (summary.scoring.map((period) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: period.periodLabel || "Game" }), period.goals.length ? (period.goals.map((goal) => (_jsxs(Text, { children: [goal.timeInPeriod.padStart(5), " ", goal.team.padEnd(3), " ", truncate(goal.scorer, 18), " ", goal.strength.padEnd(3), " ", goal.scoreAfter.padEnd(5), goal.assists.length ? ` A: ${goal.assists.join(", ")}` : ""] }, goal.eventId)))) : (_jsx(Text, { dimColor: true, children: "No goals" }))] }, `scoring-${period.periodLabel}`)))) : (_jsx(Text, { dimColor: true, children: "No scoring data yet." })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Penalties" }), summary.penalties.length ? (summary.penalties.map((period) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: period.periodLabel || "Game" }), period.penalties.length ? (period.penalties.map((penalty, index) => (_jsxs(Text, { children: [penalty.timeInPeriod.padStart(5), " ", penalty.team.padEnd(3), " ", truncate(penalty.player, 22), " ", truncate(penalty.kind, 18), " ", `${penalty.duration}m`.padStart(3)] }, `${penalty.player}-${index}`)))) : (_jsx(Text, { dimColor: true, children: "No penalties" }))] }, `penalties-${period.periodLabel}`)))) : (_jsx(Text, { dimColor: true, children: "No penalty summary available." }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Three Stars" }), summary.threeStars.length ? (summary.threeStars.map((star) => (_jsxs(Text, { children: [String(star.star).padStart(2), " ", star.team.padEnd(3), " ", truncate(star.player, 20), " ", star.position.padEnd(2), " ", star.statLine] }, `${star.star}-${star.player}`)))) : (_jsx(Text, { dimColor: true, children: "Three stars not posted yet." }))] })] }));
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
const PBP_PAGE_SIZE = 20;
|
|
16
|
+
function renderPlayByPlay(pbp, page) {
|
|
16
17
|
if (!pbp) {
|
|
17
18
|
return _jsx(Text, { dimColor: true, children: "Loading play-by-play..." });
|
|
18
19
|
}
|
|
19
|
-
const
|
|
20
|
-
|
|
20
|
+
const reversed = [...pbp.plays].reverse();
|
|
21
|
+
const totalPages = Math.max(1, Math.ceil(reversed.length / PBP_PAGE_SIZE));
|
|
22
|
+
const clampedPage = Math.min(page, totalPages - 1);
|
|
23
|
+
const start = clampedPage * PBP_PAGE_SIZE;
|
|
24
|
+
const plays = reversed.slice(start, start + PBP_PAGE_SIZE);
|
|
25
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Plays", " ", _jsxs(Text, { dimColor: true, children: ["page ", clampedPage + 1, "/", totalPages, " j/k to page"] })] }), plays.length ? (plays.map((play) => (_jsxs(Text, { children: [play.periodLabel.padEnd(4), " ", play.timeInPeriod.padStart(5), " ", (play.team ?? "---").padEnd(3), " ", truncate(play.title, 12), " ", truncate(play.detail ?? "", 32), play.score ? ` ${play.score}` : ""] }, play.id)))) : (_jsx(Text, { dimColor: true, children: "No plays yet." }))] }));
|
|
21
26
|
}
|
|
22
27
|
function renderTeamBox(team) {
|
|
23
|
-
return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, paddingRight: 1, children: [_jsxs(Text, { bold: true, children: [team.team.abbrev, " ", team.team.score] }), _jsx(Text, { dimColor: true, children: "# Player P G A P +/- S H TOI" }), team.skaters.
|
|
28
|
+
return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, paddingRight: 1, children: [_jsxs(Text, { bold: true, children: [team.team.abbrev, " ", team.team.score] }), _jsx(Text, { dimColor: true, children: "# Player P G A P +/- S H TOI" }), team.skaters.map((player) => (_jsxs(Text, { children: [String(player.sweaterNumber ?? "").padStart(2), " ", truncate(player.name, 15), " ", player.position.padEnd(2), " ", String(player.goals).padStart(1), " ", String(player.assists).padStart(1), " ", String(player.points).padStart(1), " ", String(player.plusMinus ?? 0).padStart(3), " ", String(player.shots).padStart(1), " ", String(player.hits).padStart(1), " ", player.toi] }, player.playerId))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Goalies" }), team.goalies.length ? (team.goalies.map((goalie) => (_jsxs(Text, { children: [String(goalie.sweaterNumber ?? "").padStart(2), " ", truncate(goalie.name, 15), " SV", " ", String(goalie.saves).padStart(2), "/", String(goalie.shotsAgainst).padEnd(2), " SV%", " ", goalie.savePct.toFixed(3), " TOI ", goalie.toi] }, goalie.playerId)))) : (_jsx(Text, { dimColor: true, children: "No goalie stats." }))] })] }));
|
|
24
29
|
}
|
|
25
30
|
function renderBoxScore(box) {
|
|
26
31
|
if (!box) {
|
|
@@ -28,10 +33,10 @@ function renderBoxScore(box) {
|
|
|
28
33
|
}
|
|
29
34
|
return (_jsxs(Box, { flexDirection: "row", children: [renderTeamBox(box.away), renderTeamBox(box.home)] }));
|
|
30
35
|
}
|
|
31
|
-
export function GameDetailScreen({ game, detail, tab, }) {
|
|
36
|
+
export function GameDetailScreen({ game, detail, tab, pbpPage, }) {
|
|
32
37
|
const snapshot = detail?.game ?? game;
|
|
33
38
|
if (!snapshot) {
|
|
34
39
|
return _jsx(Text, { dimColor: true, children: "Loading game..." });
|
|
35
40
|
}
|
|
36
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [snapshot.away.abbrev, " ", snapshot.away.score, " @ ", snapshot.home.abbrev, " ", snapshot.home.score] }), _jsxs(Text, { dimColor: true, children: [snapshot.statusLabel, " | ", snapshot.contextLabel, " | ", snapshot.venue || "NHL arena"] }), _jsxs(Box, { marginTop: 1, marginBottom: 1, children: [_jsx(Text, { color: tab === "summary" ? "cyanBright" : undefined, children: "[1] Summary" }), _jsx(Text, { children: " " }), _jsx(Text, { color: tab === "pbp" ? "cyanBright" : undefined, children: "[2] Play-by-play" }), _jsx(Text, { children: " " }), _jsx(Text, { color: tab === "box" ? "cyanBright" : undefined, children: "[3] Box score" })] }), tab === "summary" && renderSummary(detail?.summary), tab === "pbp" && renderPlayByPlay(detail?.pbp), tab === "box" && renderBoxScore(detail?.box)] }));
|
|
41
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [snapshot.away.abbrev, " ", snapshot.away.score, " @ ", snapshot.home.abbrev, " ", snapshot.home.score] }), _jsxs(Text, { dimColor: true, children: [snapshot.statusLabel, " | ", snapshot.contextLabel, " | ", snapshot.venue || "NHL arena"] }), _jsxs(Box, { marginTop: 1, marginBottom: 1, children: [_jsx(Text, { color: tab === "summary" ? "cyanBright" : undefined, children: "[1] Summary" }), _jsx(Text, { children: " " }), _jsx(Text, { color: tab === "pbp" ? "cyanBright" : undefined, children: "[2] Play-by-play" }), _jsx(Text, { children: " " }), _jsx(Text, { color: tab === "box" ? "cyanBright" : undefined, children: "[3] Box score" })] }), tab === "summary" && renderSummary(detail?.summary, snapshot.away.abbrev, snapshot.home.abbrev), tab === "pbp" && renderPlayByPlay(detail?.pbp, pbpPage), tab === "box" && renderBoxScore(detail?.box)] }));
|
|
37
42
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nhl-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"nhl-tui": "./dist/index.js"
|
|
8
8
|
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
9
12
|
"scripts": {
|
|
10
13
|
"start": "node --import tsx src/index.tsx",
|
|
11
14
|
"build": "tsc -p tsconfig.json",
|
|
@@ -1,37 +0,0 @@
|
|
|
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
|
package/CONTRIBUTING.md
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
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/src/api/nhl.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
const BASE_URL = "https://api-web.nhle.com/v1";
|
|
2
|
-
|
|
3
|
-
async function requestJson(path: string): Promise<unknown> {
|
|
4
|
-
const response = await fetch(`${BASE_URL}${path}`, {
|
|
5
|
-
headers: {
|
|
6
|
-
"user-agent": "nhl-tui/0.1.0",
|
|
7
|
-
accept: "application/json",
|
|
8
|
-
},
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
if (!response.ok) {
|
|
12
|
-
const text = await response.text();
|
|
13
|
-
throw new Error(
|
|
14
|
-
`NHL API request failed: ${response.status} ${response.statusText} ${text}`.trim(),
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
return response.json();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export class NhlApi {
|
|
22
|
-
async fetchScoreboard(scoreboardDate: string): Promise<unknown> {
|
|
23
|
-
return requestJson(`/score/${scoreboardDate}`);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async fetchStandings(scoreboardDate: string): Promise<unknown> {
|
|
27
|
-
return requestJson(`/standings/${scoreboardDate}`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async fetchSkaterLeaders(limit = 10): Promise<unknown> {
|
|
31
|
-
return requestJson(
|
|
32
|
-
`/skater-stats-leaders/current?categories=points,goals,assists&limit=${limit}`,
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async fetchGoalieLeaders(limit = 10): Promise<unknown> {
|
|
37
|
-
return requestJson(
|
|
38
|
-
`/goalie-stats-leaders/current?categories=goalsAgainstAverage,savePctg,shutouts&limit=${limit}`,
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async fetchSummary(gameId: number): Promise<unknown> {
|
|
43
|
-
return requestJson(`/gamecenter/${gameId}/landing`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async fetchPlayByPlay(gameId: number): Promise<unknown> {
|
|
47
|
-
return requestJson(`/gamecenter/${gameId}/play-by-play`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async fetchBoxScore(gameId: number): Promise<unknown> {
|
|
51
|
-
return requestJson(`/gamecenter/${gameId}/boxscore`);
|
|
52
|
-
}
|
|
53
|
-
}
|
package/src/app/dates.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
function pad(value: number): string {
|
|
2
|
-
return String(value).padStart(2, "0");
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
function parseScoreboardDate(scoreboardDate: string): Date {
|
|
6
|
-
const [year, month, day] = scoreboardDate.split("-").map(Number);
|
|
7
|
-
return new Date(year, (month ?? 1) - 1, day ?? 1);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function todayScoreboardDate(now = new Date()): string {
|
|
11
|
-
return [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join("-");
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function shiftScoreboardDate(
|
|
15
|
-
scoreboardDate: string,
|
|
16
|
-
deltaDays: number,
|
|
17
|
-
): string {
|
|
18
|
-
const nextDate = parseScoreboardDate(scoreboardDate);
|
|
19
|
-
nextDate.setDate(nextDate.getDate() + deltaDays);
|
|
20
|
-
return todayScoreboardDate(nextDate);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function compareScoreboardDateToToday(
|
|
24
|
-
scoreboardDate: string,
|
|
25
|
-
now = new Date(),
|
|
26
|
-
): number {
|
|
27
|
-
const today = todayScoreboardDate(now);
|
|
28
|
-
|
|
29
|
-
if (scoreboardDate < today) {
|
|
30
|
-
return -1;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (scoreboardDate > today) {
|
|
34
|
-
return 1;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return 0;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function formatScoreboardDateLabel(
|
|
41
|
-
scoreboardDate: string,
|
|
42
|
-
now = new Date(),
|
|
43
|
-
): string {
|
|
44
|
-
const date = parseScoreboardDate(scoreboardDate);
|
|
45
|
-
const isToday = compareScoreboardDateToToday(scoreboardDate, now) === 0;
|
|
46
|
-
const parts = new Intl.DateTimeFormat(undefined, {
|
|
47
|
-
weekday: "short",
|
|
48
|
-
month: "short",
|
|
49
|
-
day: "numeric",
|
|
50
|
-
year: date.getFullYear() === now.getFullYear() ? undefined : "numeric",
|
|
51
|
-
}).format(date);
|
|
52
|
-
|
|
53
|
-
return isToday ? `Today ${parts}` : parts;
|
|
54
|
-
}
|