sba-scouting/rust/src/screens/dashboard.rs
Cal Corum 6d2b11a797 Implement Phase 4: async TUI with Dashboard and Gameday screens
Replace blocking event loop with tokio::select! over EventStream, mpsc
channel, and tick interval. Add message bus architecture with AppMessage
enum for background task results. Implement Dashboard with roster summary
cards and API sync, and Gameday with cascading team/pitcher selectors,
matchup table, and 9-slot lineup management with save/load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 07:09:25 -06:00

195 lines
6.4 KiB
Rust

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<AppMessage>) {
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<AppMessage>,
) {
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<AppMessage>,
) {
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]);
}
}