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:
parent
defe741aba
commit
c5e1fb44a6
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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", ¶ms).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", ¶ms).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), ¶ms).await
|
||||
self.get(&format!("/players/{player_id}"), ¶ms).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", ¶ms).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", ¶ms).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", ¶ms).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", ¶ms).await?;
|
||||
Ok(resp.standings)
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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?;
|
||||
|
||||
@ -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);
|
||||
|
||||
let secs = diff.num_seconds();
|
||||
if secs < 0 {
|
||||
return "just now".to_string();
|
||||
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(" "));
|
||||
}
|
||||
if secs < 60 {
|
||||
return "just now".to_string();
|
||||
spans.push(Span::styled(format!("{}:{}", pos, count), style));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user