Commit 14d8039e4a
Verified · cmc
Layout: unified · split
README.md +1
| @@ -54,6 +54,7 @@ Optional flags: | ||
| 54 | 54 | | Standings | `t` | East / West conference standings, side by side | |
| 55 | 55 | | Leaders | `l` | Top 25 players by stat category | |
| 56 | 56 | | Playoff Picture | `p` | Conference seeding with clinch and elimination status | |
| 57 | | Bracket | `k` | Playoff series matchups and series records | | |
| 57 | 58 | | Box Score | `b` | Per-player live stats for a selected game | |
| 58 | 59 | |
| 59 | 60 | ### TUI key bindings |
build/lib/nba/bracket.py added +281
| @@ -0,0 +1,281 @@ | ||
| 1 | """ | |
| 2 | Fetches and formats the NBA playoff bracket. | |
| 3 | """ | |
| 4 | ||
| 5 | import json | |
| 6 | from collections import defaultdict | |
| 7 | from typing import Optional | |
| 8 | ||
| 9 | from nba_api.stats.endpoints.commonplayoffseries import CommonPlayoffSeries | |
| 10 | from nba_api.stats.endpoints.leaguegamelog import LeagueGameLog | |
| 11 | from nba_api.stats.static import teams | |
| 12 | ||
| 13 | BOLD = "\033[1m" | |
| 14 | END = "\033[0m" | |
| 15 | GREEN = "\033[32m" | |
| 16 | ||
| 17 | ROUND_LABELS = { | |
| 18 | 1: "First Round", | |
| 19 | 2: "Conference Semifinals", | |
| 20 | 3: "Conference Finals", | |
| 21 | 4: "NBA Finals", | |
| 22 | } | |
| 23 | ||
| 24 | ||
| 25 | def fetch_bracket() -> dict: | |
| 26 | """ | |
| 27 | Fetch playoff series and completed playoff games. | |
| 28 | ||
| 29 | CommonPlayoffSeries provides the bracket's scheduled series and games. The | |
| 30 | playoff game log is used to compute each series record from completed games. | |
| 31 | """ | |
| 32 | series_endpoint = CommonPlayoffSeries() | |
| 33 | game_log_endpoint = LeagueGameLog(season_type_all_star="Playoffs") | |
| 34 | return { | |
| 35 | "series": json.loads(series_endpoint.get_json()), | |
| 36 | "game_log": json.loads(game_log_endpoint.get_json()), | |
| 37 | } | |
| 38 | ||
| 39 | ||
| 40 | def get_bracket_table(data: dict) -> str: | |
| 41 | """Return a formatted playoff bracket.""" | |
| 42 | result_sets = data["series"]["resultSets"] | |
| 43 | series_set = next((r for r in result_sets if r["name"] == "PlayoffSeries"), None) | |
| 44 | if series_set is None or not series_set["rowSet"]: | |
| 45 | return "No playoff bracket data available." | |
| 46 | ||
| 47 | headers = series_set["headers"] | |
| 48 | idx = {header: i for i, header in enumerate(headers)} | |
| 49 | team_map = _team_map() | |
| 50 | series_games = _group_series(series_set["rowSet"], idx) | |
| 51 | series_wins = _series_wins(data["game_log"], series_games) | |
| 52 | summaries = _series_summaries(team_map, series_games, series_wins) | |
| 53 | ||
| 54 | return "\n".join( | |
| 55 | [ | |
| 56 | _center(f"{BOLD}NBA Playoff Bracket{END}", 74), | |
| 57 | "", | |
| 58 | _render_conference("Western", summaries), | |
| 59 | "", | |
| 60 | _render_finals(summaries), | |
| 61 | "", | |
| 62 | _render_conference("Eastern", summaries), | |
| 63 | ] | |
| 64 | ) | |
| 65 | ||
| 66 | ||
| 67 | def _team_map() -> dict: | |
| 68 | return { | |
| 69 | team["id"]: { | |
| 70 | "abbr": team["abbreviation"], | |
| 71 | "name": f"{team['city']} {team['nickname']}", | |
| 72 | } | |
| 73 | for team in teams.get_teams() | |
| 74 | } | |
| 75 | ||
| 76 | ||
| 77 | def _group_series(rows: list, idx: dict) -> dict: | |
| 78 | series_games = defaultdict(list) | |
| 79 | for row in rows: | |
| 80 | series_id = row[idx["SERIES_ID"]] | |
| 81 | series_games[series_id].append( | |
| 82 | { | |
| 83 | "game_id": row[idx["GAME_ID"]], | |
| 84 | "game_num": row[idx["GAME_NUM"]], | |
| 85 | "home_team_id": row[idx["HOME_TEAM_ID"]], | |
| 86 | "visitor_team_id": row[idx["VISITOR_TEAM_ID"]], | |
| 87 | } | |
| 88 | ) | |
| 89 | ||
| 90 | return { | |
| 91 | series_id: sorted(games, key=lambda game: game["game_num"]) | |
| 92 | for series_id, games in series_games.items() | |
| 93 | } | |
| 94 | ||
| 95 | ||
| 96 | def _series_wins(game_log: dict, series_games: dict) -> dict: | |
| 97 | game_to_series = { | |
| 98 | game["game_id"]: series_id | |
| 99 | for series_id, games in series_games.items() | |
| 100 | for game in games | |
| 101 | } | |
| 102 | wins = defaultdict(lambda: defaultdict(int)) | |
| 103 | ||
| 104 | for series_id in series_games: | |
| 105 | wins[series_id]["completed_games"] = set() | |
| 106 | ||
| 107 | result_set = game_log["resultSets"][0] | |
| 108 | idx = {header: i for i, header in enumerate(result_set["headers"])} | |
| 109 | for row in result_set["rowSet"]: | |
| 110 | game_id = row[idx["GAME_ID"]] | |
| 111 | series_id = game_to_series.get(game_id) | |
| 112 | if series_id is None or row[idx["WL"]] != "W": | |
| 113 | continue | |
| 114 | ||
| 115 | team_id = row[idx["TEAM_ID"]] | |
| 116 | wins[series_id][team_id] += 1 | |
| 117 | wins[series_id]["completed_games"].add(game_id) | |
| 118 | ||
| 119 | return wins | |
| 120 | ||
| 121 | ||
| 122 | def _series_summaries(team_map: dict, series_games: dict, series_wins: dict) -> dict: | |
| 123 | summaries = {} | |
| 124 | for series_id, games in series_games.items(): | |
| 125 | first_game = games[0] | |
| 126 | home_id = first_game["home_team_id"] | |
| 127 | visitor_id = first_game["visitor_team_id"] | |
| 128 | wins = series_wins[series_id] | |
| 129 | home_wins = wins.get(home_id, 0) | |
| 130 | visitor_wins = wins.get(visitor_id, 0) | |
| 131 | ||
| 132 | summaries[series_id] = { | |
| 133 | "conference": _conference(series_id), | |
| 134 | "round": _round_number(series_id), | |
| 135 | "slot": _series_slot(series_id), | |
| 136 | "home_id": home_id, | |
| 137 | "visitor_id": visitor_id, | |
| 138 | "home_abbr": _abbr(team_map, home_id), | |
| 139 | "visitor_abbr": _abbr(team_map, visitor_id), | |
| 140 | "home_wins": home_wins, | |
| 141 | "visitor_wins": visitor_wins, | |
| 142 | "winner": _winner(team_map, home_id, visitor_id, home_wins, visitor_wins), | |
| 143 | } | |
| 144 | return summaries | |
| 145 | ||
| 146 | ||
| 147 | def _render_conference(conference: str, summaries: dict) -> str: | |
| 148 | first_round = _conference_round(summaries, conference, 1) | |
| 149 | semifinals = _conference_round(summaries, conference, 2) | |
| 150 | finals = _conference_round(summaries, conference, 3) | |
| 151 | final = finals[0] if finals else None | |
| 152 | ||
| 153 | lines = [ | |
| 154 | f"{BOLD}{conference.upper()} CONFERENCE{END}", | |
| 155 | "First Round Semifinals Conference Finals", | |
| 156 | ] | |
| 157 | ||
| 158 | lines.extend( | |
| 159 | [ | |
| 160 | f"{_series_box(_slot(first_round, 0), 24)} ┐", | |
| 161 | f"{_blank(24)} ├── {_series_box(_slot(semifinals, 0), 24)} ┐", | |
| 162 | f"{_series_box(_slot(first_round, 1), 24)} ┘ {_blank(24)} │", | |
| 163 | f"{_blank(24)} {_blank(24)} ├── {_series_box(final, 24)}", | |
| 164 | f"{_series_box(_slot(first_round, 2), 24)} ┐ {_blank(24)} │", | |
| 165 | f"{_blank(24)} ├── {_series_box(_slot(semifinals, 1), 24)} ┘", | |
| 166 | f"{_series_box(_slot(first_round, 3), 24)} ┘", | |
| 167 | ] | |
| 168 | ) | |
| 169 | ||
| 170 | return "\n".join(lines) | |
| 171 | ||
| 172 | ||
| 173 | def _render_finals(summaries: dict) -> str: | |
| 174 | finals = [summary for summary in summaries.values() if summary["round"] == 4] | |
| 175 | final = finals[0] if finals else None | |
| 176 | ||
| 177 | lines = [ | |
| 178 | f"{BOLD}NBA FINALS{END}", | |
| 179 | "West Champion East Champion", | |
| 180 | f"{_series_box(final, 24)}", | |
| 181 | ] | |
| 182 | if final is None: | |
| 183 | lines.append("Winner TBD") | |
| 184 | elif final["winner"]: | |
| 185 | lines.append(f"{GREEN}{final['winner']} wins the Finals{END}") | |
| 186 | return "\n".join(lines) | |
| 187 | ||
| 188 | ||
| 189 | def _conference_round(summaries: dict, conference: str, round_number: int) -> list: | |
| 190 | return sorted( | |
| 191 | [ | |
| 192 | summary | |
| 193 | for summary in summaries.values() | |
| 194 | if summary["conference"] == conference[:4] | |
| 195 | and summary["round"] == round_number | |
| 196 | ], | |
| 197 | key=lambda summary: summary["slot"], | |
| 198 | ) | |
| 199 | ||
| 200 | ||
| 201 | def _slot(items: list, idx: int) -> Optional[dict]: | |
| 202 | return items[idx] if idx < len(items) else None | |
| 203 | ||
| 204 | ||
| 205 | def _series_box(summary: Optional[dict], width: int) -> str: | |
| 206 | if summary is None: | |
| 207 | return _blank(width, "TBD") | |
| 208 | ||
| 209 | visitor = _team_line( | |
| 210 | summary["visitor_abbr"], | |
| 211 | summary["visitor_wins"], | |
| 212 | summary["winner"] == summary["visitor_abbr"], | |
| 213 | ) | |
| 214 | home = _team_line( | |
| 215 | summary["home_abbr"], | |
| 216 | summary["home_wins"], | |
| 217 | summary["winner"] == summary["home_abbr"], | |
| 218 | ) | |
| 219 | return f"{visitor} / {home}".ljust(width) | |
| 220 | ||
| 221 | ||
| 222 | def _team_line(abbr: str, wins: int, won_series: bool) -> str: | |
| 223 | label = f"{abbr} {wins}" | |
| 224 | if won_series: | |
| 225 | return f"{label}*" | |
| 226 | return label | |
| 227 | ||
| 228 | ||
| 229 | def _winner( | |
| 230 | team_map: dict, | |
| 231 | home_id: int, | |
| 232 | visitor_id: int, | |
| 233 | home_wins: int, | |
| 234 | visitor_wins: int, | |
| 235 | ) -> str: | |
| 236 | if home_wins >= 4: | |
| 237 | return _abbr(team_map, home_id) | |
| 238 | if visitor_wins >= 4: | |
| 239 | return _abbr(team_map, visitor_id) | |
| 240 | return "" | |
| 241 | ||
| 242 | ||
| 243 | def _blank(width: int, text: str = "") -> str: | |
| 244 | return text.ljust(width) | |
| 245 | ||
| 246 | ||
| 247 | def _center(text: str, width: int) -> str: | |
| 248 | return text.center(width) | |
| 249 | ||
| 250 | ||
| 251 | def _round_number(series_id: str) -> int: | |
| 252 | try: | |
| 253 | return int(series_id[-3:-1]) | |
| 254 | except ValueError: | |
| 255 | return 0 | |
| 256 | ||
| 257 | ||
| 258 | def _series_slot(series_id: str) -> int: | |
| 259 | try: | |
| 260 | return int(series_id[-1]) | |
| 261 | except ValueError: | |
| 262 | return 0 | |
| 263 | ||
| 264 | ||
| 265 | def _conference(series_id: str) -> str: | |
| 266 | round_number = _round_number(series_id) | |
| 267 | slot = _series_slot(series_id) | |
| 268 | ||
| 269 | if round_number == 4: | |
| 270 | return "NBA" | |
| 271 | if round_number == 3: | |
| 272 | return "East" if slot == 0 else "West" | |
| 273 | if round_number == 2: | |
| 274 | return "East" if slot <= 1 else "West" | |
| 275 | if round_number == 1: | |
| 276 | return "East" if slot <= 3 else "West" | |
| 277 | return "" | |
| 278 | ||
| 279 | ||
| 280 | def _abbr(team_map: dict, team_id: int) -> str: | |
| 281 | return team_map.get(team_id, {}).get("abbr", str(team_id)) | |
build/lib/nba/tui/app.py +18 −2
| @@ -13,6 +13,7 @@ from textual.containers import Horizontal | ||
| 13 | 13 | from textual.widgets import Footer, Header, Static, TabbedContent, TabPane |
| 14 | 14 | |
| 15 | 15 | from nba import fetch_data |
| 16 | from nba import bracket as bracket_mod | |
| 16 | 17 | from nba import leaders as leaders_mod |
| 17 | 18 | from nba import playoff as playoff_mod |
| 18 | 19 | from nba import box_score as box_score_mod |
| @@ -32,6 +33,7 @@ class NBAApp(App): | ||
| 32 | 33 | ("t", "show_standings", "Standings"), |
| 33 | 34 | ("l", "show_leaders", "Leaders"), |
| 34 | 35 | ("p", "show_playoff", "Playoff"), |
| 36 | ("k", "show_bracket", "Bracket"), | |
| 35 | 37 | ("b", "show_boxscore", "Box Score"), |
| 36 | 38 | ("r", "refresh_now", "Refresh"), |
| 37 | 39 | ("comma", "prev_category", "◀ Cat"), |
| @@ -49,6 +51,7 @@ class NBAApp(App): | ||
| 49 | 51 | self._leaders_data: dict | None = None |
| 50 | 52 | self._leaders_cat_idx: int = 0 |
| 51 | 53 | self._playoff_data: dict | None = None |
| 54 | self._bracket_data: dict | None = None | |
| 52 | 55 | |
| 53 | 56 | def compose(self) -> ComposeResult: |
| 54 | 57 | yield Header() |
| @@ -65,6 +68,8 @@ class NBAApp(App): | ||
| 65 | 68 | with Horizontal(id="playoff-container"): |
| 66 | 69 | yield Static("Loading...", id="playoff-west-content") |
| 67 | 70 | yield Static("Loading...", id="playoff-east-content") |
| 71 | with TabPane("Bracket", id="bracket"): | |
| 72 | yield Static("Loading...", id="bracket-content") | |
| 68 | 73 | with TabPane("Box Score", id="boxscore"): |
| 69 | 74 | with Horizontal(id="boxscore-container"): |
| 70 | 75 | yield Static( |
| @@ -84,7 +89,7 @@ class NBAApp(App): | ||
| 84 | 89 | # ------------------------------------------------------------------ # |
| 85 | 90 | |
| 86 | 91 | async def _do_refresh(self) -> None: |
| 87 | """Fetch scores, standings, leaders, and playoff picture in parallel.""" | |
| 92 | """Fetch scores, standings, leaders, playoff picture, and bracket in parallel.""" | |
| 88 | 93 | loop = asyncio.get_event_loop() |
| 89 | 94 | cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] |
| 90 | 95 | |
| @@ -92,10 +97,11 @@ class NBAApp(App): | ||
| 92 | 97 | loop.run_in_executor(None, fetch_data.fetch_data), |
| 93 | 98 | loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)), |
| 94 | 99 | loop.run_in_executor(None, playoff_mod.fetch_playoff_picture), |
| 100 | loop.run_in_executor(None, bracket_mod.fetch_bracket), | |
| 95 | 101 | return_exceptions=True, |
| 96 | 102 | ) |
| 97 | 103 | |
| 98 | games_ranks, leaders_data, playoff_data = results | |
| 104 | games_ranks, leaders_data, playoff_data, bracket_data = results | |
| 99 | 105 | |
| 100 | 106 | if not isinstance(games_ranks, Exception): |
| 101 | 107 | self._games, self._ranks = games_ranks |
| @@ -103,6 +109,8 @@ class NBAApp(App): | ||
| 103 | 109 | self._leaders_data = leaders_data |
| 104 | 110 | if not isinstance(playoff_data, Exception): |
| 105 | 111 | self._playoff_data = playoff_data |
| 112 | if not isinstance(bracket_data, Exception): | |
| 113 | self._bracket_data = bracket_data | |
| 106 | 114 | |
| 107 | 115 | self._update_widgets() |
| 108 | 116 | self.query_one(CountdownBar).reset(self.refresh_interval) |
| @@ -133,6 +141,11 @@ class NBAApp(App): | ||
| 133 | 141 | Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) |
| 134 | 142 | ) |
| 135 | 143 | |
| 144 | if self._bracket_data: | |
| 145 | self.query_one("#bracket-content", Static).update( | |
| 146 | Text.from_ansi(bracket_mod.get_bracket_table(self._bracket_data)) | |
| 147 | ) | |
| 148 | ||
| 136 | 149 | # ------------------------------------------------------------------ # |
| 137 | 150 | # Key handlers # |
| 138 | 151 | # ------------------------------------------------------------------ # |
| @@ -162,6 +175,9 @@ class NBAApp(App): | ||
| 162 | 175 | def action_show_playoff(self) -> None: |
| 163 | 176 | self.query_one(TabbedContent).active = "playoff" |
| 164 | 177 | |
| 178 | def action_show_bracket(self) -> None: | |
| 179 | self.query_one(TabbedContent).active = "bracket" | |
| 180 | ||
| 165 | 181 | def action_show_boxscore(self) -> None: |
| 166 | 182 | self.query_one(TabbedContent).active = "boxscore" |
| 167 | 183 | |
build/lib/nba/tui/styles.tcss +3 −2
| @@ -16,7 +16,7 @@ TabPane { | ||
| 16 | 16 | width: auto; |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | /* Standings & Playoff tabs — two columns side by side */ | |
| 19 | /* Standings, Playoff, Bracket, and Box Score tabs */ | |
| 20 | 20 | #standings-container, |
| 21 | 21 | #playoff-container, |
| 22 | 22 | #boxscore-container { |
| @@ -27,7 +27,8 @@ TabPane { | ||
| 27 | 27 | #east-content, |
| 28 | 28 | #west-content, |
| 29 | 29 | #playoff-east-content, |
| 30 | #playoff-west-content { | |
| 30 | #playoff-west-content, | |
| 31 | #bracket-content { | |
| 31 | 32 | width: 1fr; |
| 32 | 33 | overflow: auto auto; |
| 33 | 34 | padding-right: 2; |
build/lib/nba/tui/widgets.py +2 −1
| @@ -41,7 +41,8 @@ class CountdownBar(Static): | ||
| 41 | 41 | else: |
| 42 | 42 | self.update( |
| 43 | 43 | f"Next refresh in {value}s | " |
| 44 | "\\[1-9] Box Score | \\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" | |
| 44 | "\\[1-9] Box Score | \\[k] Bracket | " | |
| 45 | "\\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" | |
| 45 | 46 | ) |
| 46 | 47 | |
| 47 | 48 | def reset(self, interval: int) -> None: |
nba/bracket.py added +281
| @@ -0,0 +1,281 @@ | ||
| 1 | """ | |
| 2 | Fetches and formats the NBA playoff bracket. | |
| 3 | """ | |
| 4 | ||
| 5 | import json | |
| 6 | from collections import defaultdict | |
| 7 | from typing import Optional | |
| 8 | ||
| 9 | from nba_api.stats.endpoints.commonplayoffseries import CommonPlayoffSeries | |
| 10 | from nba_api.stats.endpoints.leaguegamelog import LeagueGameLog | |
| 11 | from nba_api.stats.static import teams | |
| 12 | ||
| 13 | BOLD = "\033[1m" | |
| 14 | END = "\033[0m" | |
| 15 | GREEN = "\033[32m" | |
| 16 | ||
| 17 | ROUND_LABELS = { | |
| 18 | 1: "First Round", | |
| 19 | 2: "Conference Semifinals", | |
| 20 | 3: "Conference Finals", | |
| 21 | 4: "NBA Finals", | |
| 22 | } | |
| 23 | ||
| 24 | ||
| 25 | def fetch_bracket() -> dict: | |
| 26 | """ | |
| 27 | Fetch playoff series and completed playoff games. | |
| 28 | ||
| 29 | CommonPlayoffSeries provides the bracket's scheduled series and games. The | |
| 30 | playoff game log is used to compute each series record from completed games. | |
| 31 | """ | |
| 32 | series_endpoint = CommonPlayoffSeries() | |
| 33 | game_log_endpoint = LeagueGameLog(season_type_all_star="Playoffs") | |
| 34 | return { | |
| 35 | "series": json.loads(series_endpoint.get_json()), | |
| 36 | "game_log": json.loads(game_log_endpoint.get_json()), | |
| 37 | } | |
| 38 | ||
| 39 | ||
| 40 | def get_bracket_table(data: dict) -> str: | |
| 41 | """Return a formatted playoff bracket.""" | |
| 42 | result_sets = data["series"]["resultSets"] | |
| 43 | series_set = next((r for r in result_sets if r["name"] == "PlayoffSeries"), None) | |
| 44 | if series_set is None or not series_set["rowSet"]: | |
| 45 | return "No playoff bracket data available." | |
| 46 | ||
| 47 | headers = series_set["headers"] | |
| 48 | idx = {header: i for i, header in enumerate(headers)} | |
| 49 | team_map = _team_map() | |
| 50 | series_games = _group_series(series_set["rowSet"], idx) | |
| 51 | series_wins = _series_wins(data["game_log"], series_games) | |
| 52 | summaries = _series_summaries(team_map, series_games, series_wins) | |
| 53 | ||
| 54 | return "\n".join( | |
| 55 | [ | |
| 56 | _center(f"{BOLD}NBA Playoff Bracket{END}", 74), | |
| 57 | "", | |
| 58 | _render_conference("Western", summaries), | |
| 59 | "", | |
| 60 | _render_finals(summaries), | |
| 61 | "", | |
| 62 | _render_conference("Eastern", summaries), | |
| 63 | ] | |
| 64 | ) | |
| 65 | ||
| 66 | ||
| 67 | def _team_map() -> dict: | |
| 68 | return { | |
| 69 | team["id"]: { | |
| 70 | "abbr": team["abbreviation"], | |
| 71 | "name": f"{team['city']} {team['nickname']}", | |
| 72 | } | |
| 73 | for team in teams.get_teams() | |
| 74 | } | |
| 75 | ||
| 76 | ||
| 77 | def _group_series(rows: list, idx: dict) -> dict: | |
| 78 | series_games = defaultdict(list) | |
| 79 | for row in rows: | |
| 80 | series_id = row[idx["SERIES_ID"]] | |
| 81 | series_games[series_id].append( | |
| 82 | { | |
| 83 | "game_id": row[idx["GAME_ID"]], | |
| 84 | "game_num": row[idx["GAME_NUM"]], | |
| 85 | "home_team_id": row[idx["HOME_TEAM_ID"]], | |
| 86 | "visitor_team_id": row[idx["VISITOR_TEAM_ID"]], | |
| 87 | } | |
| 88 | ) | |
| 89 | ||
| 90 | return { | |
| 91 | series_id: sorted(games, key=lambda game: game["game_num"]) | |
| 92 | for series_id, games in series_games.items() | |
| 93 | } | |
| 94 | ||
| 95 | ||
| 96 | def _series_wins(game_log: dict, series_games: dict) -> dict: | |
| 97 | game_to_series = { | |
| 98 | game["game_id"]: series_id | |
| 99 | for series_id, games in series_games.items() | |
| 100 | for game in games | |
| 101 | } | |
| 102 | wins = defaultdict(lambda: defaultdict(int)) | |
| 103 | ||
| 104 | for series_id in series_games: | |
| 105 | wins[series_id]["completed_games"] = set() | |
| 106 | ||
| 107 | result_set = game_log["resultSets"][0] | |
| 108 | idx = {header: i for i, header in enumerate(result_set["headers"])} | |
| 109 | for row in result_set["rowSet"]: | |
| 110 | game_id = row[idx["GAME_ID"]] | |
| 111 | series_id = game_to_series.get(game_id) | |
| 112 | if series_id is None or row[idx["WL"]] != "W": | |
| 113 | continue | |
| 114 | ||
| 115 | team_id = row[idx["TEAM_ID"]] | |
| 116 | wins[series_id][team_id] += 1 | |
| 117 | wins[series_id]["completed_games"].add(game_id) | |
| 118 | ||
| 119 | return wins | |
| 120 | ||
| 121 | ||
| 122 | def _series_summaries(team_map: dict, series_games: dict, series_wins: dict) -> dict: | |
| 123 | summaries = {} | |
| 124 | for series_id, games in series_games.items(): | |
| 125 | first_game = games[0] | |
| 126 | home_id = first_game["home_team_id"] | |
| 127 | visitor_id = first_game["visitor_team_id"] | |
| 128 | wins = series_wins[series_id] | |
| 129 | home_wins = wins.get(home_id, 0) | |
| 130 | visitor_wins = wins.get(visitor_id, 0) | |
| 131 | ||
| 132 | summaries[series_id] = { | |
| 133 | "conference": _conference(series_id), | |
| 134 | "round": _round_number(series_id), | |
| 135 | "slot": _series_slot(series_id), | |
| 136 | "home_id": home_id, | |
| 137 | "visitor_id": visitor_id, | |
| 138 | "home_abbr": _abbr(team_map, home_id), | |
| 139 | "visitor_abbr": _abbr(team_map, visitor_id), | |
| 140 | "home_wins": home_wins, | |
| 141 | "visitor_wins": visitor_wins, | |
| 142 | "winner": _winner(team_map, home_id, visitor_id, home_wins, visitor_wins), | |
| 143 | } | |
| 144 | return summaries | |
| 145 | ||
| 146 | ||
| 147 | def _render_conference(conference: str, summaries: dict) -> str: | |
| 148 | first_round = _conference_round(summaries, conference, 1) | |
| 149 | semifinals = _conference_round(summaries, conference, 2) | |
| 150 | finals = _conference_round(summaries, conference, 3) | |
| 151 | final = finals[0] if finals else None | |
| 152 | ||
| 153 | lines = [ | |
| 154 | f"{BOLD}{conference.upper()} CONFERENCE{END}", | |
| 155 | "First Round Semifinals Conference Finals", | |
| 156 | ] | |
| 157 | ||
| 158 | lines.extend( | |
| 159 | [ | |
| 160 | f"{_series_box(_slot(first_round, 0), 24)} ┐", | |
| 161 | f"{_blank(24)} ├── {_series_box(_slot(semifinals, 0), 24)} ┐", | |
| 162 | f"{_series_box(_slot(first_round, 1), 24)} ┘ {_blank(24)} │", | |
| 163 | f"{_blank(24)} {_blank(24)} ├── {_series_box(final, 24)}", | |
| 164 | f"{_series_box(_slot(first_round, 2), 24)} ┐ {_blank(24)} │", | |
| 165 | f"{_blank(24)} ├── {_series_box(_slot(semifinals, 1), 24)} ┘", | |
| 166 | f"{_series_box(_slot(first_round, 3), 24)} ┘", | |
| 167 | ] | |
| 168 | ) | |
| 169 | ||
| 170 | return "\n".join(lines) | |
| 171 | ||
| 172 | ||
| 173 | def _render_finals(summaries: dict) -> str: | |
| 174 | finals = [summary for summary in summaries.values() if summary["round"] == 4] | |
| 175 | final = finals[0] if finals else None | |
| 176 | ||
| 177 | lines = [ | |
| 178 | f"{BOLD}NBA FINALS{END}", | |
| 179 | "West Champion East Champion", | |
| 180 | f"{_series_box(final, 24)}", | |
| 181 | ] | |
| 182 | if final is None: | |
| 183 | lines.append("Winner TBD") | |
| 184 | elif final["winner"]: | |
| 185 | lines.append(f"{GREEN}{final['winner']} wins the Finals{END}") | |
| 186 | return "\n".join(lines) | |
| 187 | ||
| 188 | ||
| 189 | def _conference_round(summaries: dict, conference: str, round_number: int) -> list: | |
| 190 | return sorted( | |
| 191 | [ | |
| 192 | summary | |
| 193 | for summary in summaries.values() | |
| 194 | if summary["conference"] == conference[:4] | |
| 195 | and summary["round"] == round_number | |
| 196 | ], | |
| 197 | key=lambda summary: summary["slot"], | |
| 198 | ) | |
| 199 | ||
| 200 | ||
| 201 | def _slot(items: list, idx: int) -> Optional[dict]: | |
| 202 | return items[idx] if idx < len(items) else None | |
| 203 | ||
| 204 | ||
| 205 | def _series_box(summary: Optional[dict], width: int) -> str: | |
| 206 | if summary is None: | |
| 207 | return _blank(width, "TBD") | |
| 208 | ||
| 209 | visitor = _team_line( | |
| 210 | summary["visitor_abbr"], | |
| 211 | summary["visitor_wins"], | |
| 212 | summary["winner"] == summary["visitor_abbr"], | |
| 213 | ) | |
| 214 | home = _team_line( | |
| 215 | summary["home_abbr"], | |
| 216 | summary["home_wins"], | |
| 217 | summary["winner"] == summary["home_abbr"], | |
| 218 | ) | |
| 219 | return f"{visitor} / {home}".ljust(width) | |
| 220 | ||
| 221 | ||
| 222 | def _team_line(abbr: str, wins: int, won_series: bool) -> str: | |
| 223 | label = f"{abbr} {wins}" | |
| 224 | if won_series: | |
| 225 | return f"{label}*" | |
| 226 | return label | |
| 227 | ||
| 228 | ||
| 229 | def _winner( | |
| 230 | team_map: dict, | |
| 231 | home_id: int, | |
| 232 | visitor_id: int, | |
| 233 | home_wins: int, | |
| 234 | visitor_wins: int, | |
| 235 | ) -> str: | |
| 236 | if home_wins >= 4: | |
| 237 | return _abbr(team_map, home_id) | |
| 238 | if visitor_wins >= 4: | |
| 239 | return _abbr(team_map, visitor_id) | |
| 240 | return "" | |
| 241 | ||
| 242 | ||
| 243 | def _blank(width: int, text: str = "") -> str: | |
| 244 | return text.ljust(width) | |
| 245 | ||
| 246 | ||
| 247 | def _center(text: str, width: int) -> str: | |
| 248 | return text.center(width) | |
| 249 | ||
| 250 | ||
| 251 | def _round_number(series_id: str) -> int: | |
| 252 | try: | |
| 253 | return int(series_id[-3:-1]) | |
| 254 | except ValueError: | |
| 255 | return 0 | |
| 256 | ||
| 257 | ||
| 258 | def _series_slot(series_id: str) -> int: | |
| 259 | try: | |
| 260 | return int(series_id[-1]) | |
| 261 | except ValueError: | |
| 262 | return 0 | |
| 263 | ||
| 264 | ||
| 265 | def _conference(series_id: str) -> str: | |
| 266 | round_number = _round_number(series_id) | |
| 267 | slot = _series_slot(series_id) | |
| 268 | ||
| 269 | if round_number == 4: | |
| 270 | return "NBA" | |
| 271 | if round_number == 3: | |
| 272 | return "East" if slot == 0 else "West" | |
| 273 | if round_number == 2: | |
| 274 | return "East" if slot <= 1 else "West" | |
| 275 | if round_number == 1: | |
| 276 | return "East" if slot <= 3 else "West" | |
| 277 | return "" | |
| 278 | ||
| 279 | ||
| 280 | def _abbr(team_map: dict, team_id: int) -> str: | |
| 281 | return team_map.get(team_id, {}).get("abbr", str(team_id)) | |
nba/tui/app.py +18 −2
| @@ -13,6 +13,7 @@ from textual.containers import Horizontal | ||
| 13 | 13 | from textual.widgets import Footer, Header, Static, TabbedContent, TabPane |
| 14 | 14 | |
| 15 | 15 | from nba import fetch_data |
| 16 | from nba import bracket as bracket_mod | |
| 16 | 17 | from nba import leaders as leaders_mod |
| 17 | 18 | from nba import playoff as playoff_mod |
| 18 | 19 | from nba import box_score as box_score_mod |
| @@ -32,6 +33,7 @@ class NBAApp(App): | ||
| 32 | 33 | ("t", "show_standings", "Standings"), |
| 33 | 34 | ("l", "show_leaders", "Leaders"), |
| 34 | 35 | ("p", "show_playoff", "Playoff"), |
| 36 | ("k", "show_bracket", "Bracket"), | |
| 35 | 37 | ("b", "show_boxscore", "Box Score"), |
| 36 | 38 | ("r", "refresh_now", "Refresh"), |
| 37 | 39 | ("comma", "prev_category", "◀ Cat"), |
| @@ -49,6 +51,7 @@ class NBAApp(App): | ||
| 49 | 51 | self._leaders_data: dict | None = None |
| 50 | 52 | self._leaders_cat_idx: int = 0 |
| 51 | 53 | self._playoff_data: dict | None = None |
| 54 | self._bracket_data: dict | None = None | |
| 52 | 55 | |
| 53 | 56 | def compose(self) -> ComposeResult: |
| 54 | 57 | yield Header() |
| @@ -65,6 +68,8 @@ class NBAApp(App): | ||
| 65 | 68 | with Horizontal(id="playoff-container"): |
| 66 | 69 | yield Static("Loading...", id="playoff-west-content") |
| 67 | 70 | yield Static("Loading...", id="playoff-east-content") |
| 71 | with TabPane("Bracket", id="bracket"): | |
| 72 | yield Static("Loading...", id="bracket-content") | |
| 68 | 73 | with TabPane("Box Score", id="boxscore"): |
| 69 | 74 | with Horizontal(id="boxscore-container"): |
| 70 | 75 | yield Static( |
| @@ -84,7 +89,7 @@ class NBAApp(App): | ||
| 84 | 89 | # ------------------------------------------------------------------ # |
| 85 | 90 | |
| 86 | 91 | async def _do_refresh(self) -> None: |
| 87 | """Fetch scores, standings, leaders, and playoff picture in parallel.""" | |
| 92 | """Fetch scores, standings, leaders, playoff picture, and bracket in parallel.""" | |
| 88 | 93 | loop = asyncio.get_event_loop() |
| 89 | 94 | cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] |
| 90 | 95 | |
| @@ -92,10 +97,11 @@ class NBAApp(App): | ||
| 92 | 97 | loop.run_in_executor(None, fetch_data.fetch_data), |
| 93 | 98 | loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)), |
| 94 | 99 | loop.run_in_executor(None, playoff_mod.fetch_playoff_picture), |
| 100 | loop.run_in_executor(None, bracket_mod.fetch_bracket), | |
| 95 | 101 | return_exceptions=True, |
| 96 | 102 | ) |
| 97 | 103 | |
| 98 | games_ranks, leaders_data, playoff_data = results | |
| 104 | games_ranks, leaders_data, playoff_data, bracket_data = results | |
| 99 | 105 | |
| 100 | 106 | if not isinstance(games_ranks, Exception): |
| 101 | 107 | self._games, self._ranks = games_ranks |
| @@ -103,6 +109,8 @@ class NBAApp(App): | ||
| 103 | 109 | self._leaders_data = leaders_data |
| 104 | 110 | if not isinstance(playoff_data, Exception): |
| 105 | 111 | self._playoff_data = playoff_data |
| 112 | if not isinstance(bracket_data, Exception): | |
| 113 | self._bracket_data = bracket_data | |
| 106 | 114 | |
| 107 | 115 | self._update_widgets() |
| 108 | 116 | self.query_one(CountdownBar).reset(self.refresh_interval) |
| @@ -133,6 +141,11 @@ class NBAApp(App): | ||
| 133 | 141 | Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) |
| 134 | 142 | ) |
| 135 | 143 | |
| 144 | if self._bracket_data: | |
| 145 | self.query_one("#bracket-content", Static).update( | |
| 146 | Text.from_ansi(bracket_mod.get_bracket_table(self._bracket_data)) | |
| 147 | ) | |
| 148 | ||
| 136 | 149 | # ------------------------------------------------------------------ # |
| 137 | 150 | # Key handlers # |
| 138 | 151 | # ------------------------------------------------------------------ # |
| @@ -162,6 +175,9 @@ class NBAApp(App): | ||
| 162 | 175 | def action_show_playoff(self) -> None: |
| 163 | 176 | self.query_one(TabbedContent).active = "playoff" |
| 164 | 177 | |
| 178 | def action_show_bracket(self) -> None: | |
| 179 | self.query_one(TabbedContent).active = "bracket" | |
| 180 | ||
| 165 | 181 | def action_show_boxscore(self) -> None: |
| 166 | 182 | self.query_one(TabbedContent).active = "boxscore" |
| 167 | 183 | |
nba/tui/styles.tcss +3 −2
| @@ -16,7 +16,7 @@ TabPane { | ||
| 16 | 16 | width: auto; |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | /* Standings & Playoff tabs — two columns side by side */ | |
| 19 | /* Standings, Playoff, Bracket, and Box Score tabs */ | |
| 20 | 20 | #standings-container, |
| 21 | 21 | #playoff-container, |
| 22 | 22 | #boxscore-container { |
| @@ -27,7 +27,8 @@ TabPane { | ||
| 27 | 27 | #east-content, |
| 28 | 28 | #west-content, |
| 29 | 29 | #playoff-east-content, |
| 30 | #playoff-west-content { | |
| 30 | #playoff-west-content, | |
| 31 | #bracket-content { | |
| 31 | 32 | width: 1fr; |
| 32 | 33 | overflow: auto auto; |
| 33 | 34 | padding-right: 2; |
nba/tui/widgets.py +2 −1
| @@ -41,7 +41,8 @@ class CountdownBar(Static): | ||
| 41 | 41 | else: |
| 42 | 42 | self.update( |
| 43 | 43 | f"Next refresh in {value}s | " |
| 44 | "\\[1-9] Box Score | \\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" | |
| 44 | "\\[1-9] Box Score | \\[k] Bracket | " | |
| 45 | "\\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" | |
| 45 | 46 | ) |
| 46 | 47 | |
| 47 | 48 | def reset(self, interval: int) -> None: |
nba_scores.egg-info/PKG-INFO +4 −11
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | Metadata-Version: 2.4 |
| 2 | 2 | Name: nba-scores |
| 3 | Version: 0.1.7 | |
| 3 | Version: 0.2.2 | |
| 4 | 4 | Summary: A CLI interface to get current NBA scores and standings. |
| 5 | Author-email: Christian Cleberg <hello@cmc.pub> | |
| 5 | Author-email: Christian Cleberg <hello@cleberg.net> | |
| 6 | 6 | License-Expression: GPL-3.0-or-later |
| 7 | 7 | Project-URL: Homepage, https://git.cleberg.net/nba-scores.git |
| 8 | 8 | Project-URL: Issues, https://git.cleberg.net/nba-scores.git |
| @@ -38,18 +38,10 @@ auto-refresh. | ||
| 38 | 38 | |
| 39 | 39 | [Back to top](#table-of-contents) |
| 40 | 40 | |
| 41 | ## PyPi | |
| 42 | ||
| 43 | ```shell | |
| 44 | pipx install nba-scores | |
| 45 | ``` | |
| 46 | ||
| 47 | ## Manual | |
| 48 | ||
| 49 | 41 | ```shell |
| 50 | 42 | git clone https://git.cleberg.net/nba-scores.git |
| 51 | 43 | cd nba-scores |
| 52 | uv sync | |
| 44 | pipx install . | |
| 53 | 45 | ``` |
| 54 | 46 | |
| 55 | 47 | # Usage |
| @@ -81,6 +73,7 @@ Optional flags: | ||
| 81 | 73 | | Standings | `t` | East / West conference standings, side by side | |
| 82 | 74 | | Leaders | `l` | Top 25 players by stat category | |
| 83 | 75 | | Playoff Picture | `p` | Conference seeding with clinch and elimination status | |
| 76 | | Bracket | `k` | Playoff series matchups and series records | | |
| 84 | 77 | | Box Score | `b` | Per-player live stats for a selected game | |
| 85 | 78 | |
| 86 | 79 | ### TUI key bindings |
nba_scores.egg-info/SOURCES.txt +1
| @@ -4,6 +4,7 @@ pyproject.toml | ||
| 4 | 4 | nba/__init__.py |
| 5 | 5 | nba/__main__.py |
| 6 | 6 | nba/box_score.py |
| 7 | nba/bracket.py | |
| 7 | 8 | nba/cli.py |
| 8 | 9 | nba/fetch_data.py |
| 9 | 10 | nba/leaders.py |
pyproject.toml +2 −2
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | [project] |
| 2 | 2 | name = "nba-scores" |
| 3 | version = "0.1.7" | |
| 3 | version = "0.2.2" | |
| 4 | 4 | authors = [ |
| 5 | { name="Christian Cleberg", email="hello@cmc.pub" }, | |
| 5 | { name="Christian Cleberg", email="hello@cleberg.net" }, | |
| 6 | 6 | ] |
| 7 | 7 | description = "A CLI interface to get current NBA scores and standings." |
| 8 | 8 | readme = "README.md" |
uv.lock +1 −1
| @@ -361,7 +361,7 @@ wheels = [ | ||
| 361 | 361 | |
| 362 | 362 | [[package]] |
| 363 | 363 | name = "nba-scores" |
| 364 | version = "0.1.7" | |
| 364 | version = "0.2.1" | |
| 365 | 365 | source = { editable = "." } |
| 366 | 366 | dependencies = [ |
| 367 | 367 | { name = "argparse" }, |