diff --git a/rust/.claude/settings.json b/rust/.claude/settings.json index 1e9c730..b5b057c 100644 --- a/rust/.claude/settings.json +++ b/rust/.claude/settings.json @@ -1,12 +1,14 @@ { "hooks": { - "PostToolCall": [ + "PostToolUse": [ { - "matcher": { - "tool_name": "Edit|Write", - "file_glob": "**/*.rs" - }, - "command": "cd /mnt/NV2/Development/sba-scouting/rust && cargo check --message-format=short 2>&1 | grep '^src/' | head -20" + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "if echo \"$TOOL_INPUT\" | grep -q '\\.rs'; then cd /mnt/NV2/Development/sba-scouting/rust && cargo check --message-format=short 2>&1 | grep '^src/' | head -20; fi" + } + ] } ] } diff --git a/rust/src/api/client.rs b/rust/src/api/client.rs index 5b96ac7..85d2da1 100644 --- a/rust/src/api/client.rs +++ b/rust/src/api/client.rs @@ -26,6 +26,8 @@ pub struct LeagueApiClient { api_key: String, } +type Params = Vec<(&'static str, String)>; + impl LeagueApiClient { pub fn new(base_url: &str, api_key: &str, timeout_secs: u64) -> Result { let client = Client::builder() @@ -39,11 +41,7 @@ impl LeagueApiClient { }) } - async fn get( - &self, - path: &str, - params: &[(String, String)], - ) -> Result { + async fn get(&self, path: &str, params: &Params) -> Result { let url = format!("{}/api/v3{}", self.base_url, path); let mut req = self.client.get(&url).query(params); if !self.api_key.is_empty() { @@ -58,15 +56,9 @@ impl LeagueApiClient { } return Err(ApiError::Http { status: status.as_u16(), message: body }); } - let body = response.text().await.map_err(ApiError::Request)?; - let json = serde_json::from_str::(&body)?; - Ok(json) + response.json::().await.map_err(ApiError::Request) } - // ------------------------------------------------------------------------- - // Public endpoint methods - // ------------------------------------------------------------------------- - pub async fn get_teams( &self, season: Option, @@ -74,26 +66,26 @@ impl LeagueApiClient { active_only: bool, short_output: bool, ) -> Result { - let mut params: Vec<(String, String)> = Vec::new(); + let mut params: Params = Vec::new(); if let Some(s) = season { - params.push(("season".to_string(), s.to_string())); + params.push(("season", s.to_string())); } if let Some(abbrevs) = team_abbrev { for a in abbrevs { - params.push(("team_abbrev".to_string(), a.to_string())); + params.push(("team_abbrev", a.to_string())); } } if active_only { - params.push(("active_only".to_string(), "true".to_string())); + params.push(("active_only", "true".to_string())); } if short_output { - params.push(("short_output".to_string(), "true".to_string())); + params.push(("short_output", "true".to_string())); } self.get("/teams", ¶ms).await } pub async fn get_team(&self, team_id: i64) -> Result { - self.get(&format!("/teams/{}", team_id), &[]).await + self.get(&format!("/teams/{team_id}"), &vec![]).await } pub async fn get_team_roster( @@ -101,7 +93,7 @@ impl LeagueApiClient { team_id: i64, which: &str, ) -> Result { - self.get(&format!("/teams/{}/roster/{}", team_id, which), &[]).await + self.get(&format!("/teams/{team_id}/roster/{which}"), &vec![]).await } pub async fn get_players( @@ -112,25 +104,25 @@ impl LeagueApiClient { name: Option<&str>, short_output: bool, ) -> Result { - let mut params: Vec<(String, String)> = Vec::new(); + let mut params: Params = Vec::new(); if let Some(s) = season { - params.push(("season".to_string(), s.to_string())); + params.push(("season", s.to_string())); } if let Some(ids) = team_id { for id in ids { - params.push(("team_id".to_string(), id.to_string())); + params.push(("team_id", id.to_string())); } } if let Some(positions) = pos { for p in positions { - params.push(("pos".to_string(), p.to_string())); + params.push(("pos", p.to_string())); } } if let Some(n) = name { - params.push(("name".to_string(), n.to_string())); + params.push(("name", n.to_string())); } if short_output { - params.push(("short_output".to_string(), "true".to_string())); + params.push(("short_output", "true".to_string())); } self.get("/players", ¶ms).await } @@ -140,11 +132,11 @@ impl LeagueApiClient { player_id: i64, short_output: bool, ) -> Result { - let mut params: Vec<(String, String)> = Vec::new(); + let mut params: Params = Vec::new(); if short_output { - params.push(("short_output".to_string(), "true".to_string())); + params.push(("short_output", "true".to_string())); } - self.get(&format!("/players/{}", player_id), ¶ms).await + self.get(&format!("/players/{player_id}"), ¶ms).await } pub async fn search_players( @@ -153,13 +145,12 @@ impl LeagueApiClient { season: Option, limit: Option, ) -> Result { - let mut params: Vec<(String, String)> = Vec::new(); - params.push(("q".to_string(), query.to_string())); + let mut params: Params = vec![("q", query.to_string())]; if let Some(s) = season { - params.push(("season".to_string(), s.to_string())); + params.push(("season", s.to_string())); } if let Some(l) = limit { - params.push(("limit".to_string(), l.to_string())); + params.push(("limit", l.to_string())); } self.get("/players/search", ¶ms).await } @@ -174,31 +165,32 @@ impl LeagueApiClient { frozen: bool, short_output: bool, ) -> Result { - let mut params: Vec<(String, String)> = Vec::new(); - params.push(("season".to_string(), season.to_string())); - params.push(("week_start".to_string(), week_start.to_string())); + let mut params: Params = vec![ + ("season", season.to_string()), + ("week_start", week_start.to_string()), + ]; if let Some(abbrevs) = team_abbrev { for a in abbrevs { - params.push(("team_abbrev".to_string(), a.to_string())); + params.push(("team_abbrev", a.to_string())); } } if let Some(we) = week_end { - params.push(("week_end".to_string(), we.to_string())); + params.push(("week_end", we.to_string())); } if cancelled { - params.push(("cancelled".to_string(), "true".to_string())); + params.push(("cancelled", "true".to_string())); } if frozen { - params.push(("frozen".to_string(), "true".to_string())); + params.push(("frozen", "true".to_string())); } if short_output { - params.push(("short_output".to_string(), "true".to_string())); + params.push(("short_output", "true".to_string())); } self.get("/transactions", ¶ms).await } pub async fn get_current(&self) -> Result { - self.get("/current", &[]).await + self.get("/current", &vec![]).await } pub async fn get_schedule( @@ -207,19 +199,18 @@ impl LeagueApiClient { week: Option, team_id: Option, ) -> Result { - let mut params: Vec<(String, String)> = Vec::new(); - params.push(("season".to_string(), season.to_string())); + let mut params: Params = vec![("season", season.to_string())]; if let Some(w) = week { - params.push(("week".to_string(), w.to_string())); + params.push(("week", w.to_string())); } if let Some(t) = team_id { - params.push(("team_id".to_string(), t.to_string())); + params.push(("team_id", t.to_string())); } self.get("/schedules", ¶ms).await } pub async fn get_standings(&self, season: i64) -> Result, ApiError> { - let params = vec![("season".to_string(), season.to_string())]; + let params: Params = vec![("season", season.to_string())]; let resp: StandingsResponse = self.get("/standings", ¶ms).await?; Ok(resp.standings) } diff --git a/rust/src/api/importer.rs b/rust/src/api/importer.rs index df3cacd..75dd214 100644 --- a/rust/src/api/importer.rs +++ b/rust/src/api/importer.rs @@ -69,6 +69,27 @@ pub fn parse_int(value: &str, default: i32) -> i32 { } } +fn opt_str(v: &str) -> Option { + let t = v.trim(); + if t.is_empty() { None } else { Some(t.to_string()) } +} + +fn opt_float(v: &str) -> Option { + if v.trim().is_empty() { None } else { Some(parse_float(v, 0.0)) } +} + +fn opt_int(v: &str) -> Option { + if v.trim().is_empty() { None } else { Some(parse_int(v, 0) as i64) } +} + +fn open_csv(path: &Path) -> Result<(csv::Reader, HashMap, String)> { + let source = path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown").to_string(); + let mut rdr = csv::ReaderBuilder::new().has_headers(true).from_path(path)?; + let headers = rdr.headers()?.clone(); + let header_index = headers.iter().enumerate().map(|(i, h)| (h.to_string(), i)).collect(); + Ok((rdr, header_index, source)) +} + /// Parse an endurance string like `"S(5) R(4)"` or `"R(1) C(6)"`. /// /// Returns `(start, relief, close)` — any component may be `None` if absent. @@ -117,20 +138,7 @@ pub async fn import_batter_cards( update_existing: bool, ) -> Result { let mut result = ImportResult::default(); - - let source = csv_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); - - let mut rdr = csv::ReaderBuilder::new().has_headers(true).from_path(csv_path)?; - - // Build O(1) header → column-index lookup before iterating records. - let headers = rdr.headers()?.clone(); - let header_index: HashMap = - headers.iter().enumerate().map(|(i, h)| (h.to_string(), i)).collect(); - + let (mut rdr, header_index, source) = open_csv(csv_path)?; let mut tx = pool.begin().await?; for record_result in rdr.records() { @@ -203,15 +211,6 @@ pub async fn import_batter_cards( Some(parse_int(catcher_arm_str, 0) as i64) }; - // Helpers for optional DB fields. - let opt_str = |v: &str| -> Option { - let t = v.trim(); - if t.is_empty() { None } else { Some(t.to_string()) } - }; - let opt_float = |v: &str| -> Option { - if v.trim().is_empty() { None } else { Some(parse_float(v, 0.0)) } - }; - sqlx::query( "INSERT OR REPLACE INTO batter_cards ( player_id, @@ -308,19 +307,7 @@ pub async fn import_pitcher_cards( update_existing: bool, ) -> Result { let mut result = ImportResult::default(); - - let source = csv_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); - - let mut rdr = csv::ReaderBuilder::new().has_headers(true).from_path(csv_path)?; - - let headers = rdr.headers()?.clone(); - let header_index: HashMap = - headers.iter().enumerate().map(|(i, h)| (h.to_string(), i)).collect(); - + let (mut rdr, header_index, source) = open_csv(csv_path)?; let mut tx = pool.begin().await?; for record_result in rdr.records() { @@ -387,29 +374,12 @@ pub async fn import_pitcher_cards( }; let (mut endur_start, mut endur_relief, mut endur_close) = parse_endurance(endur_raw); - let sp_raw = get("SP").trim(); - if !sp_raw.is_empty() { - endur_start = Some(parse_int(sp_raw, 0)); + for (col, slot) in [("SP", &mut endur_start), ("RP", &mut endur_relief), ("CP", &mut endur_close)] { + let v = get(col).trim(); + if !v.is_empty() { + *slot = Some(parse_int(v, 0)); + } } - let rp_raw = get("RP").trim(); - if !rp_raw.is_empty() { - endur_relief = Some(parse_int(rp_raw, 0)); - } - let cp_raw = get("CP").trim(); - if !cp_raw.is_empty() { - endur_close = Some(parse_int(cp_raw, 0)); - } - - let opt_str = |v: &str| -> Option { - let t = v.trim(); - if t.is_empty() { None } else { Some(t.to_string()) } - }; - let opt_float = |v: &str| -> Option { - if v.trim().is_empty() { None } else { Some(parse_float(v, 0.0)) } - }; - let opt_int = |v: &str| -> Option { - if v.trim().is_empty() { None } else { Some(parse_int(v, 0) as i64) } - }; sqlx::query( "INSERT OR REPLACE INTO pitcher_cards ( diff --git a/rust/src/api/sync.rs b/rust/src/api/sync.rs index 053b944..9f94b6b 100644 --- a/rust/src/api/sync.rs +++ b/rust/src/api/sync.rs @@ -16,17 +16,15 @@ pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient let response = client.get_teams(Some(season), None, false, false).await?; let mut tx = pool.begin().await?; - let mut count: i64 = 0; let synced_at = chrono::Utc::now().naive_utc(); + let count = response.teams.len() as i64; for data in response.teams { let manager1_name = data.manager1.and_then(|m| m.name); let manager2_name = data.manager2.and_then(|m| m.name); - let gm_discord_id = data.gm_discord_id; - let gm2_discord_id = data.gm2_discord_id; - let division_id = data.division.as_ref().map(|d| d.id).flatten(); + let division_id = data.division.as_ref().and_then(|d| d.id); let division_name = data.division.as_ref().and_then(|d| d.division_name.clone()); - let league_abbrev = data.division.as_ref().and_then(|d| d.league_abbrev.clone()); + let league_abbrev = data.division.and_then(|d| d.league_abbrev); sqlx::query( "INSERT OR REPLACE INTO teams \ @@ -42,8 +40,8 @@ pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient .bind(season) .bind(manager1_name) .bind(manager2_name) - .bind(gm_discord_id) - .bind(gm2_discord_id) + .bind(data.gm_discord_id) + .bind(data.gm2_discord_id) .bind(division_id) .bind(division_name) .bind(league_abbrev) @@ -55,8 +53,6 @@ pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient .bind(synced_at) .execute(&mut *tx) .await?; - - count += 1; } tx.commit().await?; @@ -79,8 +75,8 @@ pub async fn sync_players( .await?; let mut tx = pool.begin().await?; - let mut count: i64 = 0; let synced_at = chrono::Utc::now().naive_utc(); + let count = response.players.len() as i64; for data in response.players { let player_team_id = data.team.map(|t| t.id); @@ -147,8 +143,6 @@ pub async fn sync_players( .bind(synced_at) .execute(&mut *tx) .await?; - - count += 1; } tx.commit().await?; @@ -168,9 +162,11 @@ pub async fn sync_transactions( team_abbrev: Option<&str>, client: &LeagueApiClient, ) -> Result { - let abbrev_vec: Vec<&str> = team_abbrev.into_iter().collect(); - let abbrev_slice: Option<&[&str]> = - if abbrev_vec.is_empty() { None } else { Some(&abbrev_vec) }; + let abbrev_arr; + let abbrev_slice: Option<&[&str]> = match team_abbrev { + Some(s) => { abbrev_arr = [s]; Some(&abbrev_arr) } + None => None, + }; let response = client .get_transactions(season, week_start, week_end, abbrev_slice, false, false, false) diff --git a/rust/src/api/types.rs b/rust/src/api/types.rs index ed674ca..a09ecca 100644 --- a/rust/src/api/types.rs +++ b/rust/src/api/types.rs @@ -4,15 +4,9 @@ use serde::{Deserialize, Serialize}; // Shared nested types // ============================================================================= -/// Minimal team reference used inside player/transaction responses. +/// Minimal id-only reference used inside player/transaction/team responses. #[derive(Debug, Deserialize)] -pub struct TeamRef { - pub id: i64, -} - -/// Minimal player reference used inside transaction responses. -#[derive(Debug, Deserialize)] -pub struct PlayerRef { +pub struct IdRef { pub id: i64, } @@ -43,33 +37,24 @@ pub struct TeamsResponse { #[derive(Debug, Deserialize)] pub struct TeamData { pub id: i64, - #[serde(default)] pub abbrev: Option, - #[serde(rename = "sname", default)] + #[serde(rename = "sname")] pub short_name: Option, - #[serde(rename = "lname", default)] + #[serde(rename = "lname")] pub long_name: Option, - #[serde(default)] pub thumbnail: Option, - #[serde(default)] pub color: Option, - #[serde(default)] pub dice_color: Option, - #[serde(default)] pub stadium: Option, - #[serde(default)] pub salary_cap: Option, /// Discord user ID of the primary GM (API sends as string). - #[serde(rename = "gmid", default)] + #[serde(rename = "gmid")] pub gm_discord_id: Option, /// Discord user ID of the secondary GM (API sends as string). - #[serde(rename = "gmid2", default)] + #[serde(rename = "gmid2")] pub gm2_discord_id: Option, - #[serde(default)] pub manager1: Option, - #[serde(default)] pub manager2: Option, - #[serde(default)] pub division: Option, } @@ -90,48 +75,32 @@ pub struct PlayerData { pub headshot: Option, pub vanity_card: Option, /// Strat-O-Matic WAR equivalent — API field is "wara". - #[serde(rename = "wara", default)] + #[serde(rename = "wara")] pub swar: Option, /// SBA player ID — API field is "sbaplayer". - #[serde(rename = "sbaplayer", default)] + #[serde(rename = "sbaplayer")] pub sbaplayer_id: Option, /// Primary card image URL — API field is "image". - #[serde(rename = "image", default)] + #[serde(rename = "image")] pub card_image: Option, /// Alternate card image URL — API field is "image2". - #[serde(rename = "image2", default)] + #[serde(rename = "image2")] pub card_image_alt: Option, - #[serde(default)] - pub team: Option, - #[serde(default)] + pub team: Option, pub pos_1: Option, - #[serde(default)] pub pos_2: Option, - #[serde(default)] pub pos_3: Option, - #[serde(default)] pub pos_4: Option, - #[serde(default)] pub pos_5: Option, - #[serde(default)] pub pos_6: Option, - #[serde(default)] pub pos_7: Option, - #[serde(default)] pub pos_8: Option, - #[serde(default)] pub injury_rating: Option, - #[serde(default)] pub il_return: Option, - #[serde(default)] pub demotion_week: Option, - #[serde(default)] pub strat_code: Option, - #[serde(default)] pub bbref_id: Option, - #[serde(default)] pub last_game: Option, - #[serde(default)] pub last_game2: Option, } @@ -148,20 +117,14 @@ pub struct TransactionsResponse { #[derive(Debug, Deserialize)] pub struct TransactionData { /// Transaction move ID — API field is "moveid" (string like "Season-013-Week-11-1772073335"). - #[serde(rename = "moveid", default)] + #[serde(rename = "moveid")] pub move_id: Option, - #[serde(default)] pub week: Option, - #[serde(default)] pub cancelled: Option, - #[serde(default)] pub frozen: Option, - #[serde(default)] - pub player: Option, - #[serde(default)] - pub oldteam: Option, - #[serde(default)] - pub newteam: Option, + pub player: Option, + pub oldteam: Option, + pub newteam: Option, } // ============================================================================= @@ -181,22 +144,18 @@ pub struct CurrentResponse { #[derive(Debug, Deserialize, Serialize)] pub struct StandingsTeamRef { pub id: i64, - #[serde(default)] pub abbrev: Option, - #[serde(rename = "sname", default)] + #[serde(rename = "sname")] pub short_name: Option, - #[serde(rename = "lname", default)] + #[serde(rename = "lname")] pub long_name: Option, - #[serde(default)] pub division: Option, } #[derive(Debug, Deserialize, Serialize)] pub struct StandingsDivision { pub id: Option, - #[serde(default)] pub division_name: Option, - #[serde(default)] pub division_abbrev: Option, } @@ -215,9 +174,7 @@ pub struct StandingsEntry { pub losses: i64, #[serde(default)] pub run_diff: i64, - #[serde(default)] pub div_gb: Option, - #[serde(default)] pub wc_gb: Option, #[serde(default)] pub home_wins: i64, @@ -231,7 +188,6 @@ pub struct StandingsEntry { pub last8_wins: i64, #[serde(default)] pub last8_losses: i64, - #[serde(default)] pub streak_wl: Option, #[serde(default)] pub streak_num: i64, diff --git a/rust/src/app.rs b/rust/src/app.rs index d83de61..0156d26 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -7,7 +7,7 @@ use ratatui::{ Frame, }; use sqlx::sqlite::SqlitePool; -use std::time::Instant; +use std::time::{Duration, Instant}; use tokio::sync::mpsc; use crate::api::types::StandingsEntry; @@ -96,7 +96,7 @@ pub struct Notification { pub message: String, pub level: NotifyLevel, pub created_at: Instant, - pub duration_ms: u64, + pub duration: Duration, } impl Notification { @@ -105,12 +105,12 @@ impl Notification { message, level, created_at: Instant::now(), - duration_ms: 3000, + duration: Duration::from_millis(3000), } } pub fn is_expired(&self) -> bool { - self.created_at.elapsed().as_millis() > self.duration_ms as u128 + self.created_at.elapsed() > self.duration } } @@ -188,11 +188,7 @@ impl App { pub fn on_tick(&mut self) { self.tick_count = self.tick_count.wrapping_add(1); - if let Some(n) = &self.notification { - if n.is_expired() { - self.notification = None; - } - } + self.notification = self.notification.take().filter(|n| !n.is_expired()); } /// Returns false when a text input or selector popup is active. @@ -278,7 +274,7 @@ impl App { self.notification = Some(Notification::new(text, level)); } _ => match &mut self.screen { - ActiveScreen::Dashboard(s) => s.handle_message(msg, &self.pool, &self.tx), + ActiveScreen::Dashboard(s) => s.handle_message(msg, &self.pool, &self.settings, &self.tx), ActiveScreen::Gameday(s) => s.handle_message(msg), ActiveScreen::Roster(s) => s.handle_message(msg), ActiveScreen::Matchup(s) => s.handle_message(msg, &self.pool, &self.settings, &self.tx), @@ -292,12 +288,7 @@ impl App { /// If leaving the Matchup screen, save its state for later restoration. fn cache_matchup_if_active(&mut self) { if matches!(&self.screen, ActiveScreen::Matchup(_)) { - // Swap screen with a temporary placeholder, extract the matchup state - let placeholder = ActiveScreen::Dashboard(Box::new(DashboardState::new( - String::new(), - 0, - &self.settings, - ))); + let placeholder = ActiveScreen::Settings(Box::new(SettingsState::new(&self.settings))); let old = std::mem::replace(&mut self.screen, placeholder); if let ActiveScreen::Matchup(state) = old { self.cached_matchup = Some(state); diff --git a/rust/src/calc/league_stats.rs b/rust/src/calc/league_stats.rs index 58f99c8..b80f2a8 100644 --- a/rust/src/calc/league_stats.rs +++ b/rust/src/calc/league_stats.rs @@ -12,7 +12,7 @@ static BATTER_CACHE: OnceLock>> = OnceLock::new( static PITCHER_CACHE: OnceLock>> = OnceLock::new(); /// Distribution statistics for a single stat across the league. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct StatDistribution { pub avg: f64, pub stdev: f64, @@ -45,25 +45,26 @@ pub struct BatterLeagueStats { impl Default for BatterLeagueStats { fn default() -> Self { + // StatDistribution is Copy — no clone() needed. let d = StatDistribution { avg: 0.0, stdev: 1.0 }; Self { - so_vlhp: d.clone(), - bb_vlhp: d.clone(), - hit_vlhp: d.clone(), - ob_vlhp: d.clone(), - tb_vlhp: d.clone(), - hr_vlhp: d.clone(), - dp_vlhp: d.clone(), - bphr_vlhp: d.clone(), - bp1b_vlhp: d.clone(), - so_vrhp: d.clone(), - bb_vrhp: d.clone(), - hit_vrhp: d.clone(), - ob_vrhp: d.clone(), - tb_vrhp: d.clone(), - hr_vrhp: d.clone(), - dp_vrhp: d.clone(), - bphr_vrhp: d.clone(), + so_vlhp: d, + bb_vlhp: d, + hit_vlhp: d, + ob_vlhp: d, + tb_vlhp: d, + hr_vlhp: d, + dp_vlhp: d, + bphr_vlhp: d, + bp1b_vlhp: d, + so_vrhp: d, + bb_vrhp: d, + hit_vrhp: d, + ob_vrhp: d, + tb_vrhp: d, + hr_vrhp: d, + dp_vrhp: d, + bphr_vrhp: d, bp1b_vrhp: d, } } @@ -96,25 +97,26 @@ pub struct PitcherLeagueStats { impl Default for PitcherLeagueStats { fn default() -> Self { + // StatDistribution is Copy — no clone() needed. let d = StatDistribution { avg: 0.0, stdev: 1.0 }; Self { - so_vlhb: d.clone(), - bb_vlhb: d.clone(), - hit_vlhb: d.clone(), - ob_vlhb: d.clone(), - tb_vlhb: d.clone(), - hr_vlhb: d.clone(), - dp_vlhb: d.clone(), - bphr_vlhb: d.clone(), - bp1b_vlhb: d.clone(), - so_vrhb: d.clone(), - bb_vrhb: d.clone(), - hit_vrhb: d.clone(), - ob_vrhb: d.clone(), - tb_vrhb: d.clone(), - hr_vrhb: d.clone(), - dp_vrhb: d.clone(), - bphr_vrhb: d.clone(), + so_vlhb: d, + bb_vlhb: d, + hit_vlhb: d, + ob_vlhb: d, + tb_vlhb: d, + hr_vlhb: d, + dp_vlhb: d, + bphr_vlhb: d, + bp1b_vlhb: d, + so_vrhb: d, + bb_vrhb: d, + hit_vrhb: d, + ob_vrhb: d, + tb_vrhb: d, + hr_vrhb: d, + dp_vrhb: d, + bphr_vrhb: d, bp1b_vrhb: d, } } diff --git a/rust/src/calc/matchup.rs b/rust/src/calc/matchup.rs index b594986..e5e458e 100644 --- a/rust/src/calc/matchup.rs +++ b/rust/src/calc/matchup.rs @@ -48,6 +48,32 @@ pub fn calculate_weighted_score( static DEFAULT_DIST: StatDistribution = StatDistribution { avg: 0.0, stdev: 1.0 }; +/// Resolve the batter's effective handedness given pitcher handedness. +/// +/// Switch hitters ("S") bat opposite to the pitcher's hand. +/// Returns ("effective_hand", "batter_split_label", "pitcher_split_label"). +fn resolve_handedness<'a>(batter_hand: &'a str, pitcher_hand: &str) -> (&'a str, &'static str, &'static str) { + let effective_hand = if batter_hand == "S" { + if pitcher_hand == "R" { "L" } else { "R" } + } else { + batter_hand + }; + let batter_split = if pitcher_hand == "L" { "vLHP" } else { "vRHP" }; + let pitcher_split = if effective_hand == "L" { "vLHB" } else { "vRHB" }; + (effective_hand, batter_split, pitcher_split) +} + +/// Sort matchup results descending by rating (rated players first, unrated last). +fn sort_matchups_desc(results: &mut [MatchupResult]) { + results.sort_by(|a, b| match (&b.rating, &a.rating) { + (Some(br), Some(ar)) => br.partial_cmp(ar).unwrap_or(std::cmp::Ordering::Equal), + (Some(_), None) => std::cmp::Ordering::Greater, // b rated, a not → b first + (None, Some(_)) => std::cmp::Ordering::Less, // a rated, b not → a first + (None, None) => std::cmp::Ordering::Equal, + }); +} + + /// Map (stat_name, pitcher_hand) to the batter card field value. /// pitcher_hand: "L" → vs left-handed pitchers, "R" → vs right-handed pitchers. pub(crate) fn get_batter_stat(card: &BatterCard, stat: &str, pitcher_hand: &str) -> f64 { @@ -227,16 +253,7 @@ pub fn calculate_matchup( ) -> MatchupResult { let batter_hand = player.hand.as_deref().unwrap_or("R"); let pitcher_hand = pitcher.hand.as_deref().unwrap_or("R"); - - // Switch hitter bats opposite of pitcher's hand - let effective_hand = if batter_hand == "S" { - if pitcher_hand == "R" { "L" } else { "R" } - } else { - batter_hand - }; - - let batter_split = if pitcher_hand == "L" { "vLHP" } else { "vRHP" }; - let pitcher_split = if effective_hand == "L" { "vLHB" } else { "vRHB" }; + let (effective_hand, batter_split, pitcher_split) = resolve_handedness(batter_hand, pitcher_hand); let Some(batter_card) = batter_card else { return MatchupResult { @@ -296,13 +313,7 @@ pub fn calculate_team_matchups( }) .collect(); - results.sort_by(|a, b| match (&b.rating, &a.rating) { - (Some(br), Some(ar)) => br.partial_cmp(ar).unwrap_or(std::cmp::Ordering::Equal), - (Some(_), None) => std::cmp::Ordering::Greater, // b rated, a not → b first - (None, Some(_)) => std::cmp::Ordering::Less, // a rated, b not → a first - (None, None) => std::cmp::Ordering::Equal, - }); - + sort_matchups_desc(&mut results); results } @@ -320,17 +331,9 @@ pub async fn calculate_matchup_cached( ) -> Result { let batter_hand = player.hand.as_deref().unwrap_or("R"); let pitcher_hand = pitcher.hand.as_deref().unwrap_or("R"); - - // Switch hitter bats opposite of pitcher's hand - let effective_hand = if batter_hand == "S" { - if pitcher_hand == "R" { "L" } else { "R" } - } else { - batter_hand - }; - - let batter_split_label = if pitcher_hand == "L" { "vLHP" } else { "vRHP" }; + let (effective_hand, batter_split_label, pitcher_split_label) = + resolve_handedness(batter_hand, pitcher_hand); let batter_split_key = if pitcher_hand == "L" { "vlhp" } else { "vrhp" }; - let pitcher_split_label = if effective_hand == "L" { "vLHB" } else { "vRHB" }; let pitcher_split_key = if effective_hand == "L" { "vlhb" } else { "vrhb" }; let no_rating = || MatchupResult { @@ -399,13 +402,7 @@ pub async fn calculate_team_matchups_cached( results.push(result); } - results.sort_by(|a, b| match (&b.rating, &a.rating) { - (Some(br), Some(ar)) => br.partial_cmp(ar).unwrap_or(std::cmp::Ordering::Equal), - (Some(_), None) => std::cmp::Ordering::Greater, // b rated, a not → b first - (None, Some(_)) => std::cmp::Ordering::Less, // a rated, b not → a first - (None, None) => std::cmp::Ordering::Equal, - }); - + sort_matchups_desc(&mut results); Ok(results) } diff --git a/rust/src/calc/score_cache.rs b/rust/src/calc/score_cache.rs index 83f376f..2510869 100644 --- a/rust/src/calc/score_cache.rs +++ b/rust/src/calc/score_cache.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::SqlitePool; use std::collections::HashMap; +use std::sync::OnceLock; use crate::calc::league_stats::{ calculate_batter_league_stats, calculate_pitcher_league_stats, BatterLeagueStats, @@ -30,30 +31,47 @@ pub struct CacheRebuildResult { pub pitcher_splits: i64, } -/// Generate a stable hash of the current weight configuration. +// ============================================================================= +// Hashing helpers +// ============================================================================= + +/// SHA-256 hash of `data`, returned as the first 16 lowercase hex characters. +fn hex16_hash(data: impl AsRef<[u8]>) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + let result = hasher.finalize(); + // format each byte as two hex digits, take first 16 chars (8 bytes) + result.iter().take(8).map(|b| format!("{:02x}", b)).collect() +} + +/// Cached hash of the compile-time weight configuration. +/// +/// BATTER_WEIGHTS and PITCHER_WEIGHTS are constants — their hash never changes +/// at runtime. Computed once on first call via OnceLock. +static WEIGHTS_HASH: OnceLock = OnceLock::new(); + +/// Return the stable hash of the current weight configuration. /// /// Uses SHA-256 of a sorted JSON representation of BATTER_WEIGHTS and PITCHER_WEIGHTS. -/// Returns the first 16 hex characters. -fn compute_weights_hash() -> String { - let batter: std::collections::BTreeMap<&str, (i32, bool)> = BATTER_WEIGHTS - .iter() - .map(|(name, w)| (*name, (w.weight, w.high_is_better))) - .collect(); - let pitcher: std::collections::BTreeMap<&str, (i32, bool)> = PITCHER_WEIGHTS - .iter() - .map(|(name, w)| (*name, (w.weight, w.high_is_better))) - .collect(); +/// Result is cached in a OnceLock since weights are compile-time constants. +fn compute_weights_hash() -> &'static str { + WEIGHTS_HASH.get_or_init(|| { + let batter: std::collections::BTreeMap<&str, (i32, bool)> = BATTER_WEIGHTS + .iter() + .map(|(name, w)| (*name, (w.weight, w.high_is_better))) + .collect(); + let pitcher: std::collections::BTreeMap<&str, (i32, bool)> = PITCHER_WEIGHTS + .iter() + .map(|(name, w)| (*name, (w.weight, w.high_is_better))) + .collect(); - let data = serde_json::json!({ - "batter": batter, - "pitcher": pitcher, - }); + let data = serde_json::json!({ + "batter": batter, + "pitcher": pitcher, + }); - let mut hasher = Sha256::new(); - hasher.update(data.to_string().as_bytes()); - let result = hasher.finalize(); - let hex: String = result.iter().map(|b| format!("{:02x}", b)).collect(); - hex[..16].to_string() + hex16_hash(data.to_string().as_bytes()) + }) } /// Generate a hash of representative league stat values to detect significant changes. @@ -67,15 +85,13 @@ fn compute_league_stats_hash(batter: &BatterLeagueStats, pitcher: &PitcherLeague pitcher.hit_vrhb.avg, pitcher.so_vrhb.avg, ]; - let repr = format!("{:?}", key_values); - - let mut hasher = Sha256::new(); - hasher.update(repr.as_bytes()); - let result = hasher.finalize(); - let hex: String = result.iter().map(|b| format!("{:02x}", b)).collect(); - hex[..16].to_string() + hex16_hash(format!("{:?}", key_values).as_bytes()) } +// ============================================================================= +// Split score calculators +// ============================================================================= + /// Calculate standardized scores for all stats on a batter card split. /// /// `split` is "vlhp" (vs left-handed pitchers) or "vrhp" (vs right-handed pitchers). @@ -128,10 +144,15 @@ fn calculate_pitcher_split_scores( (total, stat_scores) } +// ============================================================================= +// Cache management +// ============================================================================= + /// Rebuild the entire standardized score cache. /// /// Clears all existing entries and recalculates scores for every batter and pitcher -/// card using current league statistics and weight configuration. +/// card using current league statistics and weight configuration. All inserts are +/// performed inside a single transaction for atomicity and performance. pub async fn rebuild_score_cache(pool: &SqlitePool) -> Result { let batter_stats = calculate_batter_league_stats(pool).await?; let pitcher_stats = calculate_pitcher_league_stats(pool).await?; @@ -150,6 +171,10 @@ pub async fn rebuild_score_cache(pool: &SqlitePool) -> Result Result Result Result { None => return Ok(false), }; - let current_weights_hash = compute_weights_hash(); - Ok(entry.weights_hash.as_deref() == Some(current_weights_hash.as_str())) + Ok(entry.weights_hash.as_deref() == Some(compute_weights_hash())) } /// Ensure the score cache exists, rebuilding if necessary. diff --git a/rust/src/config.rs b/rust/src/config.rs index 350fa7d..1fe59c2 100644 --- a/rust/src/config.rs +++ b/rust/src/config.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use figment::{ - providers::{Env, Format, Toml}, + providers::{Env, Format, Serialized, Toml}, Figment, }; use serde::{Deserialize, Serialize}; @@ -109,7 +109,7 @@ impl Default for RatingWeights { pub fn load_settings() -> Result { Figment::new() - .merge(figment::providers::Serialized::defaults(Settings::default())) + .merge(Serialized::defaults(Settings::default())) .merge(Toml::file("settings.toml")) .merge(Env::prefixed("SBA_SCOUT_").split("__")) .extract() diff --git a/rust/src/db/models.rs b/rust/src/db/models.rs index 5f0f538..6ee24da 100644 --- a/rust/src/db/models.rs +++ b/rust/src/db/models.rs @@ -1,6 +1,7 @@ use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use std::collections::HashMap; // ============================================================================= // Core Entities (synced from league API) @@ -77,15 +78,35 @@ impl Player { } pub fn is_pitcher(&self) -> bool { - self.positions() - .iter() - .any(|p| matches!(*p, "SP" | "RP" | "CP")) + [ + self.pos_1.as_deref(), + self.pos_2.as_deref(), + self.pos_3.as_deref(), + self.pos_4.as_deref(), + self.pos_5.as_deref(), + self.pos_6.as_deref(), + self.pos_7.as_deref(), + self.pos_8.as_deref(), + ] + .into_iter() + .flatten() + .any(|p| matches!(p, "SP" | "RP" | "CP")) } pub fn is_batter(&self) -> bool { - self.positions() - .iter() - .any(|p| matches!(*p, "C" | "1B" | "2B" | "3B" | "SS" | "LF" | "CF" | "RF" | "DH")) + [ + self.pos_1.as_deref(), + self.pos_2.as_deref(), + self.pos_3.as_deref(), + self.pos_4.as_deref(), + self.pos_5.as_deref(), + self.pos_6.as_deref(), + self.pos_7.as_deref(), + self.pos_8.as_deref(), + ] + .into_iter() + .flatten() + .any(|p| matches!(p, "C" | "1B" | "2B" | "3B" | "SS" | "LF" | "CF" | "RF" | "DH")) } } @@ -237,7 +258,7 @@ impl Lineup { .unwrap_or_default() } - pub fn positions_map(&self) -> std::collections::HashMap { + pub fn positions_map(&self) -> HashMap { self.positions .as_deref() .and_then(|s| serde_json::from_str(s).ok()) @@ -248,7 +269,7 @@ impl Lineup { self.batting_order = serde_json::to_string(order).ok(); } - pub fn set_positions(&mut self, positions: &std::collections::HashMap) { + pub fn set_positions(&mut self, positions: &HashMap) { self.positions = serde_json::to_string(positions).ok(); } } @@ -290,7 +311,6 @@ pub struct SyncStatus { #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; fn make_player(pos_1: Option<&str>, pos_2: Option<&str>) -> Player { Player { diff --git a/rust/src/db/queries.rs b/rust/src/db/queries.rs index 9167ec0..eeacf3f 100644 --- a/rust/src/db/queries.rs +++ b/rust/src/db/queries.rs @@ -110,15 +110,13 @@ pub async fn search_players( Ok(players) } -pub async fn get_pitchers( +async fn get_players_by_position_filter( pool: &SqlitePool, + base_where: &str, team_id: Option, season: Option, ) -> Result> { - let mut sql = String::from( - "SELECT * FROM players \ - WHERE (pos_1 IN ('SP', 'RP', 'CP') OR pos_2 IN ('SP', 'RP', 'CP'))", - ); + let mut sql = format!("SELECT * FROM players WHERE {base_where}"); if team_id.is_some() { sql.push_str(" AND team_id = ?"); } @@ -137,62 +135,58 @@ pub async fn get_pitchers( Ok(query.fetch_all(pool).await?) } +pub async fn get_pitchers( + pool: &SqlitePool, + team_id: Option, + season: Option, +) -> Result> { + get_players_by_position_filter( + pool, + "(pos_1 IN ('SP', 'RP', 'CP') OR pos_2 IN ('SP', 'RP', 'CP'))", + team_id, + season, + ) + .await +} + pub async fn get_batters( pool: &SqlitePool, team_id: Option, season: Option, ) -> Result> { - let mut sql = String::from( - "SELECT * FROM players \ - WHERE pos_1 IN ('C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH')", - ); - if team_id.is_some() { - sql.push_str(" AND team_id = ?"); - } - if season.is_some() { - sql.push_str(" AND season = ?"); - } - sql.push_str(" ORDER BY name"); - - let mut query = sqlx::query_as::<_, Player>(&sql); - if let Some(tid) = team_id { - query = query.bind(tid); - } - if let Some(s) = season { - query = query.bind(s); - } - Ok(query.fetch_all(pool).await?) + get_players_by_position_filter( + pool, + "pos_1 IN ('C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH')", + team_id, + season, + ) + .await } -pub async fn get_players_missing_cards( - pool: &SqlitePool, - season: i64, - card_type: &str, -) -> Result> { - let players = if card_type == "batter" { - sqlx::query_as::<_, Player>( - "SELECT * FROM players \ - WHERE season = ? \ - AND pos_1 IN ('C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH') \ - AND id NOT IN (SELECT player_id FROM batter_cards) \ - ORDER BY name", - ) - .bind(season) - .fetch_all(pool) - .await? - } else { - sqlx::query_as::<_, Player>( - "SELECT * FROM players \ - WHERE season = ? \ - AND pos_1 IN ('SP', 'RP', 'CP') \ - AND id NOT IN (SELECT player_id FROM pitcher_cards) \ - ORDER BY name", - ) - .bind(season) - .fetch_all(pool) - .await? - }; - Ok(players) +pub async fn get_batters_missing_cards(pool: &SqlitePool, season: i64) -> Result> { + Ok(sqlx::query_as::<_, Player>( + "SELECT * FROM players \ + WHERE season = ? \ + AND pos_1 IN ('C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH') \ + AND id NOT IN (SELECT player_id FROM batter_cards) \ + ORDER BY name", + ) + .bind(season) + .fetch_all(pool) + .await?) +} + +pub async fn get_pitchers_missing_cards(pool: &SqlitePool, season: i64) -> Result> { + Ok(sqlx::query_as::<_, Player>( + "SELECT * FROM players \ + WHERE season = ? \ + AND pos_1 IN ('SP', 'RP', 'CP') \ + AND id NOT IN (SELECT player_id FROM pitcher_cards) \ + ORDER BY name", + ) + .bind(season) + .fetch_all(pool) + .await?) } // ============================================================================= @@ -381,36 +375,6 @@ pub async fn clear_score_cache(pool: &SqlitePool) -> Result { Ok(result.rows_affected()) } -pub async fn insert_score_cache( - pool: &SqlitePool, - batter_card_id: Option, - pitcher_card_id: Option, - split: &str, - total_score: f64, - stat_scores: &str, - weights_hash: &str, - league_stats_hash: &str, -) -> Result<()> { - let computed_at = chrono::Utc::now().naive_utc(); - sqlx::query( - "INSERT INTO standardized_score_cache \ - (batter_card_id, pitcher_card_id, split, total_score, stat_scores, computed_at, \ - weights_hash, league_stats_hash) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(batter_card_id) - .bind(pitcher_card_id) - .bind(split) - .bind(total_score) - .bind(stat_scores) - .bind(computed_at) - .bind(weights_hash) - .bind(league_stats_hash) - .execute(pool) - .await?; - Ok(()) -} - // ============================================================================= // Lineup Queries // ============================================================================= @@ -442,39 +406,27 @@ pub async fn save_lineup( let batting_order_json = serde_json::to_string(batting_order)?; let positions_json = serde_json::to_string(positions)?; - // UPDATE existing lineup if found; INSERT if not - let rows_updated = sqlx::query( - "UPDATE lineups \ - SET description = ?, lineup_type = ?, batting_order = ?, positions = ?, \ - starting_pitcher_id = ?, updated_at = datetime('now') \ - WHERE name = ?", + sqlx::query( + "INSERT INTO lineups \ + (name, description, lineup_type, batting_order, positions, starting_pitcher_id, \ + created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) \ + ON CONFLICT(name) DO UPDATE SET \ + description = excluded.description, \ + lineup_type = excluded.lineup_type, \ + batting_order = excluded.batting_order, \ + positions = excluded.positions, \ + starting_pitcher_id = excluded.starting_pitcher_id, \ + updated_at = datetime('now')", ) + .bind(name) .bind(description) .bind(lineup_type) .bind(&batting_order_json) .bind(&positions_json) .bind(starting_pitcher_id) - .bind(name) .execute(pool) - .await? - .rows_affected(); - - if rows_updated == 0 { - sqlx::query( - "INSERT INTO lineups \ - (name, description, lineup_type, batting_order, positions, starting_pitcher_id, \ - created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))", - ) - .bind(name) - .bind(description) - .bind(lineup_type) - .bind(&batting_order_json) - .bind(&positions_json) - .bind(starting_pitcher_id) - .execute(pool) - .await?; - } + .await?; Ok(()) } @@ -496,25 +448,35 @@ pub async fn get_my_roster( team_abbrev: &str, season: i64, ) -> Result { - let majors_team = get_team_by_abbrev(pool, team_abbrev, season).await?; - let majors = match majors_team { - Some(t) => get_players_by_team(pool, t.id).await?, - None => vec![], - }; - let il_abbrev = format!("{}IL", team_abbrev); - let il_team = get_team_by_abbrev(pool, &il_abbrev, season).await?; - let il = match il_team { - Some(t) => get_players_by_team(pool, t.id).await?, - None => vec![], - }; - let mil_abbrev = format!("{}MiL", team_abbrev); - let mil_team = get_team_by_abbrev(pool, &mil_abbrev, season).await?; - let minors = match mil_team { - Some(t) => get_players_by_team(pool, t.id).await?, - None => vec![], - }; + + let (majors_team, il_team, mil_team) = tokio::try_join!( + get_team_by_abbrev(pool, team_abbrev, season), + get_team_by_abbrev(pool, &il_abbrev, season), + get_team_by_abbrev(pool, &mil_abbrev, season), + )?; + + let (majors, il, minors) = tokio::try_join!( + async { + match majors_team { + Some(t) => get_players_by_team(pool, t.id).await, + None => Ok(vec![]), + } + }, + async { + match il_team { + Some(t) => get_players_by_team(pool, t.id).await, + None => Ok(vec![]), + } + }, + async { + match mil_team { + Some(t) => get_players_by_team(pool, t.id).await, + None => Ok(vec![]), + } + }, + )?; Ok(Roster { majors, minors, il }) } diff --git a/rust/src/db/schema.rs b/rust/src/db/schema.rs index bcf33bc..190072b 100644 --- a/rust/src/db/schema.rs +++ b/rust/src/db/schema.rs @@ -1,17 +1,20 @@ use anyhow::Result; -use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions}; use std::path::Path; -use std::str::FromStr; pub async fn init_pool(db_path: &Path) -> Result { - let db_url = format!("sqlite://{}?mode=rwc", db_path.display()); - let options = SqliteConnectOptions::from_str(&db_url)? + let is_memory = db_path == Path::new(":memory:"); + let mut options = SqliteConnectOptions::new() + .filename(db_path) .create_if_missing(true) - .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) .foreign_keys(false); + if !is_memory { + options = options.journal_mode(SqliteJournalMode::Wal); + } + let max_conns = if is_memory { 1 } else { 5 }; let pool = SqlitePoolOptions::new() - .max_connections(5) + .max_connections(max_conns) .connect_with(options) .await?; @@ -19,6 +22,8 @@ pub async fn init_pool(db_path: &Path) -> Result { } pub async fn create_tables(pool: &SqlitePool) -> Result<()> { + let mut tx = pool.begin().await?; + // 1. teams — API-provided PKs (no autoincrement) sqlx::query( "CREATE TABLE IF NOT EXISTS teams ( @@ -43,7 +48,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> { UNIQUE(abbrev, season) )", ) - .execute(pool) + .execute(&mut *tx) .await?; // 2. players — API-provided PKs (no autoincrement) @@ -78,7 +83,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> { synced_at TEXT )", ) - .execute(pool) + .execute(&mut *tx) .await?; // 3. batter_cards @@ -120,7 +125,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> { source TEXT )", ) - .execute(pool) + .execute(&mut *tx) .await?; // 4. pitcher_cards @@ -162,7 +167,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> { source TEXT )", ) - .execute(pool) + .execute(&mut *tx) .await?; // 5. transactions @@ -181,7 +186,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> { UNIQUE(move_id, player_id) )", ) - .execute(pool) + .execute(&mut *tx) .await?; // 6. lineups @@ -198,7 +203,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> { updated_at TEXT )", ) - .execute(pool) + .execute(&mut *tx) .await?; // 7. matchup_cache @@ -215,7 +220,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> { UNIQUE(batter_id, pitcher_id) )", ) - .execute(pool) + .execute(&mut *tx) .await?; // 8. standardized_score_cache @@ -234,7 +239,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> { UNIQUE(pitcher_card_id, split) )", ) - .execute(pool) + .execute(&mut *tx) .await?; // 9. standings_cache — JSON blob cache for league standings @@ -246,7 +251,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> { fetched_at TEXT NOT NULL )", ) - .execute(pool) + .execute(&mut *tx) .await?; // 10. sync_status @@ -259,13 +264,16 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> { last_error TEXT )", ) - .execute(pool) + .execute(&mut *tx) .await?; + tx.commit().await?; Ok(()) } pub async fn reset_database(pool: &SqlitePool) -> Result<()> { + let mut tx = pool.begin().await?; + // Drop in reverse dependency order to satisfy foreign key constraints for table in &[ "standings_cache", @@ -279,10 +287,11 @@ pub async fn reset_database(pool: &SqlitePool) -> Result<()> { "teams", "sync_status", ] { - sqlx::query(&format!("DROP TABLE IF EXISTS {}", table)) - .execute(pool) + sqlx::query(&format!("DROP TABLE IF EXISTS {table}")) + .execute(&mut *tx) .await?; } + tx.commit().await?; create_tables(pool).await } diff --git a/rust/src/main.rs b/rust/src/main.rs index 7d3edd6..9f59360 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -36,12 +36,9 @@ async fn main() -> Result<()> { } }; - let log_dir = settings.db_path.parent().unwrap_or(std::path::Path::new("data")); - let _log_guard = init_logging(log_dir); - - if let Some(parent) = settings.db_path.parent() { - std::fs::create_dir_all(parent)?; - } + let db_parent = settings.db_path.parent().unwrap_or(std::path::Path::new("data")); + let _log_guard = init_logging(db_parent); + std::fs::create_dir_all(db_parent)?; let pool = db::schema::init_pool(&settings.db_path).await?; db::schema::create_tables(&pool).await?; diff --git a/rust/src/screens/dashboard.rs b/rust/src/screens/dashboard.rs index 8352b3f..ed23119 100644 --- a/rust/src/screens/dashboard.rs +++ b/rust/src/screens/dashboard.rs @@ -14,6 +14,7 @@ use tokio::sync::mpsc; use crate::app::{AppMessage, NotifyLevel}; use crate::config::Settings; +use crate::screens::format_relative_time; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SyncState { @@ -107,7 +108,7 @@ impl DashboardState { } }); - // Load team info (full name) + // Load team info and missing cards in a single query let pool_c = pool.clone(); let tx_c = tx.clone(); let abbrev = self.team_abbrev.clone(); @@ -117,28 +118,19 @@ impl DashboardState { crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await { let _ = tx_c.send(AppMessage::TeamInfoLoaded(team.short_name, team.salary_cap)); - } - }); - // Load missing cards - 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 format_player = |p: crate::db::models::Player| { + let pos = p.pos_1.as_deref().unwrap_or("?"); + format!("{} ({})", p.name, pos) + }; + let batters = crate::db::queries::get_players_missing_batter_cards( &pool_c, team.id, season, ) .await .unwrap_or_default() .into_iter() - .map(|p| { - let pos = p.pos_1.as_deref().unwrap_or("?"); - format!("{} ({})", p.name, pos) - }) + .map(format_player) .collect(); let pitchers = crate::db::queries::get_players_missing_pitcher_cards( @@ -147,10 +139,7 @@ impl DashboardState { .await .unwrap_or_default() .into_iter() - .map(|p| { - let pos = p.pos_1.as_deref().unwrap_or("?"); - format!("{} ({})", p.name, pos) - }) + .map(format_player) .collect(); let _ = tx_c.send(AppMessage::MissingCardsLoaded { batters, pitchers }); @@ -192,8 +181,6 @@ impl DashboardState { 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(); @@ -212,6 +199,7 @@ impl DashboardState { &mut self, msg: AppMessage, pool: &SqlitePool, + settings: &Settings, tx: &mpsc::UnboundedSender, ) { match msg { @@ -264,6 +252,21 @@ impl DashboardState { if self.sync_state == SyncState::Never && !self.sync_statuses.is_empty() { self.sync_state = SyncState::Success; } + // Auto-sync if last sync was more than 24 hours ago (or never synced) + if self.sync_state != SyncState::Syncing { + let needs_sync = if self.sync_statuses.is_empty() { + true + } else { + let now = chrono::Utc::now().naive_utc(); + self.sync_statuses.iter().all(|s| match &s.last_sync { + Some(dt) => now.signed_duration_since(*dt).num_hours() >= 24, + None => true, + }) + }; + if needs_sync { + self.trigger_sync(pool, settings, tx); + } + } } AppMessage::SyncStarted => { self.sync_state = SyncState::Syncing; @@ -431,44 +434,15 @@ impl DashboardState { let batter_positions = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"]; let pitcher_positions = ["SP", "RP", "CP"]; - let mut spans: Vec = Vec::new(); - for (i, pos) in batter_positions.iter().enumerate() { - let count = self.position_counts.get(*pos).copied().unwrap_or(0); - let style = if count == 0 { - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - if i > 0 { - spans.push(Span::raw(" ")); - } - spans.push(Span::styled(format!("{}:{}", pos, count), style)); - } - - let mut pitcher_spans: Vec = Vec::new(); - for (i, pos) in pitcher_positions.iter().enumerate() { - let count = self.position_counts.get(*pos).copied().unwrap_or(0); - let style = if count == 0 { - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - if i > 0 { - pitcher_spans.push(Span::raw(" ")); - } - pitcher_spans.push(Span::styled(format!("{}:{}", pos, count), style)); - } - let lines = vec![ - Line::from(spans), - Line::from(pitcher_spans), + Line::from(build_position_spans(&batter_positions, &self.position_counts)), + Line::from(build_position_spans(&pitcher_positions, &self.position_counts)), ]; - let widget = Paragraph::new(lines) - .block( - Block::default() - .borders(Borders::ALL) - .title("Position Coverage"), - ); + let widget = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title("Position Coverage"), + ); frame.render_widget(widget, area); } @@ -512,60 +486,26 @@ impl DashboardState { Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(area); - // Batters missing cards - let batter_lines: Vec = if self.batters_missing_cards.is_empty() { - vec![Line::from(Span::styled( - "All cards imported", - Style::default().fg(Color::Green), - ))] - } else { - self.batters_missing_cards - .iter() - .take(4) - .map(|name| { - Line::from(Span::styled( - format!(" {}", name), - Style::default().fg(Color::Red), - )) - }) - .collect() - }; - let batter_widget = Paragraph::new(batter_lines).block( - Block::default() - .borders(Borders::ALL) - .title(format!( - "Batters Missing Cards ({})", - self.batters_missing_cards.len() - )), - ); + let batter_widget = + Paragraph::new(missing_cards_lines(&self.batters_missing_cards)).block( + Block::default() + .borders(Borders::ALL) + .title(format!( + "Batters Missing Cards ({})", + self.batters_missing_cards.len() + )), + ); frame.render_widget(batter_widget, cols[0]); - // Pitchers missing cards - let pitcher_lines: Vec = if self.pitchers_missing_cards.is_empty() { - vec![Line::from(Span::styled( - "All cards imported", - Style::default().fg(Color::Green), - ))] - } else { - self.pitchers_missing_cards - .iter() - .take(4) - .map(|name| { - Line::from(Span::styled( - format!(" {}", name), - Style::default().fg(Color::Red), - )) - }) - .collect() - }; - let pitcher_widget = Paragraph::new(pitcher_lines).block( - Block::default() - .borders(Borders::ALL) - .title(format!( - "Pitchers Missing Cards ({})", - self.pitchers_missing_cards.len() - )), - ); + let pitcher_widget = + Paragraph::new(missing_cards_lines(&self.pitchers_missing_cards)).block( + Block::default() + .borders(Borders::ALL) + .title(format!( + "Pitchers Missing Cards ({})", + self.pitchers_missing_cards.len() + )), + ); frame.render_widget(pitcher_widget, cols[1]); } @@ -637,25 +577,42 @@ impl DashboardState { } } -fn format_relative_time(dt: &NaiveDateTime) -> String { - let now = chrono::Utc::now().naive_utc(); - let diff = now.signed_duration_since(*dt); - - let secs = diff.num_seconds(); - if secs < 0 { - return "just now".to_string(); +fn build_position_spans<'a>( + positions: &[&'a str], + counts: &HashMap, +) -> Vec> { + let mut spans = Vec::new(); + for (i, pos) in positions.iter().enumerate() { + let count = counts.get(*pos).copied().unwrap_or(0); + let style = if count == 0 { + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled(format!("{}:{}", pos, count), style)); + } + spans +} + +fn missing_cards_lines(items: &[String]) -> Vec> { + if items.is_empty() { + vec![Line::from(Span::styled( + "All cards imported", + Style::default().fg(Color::Green), + ))] + } else { + items + .iter() + .take(4) + .map(|name| { + Line::from(Span::styled( + format!(" {}", name), + Style::default().fg(Color::Red), + )) + }) + .collect() } - if secs < 60 { - return "just now".to_string(); - } - let mins = secs / 60; - if mins < 60 { - return format!("{}m ago", mins); - } - let hours = mins / 60; - if hours < 24 { - return format!("{}h ago", hours); - } - let days = hours / 24; - format!("{}d ago", days) } diff --git a/rust/src/screens/gameday.rs b/rust/src/screens/gameday.rs index 68e1024..3cce743 100644 --- a/rust/src/screens/gameday.rs +++ b/rust/src/screens/gameday.rs @@ -172,21 +172,20 @@ impl GamedayState { let abbrev = self.team_abbrev.clone(); let season = self.season; tokio::spawn(async move { - let team = crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await; - if let Ok(Some(team)) = team { - if let Ok(batters) = + 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)); + { + 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)); } }); @@ -302,7 +301,9 @@ impl GamedayState { } fn cycle_focus(&mut self) { - self.unfocus_all_selectors(); + 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, @@ -311,16 +312,6 @@ impl GamedayState { GamedayFocus::PitcherSelector => GamedayFocus::LoadSelector, GamedayFocus::LoadSelector => GamedayFocus::MatchupTable, }; - self.update_selector_focus(); - } - - fn unfocus_all_selectors(&mut self) { - self.team_selector.is_focused = false; - self.pitcher_selector.is_focused = false; - self.load_selector.is_focused = false; - } - - fn update_selector_focus(&mut self) { 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; @@ -417,33 +408,10 @@ impl GamedayState { // ========================================================================= fn add_selected_to_lineup(&mut self) { - let Some(idx) = self.matchup_table_state.selected() else { - return; - }; - let Some(result) = self.matchup_results.get(idx) else { - return; - }; - - // Find first empty slot - let slot_idx = self.lineup_slots.iter().position(|s| s.is_empty()); - let Some(slot_idx) = slot_idx else { + let Some(slot_idx) = self.lineup_slots.iter().position(|s| s.is_empty()) else { return; // lineup full }; - - // Don't add duplicates - 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()), - }; + self.add_to_specific_slot(slot_idx); } fn add_to_specific_slot(&mut self, slot_idx: usize) { @@ -720,10 +688,7 @@ impl GamedayState { self.matchup_table_state.select(Some(0)); } } - AppMessage::LineupSaved(name) => { - // Refresh saved lineups list - let _ = name; // name used for notification in Notify - } + AppMessage::LineupSaved(_) => {} AppMessage::LineupSaveError(_) => {} _ => {} } @@ -742,24 +707,19 @@ impl GamedayState { self.render_right_panel(frame, panels[1], tick_count); // Render popup overlays last (on top) - // Team selector popup anchored to its closed position - if self.team_selector.is_open { + 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]); - self.team_selector.render_popup(frame, left_chunks[0]); - } - if self.pitcher_selector.is_open { - let left_chunks = Layout::vertical([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(0), - ]) - .split(panels[0]); - self.pitcher_selector.render_popup(frame, left_chunks[1]); + 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([ @@ -942,7 +902,7 @@ impl GamedayState { .lineup_slots .iter() .map(|slot| { - let order = format!("{}", slot.order); + let order = slot.order.to_string(); if slot.is_empty() { Row::new(vec![ Cell::from(order), diff --git a/rust/src/screens/lineup.rs b/rust/src/screens/lineup.rs index 4f55e44..46295f8 100644 --- a/rust/src/screens/lineup.rs +++ b/rust/src/screens/lineup.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ @@ -15,6 +15,7 @@ use crate::app::{AppMessage, NotifyLevel}; use crate::config::Settings; use crate::db::models::{BatterCard, Lineup, Player}; use crate::widgets::selector::{SelectorEvent, SelectorWidget}; +use crate::widgets::{format_rating, format_swar}; // ============================================================================= // Types @@ -96,7 +97,9 @@ impl LineupState { } pub fn is_input_captured(&self) -> bool { - matches!(self.focus, LineupFocus::LineupName) || self.load_selector.is_open || self.confirm_delete + matches!(self.focus, LineupFocus::LineupName) + || self.load_selector.is_open + || self.confirm_delete } pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { @@ -109,31 +112,24 @@ impl LineupState { let season = self.season; tokio::spawn(async move { let team = crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await; - if let Ok(Some(team)) = team { - if let Ok(batters) = + if let Ok(Some(team)) = team + && 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::LineupBattersLoaded(with_cards)); + { + 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::LineupBattersLoaded(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::LineupListLoaded(lineups)); - } - }); + Self::reload_lineups(pool, tx); } pub fn handle_key( @@ -231,9 +227,7 @@ impl LineupState { return; } KeyCode::Char('c') => { - for i in 0..9 { - self.lineup_slots[i] = LineupSlot::empty(i + 1); - } + self.clear_slots(); return; } _ => {} @@ -332,8 +326,14 @@ impl LineupState { // Lineup operations // ========================================================================= + fn clear_slots(&mut self) { + for i in 0..9 { + self.lineup_slots[i] = LineupSlot::empty(i + 1); + } + } + fn filtered_available(&self) -> Vec<&(Player, Option)> { - let lineup_ids: Vec = self + let lineup_ids: HashSet = self .lineup_slots .iter() .filter_map(|s| s.player_id) @@ -359,7 +359,7 @@ impl LineupState { }; // Auto-suggest position: first eligible not already assigned - let assigned_positions: Vec = self + let assigned_positions: HashSet = self .lineup_slots .iter() .filter_map(|s| s.position.clone()) @@ -367,10 +367,13 @@ impl LineupState { let eligible = player.positions(); let pos = eligible .iter() - .find(|p| !assigned_positions.contains(&p.to_string())) + .find(|p| !assigned_positions.contains(**p as &str)) .map(|p| p.to_string()) .or_else(|| Some("DH".to_string())); + // Compute new available length before mutating slots + let new_available_len = available.len() - 1; + self.lineup_slots[slot_idx] = LineupSlot { order: slot_idx + 1, player_id: Some(player.id), @@ -379,7 +382,6 @@ impl LineupState { }; // Adjust available table selection - let new_available_len = self.filtered_available().len(); if new_available_len > 0 { let new_idx = idx.min(new_available_len - 1); self.available_table_state.select(Some(new_idx)); @@ -439,10 +441,7 @@ impl LineupState { 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); - } + self.clear_slots(); // Populate from saved lineup for (i, pid) in order.iter().enumerate() { @@ -546,6 +545,16 @@ impl LineupState { }); } + fn reload_lineups(pool: &SqlitePool, tx: &mpsc::UnboundedSender) { + let pool = pool.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + if let Ok(lineups) = crate::db::queries::get_lineups(&pool).await { + let _ = tx.send(AppMessage::LineupListLoaded(lineups)); + } + }); + } + pub fn handle_message( &mut self, msg: AppMessage, @@ -565,46 +574,30 @@ impl LineupState { } AppMessage::LineupSaved(name) => { let _ = tx.send(AppMessage::Notify( - format!("Lineup '{}' saved", name), + format!("Lineup '{name}' saved"), NotifyLevel::Success, )); - // Refresh lineups list - let pool = pool.clone(); - let tx = tx.clone(); - tokio::spawn(async move { - if let Ok(lineups) = crate::db::queries::get_lineups(&pool).await { - let _ = tx.send(AppMessage::LineupListLoaded(lineups)); - } - }); + Self::reload_lineups(pool, tx); } AppMessage::LineupSaveError(e) => { let _ = tx.send(AppMessage::Notify( - format!("Save failed: {}", e), + format!("Save failed: {e}"), NotifyLevel::Error, )); } AppMessage::LineupDeleted(name) => { let _ = tx.send(AppMessage::Notify( - format!("Lineup '{}' deleted", name), + format!("Lineup '{name}' deleted"), NotifyLevel::Success, )); self.lineup_name.clear(); self.lineup_name_cursor = 0; - for i in 0..9 { - self.lineup_slots[i] = LineupSlot::empty(i + 1); - } - // Refresh lineups list - let pool = pool.clone(); - let tx = tx.clone(); - tokio::spawn(async move { - if let Ok(lineups) = crate::db::queries::get_lineups(&pool).await { - let _ = tx.send(AppMessage::LineupListLoaded(lineups)); - } - }); + self.clear_slots(); + Self::reload_lineups(pool, tx); } AppMessage::LineupDeleteError(e) => { let _ = tx.send(AppMessage::Notify( - format!("Delete failed: {}", e), + format!("Delete failed: {e}"), NotifyLevel::Error, )); } @@ -665,7 +658,8 @@ impl LineupState { let available = self.filtered_available(); if available.is_empty() { frame.render_widget( - Paragraph::new(" No available batters").style(Style::default().fg(Color::DarkGray)), + Paragraph::new(" No available batters") + .style(Style::default().fg(Color::DarkGray)), chunks[1], ); } else { @@ -685,7 +679,7 @@ impl LineupState { let (vl, vr) = card .as_ref() .map(|c| (format_rating(c.rating_vl), format_rating(c.rating_vr))) - .unwrap_or(("—".to_string(), "—".to_string())); + .unwrap_or_else(|| ("\u{2014}".to_string(), "\u{2014}".to_string())); Row::new(vec![ Cell::from(p.name.clone()), Cell::from(p.positions().join("/")), @@ -715,7 +709,8 @@ impl LineupState { } frame.render_widget( - Paragraph::new(" [Enter] Add [j/k] Scroll").style(Style::default().fg(Color::DarkGray)), + Paragraph::new(" [Enter] Add [j/k] Scroll") + .style(Style::default().fg(Color::DarkGray)), chunks[2], ); } @@ -734,11 +729,12 @@ impl LineupState { } else { Style::default().fg(Color::White) }; - let title_style = if matches!(self.focus, LineupFocus::LineupTable | LineupFocus::LineupName) { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) - }; + let title_style = + if matches!(self.focus, LineupFocus::LineupTable | LineupFocus::LineupName) { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; let display_name = if self.lineup_name.is_empty() { "" @@ -767,7 +763,7 @@ impl LineupState { .map(|slot| { if slot.is_empty() { Row::new(vec![ - Cell::from(format!("{}", slot.order)), + Cell::from(slot.order.to_string()), Cell::from("(empty)").style(Style::default().fg(Color::DarkGray)), Cell::from(""), Cell::from(""), @@ -775,25 +771,24 @@ impl LineupState { ]) } else { let name = slot.player_name.as_deref().unwrap_or("?"); - let pos = slot.position.as_deref().unwrap_or("—"); - let eligible = self + let pos = slot.position.as_deref().unwrap_or("\u{2014}"); + let player = self .available_batters .iter() .find(|(p, _)| Some(p.id) == slot.player_id) - .map(|(p, _)| p.positions().join("/")) + .map(|(p, _)| p); + let eligible = player + .map(|p| p.positions().join("/")) .unwrap_or_default(); - let hand = self - .available_batters - .iter() - .find(|(p, _)| Some(p.id) == slot.player_id) - .and_then(|(p, _)| p.hand.clone()) + let hand = player + .and_then(|p| p.hand.as_deref()) .unwrap_or_default(); Row::new(vec![ - Cell::from(format!("{}", slot.order)), + Cell::from(slot.order.to_string()), Cell::from(name.to_string()), Cell::from(pos.to_string()), Cell::from(eligible), - Cell::from(hand), + Cell::from(hand.to_string()), ]) } }) @@ -822,28 +817,3 @@ impl LineupState { frame.render_widget(hints, chunks[2]); } } - -// ============================================================================= -// Helpers -// ============================================================================= - -fn format_rating(val: Option) -> String { - match val { - Some(v) => { - let rounded = v.round() as i64; - if rounded > 0 { - format!("+{}", rounded) - } else { - format!("{}", rounded) - } - } - None => "—".to_string(), - } -} - -fn format_swar(val: Option) -> String { - match val { - Some(v) => format!("{:.2}", v), - None => "—".to_string(), - } -} diff --git a/rust/src/screens/matchup.rs b/rust/src/screens/matchup.rs index a2aba50..8fd9899 100644 --- a/rust/src/screens/matchup.rs +++ b/rust/src/screens/matchup.rs @@ -12,7 +12,8 @@ use crate::app::{AppMessage, NotifyLevel}; use crate::calc::matchup::MatchupResult; use crate::config::Settings; use crate::db::models::{BatterCard, Player}; -use crate::widgets::selector::{SelectorEvent, SelectorWidget}; +use crate::screens::tier_style; +use crate::widgets::{format_swar, selector::{SelectorEvent, SelectorWidget}}; // ============================================================================= // Types @@ -134,20 +135,19 @@ impl MatchupState { let season = self.season; tokio::spawn(async move { let team = crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await; - if let Ok(Some(team)) = team { - if let Ok(batters) = + if let Ok(Some(team)) = team + && 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)); + { + 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)); } }); } @@ -369,16 +369,9 @@ impl MatchupState { .collect(); self.pitcher_selector.set_items(items); } - AppMessage::MatchupsCalculated(mut results) => { + AppMessage::MatchupsCalculated(results) => { self.is_calculating = false; - // Apply current sort - results.sort_by(|a, b| { - b.rating.partial_cmp(&a.rating).unwrap_or(std::cmp::Ordering::Equal) - }); self.matchup_results = results; - if !self.matchup_results.is_empty() { - self.table_state.select(Some(0)); - } self.sort_results(); } _ => {} @@ -467,7 +460,7 @@ impl MatchupState { .map(|(i, r)| { let tier_style = tier_style(&r.tier); Row::new(vec![ - Cell::from(format!("{}", i + 1)), + Cell::from((i + 1).to_string()), Cell::from(r.player.name.clone()), Cell::from(r.batter_hand.clone()), Cell::from(r.player.positions().join("/")), @@ -508,24 +501,4 @@ impl MatchupState { } } -// ============================================================================= -// Helpers -// ============================================================================= -fn tier_style(tier: &str) -> Style { - match tier { - "A" => Style::default().fg(Color::Green), - "B" => Style::default().fg(Color::LightGreen), - "C" => Style::default().fg(Color::Yellow), - "D" => Style::default().fg(Color::LightRed), - "F" => Style::default().fg(Color::Red), - _ => Style::default().fg(Color::DarkGray), - } -} - -fn format_swar(val: Option) -> String { - match val { - Some(v) => format!("{:.2}", v), - None => "—".to_string(), - } -} diff --git a/rust/src/screens/mod.rs b/rust/src/screens/mod.rs index 3ab80f5..0bc7c56 100644 --- a/rust/src/screens/mod.rs +++ b/rust/src/screens/mod.rs @@ -5,3 +5,32 @@ pub mod matchup; pub mod roster; pub mod settings; pub mod standings; + +pub fn format_relative_time(dt: &chrono::NaiveDateTime) -> String { + let now = chrono::Utc::now().naive_utc(); + let secs = now.signed_duration_since(*dt).num_seconds(); + if secs < 60 { + return "just now".to_string(); + } + let mins = secs / 60; + if mins < 60 { + return format!("{}m ago", mins); + } + let hours = mins / 60; + if hours < 24 { + return format!("{}h ago", hours); + } + format!("{}d ago", hours / 24) +} + +pub fn tier_style(tier: &str) -> ratatui::style::Style { + use ratatui::style::{Color, Style}; + match tier { + "A" => Style::default().fg(Color::Green), + "B" => Style::default().fg(Color::LightGreen), + "C" => Style::default().fg(Color::Yellow), + "D" => Style::default().fg(Color::LightRed), + "F" => Style::default().fg(Color::Red), + _ => Style::default().fg(Color::DarkGray), + } +} diff --git a/rust/src/screens/roster.rs b/rust/src/screens/roster.rs index 0da3de2..e9455df 100644 --- a/rust/src/screens/roster.rs +++ b/rust/src/screens/roster.rs @@ -12,6 +12,7 @@ use tokio::sync::mpsc; use crate::app::{AppMessage, NotifyLevel}; use crate::config::Settings; use crate::db::models::Player; +use crate::widgets::{format_rating, format_swar}; // ============================================================================= // Types @@ -116,12 +117,10 @@ impl RosterState { tokio::spawn(async move { match crate::db::queries::get_my_roster(&pool, &abbrev, season).await { Ok(roster) => { - let mut swar_total = 0.0; - - let majors = build_roster_rows(&pool, &roster.majors, &mut swar_total).await; - let mut _unused = 0.0; - let minors = build_roster_rows(&pool, &roster.minors, &mut _unused).await; - let il = build_roster_rows(&pool, &roster.il, &mut _unused).await; + let majors = build_roster_rows(&pool, &roster.majors).await; + let minors = build_roster_rows(&pool, &roster.minors).await; + let il = build_roster_rows(&pool, &roster.il).await; + let swar_total = majors.iter().filter_map(|r| r.player.swar).sum(); let _ = tx.send(AppMessage::RosterPlayersLoaded { majors, @@ -253,35 +252,28 @@ impl RosterState { } fn render_tabs(&self, frame: &mut Frame, area: Rect) { - let tabs = [ - (RosterTab::Majors, "1", "Majors", self.majors.len(), self.major_slots), - (RosterTab::Minors, "2", "Minors", self.minors.len(), self.minor_slots), - ]; - - let mut spans: Vec = Vec::new(); - for (tab, key, label, count, slots) in &tabs { - let is_active = self.active_tab == *tab; - let style = if is_active { + let tab_style = |tab: RosterTab| { + if self.active_tab == tab { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) - }; - spans.push(Span::styled( - format!(" [{}] {} ({}/{})", key, label, count, slots), - style, - )); - } - // IL tab (no slot limit) - let il_active = self.active_tab == RosterTab::IL; - let il_style = if il_active { - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) + } }; - spans.push(Span::styled( - format!(" [3] IL ({})", self.il.len()), - il_style, - )); + + let spans = vec![ + Span::styled( + format!(" [1] Majors ({}/{})", self.majors.len(), self.major_slots), + tab_style(RosterTab::Majors), + ), + Span::styled( + format!(" [2] Minors ({}/{})", self.minors.len(), self.minor_slots), + tab_style(RosterTab::Minors), + ), + Span::styled( + format!(" [3] IL ({})", self.il.len()), + tab_style(RosterTab::IL), + ), + ]; frame.render_widget(Paragraph::new(Line::from(spans)), area); } @@ -304,8 +296,8 @@ impl RosterState { } // Split into batters and pitchers (rows are pre-sorted: batters first) - let batters: Vec<&RosterRow> = rows_data.iter().filter(|r| r.player.is_batter()).collect(); - let pitchers: Vec<&RosterRow> = rows_data.iter().filter(|r| r.player.is_pitcher()).collect(); + let (batters, pitchers): (Vec<&RosterRow>, Vec<&RosterRow>) = + rows_data.iter().partition(|r| r.player.is_batter()); // Map combined selection index to the correct sub-table let selected = self.table_state.selected(); @@ -458,17 +450,9 @@ impl RosterState { // Helpers // ============================================================================= -async fn build_roster_rows( - pool: &SqlitePool, - players: &[Player], - swar_total: &mut f64, -) -> Vec { +async fn build_roster_rows(pool: &SqlitePool, players: &[Player]) -> Vec { let mut rows = Vec::with_capacity(players.len()); for player in players { - if let Some(swar) = player.swar { - *swar_total += swar; - } - let (rating_vl, rating_vr, rating_overall, end_s, end_r, end_c) = if player.is_pitcher() { match crate::db::queries::get_pitcher_card(pool, player.id).await { Ok(Some(card)) => ( @@ -501,30 +485,9 @@ async fn build_roster_rows( rows } -fn format_rating(val: Option) -> String { - match val { - Some(v) => { - let rounded = v.round() as i64; - if rounded > 0 { - format!("+{}", rounded) - } else { - format!("{}", rounded) - } - } - None => "—".to_string(), - } -} - -fn format_swar(val: Option) -> String { - match val { - Some(v) => format!("{:.2}", v), - None => "—".to_string(), - } -} - fn format_endurance(val: Option) -> String { match val { - Some(v) => format!("{}", v), + Some(v) => v.to_string(), None => "—".to_string(), } } diff --git a/rust/src/screens/settings.rs b/rust/src/screens/settings.rs index c02faf4..848c0ca 100644 --- a/rust/src/screens/settings.rs +++ b/rust/src/screens/settings.rs @@ -105,27 +105,32 @@ struct SettingsSnapshot { impl SettingsState { pub fn new(settings: &Settings) -> Self { - let snapshot = SettingsSnapshot { - abbrev: settings.team.abbrev.clone(), - season_str: settings.team.season.to_string(), - major_slots_str: settings.team.major_league_slots.to_string(), - minor_slots_str: settings.team.minor_league_slots.to_string(), - base_url: settings.api.base_url.clone(), - api_key: settings.api.api_key.clone(), + let abbrev = settings.team.abbrev.clone(); + let season_str = settings.team.season.to_string(); + let major_slots_str = settings.team.major_league_slots.to_string(); + let minor_slots_str = settings.team.minor_league_slots.to_string(); + let base_url = settings.api.base_url.clone(); + let api_key = settings.api.api_key.clone(); + let original = SettingsSnapshot { + abbrev: abbrev.clone(), + season_str: season_str.clone(), + major_slots_str: major_slots_str.clone(), + minor_slots_str: minor_slots_str.clone(), + base_url: base_url.clone(), + api_key: api_key.clone(), }; - Self { - abbrev: snapshot.abbrev.clone(), - season_str: snapshot.season_str.clone(), - major_slots_str: snapshot.major_slots_str.clone(), - minor_slots_str: snapshot.minor_slots_str.clone(), - base_url: snapshot.base_url.clone(), - api_key: snapshot.api_key.clone(), + abbrev, + season_str, + major_slots_str, + minor_slots_str, + base_url, + api_key, api_key_visible: false, team_info: None, focus: SettingsFocus::TeamAbbrev, has_unsaved_changes: false, - original: snapshot, + original, } } @@ -133,6 +138,17 @@ impl SettingsState { self.focus.is_text_input() } + fn current_snapshot(&self) -> SettingsSnapshot { + SettingsSnapshot { + abbrev: self.abbrev.clone(), + season_str: self.season_str.clone(), + major_slots_str: self.major_slots_str.clone(), + minor_slots_str: self.minor_slots_str.clone(), + base_url: self.base_url.clone(), + api_key: self.api_key.clone(), + } + } + pub fn handle_key( &mut self, key: KeyEvent, @@ -244,17 +260,18 @@ impl SettingsState { SettingsFocus::MinorSlots => { self.minor_slots_str.pop(); } SettingsFocus::ApiUrl => { self.base_url.pop(); } SettingsFocus::ApiKey => { self.api_key.pop(); } - _ => {} + SettingsFocus::SaveButton | SettingsFocus::ResetButton => {} } } fn check_unsaved(&mut self) { - self.has_unsaved_changes = self.abbrev != self.original.abbrev - || self.season_str != self.original.season_str - || self.major_slots_str != self.original.major_slots_str - || self.minor_slots_str != self.original.minor_slots_str - || self.base_url != self.original.base_url - || self.api_key != self.original.api_key; + let snap = self.current_snapshot(); + self.has_unsaved_changes = snap.abbrev != self.original.abbrev + || snap.season_str != self.original.season_str + || snap.major_slots_str != self.original.major_slots_str + || snap.minor_slots_str != self.original.minor_slots_str + || snap.base_url != self.original.base_url + || snap.api_key != self.original.api_key; } fn validate_team(&self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { @@ -267,25 +284,19 @@ impl SettingsState { let pool = pool.clone(); let tx = tx.clone(); tokio::spawn(async move { - match crate::db::queries::get_team_by_abbrev(&pool, &abbrev, season).await { - Ok(Some(team)) => { + let result = crate::db::queries::get_team_by_abbrev(&pool, &abbrev, season) + .await + .ok() + .flatten() + .map(|team| { let name = if team.long_name.is_empty() { team.short_name } else { team.long_name }; - let _ = tx.send(AppMessage::SettingsTeamValidated(Some(( - name, - team.salary_cap, - )))); - } - Ok(None) => { - let _ = tx.send(AppMessage::SettingsTeamValidated(None)); - } - Err(_) => { - let _ = tx.send(AppMessage::SettingsTeamValidated(None)); - } - } + (name, team.salary_cap) + }); + let _ = tx.send(AppMessage::SettingsTeamValidated(result)); }); } @@ -324,14 +335,7 @@ impl SettingsState { Ok(toml_str) => match std::fs::write("settings.toml", &toml_str) { Ok(_) => { self.has_unsaved_changes = false; - self.original = SettingsSnapshot { - abbrev: self.abbrev.clone(), - season_str: self.season_str.clone(), - major_slots_str: self.major_slots_str.clone(), - minor_slots_str: self.minor_slots_str.clone(), - base_url: self.base_url.clone(), - api_key: self.api_key.clone(), - }; + self.original = self.current_snapshot(); let _ = tx.send(AppMessage::Notify( "Settings saved to settings.toml (restart to apply)".to_string(), NotifyLevel::Success, @@ -366,17 +370,14 @@ impl SettingsState { } pub fn handle_message(&mut self, msg: AppMessage) { - match msg { - AppMessage::SettingsTeamValidated(result) => { - self.team_info = match result { - Some((name, cap)) => { - let cap_str = cap.map(|c| format!(" (Cap: {:.2})", c)).unwrap_or_default(); - Some(format!("{}{}", name, cap_str)) - } - None => Some("Team not found".to_string()), - }; - } - _ => {} + if let AppMessage::SettingsTeamValidated(result) = msg { + self.team_info = match result { + Some((name, cap)) => { + let cap_str = cap.map(|c| format!(" (Cap: {:.2})", c)).unwrap_or_default(); + Some(format!("{}{}", name, cap_str)) + } + None => Some("Team not found".to_string()), + }; } } diff --git a/rust/src/screens/standings.rs b/rust/src/screens/standings.rs index ff118c7..e7b1025 100644 --- a/rust/src/screens/standings.rs +++ b/rust/src/screens/standings.rs @@ -6,12 +6,13 @@ use ratatui::{ widgets::{Cell, Paragraph, Row, Table}, Frame, }; +use sqlx::sqlite::SqlitePool; use tokio::sync::mpsc; use crate::api::types::StandingsEntry; use crate::app::{AppMessage, NotifyLevel}; use crate::config::Settings; -use sqlx::sqlite::SqlitePool; +use crate::screens::format_relative_time; // ============================================================================= // Types @@ -131,7 +132,6 @@ impl StandingsState { match client.get_standings(season).await { Ok(entries) => { - // Cache to DB if let Ok(json) = serde_json::to_string(&entries) { let _ = crate::db::queries::upsert_standings_cache( &pool, season, &json, @@ -146,7 +146,6 @@ impl StandingsState { format!("Standings refresh failed: {e}"), NotifyLevel::Error, )); - // Send empty refresh to clear the refreshing indicator let _ = tx.send(AppMessage::StandingsRefreshed(Vec::new())); } } @@ -191,7 +190,6 @@ impl StandingsState { self.process_standings(&entries); self.last_updated = fetched_at; } - // Only clear loading if we got data; otherwise wait for API if !self.divisions.is_empty() { self.is_loading = false; } @@ -209,9 +207,10 @@ impl StandingsState { } fn process_standings(&mut self, entries: &[StandingsEntry]) { - let rows: Vec = entries.iter().map(|e| self.entry_to_row(e)).collect(); + let team_abbrev = &self.team_abbrev; + let rows: Vec = + entries.iter().map(|e| entry_to_row(e, team_abbrev)).collect(); - // Group by division let mut div_map: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for row in &rows { @@ -227,85 +226,28 @@ impl StandingsState { .into_iter() .map(|(name, mut teams)| { teams.sort_by(|a, b| { - b.pct - .partial_cmp(&a.pct) - .unwrap_or(std::cmp::Ordering::Equal) + b.pct.partial_cmp(&a.pct).unwrap_or(std::cmp::Ordering::Equal) }); DivisionGroup { name, teams } }) .collect(); // Wildcard: non-division-leaders sorted by record - let mut div_leaders: std::collections::HashSet = std::collections::HashSet::new(); - for div in &self.divisions { - if let Some(leader) = div.teams.first() { - div_leaders.insert(leader.abbrev.clone()); - } - } + let div_leaders: std::collections::HashSet<&str> = self + .divisions + .iter() + .filter_map(|d| d.teams.first()) + .map(|t| t.abbrev.as_str()) + .collect(); + let mut wc: Vec = rows .into_iter() - .filter(|r| !div_leaders.contains(&r.abbrev)) + .filter(|r| !div_leaders.contains(r.abbrev.as_str())) .collect(); - wc.sort_by(|a, b| { - b.pct - .partial_cmp(&a.pct) - .unwrap_or(std::cmp::Ordering::Equal) - }); + wc.sort_by(|a, b| b.pct.partial_cmp(&a.pct).unwrap_or(std::cmp::Ordering::Equal)); self.wildcard = wc; } - fn entry_to_row(&self, e: &StandingsEntry) -> StandingsRow { - let abbrev = e.team.abbrev.clone().unwrap_or_default(); - let team_name = e - .team - .short_name - .clone() - .unwrap_or_else(|| abbrev.clone()); - let total = e.wins + e.losses; - let pct = if total > 0 { - e.wins as f64 / total as f64 - } else { - 0.0 - }; - let gb = match e.div_gb { - Some(0.0) => "—".to_string(), - Some(gb) => { - if gb == gb.floor() { - format!("{:.0}", gb) - } else { - format!("{:.1}", gb) - } - } - None => "—".to_string(), - }; - let streak = match &e.streak_wl { - Some(wl) => format!("{}{}", wl.to_uppercase(), e.streak_num), - None => "—".to_string(), - }; - let div_name = e - .team - .division - .as_ref() - .and_then(|d| d.division_name.clone()) - .unwrap_or_default(); - - StandingsRow { - is_my_team: abbrev == self.team_abbrev, - abbrev, - team_name, - wins: e.wins, - losses: e.losses, - pct, - gb, - run_diff: e.run_diff, - home: format!("{}-{}", e.home_wins, e.home_losses), - away: format!("{}-{}", e.away_wins, e.away_losses), - last8: format!("{}-{}", e.last8_wins, e.last8_losses), - streak, - division_name: div_name, - } - } - fn active_row_count(&self) -> usize { match self.active_tab { StandingsTab::Division => self.divisions.iter().map(|d| d.teams.len()).sum(), @@ -319,10 +261,10 @@ impl StandingsState { pub fn render(&self, frame: &mut Frame, area: Rect, tick_count: u64) { let chunks = Layout::vertical([ - Constraint::Length(1), // title + status - Constraint::Length(1), // tabs - Constraint::Min(0), // content - Constraint::Length(1), // hints + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), ]) .split(area); @@ -351,7 +293,6 @@ impl StandingsState { .add_modifier(Modifier::BOLD), )]; - // Last updated if let Some(ts) = &self.last_updated { spans.push(Span::styled( format!(" (updated {})", format_relative_time(ts)), @@ -359,7 +300,6 @@ impl StandingsState { )); } - // Refreshing indicator if self.is_refreshing { let spinner = ['|', '/', '-', '\\'][(tick_count as usize / 2) % 4]; spans.push(Span::styled( @@ -403,34 +343,19 @@ impl StandingsState { return; } - // Build flat list of rows with division headers let mut all_rows: Vec = Vec::new(); let mut team_index = 0usize; for div in &self.divisions { - // Division header row - let header_cells = vec![ - Cell::from(Span::styled( - format!(" {}", div.name), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )), - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(""), - ]; - all_rows.push(Row::new(header_cells)); + all_rows.push(Row::new(vec![Cell::from(Span::styled( + format!(" {}", div.name), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))])); for team in &div.teams { - let is_selected = self.scroll_offset == team_index; - all_rows.push(build_standings_row(team, is_selected)); + all_rows.push(build_standings_row(team, self.scroll_offset == team_index)); team_index += 1; } } @@ -469,52 +394,94 @@ impl StandingsState { // Helpers // ============================================================================= +fn entry_to_row(e: &StandingsEntry, team_abbrev: &str) -> StandingsRow { + let abbrev = e.team.abbrev.clone().unwrap_or_default(); + let team_name = e + .team + .short_name + .clone() + .unwrap_or_else(|| abbrev.clone()); + let total = e.wins + e.losses; + let pct = if total > 0 { + e.wins as f64 / total as f64 + } else { + 0.0 + }; + let gb = match e.div_gb { + Some(0.0) | None => "—".to_string(), + Some(gb) => { + if gb == gb.floor() { + format!("{:.0}", gb) + } else { + format!("{:.1}", gb) + } + } + }; + let streak = match &e.streak_wl { + Some(wl) => format!("{}{}", wl.to_uppercase(), e.streak_num), + None => "—".to_string(), + }; + let division_name = e + .team + .division + .as_ref() + .and_then(|d| d.division_name.clone()) + .unwrap_or_default(); + + StandingsRow { + is_my_team: abbrev == team_abbrev, + abbrev, + team_name, + wins: e.wins, + losses: e.losses, + pct, + gb, + run_diff: e.run_diff, + home: format!("{}-{}", e.home_wins, e.home_losses), + away: format!("{}-{}", e.away_wins, e.away_losses), + last8: format!("{}-{}", e.last8_wins, e.last8_losses), + streak, + division_name, + } +} + fn build_standings_row(team: &StandingsRow, is_selected: bool) -> Row<'static> { - let base_style = if team.is_my_team { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else if is_selected { - Style::default().bg(Color::DarkGray) - } else { - Style::default() + let row_style = { + let mut s = Style::default(); + if team.is_my_team { + s = s.fg(Color::Yellow).add_modifier(Modifier::BOLD); + } + if is_selected { + s = s.bg(Color::DarkGray); + } + s }; - let combined_style = if team.is_my_team && is_selected { - base_style.bg(Color::DarkGray) - } else { - base_style - }; - - let diff_str = if team.run_diff > 0 { - format!("+{}", team.run_diff) - } else { - format!("{}", team.run_diff) - }; + let diff_str = format!("{:+}", team.run_diff); let diff_style = if team.run_diff > 0 { - combined_style.fg(if team.is_my_team { + row_style.fg(if team.is_my_team { Color::Yellow } else { Color::Green }) } else if team.run_diff < 0 { - combined_style.fg(Color::Red) + row_style.fg(Color::Red) } else { - combined_style + row_style }; Row::new(vec![ - Cell::from(format!(" {} {}", team.abbrev, team.team_name)).style(combined_style), - Cell::from(format!("{:>2}", team.wins)).style(combined_style), - Cell::from(format!("{:>2}", team.losses)).style(combined_style), - Cell::from(format!("{:>5.3}", team.pct)).style(combined_style), - Cell::from(format!("{:>5}", team.gb)).style(combined_style), + Cell::from(format!(" {} {}", team.abbrev, team.team_name)).style(row_style), + Cell::from(format!("{:>2}", team.wins)).style(row_style), + Cell::from(format!("{:>2}", team.losses)).style(row_style), + Cell::from(format!("{:>5.3}", team.pct)).style(row_style), + Cell::from(format!("{:>5}", team.gb)).style(row_style), Cell::from(format!("{:>5}", diff_str)).style(diff_style), - Cell::from(format!("{:>5}", team.home)).style(combined_style), - Cell::from(format!("{:>5}", team.away)).style(combined_style), - Cell::from(format!("{:>4}", team.last8)).style(combined_style), - Cell::from(format!("{:>4}", team.streak)).style(combined_style), + Cell::from(format!("{:>5}", team.home)).style(row_style), + Cell::from(format!("{:>5}", team.away)).style(row_style), + Cell::from(format!("{:>4}", team.last8)).style(row_style), + Cell::from(format!("{:>4}", team.streak)).style(row_style), ]) } @@ -540,34 +507,15 @@ fn standings_header() -> Row<'static> { fn standings_widths() -> [Constraint; 10] { [ - Constraint::Length(24), // Team - Constraint::Length(3), // W - Constraint::Length(3), // L - Constraint::Length(6), // Pct - Constraint::Length(6), // GB - Constraint::Length(6), // Diff - Constraint::Length(6), // Home - Constraint::Length(6), // Away - Constraint::Length(5), // L8 - Constraint::Length(5), // Streak + Constraint::Length(24), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(6), + Constraint::Length(6), + Constraint::Length(6), + Constraint::Length(6), + Constraint::Length(6), + Constraint::Length(5), + Constraint::Length(5), ] } - -fn format_relative_time(dt: &chrono::NaiveDateTime) -> String { - let now = chrono::Utc::now().naive_utc(); - let diff = now.signed_duration_since(*dt); - let secs = diff.num_seconds(); - if secs < 60 { - return "just now".to_string(); - } - let mins = secs / 60; - if mins < 60 { - return format!("{}m ago", mins); - } - let hours = mins / 60; - if hours < 24 { - return format!("{}h ago", hours); - } - let days = hours / 24; - format!("{}d ago", days) -} diff --git a/rust/src/widgets/mod.rs b/rust/src/widgets/mod.rs index 199a414..2b47fb9 100644 --- a/rust/src/widgets/mod.rs +++ b/rust/src/widgets/mod.rs @@ -1 +1,22 @@ pub mod selector; + +pub fn format_rating(val: Option) -> String { + match val { + Some(v) => { + let rounded = v.round() as i64; + if rounded > 0 { + format!("+{rounded}") + } else { + rounded.to_string() + } + } + None => "\u{2014}".to_string(), + } +} + +pub fn format_swar(val: Option) -> String { + match val { + Some(v) => format!("{v:.2}"), + None => "\u{2014}".to_string(), + } +} diff --git a/rust/tests/db_queries.rs b/rust/tests/db_queries.rs index d077707..25d6fbf 100644 --- a/rust/tests/db_queries.rs +++ b/rust/tests/db_queries.rs @@ -196,9 +196,7 @@ async fn get_players_missing_batter_cards() { .await .unwrap(); - let missing = queries::get_players_missing_cards(&pool, 13, "batter") - .await - .unwrap(); + let missing = queries::get_batters_missing_cards(&pool, 13).await.unwrap(); assert_eq!(missing.len(), 1); assert_eq!(missing[0].name, "Mookie Betts"); }