Commit 14d8039e4a

14d8039e4a6ae27c1a7441a375b87617fd5aea4d

parent: 4d40494f3e

Verified · cmc

cmc <hello@cleberg.net> · 2026-05-04 22:03 UTC

feat: add Bracket tab for playoffs

Layout: unified · split

README.md +1
@@ -54,6 +54,7 @@ Optional flags:
54| Standings | `t` | East / West conference standings, side by side | 54| Standings | `t` | East / West conference standings, side by side |
55| Leaders | `l` | Top 25 players by stat category | 55| Leaders | `l` | Top 25 players by stat category |
56| Playoff Picture | `p` | Conference seeding with clinch and elimination status | 56| Playoff Picture | `p` | Conference seeding with clinch and elimination status |
57| Bracket | `k` | Playoff series matchups and series records |
57| Box Score | `b` | Per-player live stats for a selected game | 58| Box Score | `b` | Per-player live stats for a selected game |
58 59
59### TUI key bindings 60### TUI key bindings
build/lib/nba/bracket.py added +281
@@ -0,0 +1,281 @@
1"""
2Fetches and formats the NBA playoff bracket.
3"""
4
5import json
6from collections import defaultdict
7from typing import Optional
8
9from nba_api.stats.endpoints.commonplayoffseries import CommonPlayoffSeries
10from nba_api.stats.endpoints.leaguegamelog import LeagueGameLog
11from nba_api.stats.static import teams
12
13BOLD = "\033[1m"
14END = "\033[0m"
15GREEN = "\033[32m"
16
17ROUND_LABELS = {
18 1: "First Round",
19 2: "Conference Semifinals",
20 3: "Conference Finals",
21 4: "NBA Finals",
22}
23
24
25def fetch_bracket() -> dict:
26 """
27 Fetch playoff series and completed playoff games.
28
29 CommonPlayoffSeries provides the bracket's scheduled series and games. The
30 playoff game log is used to compute each series record from completed games.
31 """
32 series_endpoint = CommonPlayoffSeries()
33 game_log_endpoint = LeagueGameLog(season_type_all_star="Playoffs")
34 return {
35 "series": json.loads(series_endpoint.get_json()),
36 "game_log": json.loads(game_log_endpoint.get_json()),
37 }
38
39
40def get_bracket_table(data: dict) -> str:
41 """Return a formatted playoff bracket."""
42 result_sets = data["series"]["resultSets"]
43 series_set = next((r for r in result_sets if r["name"] == "PlayoffSeries"), None)
44 if series_set is None or not series_set["rowSet"]:
45 return "No playoff bracket data available."
46
47 headers = series_set["headers"]
48 idx = {header: i for i, header in enumerate(headers)}
49 team_map = _team_map()
50 series_games = _group_series(series_set["rowSet"], idx)
51 series_wins = _series_wins(data["game_log"], series_games)
52 summaries = _series_summaries(team_map, series_games, series_wins)
53
54 return "\n".join(
55 [
56 _center(f"{BOLD}NBA Playoff Bracket{END}", 74),
57 "",
58 _render_conference("Western", summaries),
59 "",
60 _render_finals(summaries),
61 "",
62 _render_conference("Eastern", summaries),
63 ]
64 )
65
66
67def _team_map() -> dict:
68 return {
69 team["id"]: {
70 "abbr": team["abbreviation"],
71 "name": f"{team['city']} {team['nickname']}",
72 }
73 for team in teams.get_teams()
74 }
75
76
77def _group_series(rows: list, idx: dict) -> dict:
78 series_games = defaultdict(list)
79 for row in rows:
80 series_id = row[idx["SERIES_ID"]]
81 series_games[series_id].append(
82 {
83 "game_id": row[idx["GAME_ID"]],
84 "game_num": row[idx["GAME_NUM"]],
85 "home_team_id": row[idx["HOME_TEAM_ID"]],
86 "visitor_team_id": row[idx["VISITOR_TEAM_ID"]],
87 }
88 )
89
90 return {
91 series_id: sorted(games, key=lambda game: game["game_num"])
92 for series_id, games in series_games.items()
93 }
94
95
96def _series_wins(game_log: dict, series_games: dict) -> dict:
97 game_to_series = {
98 game["game_id"]: series_id
99 for series_id, games in series_games.items()
100 for game in games
101 }
102 wins = defaultdict(lambda: defaultdict(int))
103
104 for series_id in series_games:
105 wins[series_id]["completed_games"] = set()
106
107 result_set = game_log["resultSets"][0]
108 idx = {header: i for i, header in enumerate(result_set["headers"])}
109 for row in result_set["rowSet"]:
110 game_id = row[idx["GAME_ID"]]
111 series_id = game_to_series.get(game_id)
112 if series_id is None or row[idx["WL"]] != "W":
113 continue
114
115 team_id = row[idx["TEAM_ID"]]
116 wins[series_id][team_id] += 1
117 wins[series_id]["completed_games"].add(game_id)
118
119 return wins
120
121
122def _series_summaries(team_map: dict, series_games: dict, series_wins: dict) -> dict:
123 summaries = {}
124 for series_id, games in series_games.items():
125 first_game = games[0]
126 home_id = first_game["home_team_id"]
127 visitor_id = first_game["visitor_team_id"]
128 wins = series_wins[series_id]
129 home_wins = wins.get(home_id, 0)
130 visitor_wins = wins.get(visitor_id, 0)
131
132 summaries[series_id] = {
133 "conference": _conference(series_id),
134 "round": _round_number(series_id),
135 "slot": _series_slot(series_id),
136 "home_id": home_id,
137 "visitor_id": visitor_id,
138 "home_abbr": _abbr(team_map, home_id),
139 "visitor_abbr": _abbr(team_map, visitor_id),
140 "home_wins": home_wins,
141 "visitor_wins": visitor_wins,
142 "winner": _winner(team_map, home_id, visitor_id, home_wins, visitor_wins),
143 }
144 return summaries
145
146
147def _render_conference(conference: str, summaries: dict) -> str:
148 first_round = _conference_round(summaries, conference, 1)
149 semifinals = _conference_round(summaries, conference, 2)
150 finals = _conference_round(summaries, conference, 3)
151 final = finals[0] if finals else None
152
153 lines = [
154 f"{BOLD}{conference.upper()} CONFERENCE{END}",
155 "First Round Semifinals Conference Finals",
156 ]
157
158 lines.extend(
159 [
160 f"{_series_box(_slot(first_round, 0), 24)} ┐",
161 f"{_blank(24)} ├── {_series_box(_slot(semifinals, 0), 24)} ┐",
162 f"{_series_box(_slot(first_round, 1), 24)} ┘ {_blank(24)} │",
163 f"{_blank(24)} {_blank(24)} ├── {_series_box(final, 24)}",
164 f"{_series_box(_slot(first_round, 2), 24)} ┐ {_blank(24)} │",
165 f"{_blank(24)} ├── {_series_box(_slot(semifinals, 1), 24)} ┘",
166 f"{_series_box(_slot(first_round, 3), 24)} ┘",
167 ]
168 )
169
170 return "\n".join(lines)
171
172
173def _render_finals(summaries: dict) -> str:
174 finals = [summary for summary in summaries.values() if summary["round"] == 4]
175 final = finals[0] if finals else None
176
177 lines = [
178 f"{BOLD}NBA FINALS{END}",
179 "West Champion East Champion",
180 f"{_series_box(final, 24)}",
181 ]
182 if final is None:
183 lines.append("Winner TBD")
184 elif final["winner"]:
185 lines.append(f"{GREEN}{final['winner']} wins the Finals{END}")
186 return "\n".join(lines)
187
188
189def _conference_round(summaries: dict, conference: str, round_number: int) -> list:
190 return sorted(
191 [
192 summary
193 for summary in summaries.values()
194 if summary["conference"] == conference[:4]
195 and summary["round"] == round_number
196 ],
197 key=lambda summary: summary["slot"],
198 )
199
200
201def _slot(items: list, idx: int) -> Optional[dict]:
202 return items[idx] if idx < len(items) else None
203
204
205def _series_box(summary: Optional[dict], width: int) -> str:
206 if summary is None:
207 return _blank(width, "TBD")
208
209 visitor = _team_line(
210 summary["visitor_abbr"],
211 summary["visitor_wins"],
212 summary["winner"] == summary["visitor_abbr"],
213 )
214 home = _team_line(
215 summary["home_abbr"],
216 summary["home_wins"],
217 summary["winner"] == summary["home_abbr"],
218 )
219 return f"{visitor} / {home}".ljust(width)
220
221
222def _team_line(abbr: str, wins: int, won_series: bool) -> str:
223 label = f"{abbr} {wins}"
224 if won_series:
225 return f"{label}*"
226 return label
227
228
229def _winner(
230 team_map: dict,
231 home_id: int,
232 visitor_id: int,
233 home_wins: int,
234 visitor_wins: int,
235) -> str:
236 if home_wins >= 4:
237 return _abbr(team_map, home_id)
238 if visitor_wins >= 4:
239 return _abbr(team_map, visitor_id)
240 return ""
241
242
243def _blank(width: int, text: str = "") -> str:
244 return text.ljust(width)
245
246
247def _center(text: str, width: int) -> str:
248 return text.center(width)
249
250
251def _round_number(series_id: str) -> int:
252 try:
253 return int(series_id[-3:-1])
254 except ValueError:
255 return 0
256
257
258def _series_slot(series_id: str) -> int:
259 try:
260 return int(series_id[-1])
261 except ValueError:
262 return 0
263
264
265def _conference(series_id: str) -> str:
266 round_number = _round_number(series_id)
267 slot = _series_slot(series_id)
268
269 if round_number == 4:
270 return "NBA"
271 if round_number == 3:
272 return "East" if slot == 0 else "West"
273 if round_number == 2:
274 return "East" if slot <= 1 else "West"
275 if round_number == 1:
276 return "East" if slot <= 3 else "West"
277 return ""
278
279
280def _abbr(team_map: dict, team_id: int) -> str:
281 return team_map.get(team_id, {}).get("abbr", str(team_id))
build/lib/nba/tui/app.py +18 −2
@@ -13,6 +13,7 @@ from textual.containers import Horizontal
13from textual.widgets import Footer, Header, Static, TabbedContent, TabPane 13from textual.widgets import Footer, Header, Static, TabbedContent, TabPane
14 14
15from nba import fetch_data 15from nba import fetch_data
16from nba import bracket as bracket_mod
16from nba import leaders as leaders_mod 17from nba import leaders as leaders_mod
17from nba import playoff as playoff_mod 18from nba import playoff as playoff_mod
18from nba import box_score as box_score_mod 19from nba import box_score as box_score_mod
@@ -32,6 +33,7 @@ class NBAApp(App):
32 ("t", "show_standings", "Standings"), 33 ("t", "show_standings", "Standings"),
33 ("l", "show_leaders", "Leaders"), 34 ("l", "show_leaders", "Leaders"),
34 ("p", "show_playoff", "Playoff"), 35 ("p", "show_playoff", "Playoff"),
36 ("k", "show_bracket", "Bracket"),
35 ("b", "show_boxscore", "Box Score"), 37 ("b", "show_boxscore", "Box Score"),
36 ("r", "refresh_now", "Refresh"), 38 ("r", "refresh_now", "Refresh"),
37 ("comma", "prev_category", "◀ Cat"), 39 ("comma", "prev_category", "◀ Cat"),
@@ -49,6 +51,7 @@ class NBAApp(App):
49 self._leaders_data: dict | None = None 51 self._leaders_data: dict | None = None
50 self._leaders_cat_idx: int = 0 52 self._leaders_cat_idx: int = 0
51 self._playoff_data: dict | None = None 53 self._playoff_data: dict | None = None
54 self._bracket_data: dict | None = None
52 55
53 def compose(self) -> ComposeResult: 56 def compose(self) -> ComposeResult:
54 yield Header() 57 yield Header()
@@ -65,6 +68,8 @@ class NBAApp(App):
65 with Horizontal(id="playoff-container"): 68 with Horizontal(id="playoff-container"):
66 yield Static("Loading...", id="playoff-west-content") 69 yield Static("Loading...", id="playoff-west-content")
67 yield Static("Loading...", id="playoff-east-content") 70 yield Static("Loading...", id="playoff-east-content")
71 with TabPane("Bracket", id="bracket"):
72 yield Static("Loading...", id="bracket-content")
68 with TabPane("Box Score", id="boxscore"): 73 with TabPane("Box Score", id="boxscore"):
69 with Horizontal(id="boxscore-container"): 74 with Horizontal(id="boxscore-container"):
70 yield Static( 75 yield Static(
@@ -84,7 +89,7 @@ class NBAApp(App):
84 # ------------------------------------------------------------------ # 89 # ------------------------------------------------------------------ #
85 90
86 async def _do_refresh(self) -> None: 91 async def _do_refresh(self) -> None:
87 """Fetch scores, standings, leaders, and playoff picture in parallel.""" 92 """Fetch scores, standings, leaders, playoff picture, and bracket in parallel."""
88 loop = asyncio.get_event_loop() 93 loop = asyncio.get_event_loop()
89 cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] 94 cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0]
90 95
@@ -92,10 +97,11 @@ class NBAApp(App):
92 loop.run_in_executor(None, fetch_data.fetch_data), 97 loop.run_in_executor(None, fetch_data.fetch_data),
93 loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)), 98 loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)),
94 loop.run_in_executor(None, playoff_mod.fetch_playoff_picture), 99 loop.run_in_executor(None, playoff_mod.fetch_playoff_picture),
100 loop.run_in_executor(None, bracket_mod.fetch_bracket),
95 return_exceptions=True, 101 return_exceptions=True,
96 ) 102 )
97 103
98 games_ranks, leaders_data, playoff_data = results 104 games_ranks, leaders_data, playoff_data, bracket_data = results
99 105
100 if not isinstance(games_ranks, Exception): 106 if not isinstance(games_ranks, Exception):
101 self._games, self._ranks = games_ranks 107 self._games, self._ranks = games_ranks
@@ -103,6 +109,8 @@ class NBAApp(App):
103 self._leaders_data = leaders_data 109 self._leaders_data = leaders_data
104 if not isinstance(playoff_data, Exception): 110 if not isinstance(playoff_data, Exception):
105 self._playoff_data = playoff_data 111 self._playoff_data = playoff_data
112 if not isinstance(bracket_data, Exception):
113 self._bracket_data = bracket_data
106 114
107 self._update_widgets() 115 self._update_widgets()
108 self.query_one(CountdownBar).reset(self.refresh_interval) 116 self.query_one(CountdownBar).reset(self.refresh_interval)
@@ -133,6 +141,11 @@ class NBAApp(App):
133 Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) 141 Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data))
134 ) 142 )
135 143
144 if self._bracket_data:
145 self.query_one("#bracket-content", Static).update(
146 Text.from_ansi(bracket_mod.get_bracket_table(self._bracket_data))
147 )
148
136 # ------------------------------------------------------------------ # 149 # ------------------------------------------------------------------ #
137 # Key handlers # 150 # Key handlers #
138 # ------------------------------------------------------------------ # 151 # ------------------------------------------------------------------ #
@@ -162,6 +175,9 @@ class NBAApp(App):
162 def action_show_playoff(self) -> None: 175 def action_show_playoff(self) -> None:
163 self.query_one(TabbedContent).active = "playoff" 176 self.query_one(TabbedContent).active = "playoff"
164 177
178 def action_show_bracket(self) -> None:
179 self.query_one(TabbedContent).active = "bracket"
180
165 def action_show_boxscore(self) -> None: 181 def action_show_boxscore(self) -> None:
166 self.query_one(TabbedContent).active = "boxscore" 182 self.query_one(TabbedContent).active = "boxscore"
167 183
build/lib/nba/tui/styles.tcss +3 −2
@@ -16,7 +16,7 @@ TabPane {
16 width: auto; 16 width: auto;
17} 17}
18 18
19/* Standings & Playoff tabs — two columns side by side */ 19/* Standings, Playoff, Bracket, and Box Score tabs */
20#standings-container, 20#standings-container,
21#playoff-container, 21#playoff-container,
22#boxscore-container { 22#boxscore-container {
@@ -27,7 +27,8 @@ TabPane {
27#east-content, 27#east-content,
28#west-content, 28#west-content,
29#playoff-east-content, 29#playoff-east-content,
30#playoff-west-content { 30#playoff-west-content,
31#bracket-content {
31 width: 1fr; 32 width: 1fr;
32 overflow: auto auto; 33 overflow: auto auto;
33 padding-right: 2; 34 padding-right: 2;
build/lib/nba/tui/widgets.py +2 −1
@@ -41,7 +41,8 @@ class CountdownBar(Static):
41 else: 41 else:
42 self.update( 42 self.update(
43 f"Next refresh in {value}s | " 43 f"Next refresh in {value}s | "
44 "\\[1-9] Box Score | \\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" 44 "\\[1-9] Box Score | \\[k] Bracket | "
45 "\\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit"
45 ) 46 )
46 47
47 def reset(self, interval: int) -> None: 48 def reset(self, interval: int) -> None:
nba/bracket.py added +281
@@ -0,0 +1,281 @@
1"""
2Fetches and formats the NBA playoff bracket.
3"""
4
5import json
6from collections import defaultdict
7from typing import Optional
8
9from nba_api.stats.endpoints.commonplayoffseries import CommonPlayoffSeries
10from nba_api.stats.endpoints.leaguegamelog import LeagueGameLog
11from nba_api.stats.static import teams
12
13BOLD = "\033[1m"
14END = "\033[0m"
15GREEN = "\033[32m"
16
17ROUND_LABELS = {
18 1: "First Round",
19 2: "Conference Semifinals",
20 3: "Conference Finals",
21 4: "NBA Finals",
22}
23
24
25def fetch_bracket() -> dict:
26 """
27 Fetch playoff series and completed playoff games.
28
29 CommonPlayoffSeries provides the bracket's scheduled series and games. The
30 playoff game log is used to compute each series record from completed games.
31 """
32 series_endpoint = CommonPlayoffSeries()
33 game_log_endpoint = LeagueGameLog(season_type_all_star="Playoffs")
34 return {
35 "series": json.loads(series_endpoint.get_json()),
36 "game_log": json.loads(game_log_endpoint.get_json()),
37 }
38
39
40def get_bracket_table(data: dict) -> str:
41 """Return a formatted playoff bracket."""
42 result_sets = data["series"]["resultSets"]
43 series_set = next((r for r in result_sets if r["name"] == "PlayoffSeries"), None)
44 if series_set is None or not series_set["rowSet"]:
45 return "No playoff bracket data available."
46
47 headers = series_set["headers"]
48 idx = {header: i for i, header in enumerate(headers)}
49 team_map = _team_map()
50 series_games = _group_series(series_set["rowSet"], idx)
51 series_wins = _series_wins(data["game_log"], series_games)
52 summaries = _series_summaries(team_map, series_games, series_wins)
53
54 return "\n".join(
55 [
56 _center(f"{BOLD}NBA Playoff Bracket{END}", 74),
57 "",
58 _render_conference("Western", summaries),
59 "",
60 _render_finals(summaries),
61 "",
62 _render_conference("Eastern", summaries),
63 ]
64 )
65
66
67def _team_map() -> dict:
68 return {
69 team["id"]: {
70 "abbr": team["abbreviation"],
71 "name": f"{team['city']} {team['nickname']}",
72 }
73 for team in teams.get_teams()
74 }
75
76
77def _group_series(rows: list, idx: dict) -> dict:
78 series_games = defaultdict(list)
79 for row in rows:
80 series_id = row[idx["SERIES_ID"]]
81 series_games[series_id].append(
82 {
83 "game_id": row[idx["GAME_ID"]],
84 "game_num": row[idx["GAME_NUM"]],
85 "home_team_id": row[idx["HOME_TEAM_ID"]],
86 "visitor_team_id": row[idx["VISITOR_TEAM_ID"]],
87 }
88 )
89
90 return {
91 series_id: sorted(games, key=lambda game: game["game_num"])
92 for series_id, games in series_games.items()
93 }
94
95
96def _series_wins(game_log: dict, series_games: dict) -> dict:
97 game_to_series = {
98 game["game_id"]: series_id
99 for series_id, games in series_games.items()
100 for game in games
101 }
102 wins = defaultdict(lambda: defaultdict(int))
103
104 for series_id in series_games:
105 wins[series_id]["completed_games"] = set()
106
107 result_set = game_log["resultSets"][0]
108 idx = {header: i for i, header in enumerate(result_set["headers"])}
109 for row in result_set["rowSet"]:
110 game_id = row[idx["GAME_ID"]]
111 series_id = game_to_series.get(game_id)
112 if series_id is None or row[idx["WL"]] != "W":
113 continue
114
115 team_id = row[idx["TEAM_ID"]]
116 wins[series_id][team_id] += 1
117 wins[series_id]["completed_games"].add(game_id)
118
119 return wins
120
121
122def _series_summaries(team_map: dict, series_games: dict, series_wins: dict) -> dict:
123 summaries = {}
124 for series_id, games in series_games.items():
125 first_game = games[0]
126 home_id = first_game["home_team_id"]
127 visitor_id = first_game["visitor_team_id"]
128 wins = series_wins[series_id]
129 home_wins = wins.get(home_id, 0)
130 visitor_wins = wins.get(visitor_id, 0)
131
132 summaries[series_id] = {
133 "conference": _conference(series_id),
134 "round": _round_number(series_id),
135 "slot": _series_slot(series_id),
136 "home_id": home_id,
137 "visitor_id": visitor_id,
138 "home_abbr": _abbr(team_map, home_id),
139 "visitor_abbr": _abbr(team_map, visitor_id),
140 "home_wins": home_wins,
141 "visitor_wins": visitor_wins,
142 "winner": _winner(team_map, home_id, visitor_id, home_wins, visitor_wins),
143 }
144 return summaries
145
146
147def _render_conference(conference: str, summaries: dict) -> str:
148 first_round = _conference_round(summaries, conference, 1)
149 semifinals = _conference_round(summaries, conference, 2)
150 finals = _conference_round(summaries, conference, 3)
151 final = finals[0] if finals else None
152
153 lines = [
154 f"{BOLD}{conference.upper()} CONFERENCE{END}",
155 "First Round Semifinals Conference Finals",
156 ]
157
158 lines.extend(
159 [
160 f"{_series_box(_slot(first_round, 0), 24)} ┐",
161 f"{_blank(24)} ├── {_series_box(_slot(semifinals, 0), 24)} ┐",
162 f"{_series_box(_slot(first_round, 1), 24)} ┘ {_blank(24)} │",
163 f"{_blank(24)} {_blank(24)} ├── {_series_box(final, 24)}",
164 f"{_series_box(_slot(first_round, 2), 24)} ┐ {_blank(24)} │",
165 f"{_blank(24)} ├── {_series_box(_slot(semifinals, 1), 24)} ┘",
166 f"{_series_box(_slot(first_round, 3), 24)} ┘",
167 ]
168 )
169
170 return "\n".join(lines)
171
172
173def _render_finals(summaries: dict) -> str:
174 finals = [summary for summary in summaries.values() if summary["round"] == 4]
175 final = finals[0] if finals else None
176
177 lines = [
178 f"{BOLD}NBA FINALS{END}",
179 "West Champion East Champion",
180 f"{_series_box(final, 24)}",
181 ]
182 if final is None:
183 lines.append("Winner TBD")
184 elif final["winner"]:
185 lines.append(f"{GREEN}{final['winner']} wins the Finals{END}")
186 return "\n".join(lines)
187
188
189def _conference_round(summaries: dict, conference: str, round_number: int) -> list:
190 return sorted(
191 [
192 summary
193 for summary in summaries.values()
194 if summary["conference"] == conference[:4]
195 and summary["round"] == round_number
196 ],
197 key=lambda summary: summary["slot"],
198 )
199
200
201def _slot(items: list, idx: int) -> Optional[dict]:
202 return items[idx] if idx < len(items) else None
203
204
205def _series_box(summary: Optional[dict], width: int) -> str:
206 if summary is None:
207 return _blank(width, "TBD")
208
209 visitor = _team_line(
210 summary["visitor_abbr"],
211 summary["visitor_wins"],
212 summary["winner"] == summary["visitor_abbr"],
213 )
214 home = _team_line(
215 summary["home_abbr"],
216 summary["home_wins"],
217 summary["winner"] == summary["home_abbr"],
218 )
219 return f"{visitor} / {home}".ljust(width)
220
221
222def _team_line(abbr: str, wins: int, won_series: bool) -> str:
223 label = f"{abbr} {wins}"
224 if won_series:
225 return f"{label}*"
226 return label
227
228
229def _winner(
230 team_map: dict,
231 home_id: int,
232 visitor_id: int,
233 home_wins: int,
234 visitor_wins: int,
235) -> str:
236 if home_wins >= 4:
237 return _abbr(team_map, home_id)
238 if visitor_wins >= 4:
239 return _abbr(team_map, visitor_id)
240 return ""
241
242
243def _blank(width: int, text: str = "") -> str:
244 return text.ljust(width)
245
246
247def _center(text: str, width: int) -> str:
248 return text.center(width)
249
250
251def _round_number(series_id: str) -> int:
252 try:
253 return int(series_id[-3:-1])
254 except ValueError:
255 return 0
256
257
258def _series_slot(series_id: str) -> int:
259 try:
260 return int(series_id[-1])
261 except ValueError:
262 return 0
263
264
265def _conference(series_id: str) -> str:
266 round_number = _round_number(series_id)
267 slot = _series_slot(series_id)
268
269 if round_number == 4:
270 return "NBA"
271 if round_number == 3:
272 return "East" if slot == 0 else "West"
273 if round_number == 2:
274 return "East" if slot <= 1 else "West"
275 if round_number == 1:
276 return "East" if slot <= 3 else "West"
277 return ""
278
279
280def _abbr(team_map: dict, team_id: int) -> str:
281 return team_map.get(team_id, {}).get("abbr", str(team_id))
nba/tui/app.py +18 −2
@@ -13,6 +13,7 @@ from textual.containers import Horizontal
13from textual.widgets import Footer, Header, Static, TabbedContent, TabPane 13from textual.widgets import Footer, Header, Static, TabbedContent, TabPane
14 14
15from nba import fetch_data 15from nba import fetch_data
16from nba import bracket as bracket_mod
16from nba import leaders as leaders_mod 17from nba import leaders as leaders_mod
17from nba import playoff as playoff_mod 18from nba import playoff as playoff_mod
18from nba import box_score as box_score_mod 19from nba import box_score as box_score_mod
@@ -32,6 +33,7 @@ class NBAApp(App):
32 ("t", "show_standings", "Standings"), 33 ("t", "show_standings", "Standings"),
33 ("l", "show_leaders", "Leaders"), 34 ("l", "show_leaders", "Leaders"),
34 ("p", "show_playoff", "Playoff"), 35 ("p", "show_playoff", "Playoff"),
36 ("k", "show_bracket", "Bracket"),
35 ("b", "show_boxscore", "Box Score"), 37 ("b", "show_boxscore", "Box Score"),
36 ("r", "refresh_now", "Refresh"), 38 ("r", "refresh_now", "Refresh"),
37 ("comma", "prev_category", "◀ Cat"), 39 ("comma", "prev_category", "◀ Cat"),
@@ -49,6 +51,7 @@ class NBAApp(App):
49 self._leaders_data: dict | None = None 51 self._leaders_data: dict | None = None
50 self._leaders_cat_idx: int = 0 52 self._leaders_cat_idx: int = 0
51 self._playoff_data: dict | None = None 53 self._playoff_data: dict | None = None
54 self._bracket_data: dict | None = None
52 55
53 def compose(self) -> ComposeResult: 56 def compose(self) -> ComposeResult:
54 yield Header() 57 yield Header()
@@ -65,6 +68,8 @@ class NBAApp(App):
65 with Horizontal(id="playoff-container"): 68 with Horizontal(id="playoff-container"):
66 yield Static("Loading...", id="playoff-west-content") 69 yield Static("Loading...", id="playoff-west-content")
67 yield Static("Loading...", id="playoff-east-content") 70 yield Static("Loading...", id="playoff-east-content")
71 with TabPane("Bracket", id="bracket"):
72 yield Static("Loading...", id="bracket-content")
68 with TabPane("Box Score", id="boxscore"): 73 with TabPane("Box Score", id="boxscore"):
69 with Horizontal(id="boxscore-container"): 74 with Horizontal(id="boxscore-container"):
70 yield Static( 75 yield Static(
@@ -84,7 +89,7 @@ class NBAApp(App):
84 # ------------------------------------------------------------------ # 89 # ------------------------------------------------------------------ #
85 90
86 async def _do_refresh(self) -> None: 91 async def _do_refresh(self) -> None:
87 """Fetch scores, standings, leaders, and playoff picture in parallel.""" 92 """Fetch scores, standings, leaders, playoff picture, and bracket in parallel."""
88 loop = asyncio.get_event_loop() 93 loop = asyncio.get_event_loop()
89 cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0] 94 cat = leaders_mod.CATEGORIES[self._leaders_cat_idx][0]
90 95
@@ -92,10 +97,11 @@ class NBAApp(App):
92 loop.run_in_executor(None, fetch_data.fetch_data), 97 loop.run_in_executor(None, fetch_data.fetch_data),
93 loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)), 98 loop.run_in_executor(None, lambda: leaders_mod.fetch_leaders(cat)),
94 loop.run_in_executor(None, playoff_mod.fetch_playoff_picture), 99 loop.run_in_executor(None, playoff_mod.fetch_playoff_picture),
100 loop.run_in_executor(None, bracket_mod.fetch_bracket),
95 return_exceptions=True, 101 return_exceptions=True,
96 ) 102 )
97 103
98 games_ranks, leaders_data, playoff_data = results 104 games_ranks, leaders_data, playoff_data, bracket_data = results
99 105
100 if not isinstance(games_ranks, Exception): 106 if not isinstance(games_ranks, Exception):
101 self._games, self._ranks = games_ranks 107 self._games, self._ranks = games_ranks
@@ -103,6 +109,8 @@ class NBAApp(App):
103 self._leaders_data = leaders_data 109 self._leaders_data = leaders_data
104 if not isinstance(playoff_data, Exception): 110 if not isinstance(playoff_data, Exception):
105 self._playoff_data = playoff_data 111 self._playoff_data = playoff_data
112 if not isinstance(bracket_data, Exception):
113 self._bracket_data = bracket_data
106 114
107 self._update_widgets() 115 self._update_widgets()
108 self.query_one(CountdownBar).reset(self.refresh_interval) 116 self.query_one(CountdownBar).reset(self.refresh_interval)
@@ -133,6 +141,11 @@ class NBAApp(App):
133 Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data)) 141 Text.from_ansi(playoff_mod.get_east_playoff_table(self._playoff_data))
134 ) 142 )
135 143
144 if self._bracket_data:
145 self.query_one("#bracket-content", Static).update(
146 Text.from_ansi(bracket_mod.get_bracket_table(self._bracket_data))
147 )
148
136 # ------------------------------------------------------------------ # 149 # ------------------------------------------------------------------ #
137 # Key handlers # 150 # Key handlers #
138 # ------------------------------------------------------------------ # 151 # ------------------------------------------------------------------ #
@@ -162,6 +175,9 @@ class NBAApp(App):
162 def action_show_playoff(self) -> None: 175 def action_show_playoff(self) -> None:
163 self.query_one(TabbedContent).active = "playoff" 176 self.query_one(TabbedContent).active = "playoff"
164 177
178 def action_show_bracket(self) -> None:
179 self.query_one(TabbedContent).active = "bracket"
180
165 def action_show_boxscore(self) -> None: 181 def action_show_boxscore(self) -> None:
166 self.query_one(TabbedContent).active = "boxscore" 182 self.query_one(TabbedContent).active = "boxscore"
167 183
nba/tui/styles.tcss +3 −2
@@ -16,7 +16,7 @@ TabPane {
16 width: auto; 16 width: auto;
17} 17}
18 18
19/* Standings & Playoff tabs — two columns side by side */ 19/* Standings, Playoff, Bracket, and Box Score tabs */
20#standings-container, 20#standings-container,
21#playoff-container, 21#playoff-container,
22#boxscore-container { 22#boxscore-container {
@@ -27,7 +27,8 @@ TabPane {
27#east-content, 27#east-content,
28#west-content, 28#west-content,
29#playoff-east-content, 29#playoff-east-content,
30#playoff-west-content { 30#playoff-west-content,
31#bracket-content {
31 width: 1fr; 32 width: 1fr;
32 overflow: auto auto; 33 overflow: auto auto;
33 padding-right: 2; 34 padding-right: 2;
nba/tui/widgets.py +2 −1
@@ -41,7 +41,8 @@ class CountdownBar(Static):
41 else: 41 else:
42 self.update( 42 self.update(
43 f"Next refresh in {value}s | " 43 f"Next refresh in {value}s | "
44 "\\[1-9] Box Score | \\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit" 44 "\\[1-9] Box Score | \\[k] Bracket | "
45 "\\[</>] Leaders Cat | \\[r] Refresh | \\[q] Quit"
45 ) 46 )
46 47
47 def reset(self, interval: int) -> None: 48 def reset(self, interval: int) -> None:
nba_scores.egg-info/PKG-INFO +4 −11
@@ -1,8 +1,8 @@
1Metadata-Version: 2.4 1Metadata-Version: 2.4
2Name: nba-scores 2Name: nba-scores
3Version: 0.1.7 3Version: 0.2.2
4Summary: A CLI interface to get current NBA scores and standings. 4Summary: A CLI interface to get current NBA scores and standings.
5Author-email: Christian Cleberg <hello@cmc.pub> 5Author-email: Christian Cleberg <hello@cleberg.net>
6License-Expression: GPL-3.0-or-later 6License-Expression: GPL-3.0-or-later
7Project-URL: Homepage, https://git.cleberg.net/nba-scores.git 7Project-URL: Homepage, https://git.cleberg.net/nba-scores.git
8Project-URL: Issues, https://git.cleberg.net/nba-scores.git 8Project-URL: Issues, https://git.cleberg.net/nba-scores.git
@@ -38,18 +38,10 @@ auto-refresh.
38 38
39[Back to top](#table-of-contents) 39[Back to top](#table-of-contents)
40 40
41## PyPi
42
43```shell
44pipx install nba-scores
45```
46
47## Manual
48
49```shell 41```shell
50git clone https://git.cleberg.net/nba-scores.git 42git clone https://git.cleberg.net/nba-scores.git
51cd nba-scores 43cd nba-scores
52uv sync 44pipx install .
53``` 45```
54 46
55# Usage 47# Usage
@@ -81,6 +73,7 @@ Optional flags:
81| Standings | `t` | East / West conference standings, side by side | 73| Standings | `t` | East / West conference standings, side by side |
82| Leaders | `l` | Top 25 players by stat category | 74| Leaders | `l` | Top 25 players by stat category |
83| Playoff Picture | `p` | Conference seeding with clinch and elimination status | 75| Playoff Picture | `p` | Conference seeding with clinch and elimination status |
76| Bracket | `k` | Playoff series matchups and series records |
84| Box Score | `b` | Per-player live stats for a selected game | 77| Box Score | `b` | Per-player live stats for a selected game |
85 78
86### TUI key bindings 79### TUI key bindings
nba_scores.egg-info/SOURCES.txt +1
@@ -4,6 +4,7 @@ pyproject.toml
4nba/__init__.py 4nba/__init__.py
5nba/__main__.py 5nba/__main__.py
6nba/box_score.py 6nba/box_score.py
7nba/bracket.py
7nba/cli.py 8nba/cli.py
8nba/fetch_data.py 9nba/fetch_data.py
9nba/leaders.py 10nba/leaders.py
pyproject.toml +2 −2
@@ -1,8 +1,8 @@
1[project] 1[project]
2name = "nba-scores" 2name = "nba-scores"
3version = "0.1.7" 3version = "0.2.2"
4authors = [ 4authors = [
5 { name="Christian Cleberg", email="hello@cmc.pub" }, 5 { name="Christian Cleberg", email="hello@cleberg.net" },
6] 6]
7description = "A CLI interface to get current NBA scores and standings." 7description = "A CLI interface to get current NBA scores and standings."
8readme = "README.md" 8readme = "README.md"
uv.lock +1 −1
@@ -361,7 +361,7 @@ wheels = [
361 361
362[[package]] 362[[package]]
363name = "nba-scores" 363name = "nba-scores"
364version = "0.1.7" 364version = "0.2.1"
365source = { editable = "." } 365source = { editable = "." }
366dependencies = [ 366dependencies = [
367 { name = "argparse" }, 367 { name = "argparse" },