Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod player;
pub mod plays;
pub mod schedule;
pub mod season;
pub mod serde_dates;
pub mod standings;
pub mod stats;
pub mod team;
Expand Down
7 changes: 5 additions & 2 deletions api/src/live.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::boxscore::Boxscore;
use crate::plays::Plays;
use crate::schedule::Status;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

Expand Down Expand Up @@ -175,7 +176,8 @@ pub struct FullPlayer {
pub first_name: String,
pub last_name: String,
pub primary_number: Option<String>,
pub birth_date: Option<String>,
#[serde(default, with = "crate::serde_dates::optional_date")]
pub birth_date: Option<NaiveDate>,
pub current_age: Option<i64>,
pub birth_city: Option<String>,
pub birth_state_province: Option<String>,
Expand All @@ -192,7 +194,8 @@ pub struct FullPlayer {
pub is_player: Option<bool>,
pub is_verified: Option<bool>,
pub draft_year: Option<i64>,
pub mlb_debut_date: Option<String>,
#[serde(default, with = "crate::serde_dates::optional_date")]
pub mlb_debut_date: Option<NaiveDate>,
pub bat_side: Option<Side>,
pub pitch_hand: Option<Side>,
pub name_first_last: Option<String>,
Expand Down
7 changes: 5 additions & 2 deletions api/src/player.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::live::{PrimaryPosition, Side};
use crate::schedule::IdNameLink;
use crate::stats::Stat;
use chrono::NaiveDate;
use serde::Deserialize;

#[derive(Default, Debug, Deserialize)]
Expand All @@ -15,7 +16,8 @@ pub struct PersonFull {
pub id: u64,
pub full_name: String,
pub primary_number: Option<String>,
pub birth_date: Option<String>,
#[serde(default, with = "crate::serde_dates::optional_date")]
pub birth_date: Option<NaiveDate>,
pub current_age: Option<u8>,
pub birth_city: Option<String>,
pub birth_state_province: Option<String>,
Expand All @@ -25,7 +27,8 @@ pub struct PersonFull {
pub primary_position: Option<PrimaryPosition>,
pub bat_side: Option<Side>,
pub pitch_hand: Option<Side>,
pub mlb_debut_date: Option<String>,
#[serde(default, with = "crate::serde_dates::optional_date")]
pub mlb_debut_date: Option<NaiveDate>,
pub active: Option<bool>,
pub draft_year: Option<u16>,
pub current_team: Option<IdNameLink>,
Expand Down
8 changes: 5 additions & 3 deletions api/src/schedule.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::stats::DisplayName;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};

#[derive(Default, Debug, Serialize, Deserialize)]
Expand Down Expand Up @@ -32,7 +33,8 @@ pub struct IdNameLink {
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Dates {
pub date: Option<String>,
#[serde(default, with = "crate::serde_dates::optional_date")]
pub date: Option<NaiveDate>,
pub total_items: Option<u8>,
pub total_events: Option<u8>,
pub total_games: Option<u8>,
Expand All @@ -58,8 +60,8 @@ pub struct Game {
pub link: String,
// pub game_type: Option<GameType>,
pub season: String,
pub game_date: String,
pub official_date: String,
pub game_date: DateTime<Utc>,
pub official_date: NaiveDate,
pub status: Status,
pub teams: Teams,
/// Only present if `hydrate=linescore` is used.
Expand Down
65 changes: 65 additions & 0 deletions api/src/serde_dates.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
use chrono::NaiveDate;
use serde::{Deserialize, Deserializer, Serializer};

/// Tolerant serde adapter for `Option<NaiveDate>` from API `YYYY-MM-DD` strings. Deserialization
/// returns `None` for null, missing, or unparseable values so a single bad date doesn't fail the
/// whole response.
pub mod optional_date {
use super::*;

pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
where
D: Deserializer<'de>,
{
let s = Option::<String>::deserialize(deserializer)?;
Ok(s.as_deref()
.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()))
}

pub fn serialize<S>(date: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match date {
Some(d) => serializer.serialize_str(&d.format("%Y-%m-%d").to_string()),
None => serializer.serialize_none(),
}
}
}

#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Holder {
#[serde(default, with = "super::optional_date")]
date: Option<NaiveDate>,
}

#[test]
fn optional_date_handles_all_cases() {
let valid = NaiveDate::from_ymd_opt(2026, 4, 13).unwrap();

// Valid string
let h: Holder = serde_json::from_str(r#"{"date":"2026-04-13"}"#).unwrap();
assert_eq!(h.date, Some(valid));

// Null, missing, and unparseable all yield None instead of failing
for input in [
r#"{"date":null}"#,
r#"{}"#,
r#"{"date":"not-a-date"}"#,
r#"{"date":""}"#,
] {
let h: Holder = serde_json::from_str(input).unwrap();
assert_eq!(h.date, None, "input: {input}");
}

// Round-trip serializes back to the same string
let h = Holder { date: Some(valid) };
let json = serde_json::to_string(&h).unwrap();
assert_eq!(json, r#"{"date":"2026-04-13"}"#);
}
}
5 changes: 3 additions & 2 deletions api/src/standings.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::schedule::IdNameLink;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Default, Debug, Serialize, Deserialize)]
Expand All @@ -13,7 +14,7 @@ pub struct Record {
pub standings_type: String,
pub league: IdLink,
pub division: Option<IdLink>,
pub last_updated: String,
pub last_updated: DateTime<Utc>,
pub team_records: Vec<TeamRecord>,
}

Expand All @@ -40,7 +41,7 @@ pub struct TeamRecord {
pub division_games_back: String,
pub conference_games_back: String,
pub league_record: RecordElement,
pub last_updated: String,
pub last_updated: DateTime<Utc>,
pub records: Records,
pub runs_allowed: u16,
pub runs_scored: u16,
Expand Down
4 changes: 3 additions & 1 deletion api/src/stats.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::schedule::IdNameLink;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};

#[derive(Default, Debug, Serialize, Deserialize)]
Expand Down Expand Up @@ -31,7 +32,8 @@ pub struct Split {
pub team: Option<IdNameLink>,
pub player: Option<Player>,
// Game log fields (only present on gameLog splits):
pub date: Option<String>,
#[serde(default, with = "crate::serde_dates::optional_date")]
pub date: Option<NaiveDate>,
pub is_home: Option<bool>,
pub is_win: Option<bool>,
pub opponent: Option<IdNameLink>,
Expand Down
11 changes: 7 additions & 4 deletions api/src/team.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::live::{FullPlayer, PrimaryPosition};

use chrono::NaiveDate;
use serde::Deserialize;
use std::fmt;

Expand Down Expand Up @@ -51,9 +51,12 @@ pub struct Transaction {
pub person: Option<TransactionEntity>,
pub from_team: Option<TransactionEntity>,
pub to_team: Option<TransactionEntity>,
pub date: Option<String>,
pub effective_date: Option<String>,
pub resolution_date: Option<String>,
#[serde(default, with = "crate::serde_dates::optional_date")]
pub date: Option<NaiveDate>,
#[serde(default, with = "crate::serde_dates::optional_date")]
pub effective_date: Option<NaiveDate>,
#[serde(default, with = "crate::serde_dates::optional_date")]
pub resolution_date: Option<NaiveDate>,
pub type_code: Option<String>,
pub type_desc: Option<String>,
pub description: Option<String>,
Expand Down
75 changes: 75 additions & 0 deletions src/components/datetime.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
use chrono::{DateTime, NaiveDate, Utc};
use chrono_tz::Tz;

/// Format a game start time in the configured timezone as "7:05 pm".
pub fn format_game_time(utc: DateTime<Utc>, tz: Tz) -> String {
utc.with_timezone(&tz).format("%-I:%M %P").to_string()
}

/// Same as `format_game_time`, but pads single-digit hours with a leading space (" 7:05 pm") for
/// table column alignment.
pub fn format_game_time_padded(utc: DateTime<Utc>, tz: Tz) -> String {
utc.with_timezone(&tz).format("%l:%M %P").to_string()
}

/// Format a date as "Apr 13".
pub fn format_short_date(d: NaiveDate) -> String {
d.format("%b %-d").to_string()
}

/// Format a date as "4/13/2026".
pub fn format_numeric_date(d: NaiveDate) -> String {
d.format("%-m/%-d/%Y").to_string()
}

/// Format an optional date as "Apr 13", or `fallback` for `None`.
pub fn format_short_date_or(date: Option<NaiveDate>, fallback: &str) -> String {
date.map(format_short_date)
.unwrap_or_else(|| fallback.to_string())
}

/// Format an optional date as "4/13/2026", or `fallback` for `None`.
pub fn format_numeric_date_or(date: Option<NaiveDate>, fallback: &str) -> String {
date.map(format_numeric_date)
.unwrap_or_else(|| fallback.to_string())
}

#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;

#[test]
fn format_game_time_variants() {
// Single digit hour: padded version adds a leading space
let utc = Utc.with_ymd_and_hms(2026, 4, 13, 23, 5, 0).unwrap();
assert_eq!(format_game_time(utc, chrono_tz::US::Eastern), "7:05 pm");
assert_eq!(
format_game_time_padded(utc, chrono_tz::US::Eastern),
" 7:05 pm"
);

// Double digit hour: no padding difference
let utc = Utc.with_ymd_and_hms(2026, 4, 13, 14, 30, 0).unwrap();
assert_eq!(
format_game_time_padded(utc, chrono_tz::US::Eastern),
"10:30 am"
);
}

#[test]
fn format_dates() {
let d = NaiveDate::from_ymd_opt(2026, 4, 13).unwrap();
assert_eq!(format_short_date(d), "Apr 13");
assert_eq!(format_numeric_date(d), "4/13/2026");
}

#[test]
fn format_or_handles_missing() {
let d = NaiveDate::from_ymd_opt(2026, 4, 13).unwrap();
assert_eq!(format_short_date_or(Some(d), "-"), "Apr 13");
assert_eq!(format_numeric_date_or(Some(d), "-"), "4/13/2026");
assert_eq!(format_short_date_or(None, "-"), "-");
assert_eq!(format_numeric_date_or(None, "---"), "---");
}
}
1 change: 1 addition & 0 deletions src/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod banner;
pub mod boxscore;
pub mod constants;
pub mod date_selector;
pub mod datetime;
pub mod debug;
pub mod decision_pitchers;
pub mod game;
Expand Down
14 changes: 4 additions & 10 deletions src/components/schedule.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
use crate::components::constants::lookup_team_or;
use crate::components::date_selector::DateSelector;
use crate::components::datetime::format_game_time_padded;
use crate::components::decision_pitchers::GameDecisionPitchers;
use crate::components::probable_pitchers::{ProbablePitcher, ProbablePitcherMatchup};
use crate::components::standings::Team;
use crate::components::util::format_start_time_table;
use crate::state::app_settings::AppSettings;
use crate::state::app_state::HomeOrAway;
use chrono::{DateTime, NaiveDate, Utc};
use chrono_tz::Tz;
use core::option::Option::{None, Some};
use log::error;
use mlbt_api::schedule::{Game, LeagueRecord, ScheduleResponse};
use std::cmp::Ordering;
use tui::widgets::TableState;
Expand Down Expand Up @@ -137,7 +136,7 @@ impl ScheduleState {
/// Called after the user changes timezone so times update without a schedule refetch.
pub fn refresh_start_times(&mut self, tz: Tz) {
for row in &mut self.schedule {
row.start_time = format_start_time_table(row.start_time_utc, tz);
row.start_time = format_game_time_padded(row.start_time_utc, tz);
}
}

Expand Down Expand Up @@ -240,13 +239,8 @@ impl ScheduleRow {
});
let away_record = Record::from_league_record(away_team.league_record.as_ref());

let start_time_utc = DateTime::parse_from_rfc3339(&game.game_date)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|err| {
error!("invalid game_date {:?}: {err}", game.game_date);
DateTime::<Utc>::UNIX_EPOCH
});
let start_time = format_start_time_table(start_time_utc, timezone);
let start_time_utc = game.game_date;
let start_time = format_game_time_padded(start_time_utc, timezone);

let game_status = match &game.status.detailed_state {
Some(s) if s == "In Progress" => {
Expand Down
11 changes: 5 additions & 6 deletions src/components/stats/player_profile.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use crate::components::constants::{lookup_team, lookup_team_by_id};
use crate::components::datetime::format_numeric_date_or;
use crate::components::standings::Team;
use crate::components::stats::splits::{RecentSplit, RecentStats, StatSplits};
use crate::components::util::{
DimColor, OptionDisplayExt, OptionMapDisplayExt, avg_color, era_color, format_date,
DimColor, OptionDisplayExt, OptionMapDisplayExt, avg_color, era_color,
};
use mlbt_api::player::PersonFull;
use mlbt_api::stats::{Split, StatSplit};
Expand Down Expand Up @@ -105,7 +106,7 @@ impl PlayerProfile {
let weight = person.weight.map_display_or(|w| format!("{w}lb"), "");
let age = person.current_age.display_or("-");

let birth_date = person.birth_date.map_display_or(|d| format_date(d), "---");
let birth_date = format_numeric_date_or(person.birth_date, "---");
let birthplace = [
person.birth_city.as_deref(),
person.birth_state_province.as_deref(),
Expand All @@ -130,9 +131,7 @@ impl PlayerProfile {
));
}

let mlb_debut = person
.mlb_debut_date
.map_display_or(|d| format_date(d), "---");
let mlb_debut = format_numeric_date_or(person.mlb_debut_date, "---");

let mut bio = vec![
format!("{position} | {bats}/{throws} | {height} {weight} | Age: {age}").into(),
Expand Down Expand Up @@ -213,7 +212,7 @@ impl PlayerProfile {
}

fn game_log_cells(split: &Split) -> Vec<Cell<'_>> {
let date = split.date.map_display_or(|d| format_date(d), "");
let date = format_numeric_date_or(split.date, "");
let opp = split
.opponent
.map_display_or(|o| lookup_team(&o.name).abbreviation, "---");
Expand Down
Loading
Loading