summaryrefslogtreecommitdiff
path: root/build/lib/nba/tui/app.py
blob: bfdec49434b8fc4df6e31ab7d6d301a4062dd065 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
"""
Textual TUI application for NBA scores and standings.
"""

from __future__ import annotations

import asyncio
from pathlib import Path

from rich.text import Text
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Footer, Header, Static, TabbedContent, TabPane

from nba import fetch_data
from nba import leaders as leaders_mod
from nba import playoff as playoff_mod
from nba import box_score as box_score_mod
from nba.scores import get_scoreboard_table
from nba.standings import get_east_standings_table, get_west_standings_table
from nba.tui.widgets import CountdownBar, ScoresWidget


class NBAApp(App):
    """Live NBA scores and standings TUI with auto-refresh."""

    CSS_PATH = Path(__file__).parent / "styles.tcss"

    BINDINGS = [
        ("q", "quit", "Quit"),
        ("s", "show_scores", "Scores"),
        ("t", "show_standings", "Standings"),
        ("l", "show_leaders", "Leaders"),
        ("p", "show_playoff", "Playoff"),
        ("b", "show_boxscore", "Box Score"),
        ("r", "refresh_now", "Refresh"),
        ("comma", "prev_category", "◀ Cat"),
        ("full_stop", "next_category", "Cat ▶"),
    ]

    TITLE = "NBA Scores"

    def __init__(self, initial_tab: str = "scores", refresh_interval: int = 60) -> None:
        super().__init__()
        self.initial_tab = initial_tab
        self.refresh_interval = refresh_interval
        self._games: dict | None = None
        self._ranks: dict | None = None
        self._leaders_data: dict | None = None
        self._leaders_cat_idx: int = 0
        self._playoff_data: dict | None = None

    def compose(self) -> ComposeResult:
        yield Header()
        with TabbedContent(initial=self.initial_tab):
            with TabPane("Scores", id="scores"):
                yield ScoresWidget("Loading...", id="scores-content")
            with TabPane("Standings", id="standings"):
                with Horizontal(id="standings-container"):
                    yield Static("Loading...", id="west-content")
                    yield Static("Loading...", id="east-content")
            with TabPane("Leaders", id="leaders"):
                yield Static("Loading...", id="leaders-content")
            with TabPane("Playoff", id="playoff"):
                with Horizontal(id="playoff-container"):
                    yield Static("Loading...", id="playoff-west-content")
                    yield Static("Loading...", id="playoff-east-content")
            with TabPane("Box Score", id="boxscore"):
                with Horizontal(id="boxscore-container"):
                    yield Static(
                        "Press 1–9 to load a game from the Scores tab.",
                        id="home-content",
                    )
                    yield Static("", id="away-content")
        yield CountdownBar(self.refresh_interval, id="countdown")
        yield Footer()

    async def on_mount(self) -> None:
        await self._do_refresh()
        self.set_interval(self.refresh_interval, self._do_refresh)

    # ------------------------------------------------------------------ #
    # Refresh logic                                                        #
    # ------------------------------------------------------------------ #

    async def _do_refresh(self) -> None:
        """Fetch scores, standings, leaders, and playoff picture in parallel."""
        loop = asyncio.get_event_loop()
        cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0]

        results = await asyncio.gather(
            loop.run_in_executor(None, fetch_data.fetch_data),
            loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)),
            loop.run_in_executor(None, playoff_mod.fetch_playoff_picture),
            return_exceptions=True,
        )

        games_ranks, leaders_data, playoff_data = results

        if not isinstance(games_ranks, Exception):
            self._games, self._ranks = games_ranks
        if not isinstance(leaders_data, Exception):
            self._leaders_data = leaders_data
        if not isinstance(playoff_data, Exception):
            self._playoff_data = playoff_data

        self._update_widgets()
        self.query_one(CountdownBar).reset(self.refresh_interval)

    def _update_widgets(self) -> None:
        if self._games and self._ranks:
            self.query_one(ScoresWidget).update(
                Text.from_ansi(get_scoreboard_table(self._games, self._ranks))
            )
            self.query_one("#west-content", Static).update(
                Text.from_ansi(get_west_standings_table(self._ranks))
            )
            self.query_one("#east-content", Static).update(
                Text.from_ansi(get_east_standings_table(self._ranks))
            )

        if self._leaders_data:
            cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0]
            self.query_one("#leaders-content", Static).update(
                Text.from_ansi(leaders_mod.get_leaders_table(self._leaders_data, cat))
            )

        if self._playoff_data:
            self.query_one("#playoff-west-content", Static).update(
                Text.from_ansi(playoff_mod.get_west_playoff_table(self._playoff_data))
            )
            self.query_one("#playoff-east-content", Static).update(
                Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data))
            )

    # ------------------------------------------------------------------ #
    # Key handlers                                                         #
    # ------------------------------------------------------------------ #

    def on_key(self, event) -> None:
        """Handle 1–9 to select a game for the box score tab."""
        char = event.character
        if char and char.isdigit() and char != "0":
            asyncio.create_task(self._load_box_score(int(char) - 1))

    # ------------------------------------------------------------------ #
    # Actions                                                              #
    # ------------------------------------------------------------------ #

    async def action_refresh_now(self) -> None:
        await self._do_refresh()

    def action_show_scores(self) -> None:
        self.query_one(TabbedContent).active = "scores"

    def action_show_standings(self) -> None:
        self.query_one(TabbedContent).active = "standings"

    def action_show_leaders(self) -> None:
        self.query_one(TabbedContent).active = "leaders"

    def action_show_playoff(self) -> None:
        self.query_one(TabbedContent).active = "playoff"

    def action_show_boxscore(self) -> None:
        self.query_one(TabbedContent).active = "boxscore"

    async def action_prev_category(self) -> None:
        self._leaders_cat_idx = (self._leaders_cat_idx - 1) % len(leaders_mod.CATEGORIES)
        await self._refresh_leaders()

    async def action_next_category(self) -> None:
        self._leaders_cat_idx = (self._leaders_cat_idx + 1) % len(leaders_mod.CATEGORIES)
        await self._refresh_leaders()

    # ------------------------------------------------------------------ #
    # Helpers                                                              #
    # ------------------------------------------------------------------ #

    async def _refresh_leaders(self) -> None:
        cat, label = leaders_mod.CATEGORIES[self._leaders_cat_idx]
        self.query_one("#leaders-content", Static).update(f"Loading {label} leaders...")
        loop = asyncio.get_event_loop()
        try:
            data = await loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat))
            self._leaders_data = data
            self.query_one("#leaders-content", Static).update(
                Text.from_ansi(leaders_mod.get_leaders_table(data, cat))
            )
        except Exception as exc:
            self.query_one("#leaders-content", Static).update(f"Error loading leaders: {exc}")

    async def _load_box_score(self, game_idx: int) -> None:
        if not self._games:
            return
        games = self._games["scoreboard"]["games"]
        if game_idx >= len(games):
            return

        game = games[game_idx]
        game_id = game["gameId"]
        home = game["homeTeam"]["teamName"]
        away = game["awayTeam"]["teamName"]

        self.query_one("#home-content", Static).update(
            f"Loading box score: {away} @ {home}..."
        )
        self.query_one("#away-content", Static).update("")
        self.query_one(TabbedContent).active = "boxscore"

        loop = asyncio.get_event_loop()
        try:
            data = await loop.run_in_executor(
                None, lambda: box_score_mod.fetch_box_score(game_id)
            )
            home_table, away_table = box_score_mod.get_box_score_tables(data)
            self.query_one("#home-content", Static).update(Text.from_ansi(home_table))
            self.query_one("#away-content", Static).update(Text.from_ansi(away_table))
        except Exception as exc:
            self.query_one("#home-content", Static).update(f"Error loading box score: {exc}")
            self.query_one("#away-content", Static).update("")