use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ layout::{Alignment, Constraint, Layout, Rect}, style::{Color, Style}, widgets::{Block, Borders, Paragraph}, Frame, }; use sqlx::sqlite::SqlitePool; use tokio::sync::mpsc; use crate::app::{AppMessage, NotifyLevel}; use crate::config::Settings; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SyncState { Never, Syncing, Success, Error, } pub struct DashboardState { pub team_abbrev: String, pub season: i64, pub majors_count: usize, pub minors_count: usize, pub il_count: usize, pub swar_total: f64, pub sync_state: SyncState, pub sync_message: String, } impl DashboardState { pub fn new(team_abbrev: String, season: i64) -> Self { Self { team_abbrev, season, majors_count: 0, minors_count: 0, il_count: 0, swar_total: 0.0, sync_state: SyncState::Never, sync_message: String::new(), } } /// Fire async roster load on mount. pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { let pool = pool.clone(); let tx = tx.clone(); let abbrev = self.team_abbrev.clone(); let season = self.season; tokio::spawn(async move { match crate::db::queries::get_my_roster(&pool, &abbrev, season).await { Ok(roster) => { let _ = tx.send(AppMessage::RosterLoaded(roster)); } Err(e) => { let _ = tx.send(AppMessage::Notify( format!("Failed to load roster: {e}"), NotifyLevel::Error, )); } } }); } pub fn handle_key( &mut self, key: KeyEvent, pool: &SqlitePool, settings: &Settings, tx: &mpsc::UnboundedSender, ) { if let KeyCode::Char('s') = key.code { self.trigger_sync(pool, settings, tx); } } fn trigger_sync( &mut self, pool: &SqlitePool, settings: &Settings, tx: &mpsc::UnboundedSender, ) { if self.sync_state == SyncState::Syncing { return; } self.sync_state = SyncState::Syncing; self.sync_message = "Syncing...".to_string(); let pool = pool.clone(); let settings = settings.clone(); let tx = tx.clone(); tokio::spawn(async move { let _ = tx.send(AppMessage::SyncStarted); let result = crate::api::sync::sync_all(&pool, &settings).await; let _ = tx.send(AppMessage::SyncComplete(result)); }); } pub fn handle_message(&mut self, msg: AppMessage) { match msg { AppMessage::RosterLoaded(roster) => { self.majors_count = roster.majors.len(); self.minors_count = roster.minors.len(); self.il_count = roster.il.len(); self.swar_total = roster.majors.iter().filter_map(|p| p.swar).sum(); } AppMessage::SyncStarted => { self.sync_state = SyncState::Syncing; self.sync_message = "Syncing...".to_string(); } AppMessage::SyncComplete(result) => match result { Ok(r) => { self.sync_state = SyncState::Success; self.sync_message = format!( "Synced {} teams, {} players, {} transactions", r.teams, r.players, r.transactions ); } Err(e) => { self.sync_state = SyncState::Error; self.sync_message = format!("Sync failed: {e}"); } }, _ => {} } } pub fn render(&self, frame: &mut Frame, area: Rect, tick_count: u64) { let chunks = Layout::vertical([ Constraint::Length(3), // title Constraint::Length(5), // summary cards Constraint::Length(3), // sync status Constraint::Min(0), // key hints ]) .split(area); // Title let title = Paragraph::new(format!( "SBA Scout -- {} -- Season {}", self.team_abbrev, self.season )) .block(Block::default().borders(Borders::ALL)) .alignment(Alignment::Center); frame.render_widget(title, chunks[0]); // Summary cards let card_chunks = Layout::horizontal([ Constraint::Ratio(1, 4), Constraint::Ratio(1, 4), Constraint::Ratio(1, 4), Constraint::Ratio(1, 4), ]) .split(chunks[1]); let cards = [ ("Majors", format!("{}/26", self.majors_count)), ("Minors", format!("{}/6", self.minors_count)), ("IL", self.il_count.to_string()), ("sWAR", format!("{:.1}", self.swar_total)), ]; for (i, (label, value)) in cards.iter().enumerate() { let card = Paragraph::new(value.as_str()) .block(Block::default().borders(Borders::ALL).title(*label)) .alignment(Alignment::Center); frame.render_widget(card, card_chunks[i]); } // Sync status let spinner_chars = ['|', '/', '-', '\\']; let spinner = spinner_chars[(tick_count as usize / 2) % 4]; let sync_text = match self.sync_state { SyncState::Never => "[s] Sync data from API".to_string(), SyncState::Syncing => format!("{spinner} {}", self.sync_message), SyncState::Success => format!("OK {} [s] Sync again", self.sync_message), SyncState::Error => format!("ERR {} [s] Retry", self.sync_message), }; let sync_style = match self.sync_state { SyncState::Error => Style::default().fg(Color::Red), SyncState::Success => Style::default().fg(Color::Green), _ => Style::default(), }; let sync_widget = Paragraph::new(sync_text) .style(sync_style) .block(Block::default().borders(Borders::ALL).title("Sync")); frame.render_widget(sync_widget, chunks[2]); // Key hints let hints = Paragraph::new(" [s] Sync [g] Gameday [q] Quit") .style(Style::default().fg(Color::DarkGray)); frame.render_widget(hints, chunks[3]); } }