summaryrefslogtreecommitdiff
path: root/build/lib/nba
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-15 22:37:06 -0500
committerChristian Cleberg <[email protected]>2026-04-15 22:37:06 -0500
commit4d40494f3e9aade0f5302678e4df67a7613c157b (patch)
treec12b55772f72c0221aacb8d4801bde309c46f8c6 /build/lib/nba
parentb542a36c072c353e9f3ba23c0fec99e55da8289a (diff)
downloadnba-scores-4d40494f3e9aade0f5302678e4df67a7613c157b.tar.gz
nba-scores-4d40494f3e9aade0f5302678e4df67a7613c157b.tar.bz2
nba-scores-4d40494f3e9aade0f5302678e4df67a7613c157b.zip
update README
Diffstat (limited to 'build/lib/nba')
-rw-r--r--build/lib/nba/__init__.py0
-rw-r--r--build/lib/nba/__main__.py8
-rw-r--r--build/lib/nba/box_score.py81
-rw-r--r--build/lib/nba/cli.py50
-rw-r--r--build/lib/nba/fetch_data.py30
-rw-r--r--build/lib/nba/leaders.py72
-rw-r--r--build/lib/nba/playoff.py80
-rw-r--r--build/lib/nba/scores.py106
-rw-r--r--build/lib/nba/standings.py86
-rw-r--r--build/lib/nba/tui/__init__.py0
-rw-r--r--build/lib/nba/tui/app.py221
-rw-r--r--build/lib/nba/tui/styles.tcss58
-rw-r--r--build/lib/nba/tui/widgets.py50
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