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