krz/nba-scores

A CLI/TUI app for NBA scores.

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

14d8039e4a6ae27c1a7441a375b87617fd5aea4d

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-05-04T22:03:40Z

feat: add Bracket tab for playoffs
 README.md                       |   1 +
 build/lib/nba/bracket.py        | 281 ++++++++++++++++++++++++++++++++++++++++
 build/lib/nba/tui/app.py        |  20 ++-
 build/lib/nba/tui/styles.tcss   |   5 +-
 build/lib/nba/tui/widgets.py    |   3 +-
 nba/bracket.py                  | 281 ++++++++++++++++++++++++++++++++++++++++
 nba/tui/app.py                  |  20 ++-
 nba/tui/styles.tcss             |   5 +-
 nba/tui/widgets.py              |   3 +-
 nba_scores.egg-info/PKG-INFO    |  15 +--
 nba_scores.egg-info/SOURCES.txt |   1 +
 pyproject.toml                  |   4 +-
 uv.lock                         |   2 +-
 13 files changed, 617 insertions(+), 24 deletions(-)

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