sba-scouting/src/sba_scout/screens/roster.py
Cal Corum 3c76ce1cf0 Add Lineup Builder, Gameday screen, and matchup scoring system
Features:
- Lineup Builder screen: set batting order, assign positions, save/load lineups
- Gameday screen: integrated matchup scout + lineup builder side-by-side
- Matchup Scout: analyze batters vs opposing pitchers with standardized scoring
- Standardized scoring system with league AVG/STDEV calculations
- Score caching for fast matchup lookups

Lineup Builder (press 'l'):
- Dual-panel UI with available batters and 9-slot lineup
- Keyboard controls: a=add, r=remove, k/j=reorder, p=change position
- Save/load named lineups, delete saved lineups with 'd'

Gameday screen (press 'g'):
- Left panel: team/pitcher selection with matchup ratings
- Right panel: lineup builder with live matchup ratings per batter
- Players in lineup marked with * in matchup list
- Click highlighted row to toggle selection for screenshots

Other changes:
- Dynamic season configuration (removed hardcoded season=13)
- Added delete_lineup query function
- StandardizedScoreCache model for pre-computed scores
- Auto-rebuild score cache after card imports
2026-01-25 14:09:22 -06:00

275 lines
10 KiB
Python

"""
Roster Screen - Full roster view organized by roster status.
Displays Majors/Minors/IL rosters with separate tables for batters and pitchers.
"""
from typing import ClassVar
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, ScrollableContainer, Vertical
from textual.screen import Screen
from textual.widgets import (
DataTable,
Footer,
Header,
Label,
Static,
TabbedContent,
TabPane,
)
from ..config import get_settings
from ..db.models import Player
from ..db.queries import get_my_roster
from ..db.schema import get_session
class RosterScreen(Screen):
"""Roster screen showing full roster organized by roster status."""
BINDINGS: ClassVar = [
Binding("escape", "app.pop_screen", "Back"),
Binding("q", "app.pop_screen", "Back"),
Binding("f", "refresh_roster", "Refresh"),
Binding("1", "switch_tab('majors')", "Majors", show=False),
Binding("2", "switch_tab('minors')", "Minors", show=False),
Binding("3", "switch_tab('il')", "IL", show=False),
]
def compose(self) -> ComposeResult:
"""Compose the roster layout."""
settings = get_settings()
yield Header()
with Vertical(id="roster-container"):
# Team header with sWAR summary
with Horizontal(id="roster-header"):
yield Label(f"Roster - {settings.team.team_name}", id="roster-title")
yield Label("sWAR: --/--", id="swar-summary")
# Tabbed roster views
with TabbedContent(id="roster-tabs"):
with TabPane(f"Majors ({settings.team.major_league_slots})", id="majors"):
yield self._create_roster_pane("majors")
with TabPane(f"Minors ({settings.team.minor_league_slots})", id="minors"):
yield self._create_roster_pane("minors")
with TabPane("IL", id="il"):
yield self._create_roster_pane("il")
yield Footer()
def _create_roster_pane(self, roster_type: str) -> ScrollableContainer:
"""Create a roster pane with batters and pitchers tables."""
return ScrollableContainer(
Label("Batters", classes="section-label"),
DataTable(id=f"{roster_type}-batters-table", cursor_type="none", zebra_stripes=True),
Label("Pitchers", classes="section-label"),
DataTable(id=f"{roster_type}-pitchers-table", cursor_type="none", zebra_stripes=True),
classes="roster-pane",
)
async def on_mount(self) -> None:
"""Load data when screen is mounted."""
await self._setup_tables()
await self._load_roster()
async def _setup_tables(self) -> None:
"""Configure the data tables with columns."""
# Batter columns: (name, width, header_prefix)
# header_prefix adds leading spaces to align headers with right-aligned data
batter_columns = [
("Name", 20, ""),
("H", 3, ""), # Hand
("Pos", 14, ""), # Positions
("vL", 5, " "), # Rating vs LHP - 2 spaces
("vR", 5, " "), # Rating vs RHP - 2 spaces
("Ovr", 5, " "), # Overall rating - 1 space
("sWAR", 5, ""),
("Defense", None, ""), # No fixed width - will wrap
]
# Pitcher columns: (name, width, header_prefix)
pitcher_columns = [
("Name", 20, ""),
("H", 3, ""), # Hand
("Pos", 12, ""), # Positions (SP, RP, CP) - wide enough for all 3
("vL", 5, " "), # Rating vs LHB - 2 spaces
("vR", 5, " "), # Rating vs RHB - 2 spaces
("Ovr", 5, " "), # Overall rating - 1 space
("sWAR", 5, ""),
("S", 3, " "), # Starter endurance
("R", 3, " "), # Relief endurance
("C", 3, " "), # Closer endurance
]
for roster_type in ["majors", "minors", "il"]:
# Setup batter table
batter_table = self.query_one(f"#{roster_type}-batters-table", DataTable)
for name, width, prefix in batter_columns:
label = f"{prefix}{name}"
if width:
batter_table.add_column(label, key=name.lower(), width=width)
else:
batter_table.add_column(label, key=name.lower())
# Setup pitcher table
pitcher_table = self.query_one(f"#{roster_type}-pitchers-table", DataTable)
for name, width, prefix in pitcher_columns:
label = f"{prefix}{name}"
if width:
pitcher_table.add_column(label, key=name.lower(), width=width)
else:
pitcher_table.add_column(label, key=name.lower())
async def _load_roster(self) -> None:
"""Load roster data from database."""
settings = get_settings()
try:
async with get_session() as session:
roster = await get_my_roster(
session,
settings.team.team_abbrev,
settings.team.current_season,
)
# Calculate sWAR totals
majors_swar = sum(p.swar or 0 for p in roster.get("majors", []))
il_swar = sum(p.swar or 0 for p in roster.get("il", []))
total_swar = majors_swar + il_swar
# Update sWAR summary
self.query_one("#swar-summary", Label).update(
f"sWAR: {total_swar:.1f}/{settings.team.swar_cap}"
)
# Populate tables for each roster type
for roster_type in ["majors", "minors", "il"]:
players = roster.get(roster_type, [])
await self._populate_tables(roster_type, players)
except Exception as e:
self.notify(f"Error loading roster: {e}", severity="error")
async def _populate_tables(self, roster_type: str, players: list[Player]) -> None:
"""Populate batter and pitcher tables with player data."""
batter_table = self.query_one(f"#{roster_type}-batters-table", DataTable)
pitcher_table = self.query_one(f"#{roster_type}-pitchers-table", DataTable)
batter_table.clear()
pitcher_table.clear()
# Separate batters and pitchers
batters = [p for p in players if p.is_batter and p.batter_card]
pitchers = [p for p in players if p.is_pitcher and p.pitcher_card]
# Players without cards go to appropriate table based on position
for p in players:
if not p.batter_card and not p.pitcher_card:
if p.is_pitcher:
pitchers.append(p)
else:
batters.append(p)
# Sort alphabetically by name
batters.sort(key=lambda p: p.name)
pitchers.sort(key=lambda p: p.name)
# Populate batter table
for player in batters:
row = self._make_batter_row(player)
batter_table.add_row(*row, key=str(player.id))
# Populate pitcher table
for player in pitchers:
row = self._make_pitcher_row(player)
pitcher_table.add_row(*row, key=str(player.id))
def _make_batter_row(self, player: Player) -> tuple:
"""Create a table row for a batter."""
card = player.batter_card
# Get positions string
positions = ", ".join(player.positions)
# Get ratings (right-aligned with padding for alignment)
if card:
rating_vl = f"{card.rating_vl:>4.0f}" if card.rating_vl is not None else " --"
rating_vr = f"{card.rating_vr:>4.0f}" if card.rating_vr is not None else " --"
rating_ovr = (
f"{card.rating_overall:>4.0f}" if card.rating_overall is not None else " --"
)
defense = card.fielding or "--"
else:
rating_vl = " --"
rating_vr = " --"
rating_ovr = " --"
defense = "--"
return (
player.name,
player.hand or "-",
positions,
rating_vl,
rating_vr,
rating_ovr,
f"{player.swar:.2f}" if player.swar else "0.00",
defense,
)
def _make_pitcher_row(self, player: Player) -> tuple:
"""Create a table row for a pitcher."""
card = player.pitcher_card
# Get positions string (SP, RP, CP)
positions = ", ".join(player.positions)
# Get ratings (right-aligned with padding for alignment)
if card:
rating_vl = f"{card.rating_vlhb:>4.0f}" if card.rating_vlhb is not None else " --"
rating_vr = f"{card.rating_vrhb:>4.0f}" if card.rating_vrhb is not None else " --"
rating_ovr = (
f"{card.rating_overall:>4.0f}" if card.rating_overall is not None else " --"
)
endurance_s = f"{card.endurance_start:>2}" if card.endurance_start is not None else " -"
endurance_r = (
f"{card.endurance_relief:>2}" if card.endurance_relief is not None else " -"
)
endurance_c = f"{card.endurance_close:>2}" if card.endurance_close is not None else " -"
else:
rating_vl = " --"
rating_vr = " --"
rating_ovr = " --"
endurance_s = " -"
endurance_r = " -"
endurance_c = " -"
return (
player.name,
player.hand or "-",
positions,
rating_vl,
rating_vr,
rating_ovr,
f"{player.swar:.2f}" if player.swar else "0.00",
endurance_s,
endurance_r,
endurance_c,
)
async def action_refresh_roster(self) -> None:
"""Refresh roster data from database."""
self.notify("Refreshing roster...")
await self._load_roster()
self.notify("Roster refreshed", severity="information")
def action_switch_tab(self, tab_id: str) -> None:
"""Switch to a specific roster tab."""
tabs = self.query_one("#roster-tabs", TabbedContent)
tabs.active = tab_id