diff options
Diffstat (limited to 'build')
| -rw-r--r-- | build/lib/nba/bracket.py | 281 | ||||
| -rw-r--r-- | build/lib/nba/tui/app.py | 20 | ||||
| -rw-r--r-- | build/lib/nba/tui/styles.tcss | 5 | ||||
| -rw-r--r-- | build/lib/nba/tui/widgets.py | 3 |
4 files changed, 304 insertions, 5 deletions
diff --git a/build/lib/nba/bracket.py b/build/lib/nba/bracket.py new file mode 100644 index 0000000..a7f98b5 --- /dev/null +++ b/build/lib/nba/bracket.py @@ -0,0 +1,281 @@ +""" +Fetches and formats the NBA playoff bracket. +""" + +import json +from collections import defaultdict +from typing import Optional + +from nba_api.stats.endpoints.commonplayoffseries import CommonPlayoffSeries +from nba_api.stats.endpoints.leaguegamelog import LeagueGameLog +from nba_api.stats.static import teams + +BOLD = "\033[1m" +END = "\033[0m" +GREEN = "\033[32m" + +ROUND_LABELS = { + 1: "First Round", + 2: "Conference Semifinals", + 3: "Conference Finals", + 4: "NBA Finals", +} + + +def fetch_bracket() -> dict: + """ + Fetch playoff series and completed playoff games. + + CommonPlayoffSeries provides the bracket's scheduled series and games. The + playoff game log is used to compute each series record from completed games. + """ + series_endpoint = CommonPlayoffSeries() + game_log_endpoint = LeagueGameLog(season_type_all_star="Playoffs") + return { + "series": json.loads(series_endpoint.get_json()), + "game_log": json.loads(game_log_endpoint.get_json()), + } + + +def get_bracket_table(data: dict) -> str: + """Return a formatted playoff bracket.""" + result_sets = data["series"]["resultSets"] + series_set = next((r for r in result_sets if r["name"] == "PlayoffSeries"), None) + if series_set is None or not series_set["rowSet"]: + return "No playoff bracket data available." + + headers = series_set["headers"] + idx = {header: i for i, header in enumerate(headers)} + team_map = _team_map() + series_games = _group_series(series_set["rowSet"], idx) + series_wins = _series_wins(data["game_log"], series_games) + summaries = _series_summaries(team_map, series_games, series_wins) + + return "\n".join( + [ + _center(f"{BOLD}NBA Playoff Bracket{END}", 74), + "", + _render_conference("Western", summaries), + "", + _render_finals(summaries), + "", + _render_conference("Eastern", summaries), + ] + ) + + +def _team_map() -> dict: + return { + team["id"]: { + "abbr": team["abbreviation"], + "name": f"{team['city']} {team['nickname']}", + } + for team in teams.get_teams() + } + + +def _group_series(rows: list, idx: dict) -> dict: + series_games = defaultdict(list) + for row in rows: + series_id = row[idx["SERIES_ID"]] + series_games[series_id].append( + { + "game_id": row[idx["GAME_ID"]], + "game_num": row[idx["GAME_NUM"]], + "home_team_id": row[idx["HOME_TEAM_ID"]], + "visitor_team_id": row[idx["VISITOR_TEAM_ID"]], + } + ) + + return { + series_id: sorted(games, key=lambda game: game["game_num"]) + for series_id, games in series_games.items() + } + + +def _series_wins(game_log: dict, series_games: dict) -> dict: + game_to_series = { + game["game_id"]: series_id + for series_id, games in series_games.items() + for game in games + } + wins = defaultdict(lambda: defaultdict(int)) + + for series_id in series_games: + wins[series_id]["completed_games"] = set() + + result_set = game_log["resultSets"][0] + idx = {header: i for i, header in enumerate(result_set["headers"])} + for row in result_set["rowSet"]: + game_id = row[idx["GAME_ID"]] + series_id = game_to_series.get(game_id) + if series_id is None or row[idx["WL"]] != "W": + continue + + team_id = row[idx["TEAM_ID"]] + wins[series_id][team_id] += 1 + wins[series_id]["completed_games"].add(game_id) + + return wins + + +def _series_summaries(team_map: dict, series_games: dict, series_wins: dict) -> dict: + summaries = {} + for series_id, games in series_games.items(): + first_game = games[0] + home_id = first_game["home_team_id"] + visitor_id = first_game["visitor_team_id"] + wins = series_wins[series_id] + home_wins = wins.get(home_id, 0) + visitor_wins = wins.get(visitor_id, 0) + + summaries[series_id] = { + "conference": _conference(series_id), + "round": _round_number(series_id), + "slot": _series_slot(series_id), + "home_id": home_id, + "visitor_id": visitor_id, + "home_abbr": _abbr(team_map, home_id), + "visitor_abbr": _abbr(team_map, visitor_id), + "home_wins": home_wins, + "visitor_wins": visitor_wins, + "winner": _winner(team_map, home_id, visitor_id, home_wins, visitor_wins), + } + return summaries + + +def _render_conference(conference: str, summaries: dict) -> str: + first_round = _conference_round(summaries, conference, 1) + semifinals = _conference_round(summaries, conference, 2) + finals = _conference_round(summaries, conference, 3) + final = finals[0] if finals else None + + lines = [ + f"{BOLD}{conference.upper()} CONFERENCE{END}", + "First Round Semifinals Conference Finals", + ] + + lines.extend( + [ + f"{_series_box(_slot(first_round, 0), 24)} ┐", + f"{_blank(24)} ├── {_series_box(_slot(semifinals, 0), 24)} ┐", + f"{_series_box(_slot(first_round, 1), 24)} ┘ {_blank(24)} │", + f"{_blank(24)} {_blank(24)} ├── {_series_box(final, 24)}", + f"{_series_box(_slot(first_round, 2), 24)} ┐ {_blank(24)} │", + f"{_blank(24)} ├── {_series_box(_slot(semifinals, 1), 24)} ┘", + f"{_series_box(_slot(first_round, 3), 24)} ┘", + ] + ) + + return "\n".join(lines) + + +def _render_finals(summaries: dict) -> str: + finals = [summary for summary in summaries.values() if summary["round"] == 4] + final = finals[0] if finals else None + + lines = [ + f"{BOLD}NBA FINALS{END}", + "West Champion East Champion", + f"{_series_box(final, 24)}", + ] + if final is None: + lines.append("Winner TBD") + elif final["winner"]: + lines.append(f"{GREEN}{final['winner']} wins the Finals{END}") + return "\n".join(lines) + + +def _conference_round(summaries: dict, conference: str, round_number: int) -> list: + return sorted( + [ + summary + for summary in summaries.values() + if summary["conference"] == conference[:4] + and summary["round"] == round_number + ], + key=lambda summary: summary["slot"], + ) + + +def _slot(items: list, idx: int) -> Optional[dict]: + return items[idx] if idx < len(items) else None + + +def _series_box(summary: Optional[dict], width: int) -> str: + if summary is None: + return _blank(width, "TBD") + + visitor = _team_line( + summary["visitor_abbr"], + summary["visitor_wins"], + summary["winner"] == summary["visitor_abbr"], + ) + home = _team_line( + summary["home_abbr"], + summary["home_wins"], + summary["winner"] == summary["home_abbr"], + ) + return f"{visitor} / {home}".ljust(width) + + +def _team_line(abbr: str, wins: int, won_series: bool) -> str: + label = f"{abbr} {wins}" + if won_series: + return f"{label}*" + return label + + +def _winner( + team_map: dict, + home_id: int, + visitor_id: int, + home_wins: int, + visitor_wins: int, +) -> str: + if home_wins >= 4: + return _abbr(team_map, home_id) + if visitor_wins >= 4: + return _abbr(team_map, visitor_id) + return "" + + +def _blank(width: int, text: str = "") -> str: + return text.ljust(width) + + +def _center(text: str, width: int) -> str: + return text.center(width) + + +def _round_number(series_id: str) -> int: + try: + return int(series_id[-3:-1]) + except ValueError: + return 0 + + +def _series_slot(series_id: str) -> int: + try: + return int(series_id[-1]) + except ValueError: + return 0 + + +def _conference(series_id: str) -> str: + round_number = _round_number(series_id) + slot = _series_slot(series_id) + + if round_number == 4: + return "NBA" + if round_number == 3: + return "East" if slot == 0 else "West" + if round_number == 2: + return "East" if slot <= 1 else "West" + if round_number == 1: + return "East" if slot <= 3 else "West" + return "" + + +def _abbr(team_map: dict, team_id: int) -> str: + return team_map.get(team_id, {}).get("abbr", str(team_id)) diff --git a/build/lib/nba/tui/app.py b/build/lib/nba/tui/app.py index bfdec49..cfa72a6 100644 --- a/build/lib/nba/tui/app.py +++ b/build/lib/nba/tui/app.py @@ -13,6 +13,7 @@ from textual.containers import Horizontal from textual.widgets import Footer, Header, Static, TabbedContent, TabPane from nba import fetch_data +from nba import bracket as bracket_mod from nba import leaders as leaders_mod from nba import playoff as playoff_mod from nba import box_score as box_score_mod @@ -32,6 +33,7 @@ class NBAApp(App): ("t", "show_standings", "Standings"), ("l", "show_leaders", "Leaders"), ("p", "show_playoff", "Playoff"), + ("k", "show_bracket", "Bracket"), ("b", "show_boxscore", "Box Score"), ("r", "refresh_now", "Refresh"), ("comma", "prev_category", "◀ Cat"), @@ -49,6 +51,7 @@ class NBAApp(App): self._leaders_data: dict | None = None self._leaders_cat_idx: int = 0 self._playoff_data: dict | None = None + self._bracket_data: dict | None = None def compose(self) -> ComposeResult: yield Header() @@ -65,6 +68,8 @@ class NBAApp(App): with Horizontal(id="playoff-container"): yield Static("Loading...", id="playoff-west-content") yield Static("Loading...", id="playoff-east-content") + with TabPane("Bracket", id="bracket"): + yield Static("Loading...", id="bracket-content") with TabPane("Box Score", id="boxscore"): with Horizontal(id="boxscore-container"): yield Static( @@ -84,7 +89,7 @@ class NBAApp(App): # ------------------------------------------------------------------ # async def _do_refresh(self) -> None: - """Fetch scores, standings, leaders, and playoff picture in parallel.""" + """Fetch scores, standings, leaders, playoff picture, and bracket in parallel.""" loop = asyncio.get_event_loop() cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] @@ -92,10 +97,11 @@ class NBAApp(App): 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), + loop.run_in_executor(None, bracket_mod.fetch_bracket), return_exceptions=True, ) - games_ranks, leaders_data, playoff_data = results + games_ranks, leaders_data, playoff_data, bracket_data = results if not isinstance(games_ranks, Exception): self._games, self._ranks = games_ranks @@ -103,6 +109,8 @@ class NBAApp(App): self._leaders_data = leaders_data if not isinstance(playoff_data, Exception): self._playoff_data = playoff_data + if not isinstance(bracket_data, Exception): + self._bracket_data = bracket_data self._update_widgets() self.query_one(CountdownBar).reset(self.refresh_interval) @@ -133,6 +141,11 @@ class NBAApp(App): Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) ) + if self._bracket_data: + self.query_one("#bracket-content", Static).update( + Text.from_ansi(bracket_mod.get_bracket_table(self._bracket_data)) + ) + # ------------------------------------------------------------------ # # Key handlers # # ------------------------------------------------------------------ # @@ -162,6 +175,9 @@ class NBAApp(App): def action_show_playoff(self) -> None: self.query_one(TabbedContent).active = "playoff" + def action_show_bracket(self) -> None: + self.query_one(TabbedContent).active = "bracket" + def action_show_boxscore(self) -> None: self.query_one(TabbedContent).active = "boxscore" diff --git a/build/lib/nba/tui/styles.tcss b/build/lib/nba/tui/styles.tcss index 816608c..e0b73cd 100644 --- a/build/lib/nba/tui/styles.tcss +++ b/build/lib/nba/tui/styles.tcss @@ -16,7 +16,7 @@ TabPane { width: auto; } -/* Standings & Playoff tabs — two columns side by side */ +/* Standings, Playoff, Bracket, and Box Score tabs */ #standings-container, #playoff-container, #boxscore-container { @@ -27,7 +27,8 @@ TabPane { #east-content, #west-content, #playoff-east-content, -#playoff-west-content { +#playoff-west-content, +#bracket-content { width: 1fr; overflow: auto auto; padding-right: 2; diff --git a/build/lib/nba/tui/widgets.py b/build/lib/nba/tui/widgets.py index f5e8c1d..1f14ae8 100644 --- a/build/lib/nba/tui/widgets.py +++ b/build/lib/nba/tui/widgets.py @@ -41,7 +41,8 @@ class CountdownBar(Static): else: self.update( f"Next refresh in {value}s | " - "\\[1-9] Box Score | \\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" + "\\[1-9] Box Score | \\[k] Bracket | " + "\\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" ) def reset(self, interval: int) -> None: |
