""" app.py ====== MLB Pregame Dashboard — Gradio UI Run: python app.py (then open http://localhost:7860) Layout: ┌──────────────┬──────────────────────────────────────────────┐ │ SIDEBAR │ MAIN PANEL │ │ Date picker │ [Hitters] / [Pitchers] toggle │ │ Game list │ Team filter: Away | All | Home │ │ Weather │ Content │ └──────────────┴──────────────────────────────────────────────┘ """ import datetime import json import gradio as gr import pandas as pd import data_fetcher as df_mod import chart_builder as cb from data_fetcher import TEAM_COLORS, LEAGUE_AVG # ── CSS ─────────────────────────────────────────────────────────────────────── CUSTOM_CSS = """ /* ── Global ── */ body, .gradio-container { background:#0f1117 !important; color:#d4d8e8; } .gradio-container { max-width:1400px; margin:0 auto; } /* ── Sidebar ── */ #sidebar { background:#13182a; border-right:1px solid #1e2640; padding:12px 10px; min-width:220px; max-width:240px; } /* ── Game card ── */ .game-card { background:#181c2e; border:1px solid #252a40; border-radius:6px; padding:8px 10px; margin-bottom:6px; cursor:pointer; transition:border-color .15s; font-size:12px; } .game-card:hover { border-color:#4a9eff55; } .game-card.selected { border-color:#4a9eff; background:#1a2340; } .game-card .teams { font-weight:700; font-size:13px; letter-spacing:.5px; } .game-card .meta { color:#6a7a9a; font-size:11px; margin-top:2px; } /* Status badges */ .badge-today { background:#1a3a1a; color:#4adf6a; border:1px solid #2a5a2a; padding:1px 6px; border-radius:3px; font-size:10px; font-weight:700; } .badge-live { background:#3a1a1a; color:#ff5555; border:1px solid #5a2a2a; padding:1px 6px; border-radius:3px; font-size:10px; font-weight:700; animation:pulse 1.4s infinite; } .badge-future { background:#1a2a3a; color:#4a9eff; border:1px solid #2a4a6a; padding:1px 6px; border-radius:3px; font-size:10px; font-weight:700; } .badge-past { background:#222233; color:#7788aa; border:1px solid #334455; padding:1px 6px; border-radius:3px; font-size:10px; font-weight:700; } @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} } /* ── Section headers ── */ .section-title { font-size:11px; color:#5a6a8a; text-transform:uppercase; letter-spacing:1.5px; margin:12px 0 6px; padding-bottom:4px; border-bottom:1px solid #1e2640; } /* ── Tab strip (Hitters / Pitchers) ── */ #main-tabs .tab-nav { background:#13182a !important; border-bottom:1px solid #1e2640; } #main-tabs .tab-nav button { color:#6a7a9a !important; font-weight:600; font-size:14px !important; letter-spacing:.5px; } #main-tabs .tab-nav button.selected { color:#d4d8e8 !important; border-bottom:2px solid #4a9eff !important; } /* ── Team toggle ── */ .team-toggle { display:flex; gap:6px; margin-bottom:12px; align-items:center; } .team-btn { padding:3px 12px; border-radius:4px; border:1px solid #2a3050; background:#181c2e; color:#8899bb; font-size:12px; font-weight:700; cursor:pointer; transition:all .15s; letter-spacing:.5px; } .team-btn.active { border-color:#4a9eff; color:#d4d8e8; background:#1a2a44; } /* ── Hitter rows ── */ .hitter-table { width:100%; border-collapse:collapse; font-size:12px; font-family:'JetBrains Mono',monospace; } .hitter-table th { color:#5a6a8a; font-weight:600; font-size:10px; text-transform:uppercase; letter-spacing:1px; padding:4px 8px; border-bottom:1px solid #1e2640; text-align:left; } .hitter-table td { padding:5px 8px; border-bottom:1px solid #151928; } .hitter-table tr:hover td { background:#1a1f30; } .bench-row td { background:#131620 !important; color:#6a7a9a; } .bench-row:hover td { background:#181c28 !important; } .spot-col { color:#5a6a8a; width:28px; } .name-col { font-weight:600; } .hand-badge { display:inline-block; padding:1px 5px; border-radius:3px; font-size:10px; font-weight:700; margin-right:4px; } .hand-L { background:#1a2a3a; color:#4a9eff; border:1px solid #2a4a6a; } .hand-R { background:#2a1a1a; color:#ff7755; border:1px solid #4a2a2a; } .hand-S { background:#1a2a1a; color:#4adf6a; border:1px solid #2a4a2a; } .small-sample { color:#ffaa33; font-size:10px; } .recent-stat { color:#8899bb; } .official-yes { color:#4adf6a; font-size:10px; } .official-no { color:#ffaa33; font-size:10px; } /* ── Pitcher page ── */ .pitcher-header { display:flex; align-items:center; gap:12px; margin-bottom:14px; } .pitcher-name { font-size:20px; font-weight:700; letter-spacing:.5px; } .pitcher-meta { color:#6a7a9a; font-size:12px; margin-top:2px; } /* ── Splits table ── */ .splits-table { border-collapse:collapse; font-size:12px; font-family:'JetBrains Mono',monospace; width:100%; } .splits-table th { color:#5a6a8a; font-weight:600; font-size:10px; text-transform:uppercase; letter-spacing:1px; padding:3px 8px; border-bottom:1px solid #1e2640; } .splits-table td { padding:4px 8px; border-bottom:1px solid #151928; } .split-hand { color:#8899bb; font-weight:600; } .split-pa { color:#445566; font-size:10px; } /* ── Bullpen cards ── */ .bullpen-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(310px,1fr)); gap:8px; margin-top:8px; } .bp-card { background:#181c2e; border:1px solid #1e2640; border-radius:6px; padding:10px; } .bp-card-name { font-weight:700; font-size:13px; margin-bottom:4px; } .bp-pitches { display:flex; gap:6px; margin-top:6px; } .bp-pitch-day { background:#13182a; border:1px solid #252a40; border-radius:4px; padding:3px 8px; font-size:11px; text-align:center; } .bp-pitch-day .day-label { color:#5a6a8a; font-size:9px; } .bp-pitch-day .day-count { font-weight:700; color: var(--pitch-color, #d4d8e8); font-size:13px; } /* ── Weather widget ── */ .weather-box { background:#181c2e; border:1px solid #1e2640; border-radius:6px; padding:8px 10px; font-size:12px; margin-top:6px; } .weather-temp { font-size:22px; font-weight:700; } .weather-detail { color:#6a7a9a; font-size:11px; margin-top:2px; } /* ── Util ── */ .mono { font-family:'JetBrains Mono',monospace; } .dimmed { color:#445566; } """ # ── State container (shared across callbacks via gr.State) ──────────────────── _INITIAL_STATE = { "date": datetime.date.today().isoformat(), "game_idx": 0, "tab": "hitters", # 'hitters' | 'pitchers' "team_filter": "all", # 'away' | 'all' | 'home' } # ══════════════════════════════════════════════════════════════════════════════ # HTML BUILDERS # ══════════════════════════════════════════════════════════════════════════════ def _status_badge(status: str) -> str: cls = f"badge-{status.lower()}" return f'{status.upper()}' def _game_card_html(game: dict, idx: int, selected: bool) -> str: away, home = game["away_abbr"], game["home_abbr"] pri_a, _ = TEAM_COLORS.get(away, ("#333", "#aaa")) pri_h, _ = TEAM_COLORS.get(home, ("#333", "#aaa")) score = "" if game["score_away"] is not None: score = f"   {game['score_away']}–{game['score_home']}" sel_cls = " selected" if selected else "" return f"""
{away} @ {home} {score}   {_status_badge(game['status'])}
{game.get('game_time','')} · {game.get('venue','')[:25]}
SP: {game.get('away_starter_name','TBD')} vs {game.get('home_starter_name','TBD')}
""" def build_sidebar_html(games: list, selected_idx: int) -> str: cards = "".join(_game_card_html(g, i, i == selected_idx) for i, g in enumerate(games)) if not cards: cards = "
No games found.
" return cards def build_weather_html(weather: dict, game: dict) -> str: if not weather: return ( f"
" f"Weather unavailable" f" (RotoGrinders today only)
" ) precip = f" · {weather['precip_pct']}% precip" if weather.get("precip_pct") else "" return f"""
{weather.get('temp_f','--')}°F
{weather.get('conditions','--')} · Wind {weather.get('wind_mph','?')} mph {weather.get('wind_dir','')}{precip}
@ {game.get('venue','')}
""" # ══════════════════════════════════════════════════════════════════════════════ # HITTERS TAB # ══════════════════════════════════════════════════════════════════════════════ def _hand_badge(hand: str) -> str: cls = f"hand-{hand}" if hand in ("L", "R", "S") else "hand-R" return f'{hand}' def _xwoba_cell(val: float, pa: int, pitcher: bool = False) -> str: color = cb._xwoba_color(val, pitcher=pitcher) flag = ' ' if pa < 100 else "" return f'{val:.3f}{flag}' def _hitter_row(player: dict, spot: int | str, bench: bool = False, opp_throws: str = "R") -> str: """Build a single hitter table row.""" pid = player.get("player_id") splits = df_mod.get_hitter_splits(pid) if pid else df_mod._mock_hitter_splits() recent = df_mod.get_hitter_recent(pid) if pid else df_mod._mock_hitter_recent() hand = player.get("bats", "?") opp_key = f"vs_{opp_throws}" split_data = splits.get(opp_key, {}) xwoba = split_data.get("xwOBA", 0.0) pa = split_data.get("PA", 0) xwoba_html = _xwoba_cell(xwoba, pa) pos = player.get("pos", "") name = player.get("name", "Unknown") r = recent recent_str = (f'' f'{r["H"]}/{r["AB"]}  ' f'{r["HR"]}HR  {r["K"]}K  ' f'{_xwoba_cell(r["xwOBA"], 999)}' f'') bench_cls = ' class="bench-row"' if bench else "" spot_disp = f"{spot}" if not bench else "PH" return ( f"" f"{spot_disp} {pos}" f"{_hand_badge(hand)} {xwoba_html} vs{opp_throws} {pa}PA" f"{name}" f"{recent_str}" f"" ) def build_hitters_html(game: dict, team_filter: str) -> str: """Return full hitters section HTML.""" game_pk = game.get("game_pk") away_abbr = game.get("away_abbr", "") home_abbr = game.get("home_abbr", "") lineups = df_mod.get_lineups(game_pk, away_abbr, home_abbr, datetime.date.today()) # Determine opposing pitcher handedness for splits context # (simplified — use announced starter; default R if unknown) away_throws = "R" # TODO: look up from pitcher profile home_throws = "R" sections = [] teams = [] if team_filter in ("all", "away"): teams.append(("away", away_abbr, home_throws)) if team_filter in ("all", "home"): teams.append(("home", home_abbr, away_throws)) for side, abbr, opp_throws in teams: side_data = lineups.get(side, {}) players = side_data.get("players", []) bench = lineups.get(f"{side}_bench", []) official = side_data.get("official", False) pri, sec = TEAM_COLORS.get(abbr, ("#333", "#aaa")) off_label = ('✓ Official' if official else '~ Projected') opp_note = f"vs {'LHP' if opp_throws=='L' else 'RHP'} (default splits)" rows = "" for i, p in enumerate(players, 1): rows += _hitter_row(p, i, bench=False, opp_throws=opp_throws) bench_rows = "" if bench: bench_rows = ( "BENCH / PH" ) for p in bench[:6]: bench_rows += _hitter_row(p, "PH", bench=True, opp_throws=opp_throws) header_row = ( "" "Spot/Pos" "Splits" "Name" "Last 7 days" "" ) # If no players loaded, show a message if not rows: rows = ("" "Lineup not yet available — check back closer to game time.") section = f"""
{abbr} {off_label} · {opp_note}
{header_row}{rows}{bench_rows}
""" sections.append(section) return "".join(sections) # ══════════════════════════════════════════════════════════════════════════════ # PITCHERS TAB — returns (html_str, [figures]) # ══════════════════════════════════════════════════════════════════════════════ def build_pitchers_content(game: dict, team_filter: str): """ Returns a dict with HTML and figures for the pitchers view. Called from the Gradio callback; figures are rendered into Plot components. """ away_abbr = game.get("away_abbr", "") home_abbr = game.get("home_abbr", "") results = {} teams = [] if team_filter in ("all", "away"): teams.append(("away", away_abbr, game.get("away_starter_name", "TBD"), game.get("away_starter_id"))) if team_filter in ("all", "home"): teams.append(("home", home_abbr, game.get("home_starter_name", "TBD"), game.get("home_starter_id"))) for side, abbr, starter_name, starter_id in teams: pri, sec = TEAM_COLORS.get(abbr, ("#333", "#aaa")) # ── Starter data ────────────────────────────────────────────────────── game_log = df_mod.get_pitcher_game_log(starter_id) if starter_id else df_mod._mock_pitcher_game_log() splits = df_mod.get_pitcher_splits(starter_id) if starter_id else df_mod._mock_pitcher_splits() velo = df_mod.get_pitcher_velo_by_inning(starter_id) if starter_id else df_mod._mock_velo_by_inning() splits_html = cb.make_splits_html(splits, pitcher=True) confirmed_label = ( '✓ Confirmed' if starter_id else '~ TBD' ) # ── Bullpen ─────────────────────────────────────────────────────────── bullpen = df_mod.get_bullpen(abbr) bp_cards = _build_bullpen_cards(bullpen) header_html = f"""
{abbr}
{starter_name}
Today's Starter   {confirmed_label}
L/R Splits (Season)
{splits_html}
Bullpen
{bp_cards} """ results[side] = { "header_html": header_html, "inn_chart": cb.make_innings_chart(game_log), "pitch_chart": cb.make_pitch_count_chart(game_log), "velo_chart": cb.make_velo_by_inning_chart(velo), } return results def _pitch_count_color(n: int) -> str: """Color pitch count: 0=dim, low=green, moderate=yellow, high=red.""" if n == 0: return "#334455" elif n < 15: return "#4adf6a" elif n < 25: return "#ffcc44" else: return "#ff5544" def _build_bullpen_cards(bullpen: list) -> str: """Build HTML grid of bullpen arm cards.""" cards = "" for arm in bullpen: name = arm.get("name", "Unknown") throws = arm.get("throws", "R") splits = arm.get("splits", {}) pitches = arm.get("pitches_last_3_days", [0, 0, 0]) splits_tbl = cb.make_splits_html(splits, pitcher=True) day_labels = ["2dAgo", "Yest", "Today"] pitch_boxes = "" for lbl, cnt in zip(day_labels, pitches): color = _pitch_count_color(cnt) pitch_boxes += ( f"
" f"
{lbl}
" f"
{cnt}
" f"
" ) cards += f"""
{name} {throws}
{splits_tbl}
Pitches (last 3 days)
{pitch_boxes}
""" return f'
{cards}
' if cards else "
No bullpen data.
" # ══════════════════════════════════════════════════════════════════════════════ # GRADIO APP # ══════════════════════════════════════════════════════════════════════════════ def build_app(): with gr.Blocks( css=CUSTOM_CSS, title="MLB Pregame Dashboard", theme=gr.themes.Base( primary_hue="blue", neutral_hue="slate", ).set( body_background_fill="#0f1117", body_text_color="#d4d8e8", block_background_fill="#13182a", border_color_primary="#1e2640", ), ) as app: # ── Shared state ────────────────────────────────────────────────────── state = gr.State(value=_INITIAL_STATE.copy()) gr.HTML('' '') with gr.Row(equal_height=True): # ── SIDEBAR ─────────────────────────────────────────────────────── with gr.Column(scale=0, min_width=240, elem_id="sidebar"): gr.HTML('
⚾ PREGAME
') date_input = gr.Textbox( label="Game Date (YYYY-MM-DD)", value=datetime.datetime.now().strftime("%Y-%m-%d"), placeholder="YYYY-MM-DD", elem_classes=["mono"], ) gr.HTML('
Games
') game_list_html = gr.HTML(value="
Loading...
") gr.HTML('
Game State & Weather
') game_state_html = gr.HTML() weather_html = gr.HTML() # ── MAIN PANEL ─────────────────────────────────────────────────── with gr.Column(scale=1): # Team filter strip with gr.Row(): gr.HTML('
SHOW:
') filter_radio = gr.Radio( choices=["Away", "All", "Home"], value="All", label="", container=False, elem_classes=["mono"], ) # Hitters / Pitchers tabs with gr.Tabs(elem_id="main-tabs") as tabs: with gr.TabItem("🔨 Hitters", id="hitters"): hitters_html = gr.HTML( value="
" "Select a game to load hitter data.
") with gr.TabItem("⚾ Pitchers", id="pitchers"): # Away pitcher section away_pitcher_html = gr.HTML() away_inn_chart = gr.Plot(label="Inn Distribution", show_label=False) away_pitch_chart = gr.Plot(label="Pitch Counts", show_label=False) away_velo_chart = gr.Plot(label="FB Velo by Inning",show_label=False) gr.HTML('
') # Home pitcher section home_pitcher_html = gr.HTML() home_inn_chart = gr.Plot(label="Inn Distribution", show_label=False) home_pitch_chart = gr.Plot(label="Pitch Counts", show_label=False) home_velo_chart = gr.Plot(label="FB Velo by Inning",show_label=False) # ── HIDDEN game index tracker ───────────────────────────────────────── game_index = gr.Number(value=0, visible=False, precision=0) # ══════════════════════════════════════════════════════════════════════ # CALLBACKS # ══════════════════════════════════════════════════════════════════════ def load_games(date_val, current_state): """Triggered when date changes. Refresh game list.""" if isinstance(date_val, datetime.datetime): date = date_val.date() else: try: date = datetime.datetime.fromisoformat(str(date_val)).date() except Exception: date = datetime.date.today() games = df_mod.get_schedule(date) new_st = dict(current_state) new_st["date"] = date.isoformat() new_st["game_idx"] = 0 new_st["games"] = games sidebar_html = build_sidebar_html(games, 0) # Auto-load first game gs_html, wx_html, hit_html = "", "", "" aw_ph, aw_ic, aw_pc, aw_vc = "", None, None, None hm_ph, hm_ic, hm_pc, hm_vc = "", None, None, None if games: game = games[0] gs_html = _game_state_html(game) wx_html = build_weather_html( df_mod.get_weather(game.get("venue", "")) if date == datetime.date.today() else {}, game, ) hit_html = build_hitters_html(game, "all") p_data = build_pitchers_content(game, "all") if "away" in p_data: aw_ph = p_data["away"]["header_html"] aw_ic = p_data["away"]["inn_chart"] aw_pc = p_data["away"]["pitch_chart"] aw_vc = p_data["away"]["velo_chart"] if "home" in p_data: hm_ph = p_data["home"]["header_html"] hm_ic = p_data["home"]["inn_chart"] hm_pc = p_data["home"]["pitch_chart"] hm_vc = p_data["home"]["velo_chart"] return ( new_st, sidebar_html, gs_html, wx_html, hit_html, aw_ph, aw_ic, aw_pc, aw_vc, hm_ph, hm_ic, hm_pc, hm_vc, ) def select_game(idx, current_state, filter_val): """Triggered by game index number change (JS sets this).""" games = current_state.get("games", []) idx = int(idx) if not games or idx >= len(games): return [gr.update()] * 12 game = games[idx] new_st = dict(current_state) new_st["game_idx"] = idx team_f = filter_val.lower() date_str = current_state.get("date", datetime.date.today().isoformat()) is_today = date_str == datetime.date.today().isoformat() sidebar_html = build_sidebar_html(games, idx) gs_html = _game_state_html(game) wx_html = build_weather_html( df_mod.get_weather(game.get("venue", "")) if is_today else {}, game ) hit_html = build_hitters_html(game, team_f) p_data = build_pitchers_content(game, team_f) aw_ph = p_data.get("away", {}).get("header_html", "") aw_ic = p_data.get("away", {}).get("inn_chart") aw_pc = p_data.get("away", {}).get("pitch_chart") aw_vc = p_data.get("away", {}).get("velo_chart") hm_ph = p_data.get("home", {}).get("header_html", "") hm_ic = p_data.get("home", {}).get("inn_chart") hm_pc = p_data.get("home", {}).get("pitch_chart") hm_vc = p_data.get("home", {}).get("velo_chart") return ( new_st, sidebar_html, gs_html, wx_html, hit_html, aw_ph, aw_ic, aw_pc, aw_vc, hm_ph, hm_ic, hm_pc, hm_vc, ) def change_filter(filter_val, current_state): """Re-render hitters + pitchers when team filter changes.""" games = current_state.get("games", []) idx = current_state.get("game_idx", 0) if not games: return [gr.update()] * 9 game = games[idx] team_f = filter_val.lower() hit_html = build_hitters_html(game, team_f) p_data = build_pitchers_content(game, team_f) aw_ph = p_data.get("away", {}).get("header_html", "") aw_ic = p_data.get("away", {}).get("inn_chart") aw_pc = p_data.get("away", {}).get("pitch_chart") aw_vc = p_data.get("away", {}).get("velo_chart") hm_ph = p_data.get("home", {}).get("header_html", "") hm_ic = p_data.get("home", {}).get("inn_chart") hm_pc = p_data.get("home", {}).get("pitch_chart") hm_vc = p_data.get("home", {}).get("velo_chart") return (hit_html, aw_ph, aw_ic, aw_pc, aw_vc, hm_ph, hm_ic, hm_pc, hm_vc) # Wire outputs list (shared between load_games and select_game) _all_outputs = [ state, game_list_html, game_state_html, weather_html, hitters_html, away_pitcher_html, away_inn_chart, away_pitch_chart, away_velo_chart, home_pitcher_html, home_inn_chart, home_pitch_chart, home_velo_chart, ] date_input.change( fn=load_games, inputs=[date_input, state], outputs=_all_outputs, ) game_index.change( fn=select_game, inputs=[game_index, state, filter_radio], outputs=_all_outputs, ) filter_radio.change( fn=change_filter, inputs=[filter_radio, state], outputs=[ hitters_html, away_pitcher_html, away_inn_chart, away_pitch_chart, away_velo_chart, home_pitcher_html, home_inn_chart, home_pitch_chart, home_velo_chart, ], ) # ── JS: clicking a game card updates the hidden game_index number ───── app.load( fn=load_games, inputs=[date_input, state], outputs=_all_outputs, ) gr.HTML(""" """) return app # ══════════════════════════════════════════════════════════════════════════════ # GAME STATE HTML # ══════════════════════════════════════════════════════════════════════════════ def _game_state_html(game: dict) -> str: status = game.get("status", "") inning = game.get("inning") outs = game.get("outs") s_a = game.get("score_away") s_h = game.get("score_home") if status == "Live" and inning: score_line = (f'
' f'{game["away_abbr"]} {s_a} – {s_h} {game["home_abbr"]}
' f'
' f'Inn {inning} · {outs} out(s)
') elif status == "Past" and s_a is not None: score_line = (f'
' f'Final: {game["away_abbr"]} {s_a} – {s_h} {game["home_abbr"]}
') else: score_line = (f'
' f'First pitch: {game.get("game_time","TBD")}
') return f"""
{_status_badge(status)}
{score_line}
""" # ══════════════════════════════════════════════════════════════════════════════ # ENTRY POINT # ══════════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": app = build_app() app.launch( server_name="0.0.0.0", server_port=7860, share=False, # set True to get a public Gradio link show_error=True, )