Skip to main content

mlbt_api/
season.rs

1use chrono::{Datelike, NaiveDate};
2use serde::Deserialize;
3
4/// If the seasons API fails, conservatively assume spring training ends before March 20.
5/// This avoids using spring training params after the regular season has started.
6const SPRING_TRAINING_FALLBACK_MONTH: u32 = 3;
7const SPRING_TRAINING_FALLBACK_DAY: u32 = 20;
8
9/// Whether the date falls in spring training or the regular season.
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub enum GameType {
12    SpringTraining,
13    RegularSeason,
14}
15
16#[derive(Default, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub(crate) struct SeasonsResponse {
19    pub seasons: Vec<SeasonInfo>,
20}
21
22/// Season date boundaries fetched from the MLB seasons API.
23#[derive(Clone, Debug, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct SeasonInfo {
26    pub regular_season_start_date: NaiveDate,
27}
28
29/// Determine the game type for a given date.
30/// Uses SeasonInfo if available, otherwise falls back to a conservative heuristic.
31pub fn game_type_for_date(date: NaiveDate, season_info: Option<&SeasonInfo>) -> GameType {
32    match season_info {
33        Some(info) => {
34            if date < info.regular_season_start_date {
35                GameType::SpringTraining
36            } else {
37                GameType::RegularSeason
38            }
39        }
40        None => {
41            let cutoff = NaiveDate::from_ymd_opt(
42                date.year(),
43                SPRING_TRAINING_FALLBACK_MONTH,
44                SPRING_TRAINING_FALLBACK_DAY,
45            );
46            match cutoff {
47                Some(c) if date < c => GameType::SpringTraining,
48                _ => GameType::RegularSeason,
49            }
50        }
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn test_spring_training_with_season_info() {
60        let info = SeasonInfo {
61            regular_season_start_date: NaiveDate::from_ymd_opt(2026, 3, 25).unwrap(),
62        };
63        let spring = NaiveDate::from_ymd_opt(2026, 3, 10).unwrap();
64        let regular = NaiveDate::from_ymd_opt(2026, 3, 25).unwrap();
65        let mid_season = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
66
67        assert_eq!(
68            game_type_for_date(spring, Some(&info)),
69            GameType::SpringTraining
70        );
71        assert_eq!(
72            game_type_for_date(regular, Some(&info)),
73            GameType::RegularSeason
74        );
75        assert_eq!(
76            game_type_for_date(mid_season, Some(&info)),
77            GameType::RegularSeason
78        );
79    }
80
81    #[test]
82    fn test_spring_training_fallback_without_season_info() {
83        let before_cutoff = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
84        let after_cutoff = NaiveDate::from_ymd_opt(2026, 3, 25).unwrap();
85        let on_cutoff = NaiveDate::from_ymd_opt(2026, 3, 20).unwrap();
86
87        assert_eq!(
88            game_type_for_date(before_cutoff, None),
89            GameType::SpringTraining
90        );
91        assert_eq!(
92            game_type_for_date(after_cutoff, None),
93            GameType::RegularSeason
94        );
95        assert_eq!(game_type_for_date(on_cutoff, None), GameType::RegularSeason);
96    }
97}