krz/nba-scores

A CLI/TUI app for NBA scores.

clone: git clone https://gitbay.org/krz/nba-scores.git

main: nba/tui/app.py · raw

  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("")