diff options
| author | Christian Cleberg <[email protected]> | 2026-04-15 22:37:06 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-15 22:37:06 -0500 |
| commit | 4d40494f3e9aade0f5302678e4df67a7613c157b (patch) | |
| tree | c12b55772f72c0221aacb8d4801bde309c46f8c6 /build/lib | |
| parent | b542a36c072c353e9f3ba23c0fec99e55da8289a (diff) | |
| download | nba-scores-4d40494f3e9aade0f5302678e4df67a7613c157b.tar.gz nba-scores-4d40494f3e9aade0f5302678e4df67a7613c157b.tar.bz2 nba-scores-4d40494f3e9aade0f5302678e4df67a7613c157b.zip | |
update README
Diffstat (limited to 'build/lib')
| -rw-r--r-- | build/lib/nba/__init__.py | 0 | ||||
| -rw-r--r-- | build/lib/nba/__main__.py | 8 | ||||
| -rw-r--r-- | build/lib/nba/box_score.py | 81 | ||||
| -rw-r--r-- | build/lib/nba/cli.py | 50 | ||||
| -rw-r--r-- | build/lib/nba/fetch_data.py | 30 | ||||
| -rw-r--r-- | build/lib/nba/leaders.py | 72 | ||||
| -rw-r--r-- | build/lib/nba/playoff.py | 80 | ||||
| -rw-r--r-- | build/lib/nba/scores.py | 106 | ||||
| -rw-r--r-- | build/lib/nba/standings.py | 86 | ||||
| -rw-r--r-- | build/lib/nba/tui/__init__.py | 0 | ||||
| -rw-r--r-- | build/lib/nba/tui/app.py | 221 | ||||
| -rw-r--r-- | build/lib/nba/tui/styles.tcss | 58 | ||||
| -rw-r--r-- | build/lib/nba/tui/widgets.py | 50 |
13 files changed, 842 insertions, 0 deletions
diff --git a/build/lib/nba/__init__.py b/build/lib/nba/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/build/lib/nba/__init__.py diff --git a/build/lib/nba/__main__.py b/build/lib/nba/__main__.py new file mode 100644 index 0000000..6619883 --- /dev/null +++ b/build/lib/nba/__main__.py @@ -0,0 +1,8 @@ +""" +Entry point for the app. +""" + +if __name__ == "__main__": + from nba.cli import nba + + nba() diff --git a/build/lib/nba/box_score.py b/build/lib/nba/box_score.py new file mode 100644 index 0000000..8c2ef01 --- /dev/null +++ b/build/lib/nba/box_score.py @@ -0,0 +1,81 @@ +""" +Fetches and formats live box scores for individual games. +""" + +import json + +from tabulate import tabulate +from nba_api.live.nba.endpoints.boxscore import BoxScore + +BOLD = "\033[1m" +END = "\033[0m" + +_HEADERS = ["Player", "Pos", "Min", "Pts", "Reb", "Ast", "Stl", "Blk", "TO", "FG", "3P", "FT", "+/-"] + + +def fetch_box_score(game_id: str) -> dict: + endpoint = BoxScore(game_id=game_id) + return json.loads(endpoint.get_json()) + + +def _parse_minutes(raw: str) -> str: + """Convert 'PT35M24.00S' → '35:24'.""" + if not raw: + return "0:00" + try: + raw = raw.replace("PT", "").replace("S", "") + mins, secs = raw.split("M") + return f"{int(mins)}:{int(float(secs)):02d}" + except Exception: + return raw + + +def _player_rows(players: list) -> list: + rows = [] + for p in players: + if p.get("status") == "INACTIVE": + continue + s = p.get("statistics", {}) + rows.append([ + p.get("name", ""), + p.get("position", ""), + _parse_minutes(s.get("minutes", "")), + s.get("points", 0), + s.get("reboundsTotal", 0), + s.get("assists", 0), + s.get("steals", 0), + s.get("blocks", 0), + s.get("turnovers", 0), + f"{s.get('fieldGoalsMade', 0)}/{s.get('fieldGoalsAttempted', 0)}", + f"{s.get('threePointersMade', 0)}/{s.get('threePointersAttempted', 0)}", + f"{s.get('freeThrowsMade', 0)}/{s.get('freeThrowsAttempted', 0)}", + s.get("plusMinusPoints", 0), + ]) + return rows + + +def get_box_score_tables(data: dict) -> tuple: + """Return (home_table_str, away_table_str).""" + game = data["game"] + home = game["homeTeam"] + away = game["awayTeam"] + + home_title = ( + f"{BOLD}{home['teamCity']} {home['teamName']} " + f"{home['score']} — {away['score']} " + f"{away['teamCity']} {away['teamName']}{END} " + f" [{game.get('gameStatusText', '')}]" + ) + + home_rows = _player_rows(home.get("players", [])) + away_rows = _player_rows(away.get("players", [])) + + home_table = ( + f"{BOLD}{home['teamCity']} {home['teamName']} ({home['score']}){END}\n" + + tabulate(home_rows, headers=_HEADERS, tablefmt="grid") + ) + away_table = ( + f"{BOLD}{away['teamCity']} {away['teamName']} ({away['score']}){END}\n" + + tabulate(away_rows, headers=_HEADERS, tablefmt="grid") + ) + return home_table, away_table diff --git a/build/lib/nba/cli.py b/build/lib/nba/cli.py new file mode 100644 index 0000000..e5f460d --- /dev/null +++ b/build/lib/nba/cli.py @@ -0,0 +1,50 @@ +""" +This script uses argparse to parse command line arguments. + +It imports the required modules and sets up a parser with basic options for demonstration purposes. +""" + +import argparse +from nba import fetch_data, scores, standings + + +def nba() -> None: + """ + Parse command-line arguments and display either scoreboard or standings. + """ + parser = argparse.ArgumentParser(description="NBA Scoreboard and Standings") + parser.add_argument( + "--scores", "-sc", action="store_true", help="Display the scoreboard" + ) + parser.add_argument( + "--standings", "-st", action="store_true", help="Display the standings" + ) + parser.add_argument( + "--tui", action="store_true", help="Launch the interactive TUI" + ) + parser.add_argument( + "--refresh", + type=int, + default=60, + metavar="SECONDS", + help="Auto-refresh interval in TUI mode (default: 60, minimum: 10)", + ) + args = parser.parse_args() + + if args.tui: + from nba.tui.app import NBAApp + + initial_tab = "standings" if args.standings else "scores" + refresh_interval = max(args.refresh, 10) + NBAApp(initial_tab=initial_tab, refresh_interval=refresh_interval).run() + return + + # Legacy static mode + games, ranks = fetch_data.fetch_data() + + if args.scores: + scores.build_scoreboard(games, ranks) + elif args.standings: + standings.build_standings(ranks) + else: + print("Please specify --scores or --standings (or use --tui for interactive mode)") diff --git a/build/lib/nba/fetch_data.py b/build/lib/nba/fetch_data.py new file mode 100644 index 0000000..49503cc --- /dev/null +++ b/build/lib/nba/fetch_data.py @@ -0,0 +1,30 @@ +""" +Fetches data for use in other modules. +""" + +import json +from nba_api.live.nba.endpoints import scoreboard +from nba_api.stats.endpoints import leaguestandings + + +def fetch_data() -> tuple: + """ + Fetches live NBA scoreboard data and standings from the NBA API. + + Returns: + games (dict): JSON parsed games data. + standings (dict): JSON parsed team standings data. + """ + # Get today's scoreboard data + games_endpoint = scoreboard.ScoreBoard() + games_json = games_endpoint.get_json() + + # Get league standings + standings_endpoint = leaguestandings.LeagueStandings() + standings_json = standings_endpoint.get_json() + + # Parse the JSON strings into Python dictionaries + games = json.loads(games_json) + standings = json.loads(standings_json) + + return games, standings diff --git a/build/lib/nba/leaders.py b/build/lib/nba/leaders.py new file mode 100644 index 0000000..aa6f3b4 --- /dev/null +++ b/build/lib/nba/leaders.py @@ -0,0 +1,72 @@ +""" +Fetches and formats NBA statistical leaders. +""" + +import json + +from tabulate import tabulate +from nba_api.stats.endpoints.leagueleaders import LeagueLeaders + +BOLD = "\033[1m" +END = "\033[0m" + +# Ordered list of (api_abbreviation, display_label) +CATEGORIES = [ + ("PTS", "Points"), + ("REB", "Rebounds"), + ("AST", "Assists"), + ("STL", "Steals"), + ("BLK", "Blocks"), + ("EFF", "Efficiency"), + ("FG_PCT", "FG%"), + ("FT_PCT", "FT%"), + ("FG3_PCT", "3P%"), +] + +# Extra columns to show alongside RANK, PLAYER, TEAM, GP for each category +_EXTRA_COLS = { + "PTS": ["PTS", "FGM", "FGA", "FG_PCT", "FTM", "FTA", "FT_PCT"], + "REB": ["REB", "OREB", "DREB", "GP"], + "AST": ["AST", "TOV", "AST_TOV", "GP"], + "STL": ["STL", "TOV", "GP"], + "BLK": ["BLK", "PF", "GP"], + "EFF": ["EFF", "PTS", "REB", "AST", "GP"], + "FG_PCT": ["FG_PCT", "FGM", "FGA", "PTS"], + "FT_PCT": ["FT_PCT", "FTM", "FTA", "PTS"], + "FG3_PCT":["FG3_PCT", "FG3M", "FG3A", "PTS"], +} + +_DISPLAY_NAMES = { + "FG_PCT": "FG%", "FT_PCT": "FT%", "FG3_PCT": "3P%", + "FG3M": "3PM", "FG3A": "3PA", + "AST_TOV": "AST/TO", +} + + +def fetch_leaders(category: str = "PTS") -> dict: + endpoint = LeagueLeaders( + stat_category_abbreviation=category, + season_type_all_star="Regular Season", + ) + return json.loads(endpoint.get_json()) + + +def get_leaders_table(data: dict, category: str = "PTS") -> str: + result = data["resultSet"] + headers = result["headers"] + rows = result["rowSet"] + + base = ["RANK", "PLAYER", "TEAM", "GP"] + extra = [c for c in _EXTRA_COLS.get(category, [category]) if c not in base] + wanted = base + extra + + idx = {h: i for i, h in enumerate(headers)} + table_data = [ + [row[idx[col]] for col in wanted if col in idx] + for row in rows[:25] + ] + display_headers = [_DISPLAY_NAMES.get(c, c) for c in wanted if c in idx] + + cat_label = dict(CATEGORIES).get(category, category) + title = f"{BOLD}League Leaders — {cat_label}{END}" + return title + "\n" + tabulate(table_data, headers=display_headers, tablefmt="grid") diff --git a/build/lib/nba/playoff.py b/build/lib/nba/playoff.py new file mode 100644 index 0000000..eb3b12b --- /dev/null +++ b/build/lib/nba/playoff.py @@ -0,0 +1,80 @@ +""" +Fetches and formats the NBA playoff picture. +""" + +import json + +from tabulate import tabulate +from nba_api.stats.endpoints.playoffpicture import PlayoffPicture + +BOLD = "\033[1m" +END = "\033[0m" +RED = "\033[91m" +GREEN = "\033[32m" +YELLOW = "\033[33m" + + +def fetch_playoff_picture() -> dict: + endpoint = PlayoffPicture() + return json.loads(endpoint.get_json()) + + +def _clinch_status(row: list, idx: dict) -> str: + """Return a color-coded status string from clinch/elimination columns.""" + def val(col): + return row[idx[col]] if col in idx else None + + if val("CLINCHED_CONFERENCE"): + return f"{BOLD}{GREEN}z-Clinched Conf{END}" + if val("CLINCHED_DIVISION") or val("CLINCHED_PLAYOFFS"): + return f"{GREEN}x-Clinched{END}" + if val("Clinched_Play_In"): + return f"{YELLOW}pi-Play-In{END}" + if val("ELIMINATED_PLAYOFFS"): + return f"{RED}e-Eliminated{END}" + return "" + + +def _build_conference_table(result_sets: list, name: str) -> str: + """Build a formatted playoff standings table for one conference.""" + rs = next((r for r in result_sets if r["name"] == name), None) + if rs is None or not rs["rowSet"]: + return "No data available." + + headers = rs["headers"] + rows = rs["rowSet"] + idx = {h: i for i, h in enumerate(headers)} + + def get(row, col, default=""): + return row[idx[col]] if col in idx else default + + table_data = [] + for row in rows: + wins = get(row, "WINS") + losses = get(row, "LOSSES") + pct = get(row, "PCT") + pct_str = f"{float(pct):.3f}" if pct not in ("", None) else "" + table_data.append([ + get(row, "RANK"), + get(row, "TEAM"), + f"{wins}-{losses}", + pct_str, + get(row, "GB"), + get(row, "HOME"), + get(row, "AWAY"), + get(row, "CONF"), + _clinch_status(row, idx), + ]) + + display_headers = ["#", "Team", "W-L", "PCT", "GB", "HOME", "AWAY", "CONF", "Status"] + conf_label = "Eastern" if "East" in name else "Western" + title = f"{BOLD}{conf_label} Conference Playoff Picture:{END}" + return title + "\n" + tabulate(table_data, headers=display_headers, tablefmt="grid") + + +def get_west_playoff_table(data: dict) -> str: + return _build_conference_table(data["resultSets"], "WestConfStandings") + + +def get_east_playoff_table(data: dict) -> str: + return _build_conference_table(data["resultSets"], "EastConfStandings") diff --git a/build/lib/nba/scores.py b/build/lib/nba/scores.py new file mode 100644 index 0000000..28e4e82 --- /dev/null +++ b/build/lib/nba/scores.py @@ -0,0 +1,106 @@ +""" +Tabulates a scoreboard for today's games. +""" + +from tabulate import tabulate + +# ANSI escape codes for text formatting +BOLD = "\033[1m" +END = "\033[0m" +RED = "\033[91m" +GREEN = "\033[32m" + + +# Function to get team record from standings +def get_team_record(team_name, standings) -> str: + """ + Retrieves a team's win-loss record from the standings data. + + Args: + team_name (str): Name of the team. + standings (dict): Team standings data. + + Returns: + record (str): Team's win-loss record in 'W-L' format. Defaults to 'N/A'. + """ + for result_set in standings["resultSets"]: + if result_set["name"] == "Standings": + for team in result_set["rowSet"]: + if team[4] == team_name: + return f"{team[12]}-{team[13]}" + return "N/A" + + +def get_scoreboard_table(games, standings) -> str: + """ + Builds and returns the current day's games as a formatted table string. + + Args: + games (dict): JSON parsed games data. + standings (dict): Team standings data. + + Returns: + str: Formatted table string with ANSI color codes. + """ + scoreboard_data = games["scoreboard"] + game_list = scoreboard_data["games"] + + if not game_list: + return "No games scheduled today." + + # Prepare the table data + table_data = [] + for game in game_list: + home_team = game["homeTeam"]["teamName"] + away_team = game["awayTeam"]["teamName"] + game_status = game["gameStatusText"] + home_score = game["homeTeam"]["score"] + away_score = game["awayTeam"]["score"] + + home_record = get_team_record(home_team, standings) + away_record = get_team_record(away_team, standings) + + # Determine the winning team + if home_score > away_score: + home_team_bold = f"{BOLD}{GREEN}{home_team} ({home_record}){END}{END}" + away_team_bold = f"{away_team} ({away_record}){END}" + home_score_bold = f"{BOLD}{GREEN}{home_score}{END}{END}" + away_score_bold = f"{away_score}{END}" + elif away_score > home_score: + home_team_bold = f"{home_team} ({home_record}){END}" + away_team_bold = f"{BOLD}{GREEN}{away_team} ({away_record}){END}{END}" + home_score_bold = f"{home_score}{END}" + away_score_bold = f"{BOLD}{GREEN}{away_score}{END}{END}" + else: + home_team_bold = f"{home_team} ({home_record})" + away_team_bold = f"{away_team} ({away_record})" + home_score_bold = f"{home_score}" + away_score_bold = f"{away_score}" + + # Determine games still in progress + if game_status != "Final": + game_status = f"{RED}{game_status}{END}" + + table_data.append( + [ + f"{home_team_bold}\n{away_team_bold}", + f"{home_score_bold}\n{away_score_bold}", + f"{BOLD}{game_status}{END}", + ] + ) + + # Define the table headers + headers = ["Team", "Score", "Game Status"] + + return tabulate(table_data, headers=headers, tablefmt="grid") + + +def build_scoreboard(games, standings) -> None: + """ + Prints the current day's games in a table format. + + Args: + games (dict): JSON parsed games data. + standings (dict): Team standings data. + """ + print(get_scoreboard_table(games, standings)) diff --git a/build/lib/nba/standings.py b/build/lib/nba/standings.py new file mode 100644 index 0000000..3abb1c0 --- /dev/null +++ b/build/lib/nba/standings.py @@ -0,0 +1,86 @@ +""" +Tabulate the current conference standings. +""" + +from tabulate import tabulate + +# ANSI escape codes for text formatting +BOLD = "\033[1m" +END = "\033[0m" +RED = "\033[91m" +GREEN = "\033[32m" + + +def _build_conference_table(standings, conference: str) -> str: + """Build a formatted table string for one conference.""" + data = [] + rank = 1 + + for result_set in standings["resultSets"]: + if result_set["name"] == "Standings": + for team in result_set["rowSet"]: + if team[5] != conference: + continue + wins = team[12] + losses = team[13] + win_pct = team[14] + streak = team[35] + + strk_color = ( + f"{RED}{streak}{END}" if int(streak) < 0 else f"{GREEN}{streak}{END}" + ) + + data.append( + [ + f"{rank}", + team[4], + f"{wins}-{losses}", + f"{win_pct:.3f}", + team[37], + strk_color, + team[19], + team[17], + team[18], + ] + ) + rank += 1 + + headers = ["Rank", "Team", "W-L", "PCT", "GB", "STRK", "L10", "HOME", "AWAY"] + label = "Eastern" if conference == "East" else "Western" + return ( + f"{BOLD}{label} Conference Standings:{END}\n" + + tabulate(data, headers=headers, tablefmt="grid") + ) + + +def get_east_standings_table(standings) -> str: + """Returns the Eastern Conference standings as a formatted string.""" + return _build_conference_table(standings, "East") + + +def get_west_standings_table(standings) -> str: + """Returns the Western Conference standings as a formatted string.""" + return _build_conference_table(standings, "West") + + +def get_standings_tables(standings) -> str: + """ + Builds and returns both conference standings as a formatted string. + + Args: + standings (dict): Team standings data. + + Returns: + str: Formatted standings string with ANSI color codes for both conferences. + """ + return get_east_standings_table(standings) + "\n\n" + get_west_standings_table(standings) + + +def build_standings(standings) -> None: + """ + Prints team standings in two separate tables. + + Args: + standings (dict): Team standings data. + """ + print(get_standings_tables(standings)) diff --git a/build/lib/nba/tui/__init__.py b/build/lib/nba/tui/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/build/lib/nba/tui/__init__.py diff --git a/build/lib/nba/tui/app.py b/build/lib/nba/tui/app.py new file mode 100644 index 0000000..bfdec49 --- /dev/null +++ b/build/lib/nba/tui/app.py @@ -0,0 +1,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("") diff --git a/build/lib/nba/tui/styles.tcss b/build/lib/nba/tui/styles.tcss new file mode 100644 index 0000000..816608c --- /dev/null +++ b/build/lib/nba/tui/styles.tcss @@ -0,0 +1,58 @@ +Screen { + background: $background; +} + +TabbedContent { + height: 1fr; +} + +TabPane { + overflow: auto auto; + padding: 1 2; +} + +/* Scores tab */ +#scores-content { + width: auto; +} + +/* Standings & Playoff tabs — two columns side by side */ +#standings-container, +#playoff-container, +#boxscore-container { + height: 1fr; + width: 100%; +} + +#east-content, +#west-content, +#playoff-east-content, +#playoff-west-content { + width: 1fr; + overflow: auto auto; + padding-right: 2; +} + +/* Leaders tab */ +#leaders-content { + width: auto; + overflow: auto auto; +} + +/* Box Score tab */ +#home-content, +#away-content { + width: 1fr; + overflow: auto auto; + padding-right: 2; +} + +/* Countdown / status bar */ +#countdown { + dock: bottom; + height: 1; + background: $panel; + color: $text-muted; + content-align: right middle; + padding-right: 2; +} diff --git a/build/lib/nba/tui/widgets.py b/build/lib/nba/tui/widgets.py new file mode 100644 index 0000000..f5e8c1d --- /dev/null +++ b/build/lib/nba/tui/widgets.py @@ -0,0 +1,50 @@ +""" +Custom Textual widgets for the NBA scores TUI. +""" + +from textual.reactive import reactive +from textual.widgets import Static + + +class ScoresWidget(Static): + """Displays the NBA scoreboard as a scrollable ANSI-formatted table.""" + + +class StandingsWidget(Static): + """Displays the NBA standings as a scrollable ANSI-formatted table.""" + + +class CountdownBar(Static): + """ + Docked status bar that counts down to the next auto-refresh. + + Maintains its own 1-second ticker and re-renders via a reactive attribute. + """ + + seconds: reactive[int] = reactive(60) + + def __init__(self, interval: int, **kwargs) -> None: + super().__init__(**kwargs) + self._interval = interval + self.seconds = interval + + def on_mount(self) -> None: + self.set_interval(1, self._tick) + + def _tick(self) -> None: + if self.seconds > 0: + self.seconds -= 1 + + def watch_seconds(self, value: int) -> None: + if value <= 0: + self.update("Refreshing...") + else: + self.update( + f"Next refresh in {value}s | " + "\\[1-9] Box Score | \\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" + ) + + def reset(self, interval: int) -> None: + """Reset the countdown to the given interval.""" + self._interval = interval + self.seconds = interval |
