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 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
  }
@@ -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
- payload = await client.fetchPlayByPlay(screen.gameId);
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
- payload = await client.fetchBoxScore(screen.gameId);
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 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 };
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
@@ -6,6 +6,7 @@ export const initialState = {
6
6
  type: "scoreboard",
7
7
  },
8
8
  scoreboardDate: todayScoreboardDate(),
9
+ followingToday: true,
9
10
  games: [],
10
11
  standingsByDate: {},
11
12
  standingsErrorByDate: {},
@@ -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 gaa = toNumber(rawStar.goalsAgainstAverage);
255
+ const goals = toNumber(rawStar.goals);
256
+ const assists = toNumber(rawStar.assists);
256
257
  const goalieStat = savePct ? `SV% ${savePct.toFixed(3)}` : "";
257
- const skaterStat = gaa ? `GAA ${gaa.toFixed(2)}` : "";
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 normalizeDetail(tab, rawPayload, now = Date.now()) {
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);
@@ -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
- delete nextStandingsByDate[oldest];
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 "change_scoreboard_date":
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: shiftScoreboardDate(state.scoreboardDate, action.delta),
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
- function renderPlayByPlay(pbp) {
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 plays = pbp.plays.slice(-20).reverse();
20
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Latest Plays" }), 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." }))] }));
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.slice(0, 10).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." }))] })] }));
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.1.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,11 +0,0 @@
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
- }
@@ -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
- }