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": {
"PostToolCall": [
"PostToolUse": [
{
"matcher": {
"tool_name": "Edit|Write",
"file_glob": "**/*.rs"
},
"command": "cd /mnt/NV2/Development/sba-scouting/rust && cargo check --message-format=short 2>&1 | grep '^src/' | head -20"
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "if echo \"$TOOL_INPUT\" | grep -q '\\.rs'; then cd /mnt/NV2/Development/sba-scouting/rust && cargo check --message-format=short 2>&1 | grep '^src/' | head -20; fi"
}
]
}
]
}

View File

@ -26,6 +26,8 @@ pub struct LeagueApiClient {
api_key: String,
}
type Params = Vec<(&'static str, String)>;
impl LeagueApiClient {
pub fn new(base_url: &str, api_key: &str, timeout_secs: u64) -> Result<Self> {
let client = Client::builder()
@ -39,11 +41,7 @@ impl LeagueApiClient {
})
}
async fn get<T: DeserializeOwned>(
&self,
path: &str,
params: &[(String, String)],
) -> Result<T, ApiError> {
async fn get<T: DeserializeOwned>(&self, path: &str, params: &Params) -> Result<T, ApiError> {
let url = format!("{}/api/v3{}", self.base_url, path);
let mut req = self.client.get(&url).query(params);
if !self.api_key.is_empty() {
@ -58,15 +56,9 @@ impl LeagueApiClient {
}
return Err(ApiError::Http { status: status.as_u16(), message: body });
}
let body = response.text().await.map_err(ApiError::Request)?;
let json = serde_json::from_str::<T>(&body)?;
Ok(json)
response.json::<T>().await.map_err(ApiError::Request)
}
// -------------------------------------------------------------------------
// Public endpoint methods
// -------------------------------------------------------------------------
pub async fn get_teams(
&self,
season: Option<i64>,
@ -74,26 +66,26 @@ impl LeagueApiClient {
active_only: bool,
short_output: bool,
) -> Result<TeamsResponse, ApiError> {
let mut params: Vec<(String, String)> = Vec::new();
let mut params: Params = Vec::new();
if let Some(s) = season {
params.push(("season".to_string(), s.to_string()));
params.push(("season", s.to_string()));
}
if let Some(abbrevs) = team_abbrev {
for a in abbrevs {
params.push(("team_abbrev".to_string(), a.to_string()));
params.push(("team_abbrev", a.to_string()));
}
}
if active_only {
params.push(("active_only".to_string(), "true".to_string()));
params.push(("active_only", "true".to_string()));
}
if short_output {
params.push(("short_output".to_string(), "true".to_string()));
params.push(("short_output", "true".to_string()));
}
self.get("/teams", &params).await
}
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(
@ -101,7 +93,7 @@ impl LeagueApiClient {
team_id: i64,
which: &str,
) -> 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(
@ -112,25 +104,25 @@ impl LeagueApiClient {
name: Option<&str>,
short_output: bool,
) -> Result<PlayersResponse, ApiError> {
let mut params: Vec<(String, String)> = Vec::new();
let mut params: Params = Vec::new();
if let Some(s) = season {
params.push(("season".to_string(), s.to_string()));
params.push(("season", s.to_string()));
}
if let Some(ids) = team_id {
for id in ids {
params.push(("team_id".to_string(), id.to_string()));
params.push(("team_id", id.to_string()));
}
}
if let Some(positions) = pos {
for p in positions {
params.push(("pos".to_string(), p.to_string()));
params.push(("pos", p.to_string()));
}
}
if let Some(n) = name {
params.push(("name".to_string(), n.to_string()));
params.push(("name", n.to_string()));
}
if short_output {
params.push(("short_output".to_string(), "true".to_string()));
params.push(("short_output", "true".to_string()));
}
self.get("/players", &params).await
}
@ -140,11 +132,11 @@ impl LeagueApiClient {
player_id: i64,
short_output: bool,
) -> Result<PlayerData, ApiError> {
let mut params: Vec<(String, String)> = Vec::new();
let mut params: Params = Vec::new();
if short_output {
params.push(("short_output".to_string(), "true".to_string()));
params.push(("short_output", "true".to_string()));
}
self.get(&format!("/players/{}", player_id), &params).await
self.get(&format!("/players/{player_id}"), &params).await
}
pub async fn search_players(
@ -153,13 +145,12 @@ impl LeagueApiClient {
season: Option<i64>,
limit: Option<i64>,
) -> Result<PlayersResponse, ApiError> {
let mut params: Vec<(String, String)> = Vec::new();
params.push(("q".to_string(), query.to_string()));
let mut params: Params = vec![("q", query.to_string())];
if let Some(s) = season {
params.push(("season".to_string(), s.to_string()));
params.push(("season", s.to_string()));
}
if let Some(l) = limit {
params.push(("limit".to_string(), l.to_string()));
params.push(("limit", l.to_string()));
}
self.get("/players/search", &params).await
}
@ -174,31 +165,32 @@ impl LeagueApiClient {
frozen: bool,
short_output: bool,
) -> Result<TransactionsResponse, ApiError> {
let mut params: Vec<(String, String)> = Vec::new();
params.push(("season".to_string(), season.to_string()));
params.push(("week_start".to_string(), week_start.to_string()));
let mut params: Params = vec![
("season", season.to_string()),
("week_start", week_start.to_string()),
];
if let Some(abbrevs) = team_abbrev {
for a in abbrevs {
params.push(("team_abbrev".to_string(), a.to_string()));
params.push(("team_abbrev", a.to_string()));
}
}
if let Some(we) = week_end {
params.push(("week_end".to_string(), we.to_string()));
params.push(("week_end", we.to_string()));
}
if cancelled {
params.push(("cancelled".to_string(), "true".to_string()));
params.push(("cancelled", "true".to_string()));
}
if frozen {
params.push(("frozen".to_string(), "true".to_string()));
params.push(("frozen", "true".to_string()));
}
if short_output {
params.push(("short_output".to_string(), "true".to_string()));
params.push(("short_output", "true".to_string()));
}
self.get("/transactions", &params).await
}
pub async fn get_current(&self) -> Result<CurrentResponse, ApiError> {
self.get("/current", &[]).await
self.get("/current", &vec![]).await
}
pub async fn get_schedule(
@ -207,19 +199,18 @@ impl LeagueApiClient {
week: Option<i64>,
team_id: Option<i64>,
) -> Result<serde_json::Value, ApiError> {
let mut params: Vec<(String, String)> = Vec::new();
params.push(("season".to_string(), season.to_string()));
let mut params: Params = vec![("season", season.to_string())];
if let Some(w) = week {
params.push(("week".to_string(), w.to_string()));
params.push(("week", w.to_string()));
}
if let Some(t) = team_id {
params.push(("team_id".to_string(), t.to_string()));
params.push(("team_id", t.to_string()));
}
self.get("/schedules", &params).await
}
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?;
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)"`.
///
/// Returns `(start, relief, close)` — any component may be `None` if absent.
@ -117,20 +138,7 @@ pub async fn import_batter_cards(
update_existing: bool,
) -> Result<ImportResult> {
let mut result = ImportResult::default();
let source = csv_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let mut rdr = csv::ReaderBuilder::new().has_headers(true).from_path(csv_path)?;
// Build O(1) header → column-index lookup before iterating records.
let headers = rdr.headers()?.clone();
let header_index: HashMap<String, usize> =
headers.iter().enumerate().map(|(i, h)| (h.to_string(), i)).collect();
let (mut rdr, header_index, source) = open_csv(csv_path)?;
let mut tx = pool.begin().await?;
for record_result in rdr.records() {
@ -203,15 +211,6 @@ pub async fn import_batter_cards(
Some(parse_int(catcher_arm_str, 0) as i64)
};
// Helpers for optional DB fields.
let opt_str = |v: &str| -> Option<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(
"INSERT OR REPLACE INTO batter_cards (
player_id,
@ -308,19 +307,7 @@ pub async fn import_pitcher_cards(
update_existing: bool,
) -> Result<ImportResult> {
let mut result = ImportResult::default();
let source = csv_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let mut rdr = csv::ReaderBuilder::new().has_headers(true).from_path(csv_path)?;
let headers = rdr.headers()?.clone();
let header_index: HashMap<String, usize> =
headers.iter().enumerate().map(|(i, h)| (h.to_string(), i)).collect();
let (mut rdr, header_index, source) = open_csv(csv_path)?;
let mut tx = pool.begin().await?;
for record_result in rdr.records() {
@ -387,29 +374,12 @@ pub async fn import_pitcher_cards(
};
let (mut endur_start, mut endur_relief, mut endur_close) = parse_endurance(endur_raw);
let sp_raw = get("SP").trim();
if !sp_raw.is_empty() {
endur_start = Some(parse_int(sp_raw, 0));
for (col, slot) in [("SP", &mut endur_start), ("RP", &mut endur_relief), ("CP", &mut endur_close)] {
let v = get(col).trim();
if !v.is_empty() {
*slot = Some(parse_int(v, 0));
}
let rp_raw = get("RP").trim();
if !rp_raw.is_empty() {
endur_relief = Some(parse_int(rp_raw, 0));
}
let cp_raw = get("CP").trim();
if !cp_raw.is_empty() {
endur_close = Some(parse_int(cp_raw, 0));
}
let opt_str = |v: &str| -> Option<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(
"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 mut tx = pool.begin().await?;
let mut count: i64 = 0;
let synced_at = chrono::Utc::now().naive_utc();
let count = response.teams.len() as i64;
for data in response.teams {
let manager1_name = data.manager1.and_then(|m| m.name);
let manager2_name = data.manager2.and_then(|m| m.name);
let gm_discord_id = data.gm_discord_id;
let gm2_discord_id = data.gm2_discord_id;
let division_id = data.division.as_ref().map(|d| d.id).flatten();
let division_id = data.division.as_ref().and_then(|d| d.id);
let division_name = data.division.as_ref().and_then(|d| d.division_name.clone());
let league_abbrev = data.division.as_ref().and_then(|d| d.league_abbrev.clone());
let league_abbrev = data.division.and_then(|d| d.league_abbrev);
sqlx::query(
"INSERT OR REPLACE INTO teams \
@ -42,8 +40,8 @@ pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient
.bind(season)
.bind(manager1_name)
.bind(manager2_name)
.bind(gm_discord_id)
.bind(gm2_discord_id)
.bind(data.gm_discord_id)
.bind(data.gm2_discord_id)
.bind(division_id)
.bind(division_name)
.bind(league_abbrev)
@ -55,8 +53,6 @@ pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient
.bind(synced_at)
.execute(&mut *tx)
.await?;
count += 1;
}
tx.commit().await?;
@ -79,8 +75,8 @@ pub async fn sync_players(
.await?;
let mut tx = pool.begin().await?;
let mut count: i64 = 0;
let synced_at = chrono::Utc::now().naive_utc();
let count = response.players.len() as i64;
for data in response.players {
let player_team_id = data.team.map(|t| t.id);
@ -147,8 +143,6 @@ pub async fn sync_players(
.bind(synced_at)
.execute(&mut *tx)
.await?;
count += 1;
}
tx.commit().await?;
@ -168,9 +162,11 @@ pub async fn sync_transactions(
team_abbrev: Option<&str>,
client: &LeagueApiClient,
) -> Result<i64> {
let abbrev_vec: Vec<&str> = team_abbrev.into_iter().collect();
let abbrev_slice: Option<&[&str]> =
if abbrev_vec.is_empty() { None } else { Some(&abbrev_vec) };
let abbrev_arr;
let abbrev_slice: Option<&[&str]> = match team_abbrev {
Some(s) => { abbrev_arr = [s]; Some(&abbrev_arr) }
None => None,
};
let response = client
.get_transactions(season, week_start, week_end, abbrev_slice, false, false, false)

View File

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

View File

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

View File

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

View File

@ -48,6 +48,32 @@ pub fn calculate_weighted_score(
static DEFAULT_DIST: StatDistribution = StatDistribution { avg: 0.0, stdev: 1.0 };
/// Resolve the batter's effective handedness given pitcher handedness.
///
/// Switch hitters ("S") bat opposite to the pitcher's hand.
/// Returns ("effective_hand", "batter_split_label", "pitcher_split_label").
fn resolve_handedness<'a>(batter_hand: &'a str, pitcher_hand: &str) -> (&'a str, &'static str, &'static str) {
let effective_hand = if batter_hand == "S" {
if pitcher_hand == "R" { "L" } else { "R" }
} else {
batter_hand
};
let batter_split = if pitcher_hand == "L" { "vLHP" } else { "vRHP" };
let pitcher_split = if effective_hand == "L" { "vLHB" } else { "vRHB" };
(effective_hand, batter_split, pitcher_split)
}
/// Sort matchup results descending by rating (rated players first, unrated last).
fn sort_matchups_desc(results: &mut [MatchupResult]) {
results.sort_by(|a, b| match (&b.rating, &a.rating) {
(Some(br), Some(ar)) => br.partial_cmp(ar).unwrap_or(std::cmp::Ordering::Equal),
(Some(_), None) => std::cmp::Ordering::Greater, // b rated, a not → b first
(None, Some(_)) => std::cmp::Ordering::Less, // a rated, b not → a first
(None, None) => std::cmp::Ordering::Equal,
});
}
/// Map (stat_name, pitcher_hand) to the batter card field value.
/// pitcher_hand: "L" → vs left-handed pitchers, "R" → vs right-handed pitchers.
pub(crate) fn get_batter_stat(card: &BatterCard, stat: &str, pitcher_hand: &str) -> f64 {
@ -227,16 +253,7 @@ pub fn calculate_matchup(
) -> MatchupResult {
let batter_hand = player.hand.as_deref().unwrap_or("R");
let pitcher_hand = pitcher.hand.as_deref().unwrap_or("R");
// Switch hitter bats opposite of pitcher's hand
let effective_hand = if batter_hand == "S" {
if pitcher_hand == "R" { "L" } else { "R" }
} else {
batter_hand
};
let batter_split = if pitcher_hand == "L" { "vLHP" } else { "vRHP" };
let pitcher_split = if effective_hand == "L" { "vLHB" } else { "vRHB" };
let (effective_hand, batter_split, pitcher_split) = resolve_handedness(batter_hand, pitcher_hand);
let Some(batter_card) = batter_card else {
return MatchupResult {
@ -296,13 +313,7 @@ pub fn calculate_team_matchups(
})
.collect();
results.sort_by(|a, b| match (&b.rating, &a.rating) {
(Some(br), Some(ar)) => br.partial_cmp(ar).unwrap_or(std::cmp::Ordering::Equal),
(Some(_), None) => std::cmp::Ordering::Greater, // b rated, a not → b first
(None, Some(_)) => std::cmp::Ordering::Less, // a rated, b not → a first
(None, None) => std::cmp::Ordering::Equal,
});
sort_matchups_desc(&mut results);
results
}
@ -320,17 +331,9 @@ pub async fn calculate_matchup_cached(
) -> Result<MatchupResult> {
let batter_hand = player.hand.as_deref().unwrap_or("R");
let pitcher_hand = pitcher.hand.as_deref().unwrap_or("R");
// Switch hitter bats opposite of pitcher's hand
let effective_hand = if batter_hand == "S" {
if pitcher_hand == "R" { "L" } else { "R" }
} else {
batter_hand
};
let batter_split_label = if pitcher_hand == "L" { "vLHP" } else { "vRHP" };
let (effective_hand, batter_split_label, pitcher_split_label) =
resolve_handedness(batter_hand, pitcher_hand);
let batter_split_key = if pitcher_hand == "L" { "vlhp" } else { "vrhp" };
let pitcher_split_label = if effective_hand == "L" { "vLHB" } else { "vRHB" };
let pitcher_split_key = if effective_hand == "L" { "vlhb" } else { "vrhb" };
let no_rating = || MatchupResult {
@ -399,13 +402,7 @@ pub async fn calculate_team_matchups_cached(
results.push(result);
}
results.sort_by(|a, b| match (&b.rating, &a.rating) {
(Some(br), Some(ar)) => br.partial_cmp(ar).unwrap_or(std::cmp::Ordering::Equal),
(Some(_), None) => std::cmp::Ordering::Greater, // b rated, a not → b first
(None, Some(_)) => std::cmp::Ordering::Less, // a rated, b not → a first
(None, None) => std::cmp::Ordering::Equal,
});
sort_matchups_desc(&mut results);
Ok(results)
}

View File

@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::SqlitePool;
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::calc::league_stats::{
calculate_batter_league_stats, calculate_pitcher_league_stats, BatterLeagueStats,
@ -30,11 +31,31 @@ pub struct CacheRebuildResult {
pub pitcher_splits: i64,
}
/// Generate a stable hash of the current weight configuration.
// =============================================================================
// Hashing helpers
// =============================================================================
/// SHA-256 hash of `data`, returned as the first 16 lowercase hex characters.
fn hex16_hash(data: impl AsRef<[u8]>) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
let result = hasher.finalize();
// format each byte as two hex digits, take first 16 chars (8 bytes)
result.iter().take(8).map(|b| format!("{:02x}", b)).collect()
}
/// Cached hash of the compile-time weight configuration.
///
/// BATTER_WEIGHTS and PITCHER_WEIGHTS are constants — their hash never changes
/// at runtime. Computed once on first call via OnceLock.
static WEIGHTS_HASH: OnceLock<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.
/// Returns the first 16 hex characters.
fn compute_weights_hash() -> String {
/// Result is cached in a OnceLock since weights are compile-time constants.
fn compute_weights_hash() -> &'static str {
WEIGHTS_HASH.get_or_init(|| {
let batter: std::collections::BTreeMap<&str, (i32, bool)> = BATTER_WEIGHTS
.iter()
.map(|(name, w)| (*name, (w.weight, w.high_is_better)))
@ -49,11 +70,8 @@ fn compute_weights_hash() -> String {
"pitcher": pitcher,
});
let mut hasher = Sha256::new();
hasher.update(data.to_string().as_bytes());
let result = hasher.finalize();
let hex: String = result.iter().map(|b| format!("{:02x}", b)).collect();
hex[..16].to_string()
hex16_hash(data.to_string().as_bytes())
})
}
/// Generate a hash of representative league stat values to detect significant changes.
@ -67,15 +85,13 @@ fn compute_league_stats_hash(batter: &BatterLeagueStats, pitcher: &PitcherLeague
pitcher.hit_vrhb.avg,
pitcher.so_vrhb.avg,
];
let repr = format!("{:?}", key_values);
let mut hasher = Sha256::new();
hasher.update(repr.as_bytes());
let result = hasher.finalize();
let hex: String = result.iter().map(|b| format!("{:02x}", b)).collect();
hex[..16].to_string()
hex16_hash(format!("{:?}", key_values).as_bytes())
}
// =============================================================================
// Split score calculators
// =============================================================================
/// Calculate standardized scores for all stats on a batter card split.
///
/// `split` is "vlhp" (vs left-handed pitchers) or "vrhp" (vs right-handed pitchers).
@ -128,10 +144,15 @@ fn calculate_pitcher_split_scores(
(total, stat_scores)
}
// =============================================================================
// Cache management
// =============================================================================
/// Rebuild the entire standardized score cache.
///
/// Clears all existing entries and recalculates scores for every batter and pitcher
/// card using current league statistics and weight configuration.
/// card using current league statistics and weight configuration. All inserts are
/// performed inside a single transaction for atomicity and performance.
pub async fn rebuild_score_cache(pool: &SqlitePool) -> Result<CacheRebuildResult> {
let batter_stats = calculate_batter_league_stats(pool).await?;
let pitcher_stats = calculate_pitcher_league_stats(pool).await?;
@ -150,6 +171,10 @@ pub async fn rebuild_score_cache(pool: &SqlitePool) -> Result<CacheRebuildResult
let mut batter_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 split in ["vlhp", "vrhp"] {
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(&stat_scores_json)
.bind(computed_at)
.bind(&weights_hash)
.bind(weights_hash)
.bind(&league_hash)
.execute(pool)
.execute(&mut *tx)
.await?;
batter_count += 1;
}
@ -189,14 +214,16 @@ pub async fn rebuild_score_cache(pool: &SqlitePool) -> Result<CacheRebuildResult
.bind(total)
.bind(&stat_scores_json)
.bind(computed_at)
.bind(&weights_hash)
.bind(weights_hash)
.bind(&league_hash)
.execute(pool)
.execute(&mut *tx)
.await?;
pitcher_count += 1;
}
}
tx.commit().await?;
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),
};
let current_weights_hash = compute_weights_hash();
Ok(entry.weights_hash.as_deref() == Some(current_weights_hash.as_str()))
Ok(entry.weights_hash.as_deref() == Some(compute_weights_hash()))
}
/// Ensure the score cache exists, rebuilding if necessary.

View File

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

View File

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

View File

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

View File

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

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 _log_guard = init_logging(log_dir);
if let Some(parent) = settings.db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let db_parent = settings.db_path.parent().unwrap_or(std::path::Path::new("data"));
let _log_guard = init_logging(db_parent);
std::fs::create_dir_all(db_parent)?;
let pool = db::schema::init_pool(&settings.db_path).await?;
db::schema::create_tables(&pool).await?;

View File

@ -14,6 +14,7 @@ use tokio::sync::mpsc;
use crate::app::{AppMessage, NotifyLevel};
use crate::config::Settings;
use crate::screens::format_relative_time;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncState {
@ -107,7 +108,7 @@ impl DashboardState {
}
});
// Load team info (full name)
// Load team info and missing cards in a single query
let pool_c = pool.clone();
let tx_c = tx.clone();
let abbrev = self.team_abbrev.clone();
@ -117,28 +118,19 @@ impl DashboardState {
crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await
{
let _ = tx_c.send(AppMessage::TeamInfoLoaded(team.short_name, team.salary_cap));
}
});
// Load missing cards
let pool_c = pool.clone();
let tx_c = tx.clone();
let abbrev = self.team_abbrev.clone();
let season = self.season;
tokio::spawn(async move {
if let Ok(Some(team)) =
crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await
{
let format_player = |p: crate::db::models::Player| {
let pos = p.pos_1.as_deref().unwrap_or("?");
format!("{} ({})", p.name, pos)
};
let batters = crate::db::queries::get_players_missing_batter_cards(
&pool_c, team.id, season,
)
.await
.unwrap_or_default()
.into_iter()
.map(|p| {
let pos = p.pos_1.as_deref().unwrap_or("?");
format!("{} ({})", p.name, pos)
})
.map(format_player)
.collect();
let pitchers = crate::db::queries::get_players_missing_pitcher_cards(
@ -147,10 +139,7 @@ impl DashboardState {
.await
.unwrap_or_default()
.into_iter()
.map(|p| {
let pos = p.pos_1.as_deref().unwrap_or("?");
format!("{} ({})", p.name, pos)
})
.map(format_player)
.collect();
let _ = tx_c.send(AppMessage::MissingCardsLoaded { batters, pitchers });
@ -192,8 +181,6 @@ impl DashboardState {
if self.sync_state == SyncState::Syncing {
return;
}
self.sync_state = SyncState::Syncing;
self.sync_message = "Syncing...".to_string();
let pool = pool.clone();
let settings = settings.clone();
let tx = tx.clone();
@ -212,6 +199,7 @@ impl DashboardState {
&mut self,
msg: AppMessage,
pool: &SqlitePool,
settings: &Settings,
tx: &mpsc::UnboundedSender<AppMessage>,
) {
match msg {
@ -264,6 +252,21 @@ impl DashboardState {
if self.sync_state == SyncState::Never && !self.sync_statuses.is_empty() {
self.sync_state = SyncState::Success;
}
// Auto-sync if last sync was more than 24 hours ago (or never synced)
if self.sync_state != SyncState::Syncing {
let needs_sync = if self.sync_statuses.is_empty() {
true
} else {
let now = chrono::Utc::now().naive_utc();
self.sync_statuses.iter().all(|s| match &s.last_sync {
Some(dt) => now.signed_duration_since(*dt).num_hours() >= 24,
None => true,
})
};
if needs_sync {
self.trigger_sync(pool, settings, tx);
}
}
}
AppMessage::SyncStarted => {
self.sync_state = SyncState::Syncing;
@ -431,40 +434,11 @@ impl DashboardState {
let batter_positions = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"];
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![
Line::from(spans),
Line::from(pitcher_spans),
Line::from(build_position_spans(&batter_positions, &self.position_counts)),
Line::from(build_position_spans(&pitcher_positions, &self.position_counts)),
];
let widget = Paragraph::new(lines)
.block(
let widget = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Position Coverage"),
@ -512,25 +486,8 @@ impl DashboardState {
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
// Batters missing cards
let batter_lines: Vec<Line> = if self.batters_missing_cards.is_empty() {
vec![Line::from(Span::styled(
"All cards imported",
Style::default().fg(Color::Green),
))]
} else {
self.batters_missing_cards
.iter()
.take(4)
.map(|name| {
Line::from(Span::styled(
format!(" {}", name),
Style::default().fg(Color::Red),
))
})
.collect()
};
let batter_widget = Paragraph::new(batter_lines).block(
let batter_widget =
Paragraph::new(missing_cards_lines(&self.batters_missing_cards)).block(
Block::default()
.borders(Borders::ALL)
.title(format!(
@ -540,25 +497,8 @@ impl DashboardState {
);
frame.render_widget(batter_widget, cols[0]);
// Pitchers missing cards
let pitcher_lines: Vec<Line> = if self.pitchers_missing_cards.is_empty() {
vec![Line::from(Span::styled(
"All cards imported",
Style::default().fg(Color::Green),
))]
} else {
self.pitchers_missing_cards
.iter()
.take(4)
.map(|name| {
Line::from(Span::styled(
format!(" {}", name),
Style::default().fg(Color::Red),
))
})
.collect()
};
let pitcher_widget = Paragraph::new(pitcher_lines).block(
let pitcher_widget =
Paragraph::new(missing_cards_lines(&self.pitchers_missing_cards)).block(
Block::default()
.borders(Borders::ALL)
.title(format!(
@ -637,25 +577,42 @@ impl DashboardState {
}
}
fn format_relative_time(dt: &NaiveDateTime) -> String {
let now = chrono::Utc::now().naive_utc();
let diff = now.signed_duration_since(*dt);
fn build_position_spans<'a>(
positions: &[&'a str],
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();
if secs < 0 {
return "just now".to_string();
fn missing_cards_lines(items: &[String]) -> Vec<Line<'_>> {
if items.is_empty() {
vec![Line::from(Span::styled(
"All cards imported",
Style::default().fg(Color::Green),
))]
} else {
items
.iter()
.take(4)
.map(|name| {
Line::from(Span::styled(
format!(" {}", name),
Style::default().fg(Color::Red),
))
})
.collect()
}
if secs < 60 {
return "just now".to_string();
}
let mins = secs / 60;
if mins < 60 {
return format!("{}m ago", mins);
}
let hours = mins / 60;
if hours < 24 {
return format!("{}h ago", hours);
}
let days = hours / 24;
format!("{}d ago", days)
}

View File

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

View File

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
@ -15,6 +15,7 @@ use crate::app::{AppMessage, NotifyLevel};
use crate::config::Settings;
use crate::db::models::{BatterCard, Lineup, Player};
use crate::widgets::selector::{SelectorEvent, SelectorWidget};
use crate::widgets::{format_rating, format_swar};
// =============================================================================
// Types
@ -96,7 +97,9 @@ impl LineupState {
}
pub fn is_input_captured(&self) -> bool {
matches!(self.focus, LineupFocus::LineupName) || self.load_selector.is_open || self.confirm_delete
matches!(self.focus, LineupFocus::LineupName)
|| self.load_selector.is_open
|| self.confirm_delete
}
pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) {
@ -109,8 +112,8 @@ impl LineupState {
let season = self.season;
tokio::spawn(async move {
let team = crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await;
if let Ok(Some(team)) = team {
if let Ok(batters) =
if let Ok(Some(team)) = team
&& let Ok(batters) =
crate::db::queries::get_batters(&pool_c, Some(team.id), Some(season)).await
{
let mut with_cards = Vec::with_capacity(batters.len());
@ -123,17 +126,10 @@ impl LineupState {
}
let _ = tx_c.send(AppMessage::LineupBattersLoaded(with_cards));
}
}
});
// Load saved lineups
let pool_c = pool.clone();
let tx_c = tx.clone();
tokio::spawn(async move {
if let Ok(lineups) = crate::db::queries::get_lineups(&pool_c).await {
let _ = tx_c.send(AppMessage::LineupListLoaded(lineups));
}
});
Self::reload_lineups(pool, tx);
}
pub fn handle_key(
@ -231,9 +227,7 @@ impl LineupState {
return;
}
KeyCode::Char('c') => {
for i in 0..9 {
self.lineup_slots[i] = LineupSlot::empty(i + 1);
}
self.clear_slots();
return;
}
_ => {}
@ -332,8 +326,14 @@ impl LineupState {
// Lineup operations
// =========================================================================
fn clear_slots(&mut self) {
for i in 0..9 {
self.lineup_slots[i] = LineupSlot::empty(i + 1);
}
}
fn filtered_available(&self) -> Vec<&(Player, Option<BatterCard>)> {
let lineup_ids: Vec<i64> = self
let lineup_ids: HashSet<i64> = self
.lineup_slots
.iter()
.filter_map(|s| s.player_id)
@ -359,7 +359,7 @@ impl LineupState {
};
// Auto-suggest position: first eligible not already assigned
let assigned_positions: Vec<String> = self
let assigned_positions: HashSet<String> = self
.lineup_slots
.iter()
.filter_map(|s| s.position.clone())
@ -367,10 +367,13 @@ impl LineupState {
let eligible = player.positions();
let pos = eligible
.iter()
.find(|p| !assigned_positions.contains(&p.to_string()))
.find(|p| !assigned_positions.contains(**p as &str))
.map(|p| p.to_string())
.or_else(|| Some("DH".to_string()));
// Compute new available length before mutating slots
let new_available_len = available.len() - 1;
self.lineup_slots[slot_idx] = LineupSlot {
order: slot_idx + 1,
player_id: Some(player.id),
@ -379,7 +382,6 @@ impl LineupState {
};
// Adjust available table selection
let new_available_len = self.filtered_available().len();
if new_available_len > 0 {
let new_idx = idx.min(new_available_len - 1);
self.available_table_state.select(Some(new_idx));
@ -439,10 +441,7 @@ impl LineupState {
let order = lineup.batting_order_vec();
let positions_map = lineup.positions_map();
// Clear all slots
for i in 0..9 {
self.lineup_slots[i] = LineupSlot::empty(i + 1);
}
self.clear_slots();
// Populate from saved lineup
for (i, pid) in order.iter().enumerate() {
@ -546,6 +545,16 @@ impl LineupState {
});
}
fn reload_lineups(pool: &SqlitePool, tx: &mpsc::UnboundedSender<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(
&mut self,
msg: AppMessage,
@ -565,46 +574,30 @@ impl LineupState {
}
AppMessage::LineupSaved(name) => {
let _ = tx.send(AppMessage::Notify(
format!("Lineup '{}' saved", name),
format!("Lineup '{name}' saved"),
NotifyLevel::Success,
));
// Refresh lineups list
let pool = pool.clone();
let tx = tx.clone();
tokio::spawn(async move {
if let Ok(lineups) = crate::db::queries::get_lineups(&pool).await {
let _ = tx.send(AppMessage::LineupListLoaded(lineups));
}
});
Self::reload_lineups(pool, tx);
}
AppMessage::LineupSaveError(e) => {
let _ = tx.send(AppMessage::Notify(
format!("Save failed: {}", e),
format!("Save failed: {e}"),
NotifyLevel::Error,
));
}
AppMessage::LineupDeleted(name) => {
let _ = tx.send(AppMessage::Notify(
format!("Lineup '{}' deleted", name),
format!("Lineup '{name}' deleted"),
NotifyLevel::Success,
));
self.lineup_name.clear();
self.lineup_name_cursor = 0;
for i in 0..9 {
self.lineup_slots[i] = LineupSlot::empty(i + 1);
}
// Refresh lineups list
let pool = pool.clone();
let tx = tx.clone();
tokio::spawn(async move {
if let Ok(lineups) = crate::db::queries::get_lineups(&pool).await {
let _ = tx.send(AppMessage::LineupListLoaded(lineups));
}
});
self.clear_slots();
Self::reload_lineups(pool, tx);
}
AppMessage::LineupDeleteError(e) => {
let _ = tx.send(AppMessage::Notify(
format!("Delete failed: {}", e),
format!("Delete failed: {e}"),
NotifyLevel::Error,
));
}
@ -665,7 +658,8 @@ impl LineupState {
let available = self.filtered_available();
if available.is_empty() {
frame.render_widget(
Paragraph::new(" No available batters").style(Style::default().fg(Color::DarkGray)),
Paragraph::new(" No available batters")
.style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
} else {
@ -685,7 +679,7 @@ impl LineupState {
let (vl, vr) = card
.as_ref()
.map(|c| (format_rating(c.rating_vl), format_rating(c.rating_vr)))
.unwrap_or(("".to_string(), "".to_string()));
.unwrap_or_else(|| ("\u{2014}".to_string(), "\u{2014}".to_string()));
Row::new(vec![
Cell::from(p.name.clone()),
Cell::from(p.positions().join("/")),
@ -715,7 +709,8 @@ impl LineupState {
}
frame.render_widget(
Paragraph::new(" [Enter] Add [j/k] Scroll").style(Style::default().fg(Color::DarkGray)),
Paragraph::new(" [Enter] Add [j/k] Scroll")
.style(Style::default().fg(Color::DarkGray)),
chunks[2],
);
}
@ -734,7 +729,8 @@ impl LineupState {
} else {
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)
} else {
Style::default().fg(Color::DarkGray)
@ -767,7 +763,7 @@ impl LineupState {
.map(|slot| {
if slot.is_empty() {
Row::new(vec![
Cell::from(format!("{}", slot.order)),
Cell::from(slot.order.to_string()),
Cell::from("(empty)").style(Style::default().fg(Color::DarkGray)),
Cell::from(""),
Cell::from(""),
@ -775,25 +771,24 @@ impl LineupState {
])
} else {
let name = slot.player_name.as_deref().unwrap_or("?");
let pos = slot.position.as_deref().unwrap_or("");
let eligible = self
let pos = slot.position.as_deref().unwrap_or("\u{2014}");
let player = self
.available_batters
.iter()
.find(|(p, _)| Some(p.id) == slot.player_id)
.map(|(p, _)| p.positions().join("/"))
.map(|(p, _)| p);
let eligible = player
.map(|p| p.positions().join("/"))
.unwrap_or_default();
let hand = self
.available_batters
.iter()
.find(|(p, _)| Some(p.id) == slot.player_id)
.and_then(|(p, _)| p.hand.clone())
let hand = player
.and_then(|p| p.hand.as_deref())
.unwrap_or_default();
Row::new(vec![
Cell::from(format!("{}", slot.order)),
Cell::from(slot.order.to_string()),
Cell::from(name.to_string()),
Cell::from(pos.to_string()),
Cell::from(eligible),
Cell::from(hand),
Cell::from(hand.to_string()),
])
}
})
@ -822,28 +817,3 @@ impl LineupState {
frame.render_widget(hints, chunks[2]);
}
}
// =============================================================================
// Helpers
// =============================================================================
fn format_rating(val: Option<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::config::Settings;
use crate::db::models::{BatterCard, Player};
use crate::widgets::selector::{SelectorEvent, SelectorWidget};
use crate::screens::tier_style;
use crate::widgets::{format_swar, selector::{SelectorEvent, SelectorWidget}};
// =============================================================================
// Types
@ -134,8 +135,8 @@ impl MatchupState {
let season = self.season;
tokio::spawn(async move {
let team = crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await;
if let Ok(Some(team)) = team {
if let Ok(batters) =
if let Ok(Some(team)) = team
&& let Ok(batters) =
crate::db::queries::get_batters(&pool_c, Some(team.id), Some(season)).await
{
let mut with_cards = Vec::with_capacity(batters.len());
@ -148,7 +149,6 @@ impl MatchupState {
}
let _ = tx_c.send(AppMessage::MyBattersLoaded(with_cards));
}
}
});
}
@ -369,16 +369,9 @@ impl MatchupState {
.collect();
self.pitcher_selector.set_items(items);
}
AppMessage::MatchupsCalculated(mut results) => {
AppMessage::MatchupsCalculated(results) => {
self.is_calculating = false;
// Apply current sort
results.sort_by(|a, b| {
b.rating.partial_cmp(&a.rating).unwrap_or(std::cmp::Ordering::Equal)
});
self.matchup_results = results;
if !self.matchup_results.is_empty() {
self.table_state.select(Some(0));
}
self.sort_results();
}
_ => {}
@ -467,7 +460,7 @@ impl MatchupState {
.map(|(i, r)| {
let tier_style = tier_style(&r.tier);
Row::new(vec![
Cell::from(format!("{}", i + 1)),
Cell::from((i + 1).to_string()),
Cell::from(r.player.name.clone()),
Cell::from(r.batter_hand.clone()),
Cell::from(r.player.positions().join("/")),
@ -508,24 +501,4 @@ impl MatchupState {
}
}
// =============================================================================
// Helpers
// =============================================================================
fn tier_style(tier: &str) -> Style {
match tier {
"A" => Style::default().fg(Color::Green),
"B" => Style::default().fg(Color::LightGreen),
"C" => Style::default().fg(Color::Yellow),
"D" => Style::default().fg(Color::LightRed),
"F" => Style::default().fg(Color::Red),
_ => Style::default().fg(Color::DarkGray),
}
}
fn format_swar(val: Option<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 settings;
pub mod standings;
pub fn format_relative_time(dt: &chrono::NaiveDateTime) -> String {
let now = chrono::Utc::now().naive_utc();
let secs = now.signed_duration_since(*dt).num_seconds();
if secs < 60 {
return "just now".to_string();
}
let mins = secs / 60;
if mins < 60 {
return format!("{}m ago", mins);
}
let hours = mins / 60;
if hours < 24 {
return format!("{}h ago", hours);
}
format!("{}d ago", hours / 24)
}
pub fn tier_style(tier: &str) -> ratatui::style::Style {
use ratatui::style::{Color, Style};
match tier {
"A" => Style::default().fg(Color::Green),
"B" => Style::default().fg(Color::LightGreen),
"C" => Style::default().fg(Color::Yellow),
"D" => Style::default().fg(Color::LightRed),
"F" => Style::default().fg(Color::Red),
_ => Style::default().fg(Color::DarkGray),
}
}

View File

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

View File

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

View File

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

View File

@ -1 +1,22 @@
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
.unwrap();
let missing = queries::get_players_missing_cards(&pool, 13, "batter")
.await
.unwrap();
let missing = queries::get_batters_missing_cards(&pool, 13).await.unwrap();
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].name, "Mookie Betts");
}