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.
package/src/index.tsx DELETED
@@ -1,15 +0,0 @@
1
- #!/usr/bin/env node
2
- import { render } from "ink";
3
- import { NhlApi } from "./api/nhl.js";
4
- import { App } from "./ui/App.js";
5
-
6
- if (typeof performance.measure === "function") {
7
- const originalMeasure = performance.measure.bind(performance);
8
- performance.measure = ((...args: Parameters<typeof performance.measure>) => {
9
- const entry = originalMeasure(...args);
10
- performance.clearMeasures();
11
- return entry;
12
- }) as typeof performance.measure;
13
- }
14
-
15
- render(<App client={new NhlApi()} />);
package/src/ui/App.tsx DELETED
@@ -1,151 +0,0 @@
1
- import { Box, Text, useApp, useInput } from "ink";
2
- import { useEffect, useRef } from "react";
3
- import { type NhlApi } from "../api/nhl.js";
4
- import { formatScoreboardDateLabel } from "../app/dates.js";
5
- import {
6
- getDetailPollDelayMs,
7
- getScoreboardPollDelayMs,
8
- useAppPolling,
9
- } from "../app/polling.js";
10
- import {
11
- selectCurrentLeaders,
12
- selectCurrentStandings,
13
- selectSelectedGame,
14
- selectVisibleGames,
15
- useAppStore,
16
- } from "../app/store.js";
17
- import { useBannerTimer } from "../app/timers.js";
18
- import { handleAppInput } from "../app/input.js";
19
- import { Banner } from "./components/Banner.js";
20
- import { Footer } from "./components/Footer.js";
21
- import { StatusLine } from "./components/StatusLine.js";
22
- import { GameDetailScreen } from "./screens/GameDetailScreen.js";
23
- import { LeadersScreen } from "./screens/LeadersScreen.js";
24
- import { ScoreboardScreen } from "./screens/ScoreboardScreen.js";
25
- import { StandingsScreen } from "./screens/StandingsScreen.js";
26
-
27
- type AppProps = {
28
- client: NhlApi;
29
- };
30
-
31
- export function App({ client }: AppProps) {
32
- const { exit } = useApp();
33
- const [state, dispatch] = useAppStore();
34
- const stateRef = useRef(state);
35
-
36
- useEffect(() => {
37
- stateRef.current = state;
38
- }, [state]);
39
-
40
- useAppPolling({
41
- client,
42
- dispatch,
43
- stateRef,
44
- scoreboardDate: state.scoreboardDate,
45
- screenType: state.screen.type,
46
- screenGameId: state.screen.type === "game" ? state.screen.gameId : undefined,
47
- screenTab: state.screen.type === "game" ? state.screen.tab : undefined,
48
- manualRefreshToken: state.manualRefreshToken,
49
- });
50
-
51
- useBannerTimer(state.activeBanner, () => {
52
- dispatch({ type: "dismiss_banner" });
53
- });
54
-
55
- useInput((input, key) => {
56
- handleAppInput(input, key, stateRef.current, dispatch, exit);
57
- });
58
-
59
- const screen = state.screen;
60
- const gameScreen = screen.type === "game" ? screen : undefined;
61
- const visibleGames = selectVisibleGames(state);
62
- const selectedGame = selectSelectedGame(state);
63
- const standings = selectCurrentStandings(state);
64
- const standingsErrorMessage = state.standingsErrorByDate[state.scoreboardDate];
65
- const leaders = selectCurrentLeaders(state);
66
- const detail = gameScreen ? state.gameDetails[gameScreen.gameId] : undefined;
67
- const detailErrorMessage = gameScreen
68
- ? state.gameDetailErrors[gameScreen.gameId]
69
- : undefined;
70
- let detailGame =
71
- detail?.game ??
72
- (gameScreen
73
- ? state.games.find((game) => game.id === gameScreen.gameId)
74
- : undefined);
75
- let pollDelayMs: number | undefined = getScoreboardPollDelayMs(
76
- state.games,
77
- state.scoreboardDate,
78
- );
79
- let statusUpdatedAt = state.scoreboardUpdatedAt;
80
- let screenErrorMessage = state.scoreboardErrorMessage;
81
-
82
- if (screen.type === "standings") {
83
- pollDelayMs = undefined;
84
- statusUpdatedAt = standings?.lastUpdatedAt;
85
- screenErrorMessage = standingsErrorMessage;
86
- }
87
-
88
- if (screen.type === "leaders") {
89
- pollDelayMs = undefined;
90
- statusUpdatedAt = leaders?.lastUpdatedAt;
91
- screenErrorMessage = state.leadersErrorMessage;
92
- }
93
-
94
- if (gameScreen) {
95
- detailGame =
96
- detail?.game ??
97
- state.games.find((game) => game.id === gameScreen.gameId);
98
- pollDelayMs = getDetailPollDelayMs(gameScreen.tab, detailGame);
99
- statusUpdatedAt = detail?.lastUpdatedAt;
100
- screenErrorMessage = detailErrorMessage;
101
- }
102
-
103
- return (
104
- <Box flexDirection="column">
105
- <Text bold color="cyanBright">
106
- nhl-tui
107
- </Text>
108
- <Banner banner={state.activeBanner} />
109
- <Box marginBottom={1}>
110
- {screen.type === "scoreboard" ? (
111
- <ScoreboardScreen
112
- dateLabel={formatScoreboardDateLabel(state.scoreboardDate)}
113
- games={visibleGames}
114
- selectedGameId={state.selectedGameId}
115
- loading={
116
- state.scoreboardLoadedDate !== state.scoreboardDate &&
117
- !state.scoreboardErrorMessage
118
- }
119
- errorMessage={state.scoreboardErrorMessage}
120
- />
121
- ) : screen.type === "standings" ? (
122
- <StandingsScreen
123
- dateLabel={formatScoreboardDateLabel(state.scoreboardDate)}
124
- standings={standings}
125
- loading={!standings && !standingsErrorMessage}
126
- errorMessage={standingsErrorMessage}
127
- />
128
- ) : screen.type === "leaders" ? (
129
- <LeadersScreen
130
- leaders={leaders}
131
- loading={!leaders && !state.leadersErrorMessage}
132
- errorMessage={state.leadersErrorMessage}
133
- />
134
- ) : (
135
- <GameDetailScreen
136
- game={detailGame ?? selectedGame}
137
- detail={detail}
138
- tab={gameScreen?.tab ?? "summary"}
139
- />
140
- )}
141
- </Box>
142
- <StatusLine
143
- updatedAt={statusUpdatedAt}
144
- pollDelayMs={pollDelayMs}
145
- errorMessage={screenErrorMessage}
146
- hideAutoWhenDisabled={screen.type === "standings" || screen.type === "leaders"}
147
- />
148
- <Footer mode={screen.type} />
149
- </Box>
150
- );
151
- }
@@ -1,23 +0,0 @@
1
- import { Box, Text } from "ink";
2
- import type { Banner as BannerState } from "../../domain/types.js";
3
-
4
- type BannerProps = {
5
- banner?: BannerState;
6
- };
7
-
8
- export function Banner({ banner }: BannerProps) {
9
- if (!banner) {
10
- return null;
11
- }
12
-
13
- return (
14
- <Box marginBottom={1}>
15
- <Text backgroundColor="green" color="black" bold>
16
- {" "}
17
- {banner.title}
18
- {banner.subtitle ? ` ${banner.subtitle}` : ""}
19
- {" "}
20
- </Text>
21
- </Box>
22
- );
23
- }
@@ -1,17 +0,0 @@
1
- import { Text } from "ink";
2
- import type { AppScreen } from "../../domain/types.js";
3
-
4
- type FooterProps = {
5
- mode: AppScreen["type"];
6
- };
7
-
8
- export function Footer({ mode }: FooterProps) {
9
- const text =
10
- mode === "game"
11
- ? "<-/-> tabs esc back r refresh q quit"
12
- : mode === "standings" || mode === "leaders"
13
- ? "esc back q quit"
14
- : "<-/-> day ^/v move enter open s standings l leaders r refresh esc/q quit";
15
-
16
- return <Text dimColor>{text}</Text>;
17
- }
@@ -1,45 +0,0 @@
1
- import { Box, Text } from "ink";
2
- import type { NormalizedGame } from "../../domain/types.js";
3
- import { GameRow } from "./GameRow.js";
4
-
5
- type GameListProps = {
6
- games: NormalizedGame[];
7
- selectedGameId?: number;
8
- };
9
-
10
- function groupGames(games: NormalizedGame[]) {
11
- return {
12
- LIVE: games.filter((game) => game.section === "LIVE"),
13
- UPCOMING: games.filter((game) => game.section === "UPCOMING"),
14
- FINAL: games.filter((game) => game.section === "FINAL"),
15
- };
16
- }
17
-
18
- export function GameList({ games, selectedGameId }: GameListProps) {
19
- const groups = groupGames(games);
20
-
21
- return (
22
- <Box flexDirection="column">
23
- {(["LIVE", "UPCOMING", "FINAL"] as const).map((section) => {
24
- const sectionGames = groups[section];
25
-
26
- if (!sectionGames.length) {
27
- return null;
28
- }
29
-
30
- return (
31
- <Box key={section} flexDirection="column" marginBottom={1}>
32
- <Text dimColor>{section}</Text>
33
- {sectionGames.map((game) => (
34
- <GameRow
35
- key={game.id}
36
- game={game}
37
- selected={game.id === selectedGameId}
38
- />
39
- ))}
40
- </Box>
41
- );
42
- })}
43
- </Box>
44
- );
45
- }
@@ -1,60 +0,0 @@
1
- import { Box, Text } from "ink";
2
- import type { NormalizedGame } from "../../domain/types.js";
3
-
4
- type GameRowProps = {
5
- game: NormalizedGame;
6
- selected: boolean;
7
- };
8
-
9
- function pad(value: string, width: number, align: "start" | "end" = "start") {
10
- if (value.length >= width) {
11
- return value.slice(0, width);
12
- }
13
-
14
- return align === "end" ? value.padStart(width) : value.padEnd(width);
15
- }
16
-
17
- function scoreValue(score: number, upcoming: boolean) {
18
- return upcoming ? "-" : String(score);
19
- }
20
-
21
- function statusColor(game: NormalizedGame): string | undefined {
22
- if (game.phase === "live") {
23
- return "greenBright";
24
- }
25
-
26
- if (game.phase === "final") {
27
- return "white";
28
- }
29
-
30
- return "gray";
31
- }
32
-
33
- export function GameRow({ game, selected }: GameRowProps) {
34
- const rowColor = selected ? "cyanBright" : undefined;
35
- const upcoming = game.phase === "upcoming";
36
-
37
- return (
38
- <Box>
39
- <Text color={selected ? "cyanBright" : "gray"}>{selected ? "›" : " "}</Text>
40
- <Text> </Text>
41
- <Text color={rowColor} bold={selected}>
42
- {pad(game.away.abbrev, 3)}
43
- </Text>
44
- <Text> </Text>
45
- <Text color={rowColor}>{pad(scoreValue(game.away.score, upcoming), 2, "end")}</Text>
46
- <Text> @ </Text>
47
- <Text color={rowColor} bold={selected}>
48
- {pad(game.home.abbrev, 3)}
49
- </Text>
50
- <Text> </Text>
51
- <Text color={rowColor}>{pad(scoreValue(game.home.score, upcoming), 2, "end")}</Text>
52
- <Text> </Text>
53
- <Text color={statusColor(game)} bold>
54
- {pad(game.statusLabel, 8)}
55
- </Text>
56
- <Text> </Text>
57
- <Text color={rowColor}>{game.contextLabel}</Text>
58
- </Box>
59
- );
60
- }
@@ -1,83 +0,0 @@
1
- import { Box, Text } from "ink";
2
- import { useEffect, useState } from "react";
3
-
4
- type StatusLineProps = {
5
- updatedAt?: number;
6
- pollDelayMs?: number;
7
- errorMessage?: string;
8
- hideAutoWhenDisabled?: boolean;
9
- };
10
-
11
- const MIN_STALE_MS = 10_000;
12
- const STALE_MULTIPLIER = 2;
13
-
14
- export function StatusLine({
15
- updatedAt,
16
- pollDelayMs,
17
- errorMessage,
18
- hideAutoWhenDisabled = false,
19
- }: StatusLineProps) {
20
- const [now, setNow] = useState(() => Date.now());
21
- const staleAfterMs =
22
- pollDelayMs === undefined
23
- ? undefined
24
- : Math.max(MIN_STALE_MS, pollDelayMs * STALE_MULTIPLIER);
25
-
26
- useEffect(() => {
27
- setNow(Date.now());
28
-
29
- if (!updatedAt || staleAfterMs === undefined) {
30
- return undefined;
31
- }
32
-
33
- let timeout: NodeJS.Timeout | undefined;
34
- let interval: NodeJS.Timeout | undefined;
35
-
36
- const startStaleTicker = () => {
37
- setNow(Date.now());
38
- interval = setInterval(() => {
39
- setNow(Date.now());
40
- }, 1000);
41
- };
42
-
43
- const ageMs = Date.now() - updatedAt;
44
- if (ageMs >= staleAfterMs) {
45
- startStaleTicker();
46
- } else {
47
- timeout = setTimeout(startStaleTicker, staleAfterMs - ageMs);
48
- }
49
-
50
- return () => {
51
- if (timeout) {
52
- clearTimeout(timeout);
53
- }
54
-
55
- if (interval) {
56
- clearInterval(interval);
57
- }
58
- };
59
- }, [updatedAt, staleAfterMs]);
60
-
61
- const ageMs = updatedAt ? now - updatedAt : 0;
62
- const isStale =
63
- Boolean(updatedAt) && staleAfterMs !== undefined && ageMs >= staleAfterMs;
64
- const showAuto = !(hideAutoWhenDisabled && pollDelayMs === undefined);
65
-
66
- if (!showAuto && !errorMessage) {
67
- return null;
68
- }
69
-
70
- return (
71
- <Box>
72
- {showAuto && (
73
- <Text dimColor>
74
- {pollDelayMs === undefined
75
- ? "auto off"
76
- : `auto ${Math.round(pollDelayMs / 1000)}s`}
77
- </Text>
78
- )}
79
- {isStale && <Text color="yellow">{` | stale ${Math.floor(ageMs / 1000)}s`}</Text>}
80
- {errorMessage && <Text color="redBright">{` | error ${errorMessage}`}</Text>}
81
- </Box>
82
- );
83
- }
@@ -1,199 +0,0 @@
1
- import { Box, Text } from "ink";
2
- import type {
3
- BoxScore,
4
- DetailTab,
5
- GameSummary,
6
- NormalizedGame,
7
- NormalizedGameDetail,
8
- PlayByPlay,
9
- TeamBoxScore,
10
- } from "../../domain/types.js";
11
-
12
- type GameDetailScreenProps = {
13
- game?: NormalizedGame;
14
- detail?: NormalizedGameDetail;
15
- tab: DetailTab;
16
- };
17
-
18
- function truncate(value: string, width: number): string {
19
- if (value.length <= width) {
20
- return value.padEnd(width);
21
- }
22
-
23
- return `${value.slice(0, Math.max(0, width - 1))}…`;
24
- }
25
-
26
- function renderSummary(summary: GameSummary | undefined) {
27
- if (!summary) {
28
- return <Text dimColor>Loading summary...</Text>;
29
- }
30
-
31
- return (
32
- <Box flexDirection="column">
33
- <Text bold>Scoring</Text>
34
- {summary.scoring.length ? (
35
- summary.scoring.map((period) => (
36
- <Box key={`scoring-${period.periodLabel}`} flexDirection="column">
37
- <Text dimColor>{period.periodLabel || "Game"}</Text>
38
- {period.goals.length ? (
39
- period.goals.map((goal) => (
40
- <Text key={goal.eventId}>
41
- {goal.timeInPeriod.padStart(5)} {goal.team.padEnd(3)} {truncate(goal.scorer, 18)}{" "}
42
- {goal.strength.padEnd(3)} {goal.scoreAfter.padEnd(5)}
43
- {goal.assists.length ? ` A: ${goal.assists.join(", ")}` : ""}
44
- </Text>
45
- ))
46
- ) : (
47
- <Text dimColor>No goals</Text>
48
- )}
49
- </Box>
50
- ))
51
- ) : (
52
- <Text dimColor>No scoring data yet.</Text>
53
- )}
54
- <Box marginTop={1} flexDirection="column">
55
- <Text bold>Penalties</Text>
56
- {summary.penalties.length ? (
57
- summary.penalties.map((period) => (
58
- <Box key={`penalties-${period.periodLabel}`} flexDirection="column">
59
- <Text dimColor>{period.periodLabel || "Game"}</Text>
60
- {period.penalties.length ? (
61
- period.penalties.map((penalty, index) => (
62
- <Text key={`${penalty.player}-${index}`}>
63
- {penalty.timeInPeriod.padStart(5)} {penalty.team.padEnd(3)} {truncate(
64
- penalty.player,
65
- 22,
66
- )} {truncate(penalty.kind, 18)} {`${penalty.duration}m`.padStart(3)}
67
- </Text>
68
- ))
69
- ) : (
70
- <Text dimColor>No penalties</Text>
71
- )}
72
- </Box>
73
- ))
74
- ) : (
75
- <Text dimColor>No penalty summary available.</Text>
76
- )}
77
- </Box>
78
- <Box marginTop={1} flexDirection="column">
79
- <Text bold>Three Stars</Text>
80
- {summary.threeStars.length ? (
81
- summary.threeStars.map((star) => (
82
- <Text key={`${star.star}-${star.player}`}>
83
- {String(star.star).padStart(2)} {star.team.padEnd(3)} {truncate(star.player, 20)}{" "}
84
- {star.position.padEnd(2)} {star.statLine}
85
- </Text>
86
- ))
87
- ) : (
88
- <Text dimColor>Three stars not posted yet.</Text>
89
- )}
90
- </Box>
91
- </Box>
92
- );
93
- }
94
-
95
- function renderPlayByPlay(pbp: PlayByPlay | undefined) {
96
- if (!pbp) {
97
- return <Text dimColor>Loading play-by-play...</Text>;
98
- }
99
-
100
- const plays = pbp.plays.slice(-20).reverse();
101
-
102
- return (
103
- <Box flexDirection="column">
104
- <Text bold>Latest Plays</Text>
105
- {plays.length ? (
106
- plays.map((play) => (
107
- <Text key={play.id}>
108
- {play.periodLabel.padEnd(4)} {play.timeInPeriod.padStart(5)} {(play.team ?? "---").padEnd(3)}{" "}
109
- {truncate(play.title, 12)} {truncate(play.detail ?? "", 32)}
110
- {play.score ? ` ${play.score}` : ""}
111
- </Text>
112
- ))
113
- ) : (
114
- <Text dimColor>No plays yet.</Text>
115
- )}
116
- </Box>
117
- );
118
- }
119
-
120
- function renderTeamBox(team: TeamBoxScore) {
121
- return (
122
- <Box flexDirection="column" flexGrow={1} paddingRight={1}>
123
- <Text bold>
124
- {team.team.abbrev} {team.team.score}
125
- </Text>
126
- <Text dimColor># Player P G A P +/- S H TOI</Text>
127
- {team.skaters.slice(0, 10).map((player) => (
128
- <Text key={player.playerId}>
129
- {String(player.sweaterNumber ?? "").padStart(2)} {truncate(player.name, 15)} {player.position.padEnd(2)}{" "}
130
- {String(player.goals).padStart(1)} {String(player.assists).padStart(1)}{" "}
131
- {String(player.points).padStart(1)} {String(player.plusMinus ?? 0).padStart(3)}{" "}
132
- {String(player.shots).padStart(1)} {String(player.hits).padStart(1)} {player.toi}
133
- </Text>
134
- ))}
135
- <Box marginTop={1} flexDirection="column">
136
- <Text dimColor>Goalies</Text>
137
- {team.goalies.length ? (
138
- team.goalies.map((goalie) => (
139
- <Text key={goalie.playerId}>
140
- {String(goalie.sweaterNumber ?? "").padStart(2)} {truncate(goalie.name, 15)} SV{" "}
141
- {String(goalie.saves).padStart(2)}/{String(goalie.shotsAgainst).padEnd(2)} SV%{" "}
142
- {goalie.savePct.toFixed(3)} TOI {goalie.toi}
143
- </Text>
144
- ))
145
- ) : (
146
- <Text dimColor>No goalie stats.</Text>
147
- )}
148
- </Box>
149
- </Box>
150
- );
151
- }
152
-
153
- function renderBoxScore(box: BoxScore | undefined) {
154
- if (!box) {
155
- return <Text dimColor>Loading box score...</Text>;
156
- }
157
-
158
- return (
159
- <Box flexDirection="row">
160
- {renderTeamBox(box.away)}
161
- {renderTeamBox(box.home)}
162
- </Box>
163
- );
164
- }
165
-
166
- export function GameDetailScreen({
167
- game,
168
- detail,
169
- tab,
170
- }: GameDetailScreenProps) {
171
- const snapshot = detail?.game ?? game;
172
-
173
- if (!snapshot) {
174
- return <Text dimColor>Loading game...</Text>;
175
- }
176
-
177
- return (
178
- <Box flexDirection="column">
179
- <Text bold>
180
- {snapshot.away.abbrev} {snapshot.away.score} @ {snapshot.home.abbrev} {snapshot.home.score}
181
- </Text>
182
- <Text dimColor>
183
- {snapshot.statusLabel} | {snapshot.contextLabel} | {snapshot.venue || "NHL arena"}
184
- </Text>
185
- <Box marginTop={1} marginBottom={1}>
186
- <Text color={tab === "summary" ? "cyanBright" : undefined}>
187
- [1] Summary
188
- </Text>
189
- <Text> </Text>
190
- <Text color={tab === "pbp" ? "cyanBright" : undefined}>[2] Play-by-play</Text>
191
- <Text> </Text>
192
- <Text color={tab === "box" ? "cyanBright" : undefined}>[3] Box score</Text>
193
- </Box>
194
- {tab === "summary" && renderSummary(detail?.summary)}
195
- {tab === "pbp" && renderPlayByPlay(detail?.pbp)}
196
- {tab === "box" && renderBoxScore(detail?.box)}
197
- </Box>
198
- );
199
- }
@@ -1,92 +0,0 @@
1
- import { Box, Text, useStdout } from "ink";
2
- import type { LeaderEntry, LeaderTable, NormalizedLeaders } from "../../domain/types.js";
3
-
4
- type LeadersScreenProps = {
5
- leaders?: NormalizedLeaders;
6
- loading: boolean;
7
- errorMessage?: string;
8
- };
9
-
10
- const GRID_MIN_WIDTH = 120;
11
-
12
- function ordinal(value: number): string {
13
- const mod100 = value % 100;
14
-
15
- if (mod100 >= 11 && mod100 <= 13) {
16
- return `${value}th`;
17
- }
18
-
19
- switch (value % 10) {
20
- case 1:
21
- return `${value}st`;
22
- case 2:
23
- return `${value}nd`;
24
- case 3:
25
- return `${value}rd`;
26
- default:
27
- return `${value}th`;
28
- }
29
- }
30
-
31
- function truncate(value: string, width: number): string {
32
- if (value.length <= width) {
33
- return value.padEnd(width);
34
- }
35
-
36
- return `${value.slice(0, Math.max(0, width - 1))}…`;
37
- }
38
-
39
- function renderLeaderRow(entry: LeaderEntry) {
40
- return (
41
- <Text key={`${entry.playerId}-${entry.rank}`}>
42
- {ordinal(entry.rank).padStart(4)} {entry.teamAbbrev.padEnd(3)} {truncate(entry.player, 18)} {entry.displayValue.padStart(5)}
43
- </Text>
44
- );
45
- }
46
-
47
- function renderTable(table: LeaderTable) {
48
- return (
49
- <Box key={table.key} flexDirection="column" flexGrow={1} paddingRight={2} marginBottom={1}>
50
- <Text bold>{table.title}</Text>
51
- <Text dimColor>{` RK TM PLAYER ${table.valueLabel}`}</Text>
52
- {table.entries.length ? (
53
- table.entries.map(renderLeaderRow)
54
- ) : (
55
- <Text dimColor>No data</Text>
56
- )}
57
- </Box>
58
- );
59
- }
60
-
61
- export function LeadersScreen({
62
- leaders,
63
- loading,
64
- errorMessage,
65
- }: LeadersScreenProps) {
66
- const { stdout } = useStdout();
67
- const shouldUseGrid = (stdout.columns ?? 0) >= GRID_MIN_WIDTH;
68
-
69
- return (
70
- <Box flexDirection="column">
71
- <Box marginBottom={1}>
72
- <Text dimColor>Leaders</Text>
73
- </Box>
74
- {loading ? (
75
- <Text dimColor>Loading leaders...</Text>
76
- ) : leaders ? (
77
- <Box flexDirection="column">
78
- <Box flexDirection={shouldUseGrid ? "row" : "column"} marginBottom={1}>
79
- {leaders.skaterTables.map(renderTable)}
80
- </Box>
81
- <Box flexDirection={shouldUseGrid ? "row" : "column"}>
82
- {leaders.goalieTables.map(renderTable)}
83
- </Box>
84
- </Box>
85
- ) : errorMessage ? (
86
- <Text color="redBright">Unable to load leaders: {errorMessage}</Text>
87
- ) : (
88
- <Text dimColor>No leader data available.</Text>
89
- )}
90
- </Box>
91
- );
92
- }