league-table-manager / interface.py
asvs's picture
Refactor: split monolithic app.py into maintainable modules
c04d3f9
"""Gradio UI: builds the full multi-tab interface."""
import gradio as gr
import data
from data import load_matches, get_teams_from_matches
from renderers import (
render_league_table_html,
render_match_history_html,
render_stat_cards,
render_h2h_stats_html,
get_h2h_match_history_html,
make_status,
update_score_preview,
)
from crud import add_match, delete_match_by_id, update_match
def build_interface():
"""Build and return the Gradio Blocks demo."""
load_matches()
initial_teams = get_teams_from_matches()
with gr.Blocks(title="League Table Manager") as demo:
# ── App header ──────────────────────────────────
gr.HTML("""
<div style="padding:20px 0 8px; font-family:Inter,sans-serif;">
<h1 style="font-size:1.9rem; font-weight:900; color:#f1f5f9; letter-spacing:-0.03em; margin:0;">
⚽ League Table Manager
</h1>
<p style="color:#64748b; margin:4px 0 0; font-size:0.85rem;">Track matches, standings, and head-to-head records</p>
</div>
""")
with gr.Tabs():
# ── Tab 1: Standings ─────────────────────────
with gr.Tab("Standings"):
gr.HTML("<h2 style='color:#f1f5f9; font-size:1.1rem; font-weight:700; margin:8px 0 4px; font-family:Inter,sans-serif;'>Current Standings</h2>")
standings_html = gr.HTML(value=render_league_table_html(data.matches))
with gr.Accordion("Column Guide", open=False):
gr.Markdown("""
| Column | Meaning |
|--------|---------|
| Win Rate | Win percentage (wins / games played) |
| P | Matches played |
| W | Wins |
| D | Draws |
| L | Losses |
| GF | Goals scored (Goals For) |
| GA | Goals conceded (Goals Against) |
| GD | Goal difference (GF − GA) |
| Pts | League points (W=3, D=1, L=0) |
| G/GM | Goals scored per match |
| WW | Whitewash wins (opponent scored 0) |
| 5G | Matches where you scored 5 or more |
""")
# ── Tab 2: Add Match ─────────────────────────
with gr.Tab("Add Match"):
with gr.Row():
# Left: Add Match form
with gr.Column(scale=1, min_width=280):
gr.HTML("<h2 style='color:#f1f5f9; font-size:1rem; font-weight:700; margin:4px 0 12px; font-family:Inter,sans-serif;'>Add Match</h2>")
score_preview = gr.HTML(value="""
<div style="text-align:center; padding:16px; background:#0f172a; border-radius:12px;
border:1px solid #334155; color:#64748b; font-style:italic; font-family:Inter,sans-serif; font-size:0.85rem;">
Select teams and enter goals to preview
</div>
""")
home_team = gr.Dropdown(
choices=initial_teams,
label="Home Team",
value=initial_teams[0] if initial_teams else None,
allow_custom_value=True
)
away_team = gr.Dropdown(
choices=initial_teams,
label="Away Team",
value=initial_teams[1] if len(initial_teams) > 1 else None,
allow_custom_value=True
)
with gr.Row():
home_goals = gr.Number(label="Home Goals", value=0, minimum=0, precision=0)
away_goals = gr.Number(label="Away Goals", value=0, minimum=0, precision=0)
submit_btn = gr.Button("Add Match", variant="primary")
status_html = gr.HTML(value="")
# Right: Match history
with gr.Column(scale=2):
gr.HTML("<h2 style='color:#f1f5f9; font-size:1rem; font-weight:700; margin:4px 0 12px; font-family:Inter,sans-serif;'>Match History</h2>")
matches_html = gr.HTML(value=render_match_history_html(data.matches))
# Delete accordion
with gr.Accordion("Delete a Match", open=False):
gr.Markdown("Enter the row number from the history above, or click a row to auto-fill.")
delete_preview_html = gr.HTML(value="")
with gr.Row():
delete_row_input = gr.Number(
label="Row # to Delete",
value=None,
minimum=1,
precision=0,
scale=2
)
stage_delete_btn = gr.Button("Preview Delete", variant="secondary", scale=1)
confirm_delete_btn = gr.Button("Confirm Delete", variant="stop", visible=False)
pending_match_id = gr.State(None)
# Edit accordion
with gr.Accordion("Edit a Match", open=False):
gr.Markdown("Enter the row number to edit, fill in the new values, then click Save.")
update_row_input = gr.Number(label="Row # to Edit", value=None, minimum=1, precision=0)
with gr.Row():
update_home_team = gr.Dropdown(
choices=initial_teams,
label="New Home Team",
allow_custom_value=True
)
update_away_team = gr.Dropdown(
choices=initial_teams,
label="New Away Team",
allow_custom_value=True
)
with gr.Row():
update_home_goals = gr.Number(label="Home Goals", value=0, minimum=0, precision=0)
update_away_goals = gr.Number(label="Away Goals", value=0, minimum=0, precision=0)
update_btn = gr.Button("Save Changes", variant="secondary")
update_status_html = gr.HTML(value="")
# ── Score preview events ──
for component in [home_team, away_team, home_goals, away_goals]:
component.change(
fn=update_score_preview,
inputs=[home_team, away_team, home_goals, away_goals],
outputs=[score_preview]
)
# ── Add Match ──
def add_match_full(home, away, hg, ag):
status = add_match(home, away, hg, ag)
teams = get_teams_from_matches()
return (
render_league_table_html(data.matches),
render_match_history_html(data.matches),
make_status(status),
gr.Number(value=0),
gr.Number(value=0),
gr.Dropdown(choices=teams),
gr.Dropdown(choices=teams),
gr.Dropdown(choices=teams),
gr.Dropdown(choices=teams),
)
submit_btn.click(
fn=add_match_full,
inputs=[home_team, away_team, home_goals, away_goals],
outputs=[standings_html, matches_html, status_html,
home_goals, away_goals,
home_team, away_team,
update_home_team, update_away_team],
show_progress="minimal"
)
# ── Two-step Delete ──
def stage_delete(row_number):
if row_number is None or row_number < 1:
return (
'<div style="padding:10px; background:#fee2e2; border-left:4px solid #dc2626; border-radius:6px; color:#991b1b; font-family:Inter,sans-serif;">✗ Invalid row number</div>',
None,
gr.update(visible=False)
)
sorted_m = sorted(data.matches, key=lambda x: x[5], reverse=True)
row_idx = int(row_number) - 1
if row_idx < 0 or row_idx >= len(sorted_m):
return (
f'<div style="padding:10px; background:#fee2e2; border-left:4px solid #dc2626; border-radius:6px; color:#991b1b; font-family:Inter,sans-serif;">✗ Row {int(row_number)} does not exist</div>',
None,
gr.update(visible=False)
)
m = sorted_m[row_idx]
preview = f"""
<div style="padding:12px; background:#7f1d1d20; border:1px solid #dc2626; border-radius:8px; color:#fca5a5; font-family:Inter,sans-serif;">
About to delete row <strong>{int(row_number)}</strong>:
<strong>{m[1]} {m[3]}{m[4]} {m[2]}</strong><br>
<small style="color:#94a3b8;">Click <em>Confirm Delete</em> to proceed.</small>
</div>
"""
return preview, m[0], gr.update(visible=True)
stage_delete_btn.click(
fn=stage_delete,
inputs=[delete_row_input],
outputs=[delete_preview_html, pending_match_id, confirm_delete_btn]
)
def execute_delete(match_id):
if match_id is None:
return (
render_league_table_html(data.matches),
render_match_history_html(data.matches),
make_status("Error: No match staged for deletion"),
"",
None,
gr.update(visible=False)
)
ok = delete_match_by_id(match_id)
status = "Match deleted successfully" if ok else "Error: Failed to delete match"
return (
render_league_table_html(data.matches),
render_match_history_html(data.matches),
make_status(status),
"",
None,
gr.update(visible=False)
)
confirm_delete_btn.click(
fn=execute_delete,
inputs=[pending_match_id],
outputs=[standings_html, matches_html, status_html,
delete_preview_html, pending_match_id, confirm_delete_btn],
show_progress="minimal"
)
# ── Edit Match ──
def update_match_full(row_number, new_home, new_away, new_home_goals, new_away_goals):
status = update_match(row_number, new_home, new_away, new_home_goals, new_away_goals)
teams = get_teams_from_matches()
return (
render_league_table_html(data.matches),
render_match_history_html(data.matches),
make_status(status),
gr.Dropdown(choices=teams),
gr.Dropdown(choices=teams),
gr.Dropdown(choices=teams),
gr.Dropdown(choices=teams),
)
update_btn.click(
fn=update_match_full,
inputs=[update_row_input, update_home_team, update_away_team,
update_home_goals, update_away_goals],
outputs=[standings_html, matches_html, update_status_html,
home_team, away_team,
update_home_team, update_away_team],
show_progress="minimal"
)
# ── Tab 3: Head to Head ──────────────────────
with gr.Tab("Head to Head"):
gr.HTML("<h2 style='color:#f1f5f9; font-size:1.1rem; font-weight:700; margin:8px 0 16px; font-family:Inter,sans-serif;'>Head-to-Head Comparison</h2>")
with gr.Row():
h2h_team1 = gr.Dropdown(
choices=initial_teams,
label="Team 1",
value=initial_teams[0] if initial_teams else None,
allow_custom_value=True,
scale=1
)
h2h_team2 = gr.Dropdown(
choices=initial_teams,
label="Team 2",
value=initial_teams[1] if len(initial_teams) > 1 else None,
allow_custom_value=True,
scale=1
)
h2h_stats_html = gr.HTML(
value=render_h2h_stats_html(
initial_teams[0] if initial_teams else None,
initial_teams[1] if len(initial_teams) > 1 else None,
data.matches
)
)
gr.HTML("<h3 style='color:#94a3b8; font-size:0.85rem; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; margin:20px 0 8px; font-family:Inter,sans-serif;'>Match History</h3>")
h2h_history_html = gr.HTML(
value=get_h2h_match_history_html(
initial_teams[0] if initial_teams else None,
initial_teams[1] if len(initial_teams) > 1 else None,
data.matches
)
)
def update_h2h(t1, t2):
return (
render_h2h_stats_html(t1, t2, data.matches),
get_h2h_match_history_html(t1, t2, data.matches)
)
h2h_team1.change(fn=update_h2h, inputs=[h2h_team1, h2h_team2], outputs=[h2h_stats_html, h2h_history_html])
h2h_team2.change(fn=update_h2h, inputs=[h2h_team1, h2h_team2], outputs=[h2h_stats_html, h2h_history_html])
# ── Tab 4: Records ───────────────────────────
with gr.Tab("Records"):
gr.HTML("<h2 style='color:#f1f5f9; font-size:1.1rem; font-weight:700; margin:8px 0 16px; font-family:Inter,sans-serif;'>League Records</h2>")
records_html = gr.HTML(value=render_stat_cards(data.matches))
# ── Page load refresh ──
def refresh_all():
load_matches()
teams = get_teams_from_matches()
t1 = teams[0] if teams else None
t2 = teams[1] if len(teams) > 1 else None
return (
render_league_table_html(data.matches),
render_match_history_html(data.matches),
render_h2h_stats_html(t1, t2, data.matches),
get_h2h_match_history_html(t1, t2, data.matches),
render_stat_cards(data.matches),
gr.Dropdown(choices=teams),
gr.Dropdown(choices=teams),
gr.Dropdown(choices=teams),
gr.Dropdown(choices=teams),
gr.Dropdown(choices=teams),
gr.Dropdown(choices=teams),
)
demo.load(
fn=refresh_all,
inputs=[],
outputs=[
standings_html, matches_html,
h2h_stats_html, h2h_history_html,
records_html,
home_team, away_team,
update_home_team, update_away_team,
h2h_team1, h2h_team2,
]
)
return demo