krz/nba-scores

A CLI/TUI app for NBA scores.

clone: git clone https://gitbay.org/krz/nba-scores.git

4d40494f3e9aade0f5302678e4df67a7613c157b

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-16T03:37:06Z

update README
 README.md                       |  10 +-
 build/lib/nba/__init__.py       |   0
 build/lib/nba/__main__.py       |   8 ++
 build/lib/nba/box_score.py      |  81 +++++++++++++++
 build/lib/nba/cli.py            |  50 +++++++++
 build/lib/nba/fetch_data.py     |  30 ++++++
 build/lib/nba/leaders.py        |  72 +++++++++++++
 build/lib/nba/playoff.py        |  80 +++++++++++++++
 build/lib/nba/scores.py         | 106 +++++++++++++++++++
 build/lib/nba/standings.py      |  86 ++++++++++++++++
 build/lib/nba/tui/__init__.py   |   0
 build/lib/nba/tui/app.py        | 221 ++++++++++++++++++++++++++++++++++++++++
 build/lib/nba/tui/styles.tcss   |  58 +++++++++++
 build/lib/nba/tui/widgets.py    |  50 +++++++++
 nba_scores.egg-info/PKG-INFO    |  76 ++++++++++----
 nba_scores.egg-info/SOURCES.txt |   3 +
 16 files changed, 904 insertions(+), 27 deletions(-)

diff --git a/README.md b/README.md
index 1a5ca75..e98366f 100644
--- a/README.md
+++ b/README.md
@@ -19,18 +19,10 @@ auto-refresh.
 
 [Back to top](#table-of-contents)
 
-## PyPi
-
-```shell
-pipx install nba-scores
-```
-
-## Manual
-
 ```shell
 git clone https://git.cleberg.net/nba-scores.git
 cd nba-scores
-uv sync
+pipx install .
 ```
 
 # Usage
diff --git a/build/lib/nba/__init__.py b/build/lib/nba/__init__.py
new file mode 100644
index 0000000..e69de29
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
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
diff --git a/nba_scores.egg-info/PKG-INFO b/nba_scores.egg-info/PKG-INFO
index b0263b8..a440054 100644
--- a/nba_scores.egg-info/PKG-INFO
+++ b/nba_scores.egg-info/PKG-INFO
@@ -19,16 +19,19 @@ Dynamic: license-file
 
 # NBA
 
-NBA is a simple Python package that provides you with a quick and easy
-command-line interface to current NBA scores and standings.
+![](./screenshots/preview.png)
 
-See the [Installation](#installation) & [Usage](#usage) sections below
-for more information.
+NBA is a Python package that provides a command-line interface to current NBA
+scores, standings, statistical leaders, playoff picture, and live box scores.
+It supports both a static one-shot mode and an interactive TUI with
+auto-refresh.
 
 # Table of Contents
 
 - [Installation](#installation)
 - [Usage](#usage)
+  - [Interactive TUI](#interactive-tui)
+  - [Static output](#static-output)
 - [Contributing](#contributing)
 
 # Installation
@@ -43,33 +46,70 @@ pipx install nba-scores
 
 ## Manual
 
-To run the script locally, run the following commands:
-
 ```shell
 git clone https://git.cleberg.net/nba-scores.git
 cd nba-scores
-pipx install .
+uv sync
 ```
 
-![Installation](./screenshots/installation.png)
-
 # Usage
 
 [Back to top](#table-of-contents)
 
-All commands can be passed to the program with the following template:
-`nba <ARGUMENT>`
+## Interactive TUI
+
+Launch the full interactive terminal UI:
+
+```shell
+nba --tui
+```
+
+Optional flags:
+
+| Flag               | Description                                            |
+|--------------------|--------------------------------------------------------|
+| `--tui`            | Launch the interactive TUI                             |
+| `--scores`         | Open TUI on the Scores tab (default)                   |
+| `--standings`      | Open TUI on the Standings tab                          |
+| `--refresh N`      | Auto-refresh interval in seconds (default: 60, min: 10)|
+
+### TUI tabs
+
+| Tab             | Key | Description                                              |
+|-----------------|-----|----------------------------------------------------------|
+| Scores          | `s` | Today's games with live scores and status                |
+| Standings       | `t` | East / West conference standings, side by side           |
+| Leaders         | `l` | Top 25 players by stat category                          |
+| Playoff Picture | `p` | Conference seeding with clinch and elimination status    |
+| Box Score       | `b` | Per-player live stats for a selected game                |
+
+### TUI key bindings
+
+| Key     | Action                                         |
+|---------|------------------------------------------------|
+| `1`–`9` | Load box score for game N from the Scores tab  |
+| `,`     | Cycle to the previous leaders stat category    |
+| `.`     | Cycle to the next leaders stat category        |
+| `r`     | Refresh all data immediately                   |
+| `q`     | Quit                                           |
+
+### Leaders stat categories
+
+Cycles through: Points, Rebounds, Assists, Steals, Blocks, Efficiency, FG%, FT%, 3P%
+
+## Static output
+
+Print scores or standings directly to the terminal without launching the TUI:
 
 | Argument      | Shortcut | Description                       |
 |---------------|----------|-----------------------------------|
-| `--scores`    | `-sc`    | Show today's scoreboard           |
-| `--standings` | `-st`    | Show current conference standings |
-
-Scores:
-![Scores](./screenshots/scores.png)
+| `--scores`    | `-sc`    | Print today's scoreboard          |
+| `--standings` | `-st`    | Print current conference standings|
 
-Standings:
-![Standings](./screenshots/standings.png)
+```shell
+nba --scores
+nba --standings
+```
 
 # Contributing
 
diff --git a/nba_scores.egg-info/SOURCES.txt b/nba_scores.egg-info/SOURCES.txt
index b5c9c92..c424bb6 100644
--- a/nba_scores.egg-info/SOURCES.txt
+++ b/nba_scores.egg-info/SOURCES.txt
@@ -3,8 +3,11 @@ README.md
 pyproject.toml
 nba/__init__.py
 nba/__main__.py
+nba/box_score.py
 nba/cli.py
 nba/fetch_data.py
+nba/leaders.py
+nba/playoff.py
 nba/scores.py
 nba/standings.py
 nba/tui/__init__.py