krz/nba-scores
A CLI/TUI app for NBA scores.
clone: git clone https://gitbay.org/krz/nba-scores.git
1"""
2Textual TUI application for NBA scores and standings.
3"""
4
5from __future__ import annotations
6
7import asyncio
8from pathlib import Path
9from typing import ClassVar
10
11from rich.text import Text
12from textual.app import App, ComposeResult
13from textual.binding import BindingType
14from textual.containers import Horizontal
15from textual.widgets import Footer, Header, Static, TabbedContent, TabPane
16
17from nba import box_score as box_score_mod
18from nba import bracket as bracket_mod
19from nba import fetch_data
20from nba import leaders as leaders_mod
21from nba import playoff as playoff_mod
22from nba.scores import get_scoreboard_table
23from nba.standings import get_east_standings_table, get_west_standings_table
24from nba.tui.widgets import CountdownBar, ScoresWidget
25
26
27class NBAApp(App):
28 """Live NBA scores and standings TUI with auto-refresh."""
29
30 CSS_PATH = Path(__file__).parent / "styles.tcss"
31
32 BINDINGS: ClassVar[list[BindingType]] = [
33 ("q", "quit", "Quit"),
34 ("s", "show_scores", "Scores"),
35 ("t", "show_standings", "Standings"),
36 ("l", "show_leaders", "Leaders"),
37 ("p", "show_playoff", "Playoff"),
38 ("k", "show_bracket", "Bracket"),
39 ("b", "show_boxscore", "Box Score"),
40 ("r", "refresh_now", "Refresh"),
41 ("comma", "prev_category", "◀ Cat"),
42 ("full_stop", "next_category", "Cat ▶"),
43 ]
44
45 TITLE = "NBA Scores"
46
47 def __init__(self, initial_tab: str = "scores", refresh_interval: int = 60) -> None:
48 super().__init__()
49 self.initial_tab = initial_tab
50 self.refresh_interval = refresh_interval
51 self._games: dict | None = None
52 self._ranks: dict | None = None
53 self._leaders_data: dict | None = None
54 self._leaders_cat_idx: int = 0
55 self._playoff_data: dict | None = None
56 self._bracket_data: dict | None = None
57
58 def compose(self) -> ComposeResult:
59 yield Header()
60 with TabbedContent(initial=self.initial_tab):
61 with TabPane("Scores", id="scores"):
62 yield ScoresWidget("Loading...", id="scores-content")
63 with TabPane("Standings", id="standings"):
64 with Horizontal(id="standings-container"):
65 yield Static("Loading...", id="west-content")
66 yield Static("Loading...", id="east-content")
67 with TabPane("Leaders", id="leaders"):
68 yield Static("Loading...", id="leaders-content")
69 with TabPane("Playoff", id="playoff"):
70 with Horizontal(id="playoff-container"):
71 yield Static("Loading...", id="playoff-west-content")
72 yield Static("Loading...", id="playoff-east-content")
73 with TabPane("Bracket", id="bracket"):
74 yield Static("Loading...", id="bracket-content")
75 with TabPane("Box Score", id="boxscore"):
76 with Horizontal(id="boxscore-container"):
77 yield Static(
78 "Press 1–9 to load a game from the Scores tab.",
79 id="home-content",
80 )
81 yield Static("", id="away-content")
82 yield CountdownBar(self.refresh_interval, id="countdown")
83 yield Footer()
84
85 async def on_mount(self) -> None:
86 await self._do_refresh()
87 self.set_interval(self.refresh_interval, self._do_refresh)
88
89 # ------------------------------------------------------------------ #
90 # Refresh logic #
91 # ------------------------------------------------------------------ #
92
93 async def _do_refresh(self) -> None:
94 """Fetch scores, standings, leaders, playoff picture, and bracket in parallel."""
95 loop = asyncio.get_event_loop()
96 cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0]
97
98 results = await asyncio.gather(
99 loop.run_in_executor(None, fetch_data.fetch_data),
100 loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)),
101 loop.run_in_executor(None, playoff_mod.fetch_playoff_picture),
102 loop.run_in_executor(None, bracket_mod.fetch_bracket),
103 return_exceptions=True,
104 )
105
106 games_ranks, leaders_data, playoff_data, bracket_data = results
107
108 if not isinstance(games_ranks, Exception):
109 self._games, self._ranks = games_ranks
110 if not isinstance(leaders_data, Exception):
111 self._leaders_data = leaders_data
112 if not isinstance(playoff_data, Exception):
113 self._playoff_data = playoff_data
114 if not isinstance(bracket_data, Exception):
115 self._bracket_data = bracket_data
116
117 self._update_widgets()
118 self.query_one(CountdownBar).reset(self.refresh_interval)
119
120 def _update_widgets(self) -> None:
121 if self._games and self._ranks:
122 self.query_one(ScoresWidget).update(
123 Text.from_ansi(get_scoreboard_table(self._games, self._ranks))
124 )
125 self.query_one("#west-content", Static).update(
126 Text.from_ansi(get_west_standings_table(self._ranks))
127 )
128 self.query_one("#east-content", Static).update(
129 Text.from_ansi(get_east_standings_table(self._ranks))
130 )
131
132 if self._leaders_data:
133 cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0]
134 self.query_one("#leaders-content", Static).update(
135 Text.from_ansi(leaders_mod.get_leaders_table(self._leaders_data, cat))
136 )
137
138 if self._playoff_data:
139 self.query_one("#playoff-west-content", Static).update(
140 Text.from_ansi(playoff_mod.get_west_playoff_table(self._playoff_data))
141 )
142 self.query_one("#playoff-east-content", Static).update(
143 Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data))
144 )
145
146 if self._bracket_data:
147 self.query_one("#bracket-content", Static).update(
148 Text.from_ansi(bracket_mod.get_bracket_table(self._bracket_data))
149 )
150
151 # ------------------------------------------------------------------ #
152 # Key handlers #
153 # ------------------------------------------------------------------ #
154
155 def on_key(self, event) -> None:
156 """Handle 1–9 to select a game for the box score tab."""
157 char = event.character
158 if char and char.isdigit() and char != "0":
159 asyncio.create_task(self._load_box_score(int(char) - 1))
160
161 # ------------------------------------------------------------------ #
162 # Actions #
163 # ------------------------------------------------------------------ #
164
165 async def action_refresh_now(self) -> None:
166 await self._do_refresh()
167
168 def action_show_scores(self) -> None:
169 self.query_one(TabbedContent).active = "scores"
170
171 def action_show_standings(self) -> None:
172 self.query_one(TabbedContent).active = "standings"
173
174 def action_show_leaders(self) -> None:
175 self.query_one(TabbedContent).active = "leaders"
176
177 def action_show_playoff(self) -> None:
178 self.query_one(TabbedContent).active = "playoff"
179
180 def action_show_bracket(self) -> None:
181 self.query_one(TabbedContent).active = "bracket"
182
183 def action_show_boxscore(self) -> None:
184 self.query_one(TabbedContent).active = "boxscore"
185
186 async def action_prev_category(self) -> None:
187 self._leaders_cat_idx = (self._leaders_cat_idx - 1) % len(
188 leaders_mod.CATEGORIES
189 )
190 await self._refresh_leaders()
191
192 async def action_next_category(self) -> None:
193 self._leaders_cat_idx = (self._leaders_cat_idx + 1) % len(
194 leaders_mod.CATEGORIES
195 )
196 await self._refresh_leaders()
197
198 # ------------------------------------------------------------------ #
199 # Helpers #
200 # ------------------------------------------------------------------ #
201
202 async def _refresh_leaders(self) -> None:
203 cat, label = leaders_mod.CATEGORIES[self._leaders_cat_idx]
204 self.query_one("#leaders-content", Static).update(f"Loading {label} leaders...")
205 loop = asyncio.get_event_loop()
206 try:
207 data = await loop.run_in_executor(
208 None, lambda: leaders_mod.fetch_leaders(cat)
209 )
210 self._leaders_data = data
211 self.query_one("#leaders-content", Static).update(
212 Text.from_ansi(leaders_mod.get_leaders_table(data, cat))
213 )
214 except Exception as exc:
215 self.query_one("#leaders-content", Static).update(
216 f"Error loading leaders: {exc}"
217 )
218
219 async def _load_box_score(self, game_idx: int) -> None:
220 if not self._games:
221 return
222 games = self._games["scoreboard"]["games"]
223 if game_idx >= len(games):
224 return
225
226 game = games[game_idx]
227 game_id = game["gameId"]
228 home = game["homeTeam"]["teamName"]
229 away = game["awayTeam"]["teamName"]
230
231 self.query_one("#home-content", Static).update(
232 f"Loading box score: {away} @ {home}..."
233 )
234 self.query_one("#away-content", Static).update("")
235 self.query_one(TabbedContent).active = "boxscore"
236
237 loop = asyncio.get_event_loop()
238 try:
239 data = await loop.run_in_executor(
240 None, lambda: box_score_mod.fetch_box_score(game_id)
241 )
242 home_table, away_table = box_score_mod.get_box_score_tables(data)
243 self.query_one("#home-content", Static).update(Text.from_ansi(home_table))
244 self.query_one("#away-content", Static).update(Text.from_ansi(away_table))
245 except Exception as exc:
246 self.query_one("#home-content", Static).update(
247 f"Error loading box score: {exc}"
248 )
249 self.query_one("#away-content", Static).update("")