summaryrefslogtreecommitdiff
path: root/nba
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-15 22:28:45 -0500
committerChristian Cleberg <[email protected]>2026-04-15 22:28:45 -0500
commit4cbe592e0a0fae13f0a89918a66fd983dd84464f (patch)
treec2aac9d6e3c7b0f44adf07b86385f2ce06f1cf10 /nba
parent00f695c765aead8aa0160a3c676cf85716178beb (diff)
downloadnba-scores-4cbe592e0a0fae13f0a89918a66fd983dd84464f.tar.gz
nba-scores-4cbe592e0a0fae13f0a89918a66fd983dd84464f.tar.bz2
nba-scores-4cbe592e0a0fae13f0a89918a66fd983dd84464f.zip
feat: add Textual TUI with auto-refresh, leaders, playoff picture, and box score
Introduces an interactive terminal UI via the `--tui` flag backed by the Textual framework. Scores and standings auto-refresh on a configurable interval (default 60s, minimum 10s) using asyncio with nba_api calls offloaded to a thread pool to keep the event loop unblocked. New tabs: - Scores / Standings (existing views, now live-updating) - Leaders: top-25 by stat category, cycle with < / > keys - Playoff Picture: conference seeding with clinch/elimination status - Box Score: per-player live stats, load any game with keys 1–9 Refactored scores.py and standings.py to separate rendering from I/O (get_scoreboard_table, get_east/west_standings_table), keeping the existing static CLI path fully intact. Added -sc / -st shortcut flags. Adds textual>=0.89.1,<7.0 dependency, build-system declaration for uv, and package-data entry for the bundled .tcss stylesheet.
Diffstat (limited to 'nba')
-rw-r--r--nba/__pycache__/__init__.cpython-313.pycbin0 -> 149 bytes
-rw-r--r--nba/__pycache__/__init__.cpython-314.pycbin0 -> 151 bytes
-rw-r--r--nba/__pycache__/box_score.cpython-313.pycbin0 -> 4194 bytes
-rw-r--r--nba/__pycache__/cli.cpython-313.pycbin0 -> 2166 bytes
-rw-r--r--nba/__pycache__/fetch_data.cpython-313.pycbin0 -> 1155 bytes
-rw-r--r--nba/__pycache__/leaders.cpython-313.pycbin0 -> 2847 bytes
-rw-r--r--nba/__pycache__/playoff.cpython-313.pycbin0 -> 4206 bytes
-rw-r--r--nba/__pycache__/scores.cpython-313.pycbin0 -> 3435 bytes
-rw-r--r--nba/__pycache__/standings.cpython-313.pycbin0 -> 2861 bytes
-rw-r--r--nba/box_score.py81
-rw-r--r--nba/cli.py27
-rw-r--r--nba/leaders.py72
-rw-r--r--nba/playoff.py80
-rw-r--r--nba/scores.py28
-rw-r--r--nba/standings.py117
-rw-r--r--nba/tui/__init__.py0
-rw-r--r--nba/tui/__pycache__/__init__.cpython-313.pycbin0 -> 153 bytes
-rw-r--r--nba/tui/__pycache__/__init__.cpython-314.pycbin0 -> 155 bytes
-rw-r--r--nba/tui/__pycache__/app.cpython-313.pycbin0 -> 14244 bytes
-rw-r--r--nba/tui/__pycache__/app.cpython-314.pycbin0 -> 6721 bytes
-rw-r--r--nba/tui/__pycache__/widgets.cpython-313.pycbin0 -> 2793 bytes
-rw-r--r--nba/tui/app.py221
-rw-r--r--nba/tui/styles.tcss58
-rw-r--r--nba/tui/widgets.py50
24 files changed, 668 insertions, 66 deletions
diff --git a/nba/__pycache__/__init__.cpython-313.pyc b/nba/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000..7cfbc00
--- /dev/null
+++ b/nba/__pycache__/__init__.cpython-313.pyc
Binary files differ
diff --git a/nba/__pycache__/__init__.cpython-314.pyc b/nba/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..11aa6a0
--- /dev/null
+++ b/nba/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/nba/__pycache__/box_score.cpython-313.pyc b/nba/__pycache__/box_score.cpython-313.pyc
new file mode 100644
index 0000000..f4a42d5
--- /dev/null
+++ b/nba/__pycache__/box_score.cpython-313.pyc
Binary files differ
diff --git a/nba/__pycache__/cli.cpython-313.pyc b/nba/__pycache__/cli.cpython-313.pyc
new file mode 100644
index 0000000..860f4ba
--- /dev/null
+++ b/nba/__pycache__/cli.cpython-313.pyc
Binary files differ
diff --git a/nba/__pycache__/fetch_data.cpython-313.pyc b/nba/__pycache__/fetch_data.cpython-313.pyc
new file mode 100644
index 0000000..514a7d6
--- /dev/null
+++ b/nba/__pycache__/fetch_data.cpython-313.pyc
Binary files differ
diff --git a/nba/__pycache__/leaders.cpython-313.pyc b/nba/__pycache__/leaders.cpython-313.pyc
new file mode 100644
index 0000000..fdc26de
--- /dev/null
+++ b/nba/__pycache__/leaders.cpython-313.pyc
Binary files differ
diff --git a/nba/__pycache__/playoff.cpython-313.pyc b/nba/__pycache__/playoff.cpython-313.pyc
new file mode 100644
index 0000000..864faab
--- /dev/null
+++ b/nba/__pycache__/playoff.cpython-313.pyc
Binary files differ
diff --git a/nba/__pycache__/scores.cpython-313.pyc b/nba/__pycache__/scores.cpython-313.pyc
new file mode 100644
index 0000000..d295067
--- /dev/null
+++ b/nba/__pycache__/scores.cpython-313.pyc
Binary files differ
diff --git a/nba/__pycache__/standings.cpython-313.pyc b/nba/__pycache__/standings.cpython-313.pyc
new file mode 100644
index 0000000..1f21ed2
--- /dev/null
+++ b/nba/__pycache__/standings.cpython-313.pyc
Binary files differ
diff --git a/nba/box_score.py b/nba/box_score.py
new file mode 100644
index 0000000..8c2ef01
--- /dev/null
+++ b/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/nba/cli.py b/nba/cli.py
index 82c3883..e5f460d 100644
--- a/nba/cli.py
+++ b/nba/cli.py
@@ -13,12 +13,33 @@ 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", action="store_true", help="Display the scoreboard")
parser.add_argument(
- "--standings", action="store_true", help="Display the standings"
+ "--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:
@@ -26,4 +47,4 @@ def nba() -> None:
elif args.standings:
standings.build_standings(ranks)
else:
- print("Please specify --scores or --standings")
+ print("Please specify --scores or --standings (or use --tui for interactive mode)")
diff --git a/nba/leaders.py b/nba/leaders.py
new file mode 100644
index 0000000..aa6f3b4
--- /dev/null
+++ b/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/nba/playoff.py b/nba/playoff.py
new file mode 100644
index 0000000..eb3b12b
--- /dev/null
+++ b/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/nba/scores.py b/nba/scores.py
index 47ed552..28e4e82 100644
--- a/nba/scores.py
+++ b/nba/scores.py
@@ -31,20 +31,26 @@ def get_team_record(team_name, standings) -> str:
return "N/A"
-def build_scoreboard(games, standings) -> None:
+def get_scoreboard_table(games, standings) -> str:
"""
- Prints the current day's games in a table format.
+ 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"]
- games = scoreboard_data["games"]
+ game_list = scoreboard_data["games"]
+
+ if not game_list:
+ return "No games scheduled today."
# Prepare the table data
table_data = []
- for game in games:
+ for game in game_list:
home_team = game["homeTeam"]["teamName"]
away_team = game["awayTeam"]["teamName"]
game_status = game["gameStatusText"]
@@ -86,5 +92,15 @@ def build_scoreboard(games, standings) -> None:
# Define the table headers
headers = ["Team", "Score", "Game Status"]
- # Print the table
- print(tabulate(table_data, headers=headers, tablefmt="grid"))
+ 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/nba/standings.py b/nba/standings.py
index 9b6a56e..3abb1c0 100644
--- a/nba/standings.py
+++ b/nba/standings.py
@@ -11,73 +11,76 @@ RED = "\033[91m"
GREEN = "\033[32m"
-def build_standings(standings) -> None:
- """
- Prints team standings in two separate tables.
-
- Args:
- standings (dict): Team standings data.
- """
- eastern_data = []
- western_data = []
- eastern_rank = 1
- western_rank = 1
+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"]:
- conference = team[5]
- team_name = team[4]
+ if team[5] != conference:
+ continue
wins = team[12]
losses = team[13]
win_pct = team[14]
- gb = team[37]
- home_record = team[17]
- away_record = team[18]
- last_10 = team[19]
streak = team[35]
- if int(streak) < 0:
- strk_color = f"{RED}{streak}{END}"
- else:
- strk_color = f"{GREEN}{streak}{END}"
-
- if conference == "East":
- eastern_data.append(
- [
- f"{eastern_rank}",
- f"{team_name}",
- f"{wins}-{losses}",
- f"{win_pct:.3f}",
- f"{gb}",
- f"{strk_color}",
- f"{last_10}",
- f"{home_record}",
- f"{away_record}",
- ]
- )
- eastern_rank += 1
- elif conference == "West":
- western_data.append(
- [
- f"{western_rank}",
- f"{team_name}",
- f"{wins}-{losses}",
- f"{win_pct:.3f}",
- f"{gb}",
- f"{strk_color}",
- f"{last_10}",
- f"{home_record}",
- f"{away_record}",
- ]
- )
- western_rank += 1
+ 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")
+
- print(f"{BOLD}Eastern Conference Standings:{END}")
- print(tabulate(eastern_data, headers=headers, tablefmt="grid"))
+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.
- print("\n")
- print(f"{BOLD}Western Conference Standings:{END}")
- print(tabulate(western_data, headers=headers, tablefmt="grid"))
+ 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/nba/tui/__init__.py b/nba/tui/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/nba/tui/__init__.py
diff --git a/nba/tui/__pycache__/__init__.cpython-313.pyc b/nba/tui/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000..5a335f2
--- /dev/null
+++ b/nba/tui/__pycache__/__init__.cpython-313.pyc
Binary files differ
diff --git a/nba/tui/__pycache__/__init__.cpython-314.pyc b/nba/tui/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..d1753cc
--- /dev/null
+++ b/nba/tui/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/nba/tui/__pycache__/app.cpython-313.pyc b/nba/tui/__pycache__/app.cpython-313.pyc
new file mode 100644
index 0000000..9e182f2
--- /dev/null
+++ b/nba/tui/__pycache__/app.cpython-313.pyc
Binary files differ
diff --git a/nba/tui/__pycache__/app.cpython-314.pyc b/nba/tui/__pycache__/app.cpython-314.pyc
new file mode 100644
index 0000000..c6713a7
--- /dev/null
+++ b/nba/tui/__pycache__/app.cpython-314.pyc
Binary files differ
diff --git a/nba/tui/__pycache__/widgets.cpython-313.pyc b/nba/tui/__pycache__/widgets.cpython-313.pyc
new file mode 100644
index 0000000..2b9cfc1
--- /dev/null
+++ b/nba/tui/__pycache__/widgets.cpython-313.pyc
Binary files differ
diff --git a/nba/tui/app.py b/nba/tui/app.py
new file mode 100644
index 0000000..bfdec49
--- /dev/null
+++ b/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/nba/tui/styles.tcss b/nba/tui/styles.tcss
new file mode 100644
index 0000000..816608c
--- /dev/null
+++ b/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/nba/tui/widgets.py b/nba/tui/widgets.py
new file mode 100644
index 0000000..f5e8c1d
--- /dev/null
+++ b/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