Simplify and deduplicate codebase (-261 lines)

Consolidate shared helpers (format_rating, format_swar, tier_style,
format_relative_time) into widgets/mod.rs and screens/mod.rs. Replace
heap allocations with stack arrays and HashSets, parallelize DB queries
with tokio::try_join, wrap schema init in transactions, use OnceLock for
invariant hashes, and fix clippy warnings. Auto-sync on dashboard mount
when last sync >24h ago. All 105 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-01 20:07:23 -06:00
parent defe741aba
commit c5e1fb44a6
24 changed files with 826 additions and 1087 deletions

View File

@ -1,12 +1,14 @@
{ {
"hooks": { "hooks": {
"PostToolCall": [ "PostToolUse": [
{ {
"matcher": { "matcher": "Edit|Write",
"tool_name": "Edit|Write", "hooks": [
"file_glob": "**/*.rs" {
}, "type": "command",
"command": "cd /mnt/NV2/Development/sba-scouting/rust && cargo check --message-format=short 2>&1 | grep '^src/' | head -20" "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"
}
]
} }
] ]
} }

View File

@ -26,6 +26,8 @@ pub struct LeagueApiClient {
api_key: String, api_key: String,
} }
type Params = Vec<(&'static str, String)>;
impl LeagueApiClient { impl LeagueApiClient {
pub fn new(base_url: &str, api_key: &str, timeout_secs: u64) -> Result<Self> { pub fn new(base_url: &str, api_key: &str, timeout_secs: u64) -> Result<Self> {
let client = Client::builder() let client = Client::builder()
@ -39,11 +41,7 @@ impl LeagueApiClient {
}) })
} }
async fn get<T: DeserializeOwned>( async fn get<T: DeserializeOwned>(&self, path: &str, params: &Params) -> Result<T, ApiError> {
&self,
path: &str,
params: &[(String, String)],
) -> Result<T, ApiError> {
let url = format!("{}/api/v3{}", self.base_url, path); let url = format!("{}/api/v3{}", self.base_url, path);
let mut req = self.client.get(&url).query(params); let mut req = self.client.get(&url).query(params);
if !self.api_key.is_empty() { if !self.api_key.is_empty() {
@ -58,15 +56,9 @@ impl LeagueApiClient {
} }
return Err(ApiError::Http { status: status.as_u16(), message: body }); return Err(ApiError::Http { status: status.as_u16(), message: body });
} }
let body = response.text().await.map_err(ApiError::Request)?; response.json::<T>().await.map_err(ApiError::Request)
let json = serde_json::from_str::<T>(&body)?;
Ok(json)
} }
// -------------------------------------------------------------------------
// Public endpoint methods
// -------------------------------------------------------------------------
pub async fn get_teams( pub async fn get_teams(
&self, &self,
season: Option<i64>, season: Option<i64>,
@ -74,26 +66,26 @@ impl LeagueApiClient {
active_only: bool, active_only: bool,
short_output: bool, short_output: bool,
) -> Result<TeamsResponse, ApiError> { ) -> Result<TeamsResponse, ApiError> {
let mut params: Vec<(String, String)> = Vec::new(); let mut params: Params = Vec::new();
if let Some(s) = season { 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 { if let Some(abbrevs) = team_abbrev {
for a in abbrevs { for a in abbrevs {
params.push(("team_abbrev".to_string(), a.to_string())); params.push(("team_abbrev", a.to_string()));
} }
} }
if active_only { if active_only {
params.push(("active_only".to_string(), "true".to_string())); params.push(("active_only", "true".to_string()));
} }
if short_output { if short_output {
params.push(("short_output".to_string(), "true".to_string())); params.push(("short_output", "true".to_string()));
} }
self.get("/teams", &params).await self.get("/teams", &params).await
} }
pub async fn get_team(&self, team_id: i64) -> Result<TeamData, ApiError> { pub async fn get_team(&self, team_id: i64) -> Result<TeamData, ApiError> {
self.get(&format!("/teams/{}", team_id), &[]).await self.get(&format!("/teams/{team_id}"), &vec![]).await
} }
pub async fn get_team_roster( pub async fn get_team_roster(
@ -101,7 +93,7 @@ impl LeagueApiClient {
team_id: i64, team_id: i64,
which: &str, which: &str,
) -> Result<serde_json::Value, ApiError> { ) -> Result<serde_json::Value, ApiError> {
self.get(&format!("/teams/{}/roster/{}", team_id, which), &[]).await self.get(&format!("/teams/{team_id}/roster/{which}"), &vec![]).await
} }
pub async fn get_players( pub async fn get_players(
@ -112,25 +104,25 @@ impl LeagueApiClient {
name: Option<&str>, name: Option<&str>,
short_output: bool, short_output: bool,
) -> Result<PlayersResponse, ApiError> { ) -> Result<PlayersResponse, ApiError> {
let mut params: Vec<(String, String)> = Vec::new(); let mut params: Params = Vec::new();
if let Some(s) = season { 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 { if let Some(ids) = team_id {
for id in ids { 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 { if let Some(positions) = pos {
for p in positions { for p in positions {
params.push(("pos".to_string(), p.to_string())); params.push(("pos", p.to_string()));
} }
} }
if let Some(n) = name { if let Some(n) = name {
params.push(("name".to_string(), n.to_string())); params.push(("name", n.to_string()));
} }
if short_output { if short_output {
params.push(("short_output".to_string(), "true".to_string())); params.push(("short_output", "true".to_string()));
} }
self.get("/players", &params).await self.get("/players", &params).await
} }
@ -140,11 +132,11 @@ impl LeagueApiClient {
player_id: i64, player_id: i64,
short_output: bool, short_output: bool,
) -> Result<PlayerData, ApiError> { ) -> Result<PlayerData, ApiError> {
let mut params: Vec<(String, String)> = Vec::new(); let mut params: Params = Vec::new();
if short_output { 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), &params).await self.get(&format!("/players/{player_id}"), &params).await
} }
pub async fn search_players( pub async fn search_players(
@ -153,13 +145,12 @@ impl LeagueApiClient {
season: Option<i64>, season: Option<i64>,
limit: Option<i64>, limit: Option<i64>,
) -> Result<PlayersResponse, ApiError> { ) -> Result<PlayersResponse, ApiError> {
let mut params: Vec<(String, String)> = Vec::new(); let mut params: Params = vec![("q", query.to_string())];
params.push(("q".to_string(), query.to_string()));
if let Some(s) = season { if let Some(s) = season {
params.push(("season".to_string(), s.to_string())); params.push(("season", s.to_string()));
} }
if let Some(l) = limit { if let Some(l) = limit {
params.push(("limit".to_string(), l.to_string())); params.push(("limit", l.to_string()));
} }
self.get("/players/search", &params).await self.get("/players/search", &params).await
} }
@ -174,31 +165,32 @@ impl LeagueApiClient {
frozen: bool, frozen: bool,
short_output: bool, short_output: bool,
) -> Result<TransactionsResponse, ApiError> { ) -> Result<TransactionsResponse, ApiError> {
let mut params: Vec<(String, String)> = Vec::new(); let mut params: Params = vec![
params.push(("season".to_string(), season.to_string())); ("season", season.to_string()),
params.push(("week_start".to_string(), week_start.to_string())); ("week_start", week_start.to_string()),
];
if let Some(abbrevs) = team_abbrev { if let Some(abbrevs) = team_abbrev {
for a in abbrevs { 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 { if let Some(we) = week_end {
params.push(("week_end".to_string(), we.to_string())); params.push(("week_end", we.to_string()));
} }
if cancelled { if cancelled {
params.push(("cancelled".to_string(), "true".to_string())); params.push(("cancelled", "true".to_string()));
} }
if frozen { if frozen {
params.push(("frozen".to_string(), "true".to_string())); params.push(("frozen", "true".to_string()));
} }
if short_output { if short_output {
params.push(("short_output".to_string(), "true".to_string())); params.push(("short_output", "true".to_string()));
} }
self.get("/transactions", &params).await self.get("/transactions", &params).await
} }
pub async fn get_current(&self) -> Result<CurrentResponse, ApiError> { pub async fn get_current(&self) -> Result<CurrentResponse, ApiError> {
self.get("/current", &[]).await self.get("/current", &vec![]).await
} }
pub async fn get_schedule( pub async fn get_schedule(
@ -207,19 +199,18 @@ impl LeagueApiClient {
week: Option<i64>, week: Option<i64>,
team_id: Option<i64>, team_id: Option<i64>,
) -> Result<serde_json::Value, ApiError> { ) -> Result<serde_json::Value, ApiError> {
let mut params: Vec<(String, String)> = Vec::new(); let mut params: Params = vec![("season", season.to_string())];
params.push(("season".to_string(), season.to_string()));
if let Some(w) = week { 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 { 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", &params).await self.get("/schedules", &params).await
} }
pub async fn get_standings(&self, season: i64) -> Result<Vec<StandingsEntry>, ApiError> { pub async fn get_standings(&self, season: i64) -> Result<Vec<StandingsEntry>, ApiError> {
let params = vec![("season".to_string(), season.to_string())]; let params: Params = vec![("season", season.to_string())];
let resp: StandingsResponse = self.get("/standings", &params).await?; let resp: StandingsResponse = self.get("/standings", &params).await?;
Ok(resp.standings) Ok(resp.standings)
} }

View File

@ -69,6 +69,27 @@ pub fn parse_int(value: &str, default: i32) -> i32 {
} }
} }
fn opt_str(v: &str) -> Option<String> {
let t = v.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
}
fn opt_float(v: &str) -> Option<f64> {
if v.trim().is_empty() { None } else { Some(parse_float(v, 0.0)) }
}
fn opt_int(v: &str) -> Option<i64> {
if v.trim().is_empty() { None } else { Some(parse_int(v, 0) as i64) }
}
fn open_csv(path: &Path) -> Result<(csv::Reader<std::fs::File>, HashMap<String, usize>, 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)"`. /// 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. /// Returns `(start, relief, close)` — any component may be `None` if absent.
@ -117,20 +138,7 @@ pub async fn import_batter_cards(
update_existing: bool, update_existing: bool,
) -> Result<ImportResult> { ) -> Result<ImportResult> {
let mut result = ImportResult::default(); let mut result = ImportResult::default();
let (mut rdr, header_index, source) = open_csv(csv_path)?;
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<String, usize> =
headers.iter().enumerate().map(|(i, h)| (h.to_string(), i)).collect();
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
for record_result in rdr.records() { 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) Some(parse_int(catcher_arm_str, 0) as i64)
}; };
// Helpers for optional DB fields.
let opt_str = |v: &str| -> Option<String> {
let t = v.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
};
let opt_float = |v: &str| -> Option<f64> {
if v.trim().is_empty() { None } else { Some(parse_float(v, 0.0)) }
};
sqlx::query( sqlx::query(
"INSERT OR REPLACE INTO batter_cards ( "INSERT OR REPLACE INTO batter_cards (
player_id, player_id,
@ -308,19 +307,7 @@ pub async fn import_pitcher_cards(
update_existing: bool, update_existing: bool,
) -> Result<ImportResult> { ) -> Result<ImportResult> {
let mut result = ImportResult::default(); let mut result = ImportResult::default();
let (mut rdr, header_index, source) = open_csv(csv_path)?;
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<String, usize> =
headers.iter().enumerate().map(|(i, h)| (h.to_string(), i)).collect();
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
for record_result in rdr.records() { 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 (mut endur_start, mut endur_relief, mut endur_close) = parse_endurance(endur_raw);
let sp_raw = get("SP").trim(); for (col, slot) in [("SP", &mut endur_start), ("RP", &mut endur_relief), ("CP", &mut endur_close)] {
if !sp_raw.is_empty() { let v = get(col).trim();
endur_start = Some(parse_int(sp_raw, 0)); 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<String> {
let t = v.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
};
let opt_float = |v: &str| -> Option<f64> {
if v.trim().is_empty() { None } else { Some(parse_float(v, 0.0)) }
};
let opt_int = |v: &str| -> Option<i64> {
if v.trim().is_empty() { None } else { Some(parse_int(v, 0) as i64) }
};
sqlx::query( sqlx::query(
"INSERT OR REPLACE INTO pitcher_cards ( "INSERT OR REPLACE INTO pitcher_cards (

View File

@ -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 response = client.get_teams(Some(season), None, false, false).await?;
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
let mut count: i64 = 0;
let synced_at = chrono::Utc::now().naive_utc(); let synced_at = chrono::Utc::now().naive_utc();
let count = response.teams.len() as i64;
for data in response.teams { for data in response.teams {
let manager1_name = data.manager1.and_then(|m| m.name); let manager1_name = data.manager1.and_then(|m| m.name);
let manager2_name = data.manager2.and_then(|m| m.name); let manager2_name = data.manager2.and_then(|m| m.name);
let gm_discord_id = data.gm_discord_id; let division_id = data.division.as_ref().and_then(|d| d.id);
let gm2_discord_id = data.gm2_discord_id;
let division_id = data.division.as_ref().map(|d| d.id).flatten();
let division_name = data.division.as_ref().and_then(|d| d.division_name.clone()); 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( sqlx::query(
"INSERT OR REPLACE INTO teams \ "INSERT OR REPLACE INTO teams \
@ -42,8 +40,8 @@ pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient
.bind(season) .bind(season)
.bind(manager1_name) .bind(manager1_name)
.bind(manager2_name) .bind(manager2_name)
.bind(gm_discord_id) .bind(data.gm_discord_id)
.bind(gm2_discord_id) .bind(data.gm2_discord_id)
.bind(division_id) .bind(division_id)
.bind(division_name) .bind(division_name)
.bind(league_abbrev) .bind(league_abbrev)
@ -55,8 +53,6 @@ pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient
.bind(synced_at) .bind(synced_at)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
count += 1;
} }
tx.commit().await?; tx.commit().await?;
@ -79,8 +75,8 @@ pub async fn sync_players(
.await?; .await?;
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
let mut count: i64 = 0;
let synced_at = chrono::Utc::now().naive_utc(); let synced_at = chrono::Utc::now().naive_utc();
let count = response.players.len() as i64;
for data in response.players { for data in response.players {
let player_team_id = data.team.map(|t| t.id); let player_team_id = data.team.map(|t| t.id);
@ -147,8 +143,6 @@ pub async fn sync_players(
.bind(synced_at) .bind(synced_at)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
count += 1;
} }
tx.commit().await?; tx.commit().await?;
@ -168,9 +162,11 @@ pub async fn sync_transactions(
team_abbrev: Option<&str>, team_abbrev: Option<&str>,
client: &LeagueApiClient, client: &LeagueApiClient,
) -> Result<i64> { ) -> Result<i64> {
let abbrev_vec: Vec<&str> = team_abbrev.into_iter().collect(); let abbrev_arr;
let abbrev_slice: Option<&[&str]> = let abbrev_slice: Option<&[&str]> = match team_abbrev {
if abbrev_vec.is_empty() { None } else { Some(&abbrev_vec) }; Some(s) => { abbrev_arr = [s]; Some(&abbrev_arr) }
None => None,
};
let response = client let response = client
.get_transactions(season, week_start, week_end, abbrev_slice, false, false, false) .get_transactions(season, week_start, week_end, abbrev_slice, false, false, false)

View File

@ -4,15 +4,9 @@ use serde::{Deserialize, Serialize};
// Shared nested types // Shared nested types
// ============================================================================= // =============================================================================
/// Minimal team reference used inside player/transaction responses. /// Minimal id-only reference used inside player/transaction/team responses.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct TeamRef { pub struct IdRef {
pub id: i64,
}
/// Minimal player reference used inside transaction responses.
#[derive(Debug, Deserialize)]
pub struct PlayerRef {
pub id: i64, pub id: i64,
} }
@ -43,33 +37,24 @@ pub struct TeamsResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct TeamData { pub struct TeamData {
pub id: i64, pub id: i64,
#[serde(default)]
pub abbrev: Option<String>, pub abbrev: Option<String>,
#[serde(rename = "sname", default)] #[serde(rename = "sname")]
pub short_name: Option<String>, pub short_name: Option<String>,
#[serde(rename = "lname", default)] #[serde(rename = "lname")]
pub long_name: Option<String>, pub long_name: Option<String>,
#[serde(default)]
pub thumbnail: Option<String>, pub thumbnail: Option<String>,
#[serde(default)]
pub color: Option<String>, pub color: Option<String>,
#[serde(default)]
pub dice_color: Option<String>, pub dice_color: Option<String>,
#[serde(default)]
pub stadium: Option<String>, pub stadium: Option<String>,
#[serde(default)]
pub salary_cap: Option<f64>, pub salary_cap: Option<f64>,
/// Discord user ID of the primary GM (API sends as string). /// Discord user ID of the primary GM (API sends as string).
#[serde(rename = "gmid", default)] #[serde(rename = "gmid")]
pub gm_discord_id: Option<String>, pub gm_discord_id: Option<String>,
/// Discord user ID of the secondary GM (API sends as string). /// Discord user ID of the secondary GM (API sends as string).
#[serde(rename = "gmid2", default)] #[serde(rename = "gmid2")]
pub gm2_discord_id: Option<String>, pub gm2_discord_id: Option<String>,
#[serde(default)]
pub manager1: Option<Manager>, pub manager1: Option<Manager>,
#[serde(default)]
pub manager2: Option<Manager>, pub manager2: Option<Manager>,
#[serde(default)]
pub division: Option<Division>, pub division: Option<Division>,
} }
@ -90,48 +75,32 @@ pub struct PlayerData {
pub headshot: Option<String>, pub headshot: Option<String>,
pub vanity_card: Option<String>, pub vanity_card: Option<String>,
/// Strat-O-Matic WAR equivalent — API field is "wara". /// Strat-O-Matic WAR equivalent — API field is "wara".
#[serde(rename = "wara", default)] #[serde(rename = "wara")]
pub swar: Option<f64>, pub swar: Option<f64>,
/// SBA player ID — API field is "sbaplayer". /// SBA player ID — API field is "sbaplayer".
#[serde(rename = "sbaplayer", default)] #[serde(rename = "sbaplayer")]
pub sbaplayer_id: Option<i64>, pub sbaplayer_id: Option<i64>,
/// Primary card image URL — API field is "image". /// Primary card image URL — API field is "image".
#[serde(rename = "image", default)] #[serde(rename = "image")]
pub card_image: Option<String>, pub card_image: Option<String>,
/// Alternate card image URL — API field is "image2". /// Alternate card image URL — API field is "image2".
#[serde(rename = "image2", default)] #[serde(rename = "image2")]
pub card_image_alt: Option<String>, pub card_image_alt: Option<String>,
#[serde(default)] pub team: Option<IdRef>,
pub team: Option<TeamRef>,
#[serde(default)]
pub pos_1: Option<String>, pub pos_1: Option<String>,
#[serde(default)]
pub pos_2: Option<String>, pub pos_2: Option<String>,
#[serde(default)]
pub pos_3: Option<String>, pub pos_3: Option<String>,
#[serde(default)]
pub pos_4: Option<String>, pub pos_4: Option<String>,
#[serde(default)]
pub pos_5: Option<String>, pub pos_5: Option<String>,
#[serde(default)]
pub pos_6: Option<String>, pub pos_6: Option<String>,
#[serde(default)]
pub pos_7: Option<String>, pub pos_7: Option<String>,
#[serde(default)]
pub pos_8: Option<String>, pub pos_8: Option<String>,
#[serde(default)]
pub injury_rating: Option<String>, pub injury_rating: Option<String>,
#[serde(default)]
pub il_return: Option<String>, pub il_return: Option<String>,
#[serde(default)]
pub demotion_week: Option<i64>, pub demotion_week: Option<i64>,
#[serde(default)]
pub strat_code: Option<String>, pub strat_code: Option<String>,
#[serde(default)]
pub bbref_id: Option<String>, pub bbref_id: Option<String>,
#[serde(default)]
pub last_game: Option<String>, pub last_game: Option<String>,
#[serde(default)]
pub last_game2: Option<String>, pub last_game2: Option<String>,
} }
@ -148,20 +117,14 @@ pub struct TransactionsResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct TransactionData { pub struct TransactionData {
/// Transaction move ID — API field is "moveid" (string like "Season-013-Week-11-1772073335"). /// 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<String>, pub move_id: Option<String>,
#[serde(default)]
pub week: Option<i64>, pub week: Option<i64>,
#[serde(default)]
pub cancelled: Option<bool>, pub cancelled: Option<bool>,
#[serde(default)]
pub frozen: Option<bool>, pub frozen: Option<bool>,
#[serde(default)] pub player: Option<IdRef>,
pub player: Option<PlayerRef>, pub oldteam: Option<IdRef>,
#[serde(default)] pub newteam: Option<IdRef>,
pub oldteam: Option<TeamRef>,
#[serde(default)]
pub newteam: Option<TeamRef>,
} }
// ============================================================================= // =============================================================================
@ -181,22 +144,18 @@ pub struct CurrentResponse {
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct StandingsTeamRef { pub struct StandingsTeamRef {
pub id: i64, pub id: i64,
#[serde(default)]
pub abbrev: Option<String>, pub abbrev: Option<String>,
#[serde(rename = "sname", default)] #[serde(rename = "sname")]
pub short_name: Option<String>, pub short_name: Option<String>,
#[serde(rename = "lname", default)] #[serde(rename = "lname")]
pub long_name: Option<String>, pub long_name: Option<String>,
#[serde(default)]
pub division: Option<StandingsDivision>, pub division: Option<StandingsDivision>,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct StandingsDivision { pub struct StandingsDivision {
pub id: Option<i64>, pub id: Option<i64>,
#[serde(default)]
pub division_name: Option<String>, pub division_name: Option<String>,
#[serde(default)]
pub division_abbrev: Option<String>, pub division_abbrev: Option<String>,
} }
@ -215,9 +174,7 @@ pub struct StandingsEntry {
pub losses: i64, pub losses: i64,
#[serde(default)] #[serde(default)]
pub run_diff: i64, pub run_diff: i64,
#[serde(default)]
pub div_gb: Option<f64>, pub div_gb: Option<f64>,
#[serde(default)]
pub wc_gb: Option<f64>, pub wc_gb: Option<f64>,
#[serde(default)] #[serde(default)]
pub home_wins: i64, pub home_wins: i64,
@ -231,7 +188,6 @@ pub struct StandingsEntry {
pub last8_wins: i64, pub last8_wins: i64,
#[serde(default)] #[serde(default)]
pub last8_losses: i64, pub last8_losses: i64,
#[serde(default)]
pub streak_wl: Option<String>, pub streak_wl: Option<String>,
#[serde(default)] #[serde(default)]
pub streak_num: i64, pub streak_num: i64,

View File

@ -7,7 +7,7 @@ use ratatui::{
Frame, Frame,
}; };
use sqlx::sqlite::SqlitePool; use sqlx::sqlite::SqlitePool;
use std::time::Instant; use std::time::{Duration, Instant};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::api::types::StandingsEntry; use crate::api::types::StandingsEntry;
@ -96,7 +96,7 @@ pub struct Notification {
pub message: String, pub message: String,
pub level: NotifyLevel, pub level: NotifyLevel,
pub created_at: Instant, pub created_at: Instant,
pub duration_ms: u64, pub duration: Duration,
} }
impl Notification { impl Notification {
@ -105,12 +105,12 @@ impl Notification {
message, message,
level, level,
created_at: Instant::now(), created_at: Instant::now(),
duration_ms: 3000, duration: Duration::from_millis(3000),
} }
} }
pub fn is_expired(&self) -> bool { 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) { pub fn on_tick(&mut self) {
self.tick_count = self.tick_count.wrapping_add(1); self.tick_count = self.tick_count.wrapping_add(1);
if let Some(n) = &self.notification { self.notification = self.notification.take().filter(|n| !n.is_expired());
if n.is_expired() {
self.notification = None;
}
}
} }
/// Returns false when a text input or selector popup is active. /// Returns false when a text input or selector popup is active.
@ -278,7 +274,7 @@ impl App {
self.notification = Some(Notification::new(text, level)); self.notification = Some(Notification::new(text, level));
} }
_ => match &mut self.screen { _ => 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::Gameday(s) => s.handle_message(msg),
ActiveScreen::Roster(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), 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. /// If leaving the Matchup screen, save its state for later restoration.
fn cache_matchup_if_active(&mut self) { fn cache_matchup_if_active(&mut self) {
if matches!(&self.screen, ActiveScreen::Matchup(_)) { if matches!(&self.screen, ActiveScreen::Matchup(_)) {
// Swap screen with a temporary placeholder, extract the matchup state let placeholder = ActiveScreen::Settings(Box::new(SettingsState::new(&self.settings)));
let placeholder = ActiveScreen::Dashboard(Box::new(DashboardState::new(
String::new(),
0,
&self.settings,
)));
let old = std::mem::replace(&mut self.screen, placeholder); let old = std::mem::replace(&mut self.screen, placeholder);
if let ActiveScreen::Matchup(state) = old { if let ActiveScreen::Matchup(state) = old {
self.cached_matchup = Some(state); self.cached_matchup = Some(state);

View File

@ -12,7 +12,7 @@ static BATTER_CACHE: OnceLock<Mutex<Option<BatterLeagueStats>>> = OnceLock::new(
static PITCHER_CACHE: OnceLock<Mutex<Option<PitcherLeagueStats>>> = OnceLock::new(); static PITCHER_CACHE: OnceLock<Mutex<Option<PitcherLeagueStats>>> = OnceLock::new();
/// Distribution statistics for a single stat across the league. /// Distribution statistics for a single stat across the league.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct StatDistribution { pub struct StatDistribution {
pub avg: f64, pub avg: f64,
pub stdev: f64, pub stdev: f64,
@ -45,25 +45,26 @@ pub struct BatterLeagueStats {
impl Default for BatterLeagueStats { impl Default for BatterLeagueStats {
fn default() -> Self { fn default() -> Self {
// StatDistribution is Copy — no clone() needed.
let d = StatDistribution { avg: 0.0, stdev: 1.0 }; let d = StatDistribution { avg: 0.0, stdev: 1.0 };
Self { Self {
so_vlhp: d.clone(), so_vlhp: d,
bb_vlhp: d.clone(), bb_vlhp: d,
hit_vlhp: d.clone(), hit_vlhp: d,
ob_vlhp: d.clone(), ob_vlhp: d,
tb_vlhp: d.clone(), tb_vlhp: d,
hr_vlhp: d.clone(), hr_vlhp: d,
dp_vlhp: d.clone(), dp_vlhp: d,
bphr_vlhp: d.clone(), bphr_vlhp: d,
bp1b_vlhp: d.clone(), bp1b_vlhp: d,
so_vrhp: d.clone(), so_vrhp: d,
bb_vrhp: d.clone(), bb_vrhp: d,
hit_vrhp: d.clone(), hit_vrhp: d,
ob_vrhp: d.clone(), ob_vrhp: d,
tb_vrhp: d.clone(), tb_vrhp: d,
hr_vrhp: d.clone(), hr_vrhp: d,
dp_vrhp: d.clone(), dp_vrhp: d,
bphr_vrhp: d.clone(), bphr_vrhp: d,
bp1b_vrhp: d, bp1b_vrhp: d,
} }
} }
@ -96,25 +97,26 @@ pub struct PitcherLeagueStats {
impl Default for PitcherLeagueStats { impl Default for PitcherLeagueStats {
fn default() -> Self { fn default() -> Self {
// StatDistribution is Copy — no clone() needed.
let d = StatDistribution { avg: 0.0, stdev: 1.0 }; let d = StatDistribution { avg: 0.0, stdev: 1.0 };
Self { Self {
so_vlhb: d.clone(), so_vlhb: d,
bb_vlhb: d.clone(), bb_vlhb: d,
hit_vlhb: d.clone(), hit_vlhb: d,
ob_vlhb: d.clone(), ob_vlhb: d,
tb_vlhb: d.clone(), tb_vlhb: d,
hr_vlhb: d.clone(), hr_vlhb: d,
dp_vlhb: d.clone(), dp_vlhb: d,
bphr_vlhb: d.clone(), bphr_vlhb: d,
bp1b_vlhb: d.clone(), bp1b_vlhb: d,
so_vrhb: d.clone(), so_vrhb: d,
bb_vrhb: d.clone(), bb_vrhb: d,
hit_vrhb: d.clone(), hit_vrhb: d,
ob_vrhb: d.clone(), ob_vrhb: d,
tb_vrhb: d.clone(), tb_vrhb: d,
hr_vrhb: d.clone(), hr_vrhb: d,
dp_vrhb: d.clone(), dp_vrhb: d,
bphr_vrhb: d.clone(), bphr_vrhb: d,
bp1b_vrhb: d, bp1b_vrhb: d,
} }
} }

View File

@ -48,6 +48,32 @@ pub fn calculate_weighted_score(
static DEFAULT_DIST: StatDistribution = StatDistribution { avg: 0.0, stdev: 1.0 }; 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. /// Map (stat_name, pitcher_hand) to the batter card field value.
/// pitcher_hand: "L" → vs left-handed pitchers, "R" → vs right-handed pitchers. /// 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 { pub(crate) fn get_batter_stat(card: &BatterCard, stat: &str, pitcher_hand: &str) -> f64 {
@ -227,16 +253,7 @@ pub fn calculate_matchup(
) -> MatchupResult { ) -> MatchupResult {
let batter_hand = player.hand.as_deref().unwrap_or("R"); let batter_hand = player.hand.as_deref().unwrap_or("R");
let pitcher_hand = pitcher.hand.as_deref().unwrap_or("R"); let pitcher_hand = pitcher.hand.as_deref().unwrap_or("R");
let (effective_hand, batter_split, pitcher_split) = resolve_handedness(batter_hand, pitcher_hand);
// 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 Some(batter_card) = batter_card else { let Some(batter_card) = batter_card else {
return MatchupResult { return MatchupResult {
@ -296,13 +313,7 @@ pub fn calculate_team_matchups(
}) })
.collect(); .collect();
results.sort_by(|a, b| match (&b.rating, &a.rating) { sort_matchups_desc(&mut results);
(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,
});
results results
} }
@ -320,17 +331,9 @@ pub async fn calculate_matchup_cached(
) -> Result<MatchupResult> { ) -> Result<MatchupResult> {
let batter_hand = player.hand.as_deref().unwrap_or("R"); let batter_hand = player.hand.as_deref().unwrap_or("R");
let pitcher_hand = pitcher.hand.as_deref().unwrap_or("R"); let pitcher_hand = pitcher.hand.as_deref().unwrap_or("R");
let (effective_hand, batter_split_label, pitcher_split_label) =
// Switch hitter bats opposite of pitcher's hand resolve_handedness(batter_hand, pitcher_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 batter_split_key = if pitcher_hand == "L" { "vlhp" } else { "vrhp" }; 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 pitcher_split_key = if effective_hand == "L" { "vlhb" } else { "vrhb" };
let no_rating = || MatchupResult { let no_rating = || MatchupResult {
@ -399,13 +402,7 @@ pub async fn calculate_team_matchups_cached(
results.push(result); results.push(result);
} }
results.sort_by(|a, b| match (&b.rating, &a.rating) { sort_matchups_desc(&mut results);
(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,
});
Ok(results) Ok(results)
} }

View File

@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::OnceLock;
use crate::calc::league_stats::{ use crate::calc::league_stats::{
calculate_batter_league_stats, calculate_pitcher_league_stats, BatterLeagueStats, calculate_batter_league_stats, calculate_pitcher_league_stats, BatterLeagueStats,
@ -30,11 +31,31 @@ pub struct CacheRebuildResult {
pub pitcher_splits: i64, 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<String> = 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. /// Uses SHA-256 of a sorted JSON representation of BATTER_WEIGHTS and PITCHER_WEIGHTS.
/// Returns the first 16 hex characters. /// Result is cached in a OnceLock since weights are compile-time constants.
fn compute_weights_hash() -> String { fn compute_weights_hash() -> &'static str {
WEIGHTS_HASH.get_or_init(|| {
let batter: std::collections::BTreeMap<&str, (i32, bool)> = BATTER_WEIGHTS let batter: std::collections::BTreeMap<&str, (i32, bool)> = BATTER_WEIGHTS
.iter() .iter()
.map(|(name, w)| (*name, (w.weight, w.high_is_better))) .map(|(name, w)| (*name, (w.weight, w.high_is_better)))
@ -49,11 +70,8 @@ fn compute_weights_hash() -> String {
"pitcher": pitcher, "pitcher": pitcher,
}); });
let mut hasher = Sha256::new(); hex16_hash(data.to_string().as_bytes())
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()
} }
/// Generate a hash of representative league stat values to detect significant changes. /// 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.hit_vrhb.avg,
pitcher.so_vrhb.avg, pitcher.so_vrhb.avg,
]; ];
let repr = format!("{:?}", key_values); hex16_hash(format!("{:?}", key_values).as_bytes())
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()
} }
// =============================================================================
// Split score calculators
// =============================================================================
/// Calculate standardized scores for all stats on a batter card split. /// Calculate standardized scores for all stats on a batter card split.
/// ///
/// `split` is "vlhp" (vs left-handed pitchers) or "vrhp" (vs right-handed pitchers). /// `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) (total, stat_scores)
} }
// =============================================================================
// Cache management
// =============================================================================
/// Rebuild the entire standardized score cache. /// Rebuild the entire standardized score cache.
/// ///
/// Clears all existing entries and recalculates scores for every batter and pitcher /// 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<CacheRebuildResult> { pub async fn rebuild_score_cache(pool: &SqlitePool) -> Result<CacheRebuildResult> {
let batter_stats = calculate_batter_league_stats(pool).await?; let batter_stats = calculate_batter_league_stats(pool).await?;
let pitcher_stats = calculate_pitcher_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<CacheRebuildResult
let mut batter_count: i64 = 0; let mut batter_count: i64 = 0;
let mut pitcher_count: i64 = 0; let mut pitcher_count: i64 = 0;
// Wrap all inserts in a single transaction — avoids per-row auto-commit overhead
// and ensures the cache is never in a partially-rebuilt state.
let mut tx = pool.begin().await?;
for card in &batter_cards { for card in &batter_cards {
for split in ["vlhp", "vrhp"] { for split in ["vlhp", "vrhp"] {
let (total, stat_scores) = calculate_batter_split_scores(card, split, &batter_stats); let (total, stat_scores) = calculate_batter_split_scores(card, split, &batter_stats);
@ -165,9 +190,9 @@ pub async fn rebuild_score_cache(pool: &SqlitePool) -> Result<CacheRebuildResult
.bind(total) .bind(total)
.bind(&stat_scores_json) .bind(&stat_scores_json)
.bind(computed_at) .bind(computed_at)
.bind(&weights_hash) .bind(weights_hash)
.bind(&league_hash) .bind(&league_hash)
.execute(pool) .execute(&mut *tx)
.await?; .await?;
batter_count += 1; batter_count += 1;
} }
@ -189,14 +214,16 @@ pub async fn rebuild_score_cache(pool: &SqlitePool) -> Result<CacheRebuildResult
.bind(total) .bind(total)
.bind(&stat_scores_json) .bind(&stat_scores_json)
.bind(computed_at) .bind(computed_at)
.bind(&weights_hash) .bind(weights_hash)
.bind(&league_hash) .bind(&league_hash)
.execute(pool) .execute(&mut *tx)
.await?; .await?;
pitcher_count += 1; pitcher_count += 1;
} }
} }
tx.commit().await?;
Ok(CacheRebuildResult { batter_splits: batter_count, pitcher_splits: pitcher_count }) Ok(CacheRebuildResult { batter_splits: batter_count, pitcher_splits: pitcher_count })
} }
@ -214,8 +241,7 @@ pub async fn is_cache_valid(pool: &SqlitePool) -> Result<bool> {
None => return Ok(false), None => return Ok(false),
}; };
let current_weights_hash = compute_weights_hash(); Ok(entry.weights_hash.as_deref() == Some(compute_weights_hash()))
Ok(entry.weights_hash.as_deref() == Some(current_weights_hash.as_str()))
} }
/// Ensure the score cache exists, rebuilding if necessary. /// Ensure the score cache exists, rebuilding if necessary.

View File

@ -1,7 +1,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use figment::{ use figment::{
providers::{Env, Format, Toml}, providers::{Env, Format, Serialized, Toml},
Figment, Figment,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -109,7 +109,7 @@ impl Default for RatingWeights {
pub fn load_settings() -> Result<Settings, figment::Error> { pub fn load_settings() -> Result<Settings, figment::Error> {
Figment::new() Figment::new()
.merge(figment::providers::Serialized::defaults(Settings::default())) .merge(Serialized::defaults(Settings::default()))
.merge(Toml::file("settings.toml")) .merge(Toml::file("settings.toml"))
.merge(Env::prefixed("SBA_SCOUT_").split("__")) .merge(Env::prefixed("SBA_SCOUT_").split("__"))
.extract() .extract()

View File

@ -1,6 +1,7 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; use sqlx::FromRow;
use std::collections::HashMap;
// ============================================================================= // =============================================================================
// Core Entities (synced from league API) // Core Entities (synced from league API)
@ -77,15 +78,35 @@ impl Player {
} }
pub fn is_pitcher(&self) -> bool { pub fn is_pitcher(&self) -> bool {
self.positions() [
.iter() self.pos_1.as_deref(),
.any(|p| matches!(*p, "SP" | "RP" | "CP")) 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 { pub fn is_batter(&self) -> bool {
self.positions() [
.iter() self.pos_1.as_deref(),
.any(|p| matches!(*p, "C" | "1B" | "2B" | "3B" | "SS" | "LF" | "CF" | "RF" | "DH")) 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() .unwrap_or_default()
} }
pub fn positions_map(&self) -> std::collections::HashMap<String, i64> { pub fn positions_map(&self) -> HashMap<String, i64> {
self.positions self.positions
.as_deref() .as_deref()
.and_then(|s| serde_json::from_str(s).ok()) .and_then(|s| serde_json::from_str(s).ok())
@ -248,7 +269,7 @@ impl Lineup {
self.batting_order = serde_json::to_string(order).ok(); self.batting_order = serde_json::to_string(order).ok();
} }
pub fn set_positions(&mut self, positions: &std::collections::HashMap<String, i64>) { pub fn set_positions(&mut self, positions: &HashMap<String, i64>) {
self.positions = serde_json::to_string(positions).ok(); self.positions = serde_json::to_string(positions).ok();
} }
} }
@ -290,7 +311,6 @@ pub struct SyncStatus {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::collections::HashMap;
fn make_player(pos_1: Option<&str>, pos_2: Option<&str>) -> Player { fn make_player(pos_1: Option<&str>, pos_2: Option<&str>) -> Player {
Player { Player {

View File

@ -110,15 +110,13 @@ pub async fn search_players(
Ok(players) Ok(players)
} }
pub async fn get_pitchers( async fn get_players_by_position_filter(
pool: &SqlitePool, pool: &SqlitePool,
base_where: &str,
team_id: Option<i64>, team_id: Option<i64>,
season: Option<i64>, season: Option<i64>,
) -> Result<Vec<Player>> { ) -> Result<Vec<Player>> {
let mut sql = String::from( let mut sql = format!("SELECT * FROM players WHERE {base_where}");
"SELECT * FROM players \
WHERE (pos_1 IN ('SP', 'RP', 'CP') OR pos_2 IN ('SP', 'RP', 'CP'))",
);
if team_id.is_some() { if team_id.is_some() {
sql.push_str(" AND team_id = ?"); sql.push_str(" AND team_id = ?");
} }
@ -137,40 +135,36 @@ pub async fn get_pitchers(
Ok(query.fetch_all(pool).await?) Ok(query.fetch_all(pool).await?)
} }
pub async fn get_pitchers(
pool: &SqlitePool,
team_id: Option<i64>,
season: Option<i64>,
) -> Result<Vec<Player>> {
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( pub async fn get_batters(
pool: &SqlitePool, pool: &SqlitePool,
team_id: Option<i64>, team_id: Option<i64>,
season: Option<i64>, season: Option<i64>,
) -> Result<Vec<Player>> { ) -> Result<Vec<Player>> {
let mut sql = String::from( get_players_by_position_filter(
"SELECT * FROM players \ pool,
WHERE pos_1 IN ('C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH')", "pos_1 IN ('C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH')",
); team_id,
if team_id.is_some() { season,
sql.push_str(" AND team_id = ?"); )
} .await
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?)
} }
pub async fn get_players_missing_cards( pub async fn get_batters_missing_cards(pool: &SqlitePool, season: i64) -> Result<Vec<Player>> {
pool: &SqlitePool, Ok(sqlx::query_as::<_, Player>(
season: i64,
card_type: &str,
) -> Result<Vec<Player>> {
let players = if card_type == "batter" {
sqlx::query_as::<_, Player>(
"SELECT * FROM players \ "SELECT * FROM players \
WHERE season = ? \ WHERE season = ? \
AND pos_1 IN ('C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH') \ AND pos_1 IN ('C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH') \
@ -179,9 +173,11 @@ pub async fn get_players_missing_cards(
) )
.bind(season) .bind(season)
.fetch_all(pool) .fetch_all(pool)
.await? .await?)
} else { }
sqlx::query_as::<_, Player>(
pub async fn get_pitchers_missing_cards(pool: &SqlitePool, season: i64) -> Result<Vec<Player>> {
Ok(sqlx::query_as::<_, Player>(
"SELECT * FROM players \ "SELECT * FROM players \
WHERE season = ? \ WHERE season = ? \
AND pos_1 IN ('SP', 'RP', 'CP') \ AND pos_1 IN ('SP', 'RP', 'CP') \
@ -190,9 +186,7 @@ pub async fn get_players_missing_cards(
) )
.bind(season) .bind(season)
.fetch_all(pool) .fetch_all(pool)
.await? .await?)
};
Ok(players)
} }
// ============================================================================= // =============================================================================
@ -381,36 +375,6 @@ pub async fn clear_score_cache(pool: &SqlitePool) -> Result<u64> {
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
pub async fn insert_score_cache(
pool: &SqlitePool,
batter_card_id: Option<i64>,
pitcher_card_id: Option<i64>,
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 // Lineup Queries
// ============================================================================= // =============================================================================
@ -442,29 +406,18 @@ pub async fn save_lineup(
let batting_order_json = serde_json::to_string(batting_order)?; let batting_order_json = serde_json::to_string(batting_order)?;
let positions_json = serde_json::to_string(positions)?; 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 = ?",
)
.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( sqlx::query(
"INSERT INTO lineups \ "INSERT INTO lineups \
(name, description, lineup_type, batting_order, positions, starting_pitcher_id, \ (name, description, lineup_type, batting_order, positions, starting_pitcher_id, \
created_at, updated_at) \ created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))", 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(name)
.bind(description) .bind(description)
@ -474,7 +427,6 @@ pub async fn save_lineup(
.bind(starting_pitcher_id) .bind(starting_pitcher_id)
.execute(pool) .execute(pool)
.await?; .await?;
}
Ok(()) Ok(())
} }
@ -496,25 +448,35 @@ pub async fn get_my_roster(
team_abbrev: &str, team_abbrev: &str,
season: i64, season: i64,
) -> Result<Roster> { ) -> Result<Roster> {
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_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_abbrev = format!("{}MiL", team_abbrev);
let mil_team = get_team_by_abbrev(pool, &mil_abbrev, season).await?;
let minors = match mil_team { let (majors_team, il_team, mil_team) = tokio::try_join!(
Some(t) => get_players_by_team(pool, t.id).await?, get_team_by_abbrev(pool, team_abbrev, season),
None => vec![], 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 }) Ok(Roster { majors, minors, il })
} }

View File

@ -1,17 +1,20 @@
use anyhow::Result; use anyhow::Result;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};
use std::path::Path; use std::path::Path;
use std::str::FromStr;
pub async fn init_pool(db_path: &Path) -> Result<SqlitePool> { pub async fn init_pool(db_path: &Path) -> Result<SqlitePool> {
let db_url = format!("sqlite://{}?mode=rwc", db_path.display()); let is_memory = db_path == Path::new(":memory:");
let options = SqliteConnectOptions::from_str(&db_url)? let mut options = SqliteConnectOptions::new()
.filename(db_path)
.create_if_missing(true) .create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.foreign_keys(false); .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() let pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(max_conns)
.connect_with(options) .connect_with(options)
.await?; .await?;
@ -19,6 +22,8 @@ pub async fn init_pool(db_path: &Path) -> Result<SqlitePool> {
} }
pub async fn create_tables(pool: &SqlitePool) -> Result<()> { pub async fn create_tables(pool: &SqlitePool) -> Result<()> {
let mut tx = pool.begin().await?;
// 1. teams — API-provided PKs (no autoincrement) // 1. teams — API-provided PKs (no autoincrement)
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS teams ( "CREATE TABLE IF NOT EXISTS teams (
@ -43,7 +48,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> {
UNIQUE(abbrev, season) UNIQUE(abbrev, season)
)", )",
) )
.execute(pool) .execute(&mut *tx)
.await?; .await?;
// 2. players — API-provided PKs (no autoincrement) // 2. players — API-provided PKs (no autoincrement)
@ -78,7 +83,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> {
synced_at TEXT synced_at TEXT
)", )",
) )
.execute(pool) .execute(&mut *tx)
.await?; .await?;
// 3. batter_cards // 3. batter_cards
@ -120,7 +125,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> {
source TEXT source TEXT
)", )",
) )
.execute(pool) .execute(&mut *tx)
.await?; .await?;
// 4. pitcher_cards // 4. pitcher_cards
@ -162,7 +167,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> {
source TEXT source TEXT
)", )",
) )
.execute(pool) .execute(&mut *tx)
.await?; .await?;
// 5. transactions // 5. transactions
@ -181,7 +186,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> {
UNIQUE(move_id, player_id) UNIQUE(move_id, player_id)
)", )",
) )
.execute(pool) .execute(&mut *tx)
.await?; .await?;
// 6. lineups // 6. lineups
@ -198,7 +203,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> {
updated_at TEXT updated_at TEXT
)", )",
) )
.execute(pool) .execute(&mut *tx)
.await?; .await?;
// 7. matchup_cache // 7. matchup_cache
@ -215,7 +220,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> {
UNIQUE(batter_id, pitcher_id) UNIQUE(batter_id, pitcher_id)
)", )",
) )
.execute(pool) .execute(&mut *tx)
.await?; .await?;
// 8. standardized_score_cache // 8. standardized_score_cache
@ -234,7 +239,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> {
UNIQUE(pitcher_card_id, split) UNIQUE(pitcher_card_id, split)
)", )",
) )
.execute(pool) .execute(&mut *tx)
.await?; .await?;
// 9. standings_cache — JSON blob cache for league standings // 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 fetched_at TEXT NOT NULL
)", )",
) )
.execute(pool) .execute(&mut *tx)
.await?; .await?;
// 10. sync_status // 10. sync_status
@ -259,13 +264,16 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> {
last_error TEXT last_error TEXT
)", )",
) )
.execute(pool) .execute(&mut *tx)
.await?; .await?;
tx.commit().await?;
Ok(()) Ok(())
} }
pub async fn reset_database(pool: &SqlitePool) -> Result<()> { pub async fn reset_database(pool: &SqlitePool) -> Result<()> {
let mut tx = pool.begin().await?;
// Drop in reverse dependency order to satisfy foreign key constraints // Drop in reverse dependency order to satisfy foreign key constraints
for table in &[ for table in &[
"standings_cache", "standings_cache",
@ -279,10 +287,11 @@ pub async fn reset_database(pool: &SqlitePool) -> Result<()> {
"teams", "teams",
"sync_status", "sync_status",
] { ] {
sqlx::query(&format!("DROP TABLE IF EXISTS {}", table)) sqlx::query(&format!("DROP TABLE IF EXISTS {table}"))
.execute(pool) .execute(&mut *tx)
.await?; .await?;
} }
tx.commit().await?;
create_tables(pool).await create_tables(pool).await
} }

View File

@ -36,12 +36,9 @@ async fn main() -> Result<()> {
} }
}; };
let log_dir = settings.db_path.parent().unwrap_or(std::path::Path::new("data")); let db_parent = settings.db_path.parent().unwrap_or(std::path::Path::new("data"));
let _log_guard = init_logging(log_dir); let _log_guard = init_logging(db_parent);
std::fs::create_dir_all(db_parent)?;
if let Some(parent) = settings.db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let pool = db::schema::init_pool(&settings.db_path).await?; let pool = db::schema::init_pool(&settings.db_path).await?;
db::schema::create_tables(&pool).await?; db::schema::create_tables(&pool).await?;

View File

@ -14,6 +14,7 @@ use tokio::sync::mpsc;
use crate::app::{AppMessage, NotifyLevel}; use crate::app::{AppMessage, NotifyLevel};
use crate::config::Settings; use crate::config::Settings;
use crate::screens::format_relative_time;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncState { 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 pool_c = pool.clone();
let tx_c = tx.clone(); let tx_c = tx.clone();
let abbrev = self.team_abbrev.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 crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await
{ {
let _ = tx_c.send(AppMessage::TeamInfoLoaded(team.short_name, team.salary_cap)); let _ = tx_c.send(AppMessage::TeamInfoLoaded(team.short_name, team.salary_cap));
}
});
// Load missing cards let format_player = |p: crate::db::models::Player| {
let pool_c = pool.clone(); let pos = p.pos_1.as_deref().unwrap_or("?");
let tx_c = tx.clone(); format!("{} ({})", p.name, pos)
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 batters = crate::db::queries::get_players_missing_batter_cards( let batters = crate::db::queries::get_players_missing_batter_cards(
&pool_c, team.id, season, &pool_c, team.id, season,
) )
.await .await
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.map(|p| { .map(format_player)
let pos = p.pos_1.as_deref().unwrap_or("?");
format!("{} ({})", p.name, pos)
})
.collect(); .collect();
let pitchers = crate::db::queries::get_players_missing_pitcher_cards( let pitchers = crate::db::queries::get_players_missing_pitcher_cards(
@ -147,10 +139,7 @@ impl DashboardState {
.await .await
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.map(|p| { .map(format_player)
let pos = p.pos_1.as_deref().unwrap_or("?");
format!("{} ({})", p.name, pos)
})
.collect(); .collect();
let _ = tx_c.send(AppMessage::MissingCardsLoaded { batters, pitchers }); let _ = tx_c.send(AppMessage::MissingCardsLoaded { batters, pitchers });
@ -192,8 +181,6 @@ impl DashboardState {
if self.sync_state == SyncState::Syncing { if self.sync_state == SyncState::Syncing {
return; return;
} }
self.sync_state = SyncState::Syncing;
self.sync_message = "Syncing...".to_string();
let pool = pool.clone(); let pool = pool.clone();
let settings = settings.clone(); let settings = settings.clone();
let tx = tx.clone(); let tx = tx.clone();
@ -212,6 +199,7 @@ impl DashboardState {
&mut self, &mut self,
msg: AppMessage, msg: AppMessage,
pool: &SqlitePool, pool: &SqlitePool,
settings: &Settings,
tx: &mpsc::UnboundedSender<AppMessage>, tx: &mpsc::UnboundedSender<AppMessage>,
) { ) {
match msg { match msg {
@ -264,6 +252,21 @@ impl DashboardState {
if self.sync_state == SyncState::Never && !self.sync_statuses.is_empty() { if self.sync_state == SyncState::Never && !self.sync_statuses.is_empty() {
self.sync_state = SyncState::Success; 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 => { AppMessage::SyncStarted => {
self.sync_state = SyncState::Syncing; self.sync_state = SyncState::Syncing;
@ -431,40 +434,11 @@ impl DashboardState {
let batter_positions = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"]; let batter_positions = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"];
let pitcher_positions = ["SP", "RP", "CP"]; let pitcher_positions = ["SP", "RP", "CP"];
let mut spans: Vec<Span> = 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<Span> = 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![ let lines = vec![
Line::from(spans), Line::from(build_position_spans(&batter_positions, &self.position_counts)),
Line::from(pitcher_spans), Line::from(build_position_spans(&pitcher_positions, &self.position_counts)),
]; ];
let widget = Paragraph::new(lines) let widget = Paragraph::new(lines).block(
.block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title("Position Coverage"), .title("Position Coverage"),
@ -512,25 +486,8 @@ impl DashboardState {
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area); .split(area);
// Batters missing cards let batter_widget =
let batter_lines: Vec<Line> = if self.batters_missing_cards.is_empty() { Paragraph::new(missing_cards_lines(&self.batters_missing_cards)).block(
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() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title(format!( .title(format!(
@ -540,25 +497,8 @@ impl DashboardState {
); );
frame.render_widget(batter_widget, cols[0]); frame.render_widget(batter_widget, cols[0]);
// Pitchers missing cards let pitcher_widget =
let pitcher_lines: Vec<Line> = if self.pitchers_missing_cards.is_empty() { Paragraph::new(missing_cards_lines(&self.pitchers_missing_cards)).block(
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() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title(format!( .title(format!(
@ -637,25 +577,42 @@ impl DashboardState {
} }
} }
fn format_relative_time(dt: &NaiveDateTime) -> String { fn build_position_spans<'a>(
let now = chrono::Utc::now().naive_utc(); positions: &[&'a str],
let diff = now.signed_duration_since(*dt); counts: &HashMap<String, usize>,
) -> Vec<Span<'a>> {
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
}
let secs = diff.num_seconds(); fn missing_cards_lines(items: &[String]) -> Vec<Line<'_>> {
if secs < 0 { if items.is_empty() {
return "just now".to_string(); 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)
} }

View File

@ -172,9 +172,9 @@ impl GamedayState {
let abbrev = self.team_abbrev.clone(); let abbrev = self.team_abbrev.clone();
let season = self.season; let season = self.season;
tokio::spawn(async move { tokio::spawn(async move {
let team = crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await; if let Ok(Some(team)) =
if let Ok(Some(team)) = team { crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await
if let Ok(batters) = && let Ok(batters) =
crate::db::queries::get_batters(&pool_c, Some(team.id), Some(season)).await crate::db::queries::get_batters(&pool_c, Some(team.id), Some(season)).await
{ {
let mut with_cards = Vec::with_capacity(batters.len()); let mut with_cards = Vec::with_capacity(batters.len());
@ -187,7 +187,6 @@ impl GamedayState {
} }
let _ = tx_c.send(AppMessage::MyBattersLoaded(with_cards)); let _ = tx_c.send(AppMessage::MyBattersLoaded(with_cards));
} }
}
}); });
// Load saved lineups // Load saved lineups
@ -302,7 +301,9 @@ impl GamedayState {
} }
fn cycle_focus(&mut self) { 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 { self.focus = match self.focus {
GamedayFocus::MatchupTable => GamedayFocus::LineupTable, GamedayFocus::MatchupTable => GamedayFocus::LineupTable,
GamedayFocus::LineupTable => GamedayFocus::LineupName, GamedayFocus::LineupTable => GamedayFocus::LineupName,
@ -311,16 +312,6 @@ impl GamedayState {
GamedayFocus::PitcherSelector => GamedayFocus::LoadSelector, GamedayFocus::PitcherSelector => GamedayFocus::LoadSelector,
GamedayFocus::LoadSelector => GamedayFocus::MatchupTable, 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.team_selector.is_focused = self.focus == GamedayFocus::TeamSelector;
self.pitcher_selector.is_focused = self.focus == GamedayFocus::PitcherSelector; self.pitcher_selector.is_focused = self.focus == GamedayFocus::PitcherSelector;
self.load_selector.is_focused = self.focus == GamedayFocus::LoadSelector; self.load_selector.is_focused = self.focus == GamedayFocus::LoadSelector;
@ -417,33 +408,10 @@ impl GamedayState {
// ========================================================================= // =========================================================================
fn add_selected_to_lineup(&mut self) { fn add_selected_to_lineup(&mut self) {
let Some(idx) = self.matchup_table_state.selected() else { let Some(slot_idx) = self.lineup_slots.iter().position(|s| s.is_empty()) 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 {
return; // lineup full return; // lineup full
}; };
self.add_to_specific_slot(slot_idx);
// 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()),
};
} }
fn add_to_specific_slot(&mut self, slot_idx: usize) { fn add_to_specific_slot(&mut self, slot_idx: usize) {
@ -720,10 +688,7 @@ impl GamedayState {
self.matchup_table_state.select(Some(0)); self.matchup_table_state.select(Some(0));
} }
} }
AppMessage::LineupSaved(name) => { AppMessage::LineupSaved(_) => {}
// Refresh saved lineups list
let _ = name; // name used for notification in Notify
}
AppMessage::LineupSaveError(_) => {} AppMessage::LineupSaveError(_) => {}
_ => {} _ => {}
} }
@ -742,25 +707,20 @@ impl GamedayState {
self.render_right_panel(frame, panels[1], tick_count); self.render_right_panel(frame, panels[1], tick_count);
// Render popup overlays last (on top) // Render popup overlays last (on top)
// Team selector popup anchored to its closed position if self.team_selector.is_open || self.pitcher_selector.is_open {
if self.team_selector.is_open {
let left_chunks = Layout::vertical([ let left_chunks = Layout::vertical([
Constraint::Length(3), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(3),
Constraint::Min(0), Constraint::Min(0),
]) ])
.split(panels[0]); .split(panels[0]);
if self.team_selector.is_open {
self.team_selector.render_popup(frame, left_chunks[0]); self.team_selector.render_popup(frame, left_chunks[0]);
} }
if self.pitcher_selector.is_open { 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]); self.pitcher_selector.render_popup(frame, left_chunks[1]);
} }
}
if self.load_selector.is_open { if self.load_selector.is_open {
let right_chunks = Layout::vertical([ let right_chunks = Layout::vertical([
Constraint::Length(3), Constraint::Length(3),
@ -942,7 +902,7 @@ impl GamedayState {
.lineup_slots .lineup_slots
.iter() .iter()
.map(|slot| { .map(|slot| {
let order = format!("{}", slot.order); let order = slot.order.to_string();
if slot.is_empty() { if slot.is_empty() {
Row::new(vec![ Row::new(vec![
Cell::from(order), Cell::from(order),

View File

@ -1,4 +1,4 @@
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{ use ratatui::{
@ -15,6 +15,7 @@ use crate::app::{AppMessage, NotifyLevel};
use crate::config::Settings; use crate::config::Settings;
use crate::db::models::{BatterCard, Lineup, Player}; use crate::db::models::{BatterCard, Lineup, Player};
use crate::widgets::selector::{SelectorEvent, SelectorWidget}; use crate::widgets::selector::{SelectorEvent, SelectorWidget};
use crate::widgets::{format_rating, format_swar};
// ============================================================================= // =============================================================================
// Types // Types
@ -96,7 +97,9 @@ impl LineupState {
} }
pub fn is_input_captured(&self) -> bool { 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<AppMessage>) { pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) {
@ -109,8 +112,8 @@ impl LineupState {
let season = self.season; let season = self.season;
tokio::spawn(async move { tokio::spawn(async move {
let team = crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await; let team = crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await;
if let Ok(Some(team)) = team { if let Ok(Some(team)) = team
if let Ok(batters) = && let Ok(batters) =
crate::db::queries::get_batters(&pool_c, Some(team.id), Some(season)).await crate::db::queries::get_batters(&pool_c, Some(team.id), Some(season)).await
{ {
let mut with_cards = Vec::with_capacity(batters.len()); let mut with_cards = Vec::with_capacity(batters.len());
@ -123,17 +126,10 @@ impl LineupState {
} }
let _ = tx_c.send(AppMessage::LineupBattersLoaded(with_cards)); let _ = tx_c.send(AppMessage::LineupBattersLoaded(with_cards));
} }
}
}); });
// Load saved lineups // Load saved lineups
let pool_c = pool.clone(); Self::reload_lineups(pool, tx);
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));
}
});
} }
pub fn handle_key( pub fn handle_key(
@ -231,9 +227,7 @@ impl LineupState {
return; return;
} }
KeyCode::Char('c') => { KeyCode::Char('c') => {
for i in 0..9 { self.clear_slots();
self.lineup_slots[i] = LineupSlot::empty(i + 1);
}
return; return;
} }
_ => {} _ => {}
@ -332,8 +326,14 @@ impl LineupState {
// Lineup operations // 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<BatterCard>)> { fn filtered_available(&self) -> Vec<&(Player, Option<BatterCard>)> {
let lineup_ids: Vec<i64> = self let lineup_ids: HashSet<i64> = self
.lineup_slots .lineup_slots
.iter() .iter()
.filter_map(|s| s.player_id) .filter_map(|s| s.player_id)
@ -359,7 +359,7 @@ impl LineupState {
}; };
// Auto-suggest position: first eligible not already assigned // Auto-suggest position: first eligible not already assigned
let assigned_positions: Vec<String> = self let assigned_positions: HashSet<String> = self
.lineup_slots .lineup_slots
.iter() .iter()
.filter_map(|s| s.position.clone()) .filter_map(|s| s.position.clone())
@ -367,10 +367,13 @@ impl LineupState {
let eligible = player.positions(); let eligible = player.positions();
let pos = eligible let pos = eligible
.iter() .iter()
.find(|p| !assigned_positions.contains(&p.to_string())) .find(|p| !assigned_positions.contains(**p as &str))
.map(|p| p.to_string()) .map(|p| p.to_string())
.or_else(|| Some("DH".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 { self.lineup_slots[slot_idx] = LineupSlot {
order: slot_idx + 1, order: slot_idx + 1,
player_id: Some(player.id), player_id: Some(player.id),
@ -379,7 +382,6 @@ impl LineupState {
}; };
// Adjust available table selection // Adjust available table selection
let new_available_len = self.filtered_available().len();
if new_available_len > 0 { if new_available_len > 0 {
let new_idx = idx.min(new_available_len - 1); let new_idx = idx.min(new_available_len - 1);
self.available_table_state.select(Some(new_idx)); self.available_table_state.select(Some(new_idx));
@ -439,10 +441,7 @@ impl LineupState {
let order = lineup.batting_order_vec(); let order = lineup.batting_order_vec();
let positions_map = lineup.positions_map(); let positions_map = lineup.positions_map();
// Clear all slots self.clear_slots();
for i in 0..9 {
self.lineup_slots[i] = LineupSlot::empty(i + 1);
}
// Populate from saved lineup // Populate from saved lineup
for (i, pid) in order.iter().enumerate() { for (i, pid) in order.iter().enumerate() {
@ -546,6 +545,16 @@ impl LineupState {
}); });
} }
fn reload_lineups(pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) {
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( pub fn handle_message(
&mut self, &mut self,
msg: AppMessage, msg: AppMessage,
@ -565,46 +574,30 @@ impl LineupState {
} }
AppMessage::LineupSaved(name) => { AppMessage::LineupSaved(name) => {
let _ = tx.send(AppMessage::Notify( let _ = tx.send(AppMessage::Notify(
format!("Lineup '{}' saved", name), format!("Lineup '{name}' saved"),
NotifyLevel::Success, NotifyLevel::Success,
)); ));
// Refresh lineups list Self::reload_lineups(pool, tx);
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));
}
});
} }
AppMessage::LineupSaveError(e) => { AppMessage::LineupSaveError(e) => {
let _ = tx.send(AppMessage::Notify( let _ = tx.send(AppMessage::Notify(
format!("Save failed: {}", e), format!("Save failed: {e}"),
NotifyLevel::Error, NotifyLevel::Error,
)); ));
} }
AppMessage::LineupDeleted(name) => { AppMessage::LineupDeleted(name) => {
let _ = tx.send(AppMessage::Notify( let _ = tx.send(AppMessage::Notify(
format!("Lineup '{}' deleted", name), format!("Lineup '{name}' deleted"),
NotifyLevel::Success, NotifyLevel::Success,
)); ));
self.lineup_name.clear(); self.lineup_name.clear();
self.lineup_name_cursor = 0; self.lineup_name_cursor = 0;
for i in 0..9 { self.clear_slots();
self.lineup_slots[i] = LineupSlot::empty(i + 1); Self::reload_lineups(pool, tx);
}
// 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));
}
});
} }
AppMessage::LineupDeleteError(e) => { AppMessage::LineupDeleteError(e) => {
let _ = tx.send(AppMessage::Notify( let _ = tx.send(AppMessage::Notify(
format!("Delete failed: {}", e), format!("Delete failed: {e}"),
NotifyLevel::Error, NotifyLevel::Error,
)); ));
} }
@ -665,7 +658,8 @@ impl LineupState {
let available = self.filtered_available(); let available = self.filtered_available();
if available.is_empty() { if available.is_empty() {
frame.render_widget( 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], chunks[1],
); );
} else { } else {
@ -685,7 +679,7 @@ impl LineupState {
let (vl, vr) = card let (vl, vr) = card
.as_ref() .as_ref()
.map(|c| (format_rating(c.rating_vl), format_rating(c.rating_vr))) .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![ Row::new(vec![
Cell::from(p.name.clone()), Cell::from(p.name.clone()),
Cell::from(p.positions().join("/")), Cell::from(p.positions().join("/")),
@ -715,7 +709,8 @@ impl LineupState {
} }
frame.render_widget( 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], chunks[2],
); );
} }
@ -734,7 +729,8 @@ impl LineupState {
} else { } else {
Style::default().fg(Color::White) Style::default().fg(Color::White)
}; };
let title_style = if matches!(self.focus, LineupFocus::LineupTable | LineupFocus::LineupName) { let title_style =
if matches!(self.focus, LineupFocus::LineupTable | LineupFocus::LineupName) {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else { } else {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::DarkGray)
@ -767,7 +763,7 @@ impl LineupState {
.map(|slot| { .map(|slot| {
if slot.is_empty() { if slot.is_empty() {
Row::new(vec![ 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("(empty)").style(Style::default().fg(Color::DarkGray)),
Cell::from(""), Cell::from(""),
Cell::from(""), Cell::from(""),
@ -775,25 +771,24 @@ impl LineupState {
]) ])
} else { } else {
let name = slot.player_name.as_deref().unwrap_or("?"); let name = slot.player_name.as_deref().unwrap_or("?");
let pos = slot.position.as_deref().unwrap_or(""); let pos = slot.position.as_deref().unwrap_or("\u{2014}");
let eligible = self let player = self
.available_batters .available_batters
.iter() .iter()
.find(|(p, _)| Some(p.id) == slot.player_id) .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(); .unwrap_or_default();
let hand = self let hand = player
.available_batters .and_then(|p| p.hand.as_deref())
.iter()
.find(|(p, _)| Some(p.id) == slot.player_id)
.and_then(|(p, _)| p.hand.clone())
.unwrap_or_default(); .unwrap_or_default();
Row::new(vec![ Row::new(vec![
Cell::from(format!("{}", slot.order)), Cell::from(slot.order.to_string()),
Cell::from(name.to_string()), Cell::from(name.to_string()),
Cell::from(pos.to_string()), Cell::from(pos.to_string()),
Cell::from(eligible), Cell::from(eligible),
Cell::from(hand), Cell::from(hand.to_string()),
]) ])
} }
}) })
@ -822,28 +817,3 @@ impl LineupState {
frame.render_widget(hints, chunks[2]); frame.render_widget(hints, chunks[2]);
} }
} }
// =============================================================================
// Helpers
// =============================================================================
fn format_rating(val: Option<f64>) -> 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<f64>) -> String {
match val {
Some(v) => format!("{:.2}", v),
None => "".to_string(),
}
}

View File

@ -12,7 +12,8 @@ use crate::app::{AppMessage, NotifyLevel};
use crate::calc::matchup::MatchupResult; use crate::calc::matchup::MatchupResult;
use crate::config::Settings; use crate::config::Settings;
use crate::db::models::{BatterCard, Player}; 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 // Types
@ -134,8 +135,8 @@ impl MatchupState {
let season = self.season; let season = self.season;
tokio::spawn(async move { tokio::spawn(async move {
let team = crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await; let team = crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await;
if let Ok(Some(team)) = team { if let Ok(Some(team)) = team
if let Ok(batters) = && let Ok(batters) =
crate::db::queries::get_batters(&pool_c, Some(team.id), Some(season)).await crate::db::queries::get_batters(&pool_c, Some(team.id), Some(season)).await
{ {
let mut with_cards = Vec::with_capacity(batters.len()); let mut with_cards = Vec::with_capacity(batters.len());
@ -148,7 +149,6 @@ impl MatchupState {
} }
let _ = tx_c.send(AppMessage::MyBattersLoaded(with_cards)); let _ = tx_c.send(AppMessage::MyBattersLoaded(with_cards));
} }
}
}); });
} }
@ -369,16 +369,9 @@ impl MatchupState {
.collect(); .collect();
self.pitcher_selector.set_items(items); self.pitcher_selector.set_items(items);
} }
AppMessage::MatchupsCalculated(mut results) => { AppMessage::MatchupsCalculated(results) => {
self.is_calculating = false; 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; self.matchup_results = results;
if !self.matchup_results.is_empty() {
self.table_state.select(Some(0));
}
self.sort_results(); self.sort_results();
} }
_ => {} _ => {}
@ -467,7 +460,7 @@ impl MatchupState {
.map(|(i, r)| { .map(|(i, r)| {
let tier_style = tier_style(&r.tier); let tier_style = tier_style(&r.tier);
Row::new(vec![ Row::new(vec![
Cell::from(format!("{}", i + 1)), Cell::from((i + 1).to_string()),
Cell::from(r.player.name.clone()), Cell::from(r.player.name.clone()),
Cell::from(r.batter_hand.clone()), Cell::from(r.batter_hand.clone()),
Cell::from(r.player.positions().join("/")), 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<f64>) -> String {
match val {
Some(v) => format!("{:.2}", v),
None => "".to_string(),
}
}

View File

@ -5,3 +5,32 @@ pub mod matchup;
pub mod roster; pub mod roster;
pub mod settings; pub mod settings;
pub mod standings; 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),
}
}

View File

@ -12,6 +12,7 @@ use tokio::sync::mpsc;
use crate::app::{AppMessage, NotifyLevel}; use crate::app::{AppMessage, NotifyLevel};
use crate::config::Settings; use crate::config::Settings;
use crate::db::models::Player; use crate::db::models::Player;
use crate::widgets::{format_rating, format_swar};
// ============================================================================= // =============================================================================
// Types // Types
@ -116,12 +117,10 @@ impl RosterState {
tokio::spawn(async move { tokio::spawn(async move {
match crate::db::queries::get_my_roster(&pool, &abbrev, season).await { match crate::db::queries::get_my_roster(&pool, &abbrev, season).await {
Ok(roster) => { Ok(roster) => {
let mut swar_total = 0.0; let majors = build_roster_rows(&pool, &roster.majors).await;
let minors = build_roster_rows(&pool, &roster.minors).await;
let majors = build_roster_rows(&pool, &roster.majors, &mut swar_total).await; let il = build_roster_rows(&pool, &roster.il).await;
let mut _unused = 0.0; let swar_total = majors.iter().filter_map(|r| r.player.swar).sum();
let minors = build_roster_rows(&pool, &roster.minors, &mut _unused).await;
let il = build_roster_rows(&pool, &roster.il, &mut _unused).await;
let _ = tx.send(AppMessage::RosterPlayersLoaded { let _ = tx.send(AppMessage::RosterPlayersLoaded {
majors, majors,
@ -253,35 +252,28 @@ impl RosterState {
} }
fn render_tabs(&self, frame: &mut Frame, area: Rect) { fn render_tabs(&self, frame: &mut Frame, area: Rect) {
let tabs = [ let tab_style = |tab: RosterTab| {
(RosterTab::Majors, "1", "Majors", self.majors.len(), self.major_slots), if self.active_tab == tab {
(RosterTab::Minors, "2", "Minors", self.minors.len(), self.minor_slots),
];
let mut spans: Vec<Span> = Vec::new();
for (tab, key, label, count, slots) in &tabs {
let is_active = self.active_tab == *tab;
let style = if is_active {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else { } else {
Style::default().fg(Color::DarkGray) 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(
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()), format!(" [3] IL ({})", self.il.len()),
il_style, tab_style(RosterTab::IL),
)); ),
];
frame.render_widget(Paragraph::new(Line::from(spans)), area); 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) // 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 (batters, pitchers): (Vec<&RosterRow>, Vec<&RosterRow>) =
let pitchers: Vec<&RosterRow> = rows_data.iter().filter(|r| r.player.is_pitcher()).collect(); rows_data.iter().partition(|r| r.player.is_batter());
// Map combined selection index to the correct sub-table // Map combined selection index to the correct sub-table
let selected = self.table_state.selected(); let selected = self.table_state.selected();
@ -458,17 +450,9 @@ impl RosterState {
// Helpers // Helpers
// ============================================================================= // =============================================================================
async fn build_roster_rows( async fn build_roster_rows(pool: &SqlitePool, players: &[Player]) -> Vec<RosterRow> {
pool: &SqlitePool,
players: &[Player],
swar_total: &mut f64,
) -> Vec<RosterRow> {
let mut rows = Vec::with_capacity(players.len()); let mut rows = Vec::with_capacity(players.len());
for player in players { 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() { 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 { match crate::db::queries::get_pitcher_card(pool, player.id).await {
Ok(Some(card)) => ( Ok(Some(card)) => (
@ -501,30 +485,9 @@ async fn build_roster_rows(
rows rows
} }
fn format_rating(val: Option<f64>) -> 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<f64>) -> String {
match val {
Some(v) => format!("{:.2}", v),
None => "".to_string(),
}
}
fn format_endurance(val: Option<i64>) -> String { fn format_endurance(val: Option<i64>) -> String {
match val { match val {
Some(v) => format!("{}", v), Some(v) => v.to_string(),
None => "".to_string(), None => "".to_string(),
} }
} }

View File

@ -105,27 +105,32 @@ struct SettingsSnapshot {
impl SettingsState { impl SettingsState {
pub fn new(settings: &Settings) -> Self { pub fn new(settings: &Settings) -> Self {
let snapshot = SettingsSnapshot { let abbrev = settings.team.abbrev.clone();
abbrev: settings.team.abbrev.clone(), let season_str = settings.team.season.to_string();
season_str: settings.team.season.to_string(), let major_slots_str = settings.team.major_league_slots.to_string();
major_slots_str: settings.team.major_league_slots.to_string(), let minor_slots_str = settings.team.minor_league_slots.to_string();
minor_slots_str: settings.team.minor_league_slots.to_string(), let base_url = settings.api.base_url.clone();
base_url: settings.api.base_url.clone(), let api_key = settings.api.api_key.clone();
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 { Self {
abbrev: snapshot.abbrev.clone(), abbrev,
season_str: snapshot.season_str.clone(), season_str,
major_slots_str: snapshot.major_slots_str.clone(), major_slots_str,
minor_slots_str: snapshot.minor_slots_str.clone(), minor_slots_str,
base_url: snapshot.base_url.clone(), base_url,
api_key: snapshot.api_key.clone(), api_key,
api_key_visible: false, api_key_visible: false,
team_info: None, team_info: None,
focus: SettingsFocus::TeamAbbrev, focus: SettingsFocus::TeamAbbrev,
has_unsaved_changes: false, has_unsaved_changes: false,
original: snapshot, original,
} }
} }
@ -133,6 +138,17 @@ impl SettingsState {
self.focus.is_text_input() 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( pub fn handle_key(
&mut self, &mut self,
key: KeyEvent, key: KeyEvent,
@ -244,17 +260,18 @@ impl SettingsState {
SettingsFocus::MinorSlots => { self.minor_slots_str.pop(); } SettingsFocus::MinorSlots => { self.minor_slots_str.pop(); }
SettingsFocus::ApiUrl => { self.base_url.pop(); } SettingsFocus::ApiUrl => { self.base_url.pop(); }
SettingsFocus::ApiKey => { self.api_key.pop(); } SettingsFocus::ApiKey => { self.api_key.pop(); }
_ => {} SettingsFocus::SaveButton | SettingsFocus::ResetButton => {}
} }
} }
fn check_unsaved(&mut self) { fn check_unsaved(&mut self) {
self.has_unsaved_changes = self.abbrev != self.original.abbrev let snap = self.current_snapshot();
|| self.season_str != self.original.season_str self.has_unsaved_changes = snap.abbrev != self.original.abbrev
|| self.major_slots_str != self.original.major_slots_str || snap.season_str != self.original.season_str
|| self.minor_slots_str != self.original.minor_slots_str || snap.major_slots_str != self.original.major_slots_str
|| self.base_url != self.original.base_url || snap.minor_slots_str != self.original.minor_slots_str
|| self.api_key != self.original.api_key; || snap.base_url != self.original.base_url
|| snap.api_key != self.original.api_key;
} }
fn validate_team(&self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) { fn validate_team(&self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) {
@ -267,25 +284,19 @@ impl SettingsState {
let pool = pool.clone(); let pool = pool.clone();
let tx = tx.clone(); let tx = tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
match crate::db::queries::get_team_by_abbrev(&pool, &abbrev, season).await { let result = crate::db::queries::get_team_by_abbrev(&pool, &abbrev, season)
Ok(Some(team)) => { .await
.ok()
.flatten()
.map(|team| {
let name = if team.long_name.is_empty() { let name = if team.long_name.is_empty() {
team.short_name team.short_name
} else { } else {
team.long_name team.long_name
}; };
let _ = tx.send(AppMessage::SettingsTeamValidated(Some(( (name, team.salary_cap)
name, });
team.salary_cap, let _ = tx.send(AppMessage::SettingsTeamValidated(result));
))));
}
Ok(None) => {
let _ = tx.send(AppMessage::SettingsTeamValidated(None));
}
Err(_) => {
let _ = tx.send(AppMessage::SettingsTeamValidated(None));
}
}
}); });
} }
@ -324,14 +335,7 @@ impl SettingsState {
Ok(toml_str) => match std::fs::write("settings.toml", &toml_str) { Ok(toml_str) => match std::fs::write("settings.toml", &toml_str) {
Ok(_) => { Ok(_) => {
self.has_unsaved_changes = false; self.has_unsaved_changes = false;
self.original = SettingsSnapshot { self.original = self.current_snapshot();
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(),
};
let _ = tx.send(AppMessage::Notify( let _ = tx.send(AppMessage::Notify(
"Settings saved to settings.toml (restart to apply)".to_string(), "Settings saved to settings.toml (restart to apply)".to_string(),
NotifyLevel::Success, NotifyLevel::Success,
@ -366,8 +370,7 @@ impl SettingsState {
} }
pub fn handle_message(&mut self, msg: AppMessage) { pub fn handle_message(&mut self, msg: AppMessage) {
match msg { if let AppMessage::SettingsTeamValidated(result) = msg {
AppMessage::SettingsTeamValidated(result) => {
self.team_info = match result { self.team_info = match result {
Some((name, cap)) => { Some((name, cap)) => {
let cap_str = cap.map(|c| format!(" (Cap: {:.2})", c)).unwrap_or_default(); let cap_str = cap.map(|c| format!(" (Cap: {:.2})", c)).unwrap_or_default();
@ -376,8 +379,6 @@ impl SettingsState {
None => Some("Team not found".to_string()), None => Some("Team not found".to_string()),
}; };
} }
_ => {}
}
} }
// ========================================================================= // =========================================================================

View File

@ -6,12 +6,13 @@ use ratatui::{
widgets::{Cell, Paragraph, Row, Table}, widgets::{Cell, Paragraph, Row, Table},
Frame, Frame,
}; };
use sqlx::sqlite::SqlitePool;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::api::types::StandingsEntry; use crate::api::types::StandingsEntry;
use crate::app::{AppMessage, NotifyLevel}; use crate::app::{AppMessage, NotifyLevel};
use crate::config::Settings; use crate::config::Settings;
use sqlx::sqlite::SqlitePool; use crate::screens::format_relative_time;
// ============================================================================= // =============================================================================
// Types // Types
@ -131,7 +132,6 @@ impl StandingsState {
match client.get_standings(season).await { match client.get_standings(season).await {
Ok(entries) => { Ok(entries) => {
// Cache to DB
if let Ok(json) = serde_json::to_string(&entries) { if let Ok(json) = serde_json::to_string(&entries) {
let _ = crate::db::queries::upsert_standings_cache( let _ = crate::db::queries::upsert_standings_cache(
&pool, season, &json, &pool, season, &json,
@ -146,7 +146,6 @@ impl StandingsState {
format!("Standings refresh failed: {e}"), format!("Standings refresh failed: {e}"),
NotifyLevel::Error, NotifyLevel::Error,
)); ));
// Send empty refresh to clear the refreshing indicator
let _ = tx.send(AppMessage::StandingsRefreshed(Vec::new())); let _ = tx.send(AppMessage::StandingsRefreshed(Vec::new()));
} }
} }
@ -191,7 +190,6 @@ impl StandingsState {
self.process_standings(&entries); self.process_standings(&entries);
self.last_updated = fetched_at; self.last_updated = fetched_at;
} }
// Only clear loading if we got data; otherwise wait for API
if !self.divisions.is_empty() { if !self.divisions.is_empty() {
self.is_loading = false; self.is_loading = false;
} }
@ -209,9 +207,10 @@ impl StandingsState {
} }
fn process_standings(&mut self, entries: &[StandingsEntry]) { fn process_standings(&mut self, entries: &[StandingsEntry]) {
let rows: Vec<StandingsRow> = entries.iter().map(|e| self.entry_to_row(e)).collect(); let team_abbrev = &self.team_abbrev;
let rows: Vec<StandingsRow> =
entries.iter().map(|e| entry_to_row(e, team_abbrev)).collect();
// Group by division
let mut div_map: std::collections::BTreeMap<String, Vec<StandingsRow>> = let mut div_map: std::collections::BTreeMap<String, Vec<StandingsRow>> =
std::collections::BTreeMap::new(); std::collections::BTreeMap::new();
for row in &rows { for row in &rows {
@ -227,85 +226,28 @@ impl StandingsState {
.into_iter() .into_iter()
.map(|(name, mut teams)| { .map(|(name, mut teams)| {
teams.sort_by(|a, b| { teams.sort_by(|a, b| {
b.pct b.pct.partial_cmp(&a.pct).unwrap_or(std::cmp::Ordering::Equal)
.partial_cmp(&a.pct)
.unwrap_or(std::cmp::Ordering::Equal)
}); });
DivisionGroup { name, teams } DivisionGroup { name, teams }
}) })
.collect(); .collect();
// Wildcard: non-division-leaders sorted by record // Wildcard: non-division-leaders sorted by record
let mut div_leaders: std::collections::HashSet<String> = std::collections::HashSet::new(); let div_leaders: std::collections::HashSet<&str> = self
for div in &self.divisions { .divisions
if let Some(leader) = div.teams.first() { .iter()
div_leaders.insert(leader.abbrev.clone()); .filter_map(|d| d.teams.first())
} .map(|t| t.abbrev.as_str())
} .collect();
let mut wc: Vec<StandingsRow> = rows let mut wc: Vec<StandingsRow> = rows
.into_iter() .into_iter()
.filter(|r| !div_leaders.contains(&r.abbrev)) .filter(|r| !div_leaders.contains(r.abbrev.as_str()))
.collect(); .collect();
wc.sort_by(|a, b| { wc.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)
});
self.wildcard = wc; 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 { fn active_row_count(&self) -> usize {
match self.active_tab { match self.active_tab {
StandingsTab::Division => self.divisions.iter().map(|d| d.teams.len()).sum(), 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) { pub fn render(&self, frame: &mut Frame, area: Rect, tick_count: u64) {
let chunks = Layout::vertical([ let chunks = Layout::vertical([
Constraint::Length(1), // title + status Constraint::Length(1),
Constraint::Length(1), // tabs Constraint::Length(1),
Constraint::Min(0), // content Constraint::Min(0),
Constraint::Length(1), // hints Constraint::Length(1),
]) ])
.split(area); .split(area);
@ -351,7 +293,6 @@ impl StandingsState {
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)]; )];
// Last updated
if let Some(ts) = &self.last_updated { if let Some(ts) = &self.last_updated {
spans.push(Span::styled( spans.push(Span::styled(
format!(" (updated {})", format_relative_time(ts)), format!(" (updated {})", format_relative_time(ts)),
@ -359,7 +300,6 @@ impl StandingsState {
)); ));
} }
// Refreshing indicator
if self.is_refreshing { if self.is_refreshing {
let spinner = ['|', '/', '-', '\\'][(tick_count as usize / 2) % 4]; let spinner = ['|', '/', '-', '\\'][(tick_count as usize / 2) % 4];
spans.push(Span::styled( spans.push(Span::styled(
@ -403,34 +343,19 @@ impl StandingsState {
return; return;
} }
// Build flat list of rows with division headers
let mut all_rows: Vec<Row> = Vec::new(); let mut all_rows: Vec<Row> = Vec::new();
let mut team_index = 0usize; let mut team_index = 0usize;
for div in &self.divisions { for div in &self.divisions {
// Division header row all_rows.push(Row::new(vec![Cell::from(Span::styled(
let header_cells = vec![
Cell::from(Span::styled(
format!(" {}", div.name), format!(" {}", div.name),
Style::default() Style::default()
.fg(Color::Cyan) .fg(Color::Cyan)
.add_modifier(Modifier::BOLD), .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));
for team in &div.teams { for team in &div.teams {
let is_selected = self.scroll_offset == team_index; all_rows.push(build_standings_row(team, self.scroll_offset == team_index));
all_rows.push(build_standings_row(team, is_selected));
team_index += 1; team_index += 1;
} }
} }
@ -469,52 +394,94 @@ impl StandingsState {
// Helpers // 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> { fn build_standings_row(team: &StandingsRow, is_selected: bool) -> Row<'static> {
let base_style = if team.is_my_team { let row_style = {
Style::default() let mut s = Style::default();
.fg(Color::Yellow) if team.is_my_team {
.add_modifier(Modifier::BOLD) s = s.fg(Color::Yellow).add_modifier(Modifier::BOLD);
} else if is_selected { }
Style::default().bg(Color::DarkGray) if is_selected {
} else { s = s.bg(Color::DarkGray);
Style::default() }
s
}; };
let combined_style = if team.is_my_team && is_selected { let diff_str = format!("{:+}", team.run_diff);
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_style = if team.run_diff > 0 { 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 Color::Yellow
} else { } else {
Color::Green Color::Green
}) })
} else if team.run_diff < 0 { } else if team.run_diff < 0 {
combined_style.fg(Color::Red) row_style.fg(Color::Red)
} else { } else {
combined_style row_style
}; };
Row::new(vec![ Row::new(vec![
Cell::from(format!(" {} {}", team.abbrev, team.team_name)).style(combined_style), Cell::from(format!(" {} {}", team.abbrev, team.team_name)).style(row_style),
Cell::from(format!("{:>2}", team.wins)).style(combined_style), Cell::from(format!("{:>2}", team.wins)).style(row_style),
Cell::from(format!("{:>2}", team.losses)).style(combined_style), Cell::from(format!("{:>2}", team.losses)).style(row_style),
Cell::from(format!("{:>5.3}", team.pct)).style(combined_style), Cell::from(format!("{:>5.3}", team.pct)).style(row_style),
Cell::from(format!("{:>5}", team.gb)).style(combined_style), Cell::from(format!("{:>5}", team.gb)).style(row_style),
Cell::from(format!("{:>5}", diff_str)).style(diff_style), Cell::from(format!("{:>5}", diff_str)).style(diff_style),
Cell::from(format!("{:>5}", team.home)).style(combined_style), Cell::from(format!("{:>5}", team.home)).style(row_style),
Cell::from(format!("{:>5}", team.away)).style(combined_style), Cell::from(format!("{:>5}", team.away)).style(row_style),
Cell::from(format!("{:>4}", team.last8)).style(combined_style), Cell::from(format!("{:>4}", team.last8)).style(row_style),
Cell::from(format!("{:>4}", team.streak)).style(combined_style), Cell::from(format!("{:>4}", team.streak)).style(row_style),
]) ])
} }
@ -540,34 +507,15 @@ fn standings_header() -> Row<'static> {
fn standings_widths() -> [Constraint; 10] { fn standings_widths() -> [Constraint; 10] {
[ [
Constraint::Length(24), // Team Constraint::Length(24),
Constraint::Length(3), // W Constraint::Length(3),
Constraint::Length(3), // L Constraint::Length(3),
Constraint::Length(6), // Pct Constraint::Length(6),
Constraint::Length(6), // GB Constraint::Length(6),
Constraint::Length(6), // Diff Constraint::Length(6),
Constraint::Length(6), // Home Constraint::Length(6),
Constraint::Length(6), // Away Constraint::Length(6),
Constraint::Length(5), // L8 Constraint::Length(5),
Constraint::Length(5), // Streak 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)
}

View File

@ -1 +1,22 @@
pub mod selector; pub mod selector;
pub fn format_rating(val: Option<f64>) -> 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<f64>) -> String {
match val {
Some(v) => format!("{v:.2}"),
None => "\u{2014}".to_string(),
}
}

View File

@ -196,9 +196,7 @@ async fn get_players_missing_batter_cards() {
.await .await
.unwrap(); .unwrap();
let missing = queries::get_players_missing_cards(&pool, 13, "batter") let missing = queries::get_batters_missing_cards(&pool, 13).await.unwrap();
.await
.unwrap();
assert_eq!(missing.len(), 1); assert_eq!(missing.len(), 1);
assert_eq!(missing[0].name, "Mookie Betts"); assert_eq!(missing[0].name, "Mookie Betts");
} }