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
275 lines
10 KiB
Python
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
|