Consolidate shared helpers (format_rating, format_swar, tier_style, format_relative_time) into widgets/mod.rs and screens/mod.rs. Replace heap allocations with stack arrays and HashSets, parallelize DB queries with tokio::try_join, wrap schema init in transactions, use OnceLock for invariant hashes, and fix clippy warnings. Auto-sync on dashboard mount when last sync >24h ago. All 105 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
983 lines
34 KiB
Rust
983 lines
34 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use ratatui::{
|
|
layout::{Constraint, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState},
|
|
Frame,
|
|
};
|
|
use sqlx::sqlite::SqlitePool;
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::app::{AppMessage, NotifyLevel};
|
|
use crate::calc::matchup::MatchupResult;
|
|
use crate::config::Settings;
|
|
use crate::db::models::{BatterCard, Lineup, Player};
|
|
use crate::widgets::selector::{SelectorEvent, SelectorWidget};
|
|
|
|
// =============================================================================
|
|
// Focus & slot types
|
|
// =============================================================================
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum GamedayFocus {
|
|
TeamSelector,
|
|
PitcherSelector,
|
|
MatchupTable,
|
|
LineupName,
|
|
LineupTable,
|
|
LoadSelector,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct LineupSlot {
|
|
pub order: usize,
|
|
pub player_id: Option<i64>,
|
|
pub player_name: Option<String>,
|
|
pub position: Option<String>,
|
|
pub matchup_rating: Option<f64>,
|
|
pub matchup_tier: Option<String>,
|
|
}
|
|
|
|
impl LineupSlot {
|
|
pub fn empty(order: usize) -> Self {
|
|
Self {
|
|
order,
|
|
player_id: None,
|
|
player_name: None,
|
|
position: None,
|
|
matchup_rating: None,
|
|
matchup_tier: None,
|
|
}
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.player_id.is_none()
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Gameday state
|
|
// =============================================================================
|
|
|
|
pub struct GamedayState {
|
|
pub team_abbrev: String,
|
|
pub season: i64,
|
|
|
|
// Selectors
|
|
pub team_selector: SelectorWidget<i64>,
|
|
pub pitcher_selector: SelectorWidget<i64>,
|
|
pub load_selector: SelectorWidget<String>,
|
|
|
|
// Data
|
|
pub my_batters: Vec<(Player, Option<BatterCard>)>,
|
|
pub matchup_results: Vec<MatchupResult>,
|
|
|
|
// Matchup table
|
|
pub matchup_table_state: TableState,
|
|
|
|
// Lineup
|
|
pub lineup_slots: [LineupSlot; 9],
|
|
pub lineup_name: String,
|
|
pub lineup_name_cursor: usize,
|
|
pub saved_lineups: Vec<Lineup>,
|
|
pub lineup_table_state: TableState,
|
|
|
|
// Focus
|
|
pub focus: GamedayFocus,
|
|
|
|
// Loading flags
|
|
pub is_loading_teams: bool,
|
|
pub is_loading_pitchers: bool,
|
|
pub is_calculating: bool,
|
|
pub cache_ready: bool,
|
|
}
|
|
|
|
impl GamedayState {
|
|
pub fn new(team_abbrev: String, season: i64) -> Self {
|
|
let lineup_slots = std::array::from_fn(|i| LineupSlot::empty(i + 1));
|
|
Self {
|
|
team_abbrev,
|
|
season,
|
|
team_selector: SelectorWidget::new("Opponent"),
|
|
pitcher_selector: SelectorWidget::new("Pitcher"),
|
|
load_selector: SelectorWidget::new("Load Lineup"),
|
|
my_batters: Vec::new(),
|
|
matchup_results: Vec::new(),
|
|
matchup_table_state: TableState::default(),
|
|
lineup_slots,
|
|
lineup_name: String::new(),
|
|
lineup_name_cursor: 0,
|
|
saved_lineups: Vec::new(),
|
|
lineup_table_state: TableState::default().with_selected(0),
|
|
focus: GamedayFocus::MatchupTable,
|
|
is_loading_teams: true,
|
|
is_loading_pitchers: false,
|
|
is_calculating: false,
|
|
cache_ready: false,
|
|
}
|
|
}
|
|
|
|
/// Returns true if a text input or selector popup is capturing keys.
|
|
pub fn is_input_captured(&self) -> bool {
|
|
matches!(self.focus, GamedayFocus::LineupName)
|
|
|| self.team_selector.is_open
|
|
|| self.pitcher_selector.is_open
|
|
|| self.load_selector.is_open
|
|
}
|
|
|
|
// =========================================================================
|
|
// Mount — fire background loads
|
|
// =========================================================================
|
|
|
|
pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) {
|
|
// Ensure score cache
|
|
let pool_c = pool.clone();
|
|
let tx_c = tx.clone();
|
|
tokio::spawn(async move {
|
|
match crate::calc::score_cache::ensure_cache_exists(&pool_c).await {
|
|
Ok(_) => {
|
|
let _ = tx_c.send(AppMessage::CacheReady);
|
|
}
|
|
Err(e) => {
|
|
let _ = tx_c.send(AppMessage::CacheError(format!("{e}")));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Load teams
|
|
let pool_c = pool.clone();
|
|
let tx_c = tx.clone();
|
|
let season = self.season;
|
|
tokio::spawn(async move {
|
|
match crate::db::queries::get_all_teams(&pool_c, season, true).await {
|
|
Ok(teams) => {
|
|
let _ = tx_c.send(AppMessage::TeamsLoaded(teams));
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Failed to load teams: {e}");
|
|
let _ = tx_c.send(AppMessage::Notify(
|
|
format!("Failed to load teams: {e}"),
|
|
NotifyLevel::Error,
|
|
));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Load my batters
|
|
let pool_c = pool.clone();
|
|
let tx_c = tx.clone();
|
|
let abbrev = self.team_abbrev.clone();
|
|
let season = self.season;
|
|
tokio::spawn(async move {
|
|
if let Ok(Some(team)) =
|
|
crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await
|
|
&& let Ok(batters) =
|
|
crate::db::queries::get_batters(&pool_c, Some(team.id), Some(season)).await
|
|
{
|
|
let mut with_cards = Vec::with_capacity(batters.len());
|
|
for p in batters {
|
|
let card = crate::db::queries::get_batter_card(&pool_c, p.id)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
with_cards.push((p, card));
|
|
}
|
|
let _ = tx_c.send(AppMessage::MyBattersLoaded(with_cards));
|
|
}
|
|
});
|
|
|
|
// Load saved lineups
|
|
let pool_c = pool.clone();
|
|
let tx_c = tx.clone();
|
|
tokio::spawn(async move {
|
|
if let Ok(lineups) = crate::db::queries::get_lineups(&pool_c).await {
|
|
let _ = tx_c.send(AppMessage::LineupsLoaded(lineups));
|
|
}
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// Key handling
|
|
// =========================================================================
|
|
|
|
pub fn handle_key(
|
|
&mut self,
|
|
key: KeyEvent,
|
|
pool: &SqlitePool,
|
|
_settings: &Settings,
|
|
tx: &mpsc::UnboundedSender<AppMessage>,
|
|
) {
|
|
// Try selectors first (they consume keys when open)
|
|
if self.team_selector.is_open {
|
|
if let SelectorEvent::Selected(_) = self.team_selector.handle_key(key) {
|
|
self.on_team_selected(pool, tx);
|
|
}
|
|
return;
|
|
}
|
|
if self.pitcher_selector.is_open {
|
|
if let SelectorEvent::Selected(_) = self.pitcher_selector.handle_key(key) {
|
|
self.on_pitcher_selected(pool, tx);
|
|
}
|
|
return;
|
|
}
|
|
if self.load_selector.is_open {
|
|
if let SelectorEvent::Selected(_) = self.load_selector.handle_key(key) {
|
|
self.on_lineup_load_selected();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Lineup name text input
|
|
if self.focus == GamedayFocus::LineupName {
|
|
match key.code {
|
|
KeyCode::Esc | KeyCode::Tab => {
|
|
self.focus = GamedayFocus::LineupTable;
|
|
}
|
|
KeyCode::Char(c) => {
|
|
self.lineup_name.insert(self.lineup_name_cursor, c);
|
|
self.lineup_name_cursor += 1;
|
|
}
|
|
KeyCode::Backspace => {
|
|
if self.lineup_name_cursor > 0 {
|
|
self.lineup_name_cursor -= 1;
|
|
self.lineup_name.remove(self.lineup_name_cursor);
|
|
}
|
|
}
|
|
KeyCode::Left => {
|
|
self.lineup_name_cursor = self.lineup_name_cursor.saturating_sub(1);
|
|
}
|
|
KeyCode::Right => {
|
|
if self.lineup_name_cursor < self.lineup_name.len() {
|
|
self.lineup_name_cursor += 1;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl-S: save lineup
|
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
|
|
self.save_lineup(pool, tx);
|
|
return;
|
|
}
|
|
|
|
// Tab: cycle focus
|
|
if key.code == KeyCode::Tab {
|
|
self.cycle_focus();
|
|
return;
|
|
}
|
|
|
|
// Focus-specific keys
|
|
match self.focus {
|
|
GamedayFocus::TeamSelector => match key.code {
|
|
KeyCode::Enter | KeyCode::Char(' ') => {
|
|
self.team_selector.is_open = true;
|
|
self.team_selector.is_focused = true;
|
|
}
|
|
_ => {}
|
|
},
|
|
GamedayFocus::PitcherSelector => match key.code {
|
|
KeyCode::Enter | KeyCode::Char(' ') => {
|
|
self.pitcher_selector.is_open = true;
|
|
self.pitcher_selector.is_focused = true;
|
|
}
|
|
_ => {}
|
|
},
|
|
GamedayFocus::MatchupTable => self.handle_matchup_table_key(key),
|
|
GamedayFocus::LineupTable => self.handle_lineup_table_key(key, pool, tx),
|
|
GamedayFocus::LoadSelector => match key.code {
|
|
KeyCode::Enter | KeyCode::Char(' ') => {
|
|
self.load_selector.is_open = true;
|
|
self.load_selector.is_focused = true;
|
|
}
|
|
_ => {}
|
|
},
|
|
GamedayFocus::LineupName => {} // handled above
|
|
}
|
|
}
|
|
|
|
fn cycle_focus(&mut self) {
|
|
self.team_selector.is_focused = false;
|
|
self.pitcher_selector.is_focused = false;
|
|
self.load_selector.is_focused = false;
|
|
self.focus = match self.focus {
|
|
GamedayFocus::MatchupTable => GamedayFocus::LineupTable,
|
|
GamedayFocus::LineupTable => GamedayFocus::LineupName,
|
|
GamedayFocus::LineupName => GamedayFocus::TeamSelector,
|
|
GamedayFocus::TeamSelector => GamedayFocus::PitcherSelector,
|
|
GamedayFocus::PitcherSelector => GamedayFocus::LoadSelector,
|
|
GamedayFocus::LoadSelector => GamedayFocus::MatchupTable,
|
|
};
|
|
self.team_selector.is_focused = self.focus == GamedayFocus::TeamSelector;
|
|
self.pitcher_selector.is_focused = self.focus == GamedayFocus::PitcherSelector;
|
|
self.load_selector.is_focused = self.focus == GamedayFocus::LoadSelector;
|
|
}
|
|
|
|
fn handle_matchup_table_key(&mut self, key: KeyEvent) {
|
|
match key.code {
|
|
KeyCode::Char('j') | KeyCode::Down => {
|
|
let len = self.matchup_results.len();
|
|
if len > 0 {
|
|
let i = self.matchup_table_state.selected().unwrap_or(0);
|
|
self.matchup_table_state.select(Some((i + 1) % len));
|
|
}
|
|
}
|
|
KeyCode::Char('k') | KeyCode::Up => {
|
|
let len = self.matchup_results.len();
|
|
if len > 0 {
|
|
let i = self.matchup_table_state.selected().unwrap_or(0);
|
|
self.matchup_table_state
|
|
.select(Some(i.checked_sub(1).unwrap_or(len - 1)));
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
self.add_selected_to_lineup();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_lineup_table_key(
|
|
&mut self,
|
|
key: KeyEvent,
|
|
pool: &SqlitePool,
|
|
tx: &mpsc::UnboundedSender<AppMessage>,
|
|
) {
|
|
let sel = self.lineup_table_state.selected().unwrap_or(0);
|
|
match key.code {
|
|
KeyCode::Char('j') | KeyCode::Down => {
|
|
if sel < 8 {
|
|
self.lineup_table_state.select(Some(sel + 1));
|
|
}
|
|
}
|
|
KeyCode::Char('k') | KeyCode::Up => {
|
|
self.lineup_table_state
|
|
.select(Some(sel.saturating_sub(1)));
|
|
}
|
|
KeyCode::Char('x') => {
|
|
// Remove player from slot
|
|
self.lineup_slots[sel] = LineupSlot::empty(sel + 1);
|
|
}
|
|
KeyCode::Char('p') => {
|
|
// Cycle position
|
|
self.cycle_position(sel);
|
|
}
|
|
KeyCode::Char('J') => {
|
|
// Move slot down
|
|
if sel < 8 {
|
|
self.lineup_slots.swap(sel, sel + 1);
|
|
// Fix order numbers
|
|
self.lineup_slots[sel].order = sel + 1;
|
|
self.lineup_slots[sel + 1].order = sel + 2;
|
|
self.lineup_table_state.select(Some(sel + 1));
|
|
}
|
|
}
|
|
KeyCode::Char('K') => {
|
|
// Move slot up
|
|
if sel > 0 {
|
|
self.lineup_slots.swap(sel - 1, sel);
|
|
self.lineup_slots[sel - 1].order = sel;
|
|
self.lineup_slots[sel].order = sel + 1;
|
|
self.lineup_table_state.select(Some(sel - 1));
|
|
}
|
|
}
|
|
KeyCode::Char('l') => {
|
|
// Open load selector
|
|
self.refresh_load_selector();
|
|
self.focus = GamedayFocus::LoadSelector;
|
|
self.load_selector.is_focused = true;
|
|
self.load_selector.is_open = true;
|
|
}
|
|
KeyCode::Enter => {
|
|
// Add from matchup table to this slot
|
|
self.add_to_specific_slot(sel);
|
|
}
|
|
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
self.save_lineup(pool, tx);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Lineup operations
|
|
// =========================================================================
|
|
|
|
fn add_selected_to_lineup(&mut self) {
|
|
let Some(slot_idx) = self.lineup_slots.iter().position(|s| s.is_empty()) else {
|
|
return; // lineup full
|
|
};
|
|
self.add_to_specific_slot(slot_idx);
|
|
}
|
|
|
|
fn add_to_specific_slot(&mut self, slot_idx: usize) {
|
|
let Some(matchup_idx) = self.matchup_table_state.selected() else {
|
|
return;
|
|
};
|
|
let Some(result) = self.matchup_results.get(matchup_idx) else {
|
|
return;
|
|
};
|
|
|
|
let pid = result.player.id;
|
|
if self.lineup_slots.iter().any(|s| s.player_id == Some(pid)) {
|
|
return;
|
|
}
|
|
|
|
self.lineup_slots[slot_idx] = LineupSlot {
|
|
order: slot_idx + 1,
|
|
player_id: Some(pid),
|
|
player_name: Some(result.player.name.clone()),
|
|
position: result.player.pos_1.clone(),
|
|
matchup_rating: result.rating,
|
|
matchup_tier: Some(result.tier.clone()),
|
|
};
|
|
}
|
|
|
|
fn cycle_position(&mut self, slot_idx: usize) {
|
|
let slot = &mut self.lineup_slots[slot_idx];
|
|
let Some(pid) = slot.player_id else { return };
|
|
|
|
// Get player's eligible positions
|
|
let eligible: Vec<String> = self
|
|
.my_batters
|
|
.iter()
|
|
.find(|(p, _)| p.id == pid)
|
|
.map(|(p, _)| p.positions().iter().map(|s| s.to_string()).collect())
|
|
.unwrap_or_default();
|
|
|
|
if eligible.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let current = slot.position.as_deref().unwrap_or("");
|
|
let current_idx = eligible.iter().position(|p| p == current).unwrap_or(0);
|
|
let next_idx = (current_idx + 1) % eligible.len();
|
|
slot.position = Some(eligible[next_idx].clone());
|
|
}
|
|
|
|
fn refresh_load_selector(&mut self) {
|
|
let items: Vec<(String, String)> = self
|
|
.saved_lineups
|
|
.iter()
|
|
.map(|l| (l.name.clone(), l.name.clone()))
|
|
.collect();
|
|
self.load_selector.set_items(items);
|
|
}
|
|
|
|
fn on_lineup_load_selected(&mut self) {
|
|
let Some(name) = self.load_selector.selected_value().cloned() else {
|
|
return;
|
|
};
|
|
let Some(lineup) = self.saved_lineups.iter().find(|l| l.name == name) else {
|
|
return;
|
|
};
|
|
|
|
self.lineup_name = lineup.name.clone();
|
|
self.lineup_name_cursor = self.lineup_name.len();
|
|
|
|
let order = lineup.batting_order_vec();
|
|
let positions_map = lineup.positions_map();
|
|
|
|
// Clear all slots
|
|
for i in 0..9 {
|
|
self.lineup_slots[i] = LineupSlot::empty(i + 1);
|
|
}
|
|
|
|
// Populate from saved lineup
|
|
for (i, pid) in order.iter().enumerate() {
|
|
if i >= 9 {
|
|
break;
|
|
}
|
|
let player = self.my_batters.iter().find(|(p, _)| p.id == *pid);
|
|
if let Some((p, _)) = player {
|
|
// Find position from map or default to pos_1
|
|
let pos = positions_map
|
|
.iter()
|
|
.find(|(_, v)| **v == *pid)
|
|
.map(|(k, _)| k.clone())
|
|
.or_else(|| p.pos_1.clone());
|
|
|
|
// Find matchup data if available
|
|
let matchup = self.matchup_results.iter().find(|r| r.player.id == *pid);
|
|
|
|
self.lineup_slots[i] = LineupSlot {
|
|
order: i + 1,
|
|
player_id: Some(*pid),
|
|
player_name: Some(p.name.clone()),
|
|
position: pos,
|
|
matchup_rating: matchup.and_then(|m| m.rating),
|
|
matchup_tier: matchup.map(|m| m.tier.clone()),
|
|
};
|
|
}
|
|
}
|
|
self.focus = GamedayFocus::LineupTable;
|
|
}
|
|
|
|
fn save_lineup(&self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) {
|
|
if self.lineup_name.is_empty() {
|
|
let _ = tx.send(AppMessage::Notify(
|
|
"Enter a lineup name first".to_string(),
|
|
NotifyLevel::Error,
|
|
));
|
|
return;
|
|
}
|
|
|
|
let batting_order: Vec<i64> = self
|
|
.lineup_slots
|
|
.iter()
|
|
.filter_map(|s| s.player_id)
|
|
.collect();
|
|
|
|
let mut positions: HashMap<String, i64> = HashMap::new();
|
|
for slot in &self.lineup_slots {
|
|
if let (Some(pid), Some(pos)) = (slot.player_id, &slot.position) {
|
|
positions.insert(pos.clone(), pid);
|
|
}
|
|
}
|
|
|
|
let pitcher_id = self.pitcher_selector.selected_value().copied();
|
|
let name = self.lineup_name.clone();
|
|
let pool = pool.clone();
|
|
let tx = tx.clone();
|
|
|
|
tokio::spawn(async move {
|
|
match crate::db::queries::save_lineup(
|
|
&pool,
|
|
&name,
|
|
&batting_order,
|
|
&positions,
|
|
"gameday",
|
|
None,
|
|
pitcher_id,
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
let _ = tx.send(AppMessage::LineupSaved(name));
|
|
}
|
|
Err(e) => {
|
|
let _ = tx.send(AppMessage::LineupSaveError(format!("{e}")));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// Cascading selectors
|
|
// =========================================================================
|
|
|
|
fn on_team_selected(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) {
|
|
let Some(&team_id) = self.team_selector.selected_value() else {
|
|
return;
|
|
};
|
|
|
|
// Clear pitcher selector and matchups
|
|
self.pitcher_selector.set_items(Vec::new());
|
|
self.matchup_results.clear();
|
|
self.is_loading_pitchers = true;
|
|
|
|
let pool = pool.clone();
|
|
let tx = tx.clone();
|
|
let season = self.season;
|
|
tokio::spawn(async move {
|
|
match crate::db::queries::get_pitchers(&pool, Some(team_id), Some(season)).await {
|
|
Ok(pitchers) => {
|
|
let _ = tx.send(AppMessage::PitchersLoaded(pitchers));
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Failed to load pitchers: {e}");
|
|
let _ = tx.send(AppMessage::Notify(
|
|
format!("Failed to load pitchers: {e}"),
|
|
NotifyLevel::Error,
|
|
));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn on_pitcher_selected(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) {
|
|
let Some(&pitcher_id) = self.pitcher_selector.selected_value() else {
|
|
return;
|
|
};
|
|
|
|
self.is_calculating = true;
|
|
self.matchup_results.clear();
|
|
|
|
let pool = pool.clone();
|
|
let tx = tx.clone();
|
|
let batters = self.my_batters.clone();
|
|
tokio::spawn(async move {
|
|
// Get pitcher and card
|
|
let pitcher = crate::db::queries::get_player_by_id(&pool, pitcher_id).await;
|
|
let Ok(Some(pitcher)) = pitcher else { return };
|
|
let pitcher_card = crate::db::queries::get_pitcher_card(&pool, pitcher_id)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
match crate::calc::matchup::calculate_team_matchups_cached(
|
|
&pool,
|
|
&batters,
|
|
&pitcher,
|
|
pitcher_card.as_ref(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(results) => {
|
|
let _ = tx.send(AppMessage::MatchupsCalculated(results));
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Matchup calculation failed: {e}");
|
|
let _ = tx.send(AppMessage::Notify(
|
|
format!("Matchup calculation failed: {e}"),
|
|
NotifyLevel::Error,
|
|
));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// Message handling
|
|
// =========================================================================
|
|
|
|
pub fn handle_message(&mut self, msg: AppMessage) {
|
|
match msg {
|
|
AppMessage::CacheReady => {
|
|
self.cache_ready = true;
|
|
}
|
|
AppMessage::CacheError(e) => {
|
|
self.cache_ready = false;
|
|
// Cache error is non-fatal; real-time calc will still work
|
|
tracing::warn!("Cache error: {e}");
|
|
}
|
|
AppMessage::TeamsLoaded(teams) => {
|
|
self.is_loading_teams = false;
|
|
let items: Vec<(String, i64)> = teams
|
|
.into_iter()
|
|
.filter(|t| t.abbrev != self.team_abbrev)
|
|
.map(|t| (format!("{} ({})", t.abbrev, t.short_name), t.id))
|
|
.collect();
|
|
self.team_selector.set_items(items);
|
|
}
|
|
AppMessage::MyBattersLoaded(batters) => {
|
|
self.my_batters = batters;
|
|
}
|
|
AppMessage::LineupsLoaded(lineups) => {
|
|
self.saved_lineups = lineups;
|
|
}
|
|
AppMessage::PitchersLoaded(pitchers) => {
|
|
self.is_loading_pitchers = false;
|
|
let items: Vec<(String, i64)> = pitchers
|
|
.into_iter()
|
|
.map(|p| {
|
|
let hand = p.hand.as_deref().unwrap_or("?");
|
|
(format!("{} ({})", p.name, hand), p.id)
|
|
})
|
|
.collect();
|
|
self.pitcher_selector.set_items(items);
|
|
}
|
|
AppMessage::MatchupsCalculated(results) => {
|
|
self.is_calculating = false;
|
|
self.matchup_results = results;
|
|
if !self.matchup_results.is_empty() {
|
|
self.matchup_table_state.select(Some(0));
|
|
}
|
|
}
|
|
AppMessage::LineupSaved(_) => {}
|
|
AppMessage::LineupSaveError(_) => {}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Rendering
|
|
// =========================================================================
|
|
|
|
pub fn render(&self, frame: &mut Frame, area: Rect, tick_count: u64) {
|
|
// 60/40 horizontal split
|
|
let panels = Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)])
|
|
.split(area);
|
|
|
|
self.render_left_panel(frame, panels[0], tick_count);
|
|
self.render_right_panel(frame, panels[1], tick_count);
|
|
|
|
// Render popup overlays last (on top)
|
|
if self.team_selector.is_open || self.pitcher_selector.is_open {
|
|
let left_chunks = Layout::vertical([
|
|
Constraint::Length(3),
|
|
Constraint::Length(3),
|
|
Constraint::Min(0),
|
|
])
|
|
.split(panels[0]);
|
|
if self.team_selector.is_open {
|
|
self.team_selector.render_popup(frame, left_chunks[0]);
|
|
}
|
|
if self.pitcher_selector.is_open {
|
|
self.pitcher_selector.render_popup(frame, left_chunks[1]);
|
|
}
|
|
}
|
|
if self.load_selector.is_open {
|
|
let right_chunks = Layout::vertical([
|
|
Constraint::Length(3),
|
|
Constraint::Length(3),
|
|
Constraint::Min(0),
|
|
])
|
|
.split(panels[1]);
|
|
self.load_selector.render_popup(frame, right_chunks[0]);
|
|
}
|
|
}
|
|
|
|
fn render_left_panel(&self, frame: &mut Frame, area: Rect, tick_count: u64) {
|
|
let chunks = Layout::vertical([
|
|
Constraint::Length(3), // team selector
|
|
Constraint::Length(3), // pitcher selector
|
|
Constraint::Min(0), // matchup table
|
|
])
|
|
.split(area);
|
|
|
|
self.team_selector.render_closed(frame, chunks[0]);
|
|
self.pitcher_selector.render_closed(frame, chunks[1]);
|
|
self.render_matchup_table(frame, chunks[2], tick_count);
|
|
}
|
|
|
|
fn render_matchup_table(&self, frame: &mut Frame, area: Rect, tick_count: u64) {
|
|
let is_focused = self.focus == GamedayFocus::MatchupTable;
|
|
let border_style = if is_focused {
|
|
Style::default().fg(Color::Yellow)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
|
|
if self.is_calculating {
|
|
let spinner = ['|', '/', '-', '\\'][(tick_count as usize / 2) % 4];
|
|
let widget = Paragraph::new(format!("{spinner} Calculating matchups..."))
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Matchups")
|
|
.border_style(border_style),
|
|
);
|
|
frame.render_widget(widget, area);
|
|
return;
|
|
}
|
|
|
|
if self.matchup_results.is_empty() {
|
|
let msg = if self.pitcher_selector.selected_idx.is_some() {
|
|
"No matchup data"
|
|
} else {
|
|
"Select a team and pitcher"
|
|
};
|
|
let widget = Paragraph::new(msg).block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Matchups")
|
|
.border_style(border_style),
|
|
);
|
|
frame.render_widget(widget, area);
|
|
return;
|
|
}
|
|
|
|
let lineup_pids: Vec<i64> = self
|
|
.lineup_slots
|
|
.iter()
|
|
.filter_map(|s| s.player_id)
|
|
.collect();
|
|
|
|
let header = Row::new(vec!["", "Name", "Hand", "Pos", "Tier", "Rating"])
|
|
.style(Style::default().add_modifier(Modifier::BOLD));
|
|
|
|
let rows: Vec<Row> = self
|
|
.matchup_results
|
|
.iter()
|
|
.map(|r| {
|
|
let in_lineup = if lineup_pids.contains(&r.player.id) {
|
|
"*"
|
|
} else {
|
|
" "
|
|
};
|
|
let hand = r.batter_hand.as_str();
|
|
let pos = r.player.pos_1.as_deref().unwrap_or("-");
|
|
Row::new(vec![
|
|
Cell::from(in_lineup),
|
|
Cell::from(r.player.name.as_str()),
|
|
Cell::from(hand),
|
|
Cell::from(pos),
|
|
Cell::from(r.tier.as_str()),
|
|
Cell::from(r.rating_display()),
|
|
])
|
|
})
|
|
.collect();
|
|
|
|
let table = Table::new(
|
|
rows,
|
|
[
|
|
Constraint::Length(1),
|
|
Constraint::Min(15),
|
|
Constraint::Length(4),
|
|
Constraint::Length(3),
|
|
Constraint::Length(4),
|
|
Constraint::Length(7),
|
|
],
|
|
)
|
|
.header(header)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Matchups")
|
|
.border_style(border_style),
|
|
)
|
|
.row_highlight_style(Style::default().bg(Color::DarkGray));
|
|
|
|
frame.render_stateful_widget(table, area, &mut self.matchup_table_state.clone());
|
|
}
|
|
|
|
fn render_right_panel(&self, frame: &mut Frame, area: Rect, _tick_count: u64) {
|
|
let chunks = Layout::vertical([
|
|
Constraint::Length(3), // lineup name / load selector
|
|
Constraint::Min(0), // lineup table
|
|
Constraint::Length(1), // key hints
|
|
])
|
|
.split(area);
|
|
|
|
self.render_lineup_header(frame, chunks[0]);
|
|
self.render_lineup_table(frame, chunks[1]);
|
|
self.render_lineup_hints(frame, chunks[2]);
|
|
}
|
|
|
|
fn render_lineup_header(&self, frame: &mut Frame, area: Rect) {
|
|
let cols = Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)])
|
|
.split(area);
|
|
|
|
// Lineup name input
|
|
let name_focused = self.focus == GamedayFocus::LineupName;
|
|
let name_style = if name_focused {
|
|
Style::default().fg(Color::Yellow)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
let display = if self.lineup_name.is_empty() {
|
|
"untitled".to_string()
|
|
} else {
|
|
self.lineup_name.clone()
|
|
};
|
|
let cursor_line = if name_focused {
|
|
let mut s = display.clone();
|
|
if self.lineup_name_cursor <= s.len() {
|
|
s.insert(self.lineup_name_cursor, '|');
|
|
}
|
|
s
|
|
} else {
|
|
display
|
|
};
|
|
let name_widget = Paragraph::new(cursor_line)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Lineup Name")
|
|
.border_style(name_style),
|
|
);
|
|
frame.render_widget(name_widget, cols[0]);
|
|
|
|
// Load selector
|
|
self.load_selector.render_closed(frame, cols[1]);
|
|
}
|
|
|
|
fn render_lineup_table(&self, frame: &mut Frame, area: Rect) {
|
|
let is_focused = self.focus == GamedayFocus::LineupTable;
|
|
let border_style = if is_focused {
|
|
Style::default().fg(Color::Yellow)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
|
|
let header = Row::new(vec!["#", "Name", "Pos", "Tier", "Rating"])
|
|
.style(Style::default().add_modifier(Modifier::BOLD));
|
|
|
|
let rows: Vec<Row> = self
|
|
.lineup_slots
|
|
.iter()
|
|
.map(|slot| {
|
|
let order = slot.order.to_string();
|
|
if slot.is_empty() {
|
|
Row::new(vec![
|
|
Cell::from(order),
|
|
Cell::from("---"),
|
|
Cell::from("-"),
|
|
Cell::from("-"),
|
|
Cell::from("-"),
|
|
])
|
|
.style(Style::default().fg(Color::DarkGray))
|
|
} else {
|
|
let name = slot.player_name.as_deref().unwrap_or("?");
|
|
let pos = slot.position.as_deref().unwrap_or("-");
|
|
let tier = slot.matchup_tier.as_deref().unwrap_or("-");
|
|
let rating = slot
|
|
.matchup_rating
|
|
.map(|r| {
|
|
if r >= 0.0 {
|
|
format!("+{:.0}", r)
|
|
} else {
|
|
format!("{:.0}", r)
|
|
}
|
|
})
|
|
.unwrap_or_else(|| "-".to_string());
|
|
Row::new(vec![
|
|
Cell::from(order),
|
|
Cell::from(name),
|
|
Cell::from(pos),
|
|
Cell::from(tier),
|
|
Cell::from(rating),
|
|
])
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let table = Table::new(
|
|
rows,
|
|
[
|
|
Constraint::Length(2),
|
|
Constraint::Min(12),
|
|
Constraint::Length(3),
|
|
Constraint::Length(4),
|
|
Constraint::Length(7),
|
|
],
|
|
)
|
|
.header(header)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Lineup")
|
|
.border_style(border_style),
|
|
)
|
|
.row_highlight_style(Style::default().bg(Color::DarkGray));
|
|
|
|
frame.render_stateful_widget(table, area, &mut self.lineup_table_state.clone());
|
|
}
|
|
|
|
fn render_lineup_hints(&self, frame: &mut Frame, area: Rect) {
|
|
let hints = match self.focus {
|
|
GamedayFocus::MatchupTable => {
|
|
" Enter:add j/k:nav Tab:lineup"
|
|
}
|
|
GamedayFocus::LineupTable => {
|
|
" x:remove p:pos J/K:move l:load Ctrl-S:save"
|
|
}
|
|
GamedayFocus::LineupName => {
|
|
" Type name Tab/Esc:done"
|
|
}
|
|
_ => " Tab:cycle focus",
|
|
};
|
|
let line = Line::from(vec![Span::styled(
|
|
hints,
|
|
Style::default().fg(Color::DarkGray),
|
|
)]);
|
|
let widget = Paragraph::new(line);
|
|
frame.render_widget(widget, area);
|
|
}
|
|
}
|