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>
195 lines
6.4 KiB
Rust
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]);
|
|
}
|
|
}
|