"""
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"""
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""
)
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,
)