Skip to main content

mlb_api/requests/
standings.rs

1//! Standings of a team, wins, losses, etc
2
3use crate::division::{DivisionId, NamedDivision};
4use crate::league::{LeagueId, NamedLeague};
5use crate::meta::StandingsType;
6use crate::request::{RequestURL, RequestURLBuilderExt};
7use crate::season::SeasonId;
8use crate::sport::SportId;
9use crate::stats::ThreeDecimalPlaceRateStat;
10use crate::team::NamedTeam;
11use crate::Copyright;
12use bon::Builder;
13use chrono::{NaiveDate, NaiveDateTime};
14use derive_more::{Add, AddAssign, Deref, DerefMut, Display};
15use itertools::Itertools;
16use serde::Deserialize;
17use std::cmp::Ordering;
18use std::fmt::{Debug, Display, Formatter};
19use std::marker::PhantomData;
20use std::str::FromStr;
21use serde::de::DeserializeOwned;
22use crate::hydrations::Hydrations;
23use crate::types::MLB_API_DATE_FORMAT;
24
25/// A [`Vec`] of [`DivisionalStandings`]
26///
27/// The request divides the league into its divisions and then the divisions into their teams.
28#[derive(Debug, Deserialize, PartialEq, Clone)]
29#[serde(rename_all = "camelCase", bound = "H: StandingsHydrations")]
30pub struct StandingsResponse<H: StandingsHydrations> {
31    pub copyright: Copyright,
32    #[serde(rename = "records")]
33    pub divisions: Vec<DivisionalStandings<H>>
34}
35
36/// [`TeamRecord`]s per division. `last_updated` field might be useful for caching
37#[derive(Debug, Deserialize, PartialEq, Clone)]
38#[serde(rename_all = "camelCase", bound = "H: StandingsHydrations")]
39pub struct DivisionalStandings<H: StandingsHydrations> {
40    pub standings_type: StandingsType,
41    #[serde(rename = "league")]
42    pub league_id: H::League,
43    #[serde(rename = "division")]
44    pub division_id: H::Division,
45    #[serde(rename = "sport")]
46    pub sport_id: H::Sport,
47    #[serde(deserialize_with = "crate::deserialize_datetime")]
48    pub last_updated: NaiveDateTime,
49    pub team_records: Vec<TeamRecord<H>>,
50}
51
52/// Main bulk of the response; the team's record and standings information. Lots of stuff here.
53#[derive(Debug, Deserialize, PartialEq, Clone, Deref, DerefMut)]
54#[serde(rename_all = "camelCase", bound = "H: StandingsHydrations")]
55pub struct TeamRecord<H: StandingsHydrations> {
56    pub team: H::Team,
57    pub season: SeasonId,
58    pub games_played: usize,
59    pub runs_allowed: usize,
60    pub runs_scored: usize,
61    #[serde(rename = "divisionChamp")]
62    pub is_divisional_champion: bool,
63    #[serde(rename = "divisionLeader")]
64    pub is_divisional_leader: bool,
65    pub has_wildcard: bool,
66    #[serde(deserialize_with = "crate::deserialize_datetime")]
67    pub last_updated: NaiveDateTime,
68    pub streak: Streak,
69    #[serde(rename = "records")]
70    pub splits: RecordSplits,
71
72    #[serde(rename = "clinchIndicator", default)]
73    pub clinch_kind: ClinchKind,
74    pub games_back: GamesBack,
75    pub wild_card_games_back: GamesBack,
76    pub league_games_back: GamesBack,
77    #[serde(rename = "springLeagueGamesBack")]
78    pub spring_training_games_back: GamesBack,
79    pub sport_games_back: GamesBack,
80    pub division_games_back: GamesBack,
81    pub conference_games_back: GamesBack,
82    #[deref]
83    #[deref_mut]
84    #[serde(rename = "leagueRecord")]
85    pub record: Record,
86
87    #[serde(rename = "divisionRank", deserialize_with = "crate::try_from_str", default)]
88    pub divisional_rank: Option<usize>,
89    #[serde(deserialize_with = "crate::try_from_str", default)]
90    pub league_rank: Option<usize>,
91    #[serde(deserialize_with = "crate::try_from_str", default)]
92    pub sport_rank: Option<usize>,
93}
94
95impl<H: StandingsHydrations> TeamRecord<H> {
96    /// Uses the pythagorean expected win loss pct formula
97    #[must_use]
98    pub fn expected_win_loss_pct(&self) -> ThreeDecimalPlaceRateStat {
99        /// Some use 2, some use 1.82, some use 1.80.
100        ///
101        /// The people who use 2 are using an overall less precise version
102        ///
103        /// I have no clue about 1.83 vs 1.80, so I took the mean.
104        const EXPONENT: f64 = 1.815;
105
106        let exponentified_runs_scored: f64 = (self.runs_scored as f64).powf(EXPONENT);
107        let exponentified_runs_allowed: f64 = (self.runs_allowed as f64).powf(EXPONENT);
108
109        (exponentified_runs_scored / (exponentified_runs_scored + exponentified_runs_allowed)).into()
110    }
111
112    /// Assumes 162 total games. Recommended to use the other function if available
113    ///
114    /// See [`Self::expected_end_of_season_record_with_total_games`]
115    #[must_use]
116    pub fn expected_end_of_season_record(&self) -> Record {
117        self.expected_end_of_season_record_with_total_games(162)
118    }
119
120    /// Expected record at the end of the season considering the games already played and the expected win loss pct.
121    #[must_use]
122    pub fn expected_end_of_season_record_with_total_games(&self, total_games: usize) -> Record {
123        let expected_pct: f64 = self.expected_win_loss_pct().into();
124        let remaining_games = total_games.saturating_sub(self.record.games_played());
125        let wins = (remaining_games as f64 * expected_pct).round() as usize;
126        let losses = remaining_games - wins;
127
128        self.record + Record { wins, losses }
129    }
130
131    /// Net runs scored for the team
132    #[must_use]
133    pub const fn run_differential(&self) -> isize {
134        self.runs_scored as isize - self.runs_allowed as isize
135    }
136}
137
138/// Different record splits depending on the Division, League, [`RecordSplitKind`], etc.
139#[derive(Debug, Deserialize, PartialEq, Clone)]
140pub struct RecordSplits {
141    #[serde(rename = "splitRecords", default)]
142    pub record_splits: Vec<RecordSplit>,
143    #[serde(rename = "divisionRecords", default)]
144    pub divisional_record_splits: Vec<DivisionalRecordSplit>,
145    #[serde(rename = "leagueRecords", default)]
146    pub league_record_splits: Vec<LeagueRecordSplit>,
147    #[serde(rename = "overallRecords", default)]
148    pub basic_record_splits: Vec<RecordSplit>,
149    #[serde(rename = "expectedRecords", default)]
150    pub expected_record_splits: Vec<RecordSplit>,
151}
152
153/// Different indicators for clinching the playoffs.
154///
155/// Note: This assumes the modern postseason format, if you are dealing with older formats the predicates below are not guaranteed to work.
156#[repr(u8)]
157#[derive(Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Copy, Clone, Default)]
158pub enum ClinchKind {
159    /// The Team has clinched a top seed guaranteeing a bye.
160    #[serde(rename = "z")]
161    Bye = 4,
162
163    /// The team has clinched their position in the division.
164    #[serde(rename = "y")]
165    Divisional = 3,
166
167    /// The team has clinched a wild card position.
168    #[serde(rename = "w")]
169    WildCard = 2,
170
171    /// The team has clinched a position in the postseason, however that specific placement is unknown.
172    #[serde(rename = "x")]
173    Postseason = 1,
174
175    /// Team has not clinched the postseason.
176    #[default]
177    #[serde(skip)]
178    None = 0,
179
180    // doesn't exist?
181    // #[serde(rename = "e")]
182    // Eliminated = -1,
183}
184
185impl ClinchKind {
186    /// Whether a team is guaranteed to play in the postseason.
187    ///
188    /// ## Examples
189    /// ```
190    /// use mlb_api::standings::ClinchKind;
191    ///
192    /// assert!(  ClinchKind::Bye.clinched_postseason());
193    /// assert!(  ClinchKind::WildCard.clinched_postseason());
194    /// assert!(  ClinchKind::Postseason.clinched_postseason());
195    /// assert!(! ClinchKind::None.clinched_postseason());
196    /// ```
197    #[must_use]
198    pub const fn clinched_postseason(self) -> bool {
199        self as u8 >= Self::Postseason as u8
200    }
201
202    /// Whether the [`ClinchKind`] is a final decision and cannot be changed.
203    ///
204    /// ## Examples
205    /// ```
206    /// use mlb_api::standings::ClinchKind;
207    ///
208    /// assert!(  ClinchKind::Bye.is_final());
209    /// assert!(  ClinchKind::WildCard.is_final());
210    /// assert!(! ClinchKind::Postseason.is_final());
211    /// assert!(! ClinchKind::None.is_final());
212    /// ```
213    #[must_use]
214    pub const fn is_final(self) -> bool {
215        self as u8 >= Self::WildCard as u8
216    }
217
218    /// Whether the team will play in a Wild Card Series.
219    ///
220    /// If the postseason decision [is not final](Self::is_final), the team is considered to *not* play in the wild card round. If you want different behavior use [`Self::guaranteed_in_wildcard`].
221    /// ## Examples
222    /// ```
223    /// use mlb_api::standings::ClinchKind;
224    ///
225    /// assert!(! ClinchKind::Bye.guaranteed_in_wildcard());
226    /// assert!(  ClinchKind::Divisional.guaranteed_in_wildcard());
227    /// assert!(  ClinchKind::WildCard.guaranteed_in_wildcard());
228    /// assert!(! ClinchKind::None.guaranteed_in_wildcard());
229    /// assert!(! ClinchKind::Postseason.guaranteed_in_wildcard());
230    /// ```
231    #[must_use]
232    pub const fn guaranteed_in_wildcard(self) -> bool {
233        matches!(self, Self::WildCard | Self::Divisional)
234    }
235
236    /// Whether the team has a possibility of playing in the Wild Card Series.
237    ///
238    /// ## Examples
239    /// ```
240    /// use mlb_api::standings::ClinchKind;
241    ///
242    /// assert!(! ClinchKind::Bye.guaranteed_in_wildcard());
243    /// assert!(  ClinchKind::Divisional.guaranteed_in_wildcard());
244    /// assert!(  ClinchKind::WildCard.guaranteed_in_wildcard());
245    /// assert!(  ClinchKind::None.guaranteed_in_wildcard());
246    /// assert!(  ClinchKind::Postseason.guaranteed_in_wildcard());
247    /// ```
248    #[must_use]
249    pub const fn potentially_in_wildcard(self) -> bool {
250        matches!(self, Self::WildCard | Self::Divisional | Self::Postseason | Self::None)
251    }
252}
253
254#[derive(Deserialize, PartialEq, Eq, Clone)]
255#[serde(try_from = "&str")]
256pub struct GamesBack {
257    /// How many games back a team is from the target spot.
258    ///
259    /// If negative, then `-games` is the amount of games to the target spot.
260    /// If positive, the amount of games ahead of the target spot (ex: WC1 compared to WC3).
261    /// If zero, you are matched with the target spot in terms of record, tiebreakers apply.
262    games: isize,
263
264    /// Whether the team has finished and won a game and their opponents have not, leading to being a half game ahead.
265    half: bool,
266}
267
268impl PartialOrd for GamesBack {
269    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
270        Some(self.cmp(other))
271    }
272}
273
274impl Ord for GamesBack {
275    fn cmp(&self, other: &Self) -> Ordering {
276        self.games.cmp(&other.games).then_with(|| self.half.cmp(&other.half))
277    }
278}
279
280impl Display for GamesBack {
281    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
282        if self.games > 0 {
283            write!(f, "+")?;
284        }
285
286        if self.games != 0 {
287            write!(f, "{}", self.games.abs())?;
288        } else {
289            write!(f, "-")?;
290        }
291
292        write!(f, ".{c}", c = if self.half { '5' } else { '0' })?;
293
294        Ok(())
295    }
296}
297
298impl Debug for GamesBack {
299    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
300        <Self as Display>::fmt(self, f)
301    }
302}
303
304impl<'a> TryFrom<&'a str> for GamesBack {
305    type Error = <Self as FromStr>::Err;
306
307    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
308        <Self as FromStr>::from_str(value)
309    }
310}
311
312impl FromStr for GamesBack {
313    type Err = &'static str;
314
315    fn from_str(mut s: &str) -> Result<Self, Self::Err> {
316        if s == "-" { return Ok(Self { games: 0, half: false }) }
317
318        let sign: isize = s.strip_prefix("+").map_or(-1, |s2| {
319                s = s2;
320                1
321            });
322
323        let (games, half) = s.split_once('.').unwrap_or((s, ""));
324        let games = games.parse::<usize>().map_err(|_| "invalid game quantity")?;
325        let half = half == "5";
326
327        Ok(Self {
328            games: games as isize * sign,
329            half,
330        })
331    }
332}
333
334#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Add, AddAssign)]
335pub struct Record {
336    wins: usize,
337    losses: usize,
338}
339
340impl Record {
341    /// % of games that end in a win
342    #[must_use]
343    pub fn pct(self) -> ThreeDecimalPlaceRateStat {
344        (self.wins as f64 / self.games_played() as f64).into()
345    }
346
347    /// Number of games played
348    #[must_use]
349    pub const fn games_played(self) -> usize {
350        self.wins + self.losses
351    }
352}
353
354// A repetition of a kind of game outcome; ex: W5 (last 5 games were wins), L1 (last 1 game was a loss).
355#[derive(Debug, Deserialize, PartialEq, Copy, Clone)]
356pub struct Streak {
357    #[serde(rename = "streakNumber")]
358    pub quantity: usize,
359    #[serde(rename = "streakType")]
360    pub kind: StreakKind,
361}
362
363impl Display for Streak {
364    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
365        write!(f, "{}{}", self.kind, self.quantity)
366    }
367}
368
369/// A game outcome for streak purposes
370#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Display)]
371pub enum StreakKind {
372    /// A game that ended in a win for this team.
373    #[serde(rename = "wins")]
374    #[display("W")]
375    Win,
376    /// A game that ended in a loss for this team.
377    #[serde(rename = "losses")]
378    #[display("L")]
379    Loss,
380}
381
382/// A team's record, filtered by the [`RecordSplitKind`].
383#[derive(Debug, Deserialize, PartialEq, Copy, Clone, Deref, DerefMut)]
384pub struct RecordSplit {
385    #[deref]
386    #[deref_mut]
387    #[serde(flatten)]
388    pub record: Record,
389    #[serde(rename = "type")]
390    pub kind: RecordSplitKind,
391}
392
393#[derive(Debug, Deserialize, PartialEq, Clone, Deref, DerefMut)]
394pub struct DivisionalRecordSplit {
395    #[deref]
396    #[deref_mut]
397    #[serde(flatten)]
398    pub record: Record,
399    pub division: NamedDivision,
400}
401
402#[derive(Debug, Deserialize, PartialEq, Clone, Deref, DerefMut)]
403pub struct LeagueRecordSplit {
404    #[deref]
405    #[deref_mut]
406    #[serde(flatten)]
407    pub record: Record,
408    pub league: NamedLeague,
409}
410
411#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Hash)]
412#[serde(rename_all = "camelCase")]
413pub enum RecordSplitKind {
414    /// Games as the home team
415    Home,
416    /// Games as the away team
417    Away,
418    /// Games in which you are "left" (?)
419
420    /// A
421    Left,
422    /// Games in which you are "left" && you are the home team
423    LeftHome,
424    /// Games in which you are "left" && you are the away team
425    LeftAway,
426    /// Games in which you are "right" (?)
427
428    Right,
429    /// Games in which you are "right" and the home team
430    RightHome,
431    /// Games in which you are "right" and the away team
432    RightAway,
433
434    /// Last 10 games of the season as of the current date.
435    /// Note that early in the season, [`Record::games_played`] may be < 10
436    LastTen,
437    /// Games that went to extra innings.
438    #[serde(rename = "extraInning")]
439    ExtraInnings,
440    /// Games won or lost by one run
441    OneRun,
442
443    /// what?
444    Winners,
445
446    /// Day games
447    Day,
448    /// Night games
449    Night,
450
451    /// Games played on grass
452    Grass,
453    /// Games played on turf
454    Turf,
455
456    /// Expected record using pythagorean expected win loss pct
457    ///
458    /// This value is calculated as a percentage and multiplied by the number of games that <u>have been</u> played.
459    #[allow(non_camel_case_types, reason = "proper case")]
460    #[serde(rename = "xWinLoss")]
461    xWinLoss,
462
463    /// Expected record for the season using pythagorean expected win loss pct
464    ///
465    /// This value is calculated as a percentage and multiplied by the number of games <u>in the season</u>.
466    #[allow(non_camel_case_types, reason = "proper case")]
467    #[serde(rename = "xWinLossSeason")]
468    xWinLossSeason,
469}
470
471pub trait StandingsHydrations: Hydrations<RequestData=()> {
472    type Team: Debug + DeserializeOwned + PartialEq + Clone;
473    type League: Debug + DeserializeOwned + PartialEq + Clone;
474    type Division: Debug + DeserializeOwned + PartialEq + Clone;
475    type Sport: Debug + DeserializeOwned + PartialEq + Clone;
476}
477
478impl StandingsHydrations for () {
479    type Team = NamedTeam;
480    type League = LeagueId;
481    type Division = DivisionId;
482    type Sport = SportId;
483}
484
485/// Creates hydrations for a standings request
486///
487/// ## Examples
488/// ```no_run
489/// use mlb_api::standings::{StandingsRequest, StandingsResponse};
490/// use mlb_api::standings_hydrations;
491///
492/// standings_hydrations! {
493///     pub struct ExampleHydrations {
494///         team: (),
495///         league,
496///         sport: { season }
497///     }
498/// }
499///
500/// let response: StandingsResponse<ExampleHydrations> = StandingsRequest::<ExampleHydrations>::builder().build_and_get().await.unwrap();
501/// ``` 
502/// 
503/// ## Standings Hydrations
504/// <u>Note: Fields must appear in exactly this order (or be omitted)</u>
505///
506/// | Name       | Type                   |
507/// |------------|------------------------|
508/// | `team`     | [`team_hydrations!`]   |
509/// | `league`   | [`League`]             |
510/// | `division` | [`Division`]           |
511/// | `sport`    | [`sports_hydrations!`] |
512///
513/// [`team_hydrations!`]: crate::team_hydrations
514/// [`League`]: crate::league::League
515/// [`Division`]: crate::division::Division
516/// [`sports_hydrations!`]: crate::sports_hydrations
517/// [`Conference`]: crate::conference::Conference
518#[macro_export]
519macro_rules! standings_hydrations {
520    (@ inline_structs [team: { $($inline_tt:tt)* } $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
521        ::pastey::paste! {
522            $crate::team_hydrations! {
523                $vis struct [<$name InlineTeam>] {
524                    $($inline_tt)*
525                }
526            }
527
528            $crate::standings_hydrations! { @ inline_structs [$($($tt)*)?]
529                $vis struct $name {
530                    $($field_tt)*
531                    team: [<$name InlineTeam>],
532                }
533            }
534        }
535    };
536    (@ inline_structs [sport: { $($inline_tt:tt)* } $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
537        ::pastey::paste! {
538            $crate::sports_hydrations! {
539                $vis struct [<$name InlineSport>] {
540                    $($inline_tt)*
541                }
542            }
543
544            $crate::standings_hydrations! { @ inline_structs [$($($tt)*)?]
545                $vis struct $name {
546                    $($field_tt)*
547                    sport: [<$name InlineSport>],
548                }
549            }
550        }
551    };
552    (@ inline_structs [$_01:ident : { $($_02:tt)* $(, $($tt:tt)*)?}] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
553        ::core::compile_error!("Found unknown inline struct");
554    };
555    (@ inline_structs [$field:ident $(: $value:ty)? $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
556        $crate::standings_hydrations! { @ inline_structs [$($($tt)*)?]
557            $vis struct $name {
558                $($field_tt)*
559                $field $(: $value)?,
560            }
561        }
562    };
563    (@ inline_structs [] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
564        $crate::standings_hydrations!(@ actual $vis struct $name { $($field_tt)* });
565    };
566
567    (@ team) => { $crate::team::NamedTeam };
568    (@ team $team:ty) => { $crate::team::Team<$team> };
569
570	(@ league) => { $crate::league::NamedLeague };
571	(@ league ,) => { $crate::league::League };
572	(@ unknown_league) => { $crate::league::NamedLeague::unknown_league() };
573	(@ unknown_league ,) => { unimplemented!() }; // todo: hrmm... forward error?
574
575    (@ division) => { $crate::division::NamedDivision };
576	(@ division ,) => { $crate::division::Division };
577
578    (@ sport) => { $crate::sport::SportId };
579	(@ sport $hydrations:ty) => { $crate::sport::Sport<$hydrations> };
580
581    (@ actual $vis:vis struct $name:ident {
582        $(team: $team:ty ,)?
583        $(league $league_comma:tt)?
584        $(division $division_comma:tt)?
585        $(sport: $sport:ty ,)?
586    }) => {
587        $vis struct $name {}
588
589        impl $crate::standings::StandingsHydrations for $name {
590            type Team = $crate::standings_hydrations!(@ team $($team)?);
591            type League = $crate::standings_hydrations!(@ league $($league_comma)?);
592            type Division = $crate::standings_hydrations!(@ division $($division_comma)?);
593            type Sport = $crate::standings_hydrations!(@ sport $($sport)?);
594        }
595
596        impl $crate::hydrations::Hydrations for $name {
597            type RequestData = ();
598
599            fn hydration_text(&(): &Self::RequestData) -> ::std::borrow::Cow<'static, str> {
600                let text = ::std::borrow::Cow::Borrowed(::core::concat!(
601                    $("league," $league_comma)?
602                    $("division," $division_comma)?
603                ));
604
605                $(let text = ::std::borrow::Cow::Owned!(::std::format!("{text}team({}),", <$team as $crate::hydrations::Hydrations>::hydration_text(&())));)?;
606                $(let text = ::std::borrow::Cow::Owned!(::std::format!("{text}sport({}),", <$sport as $crate::hydrations::Hydrations>::hydration_text(&())));)?;
607
608                text
609            }
610        }
611    };
612    ($vis:vis struct $name:ident {
613        $($field_tt:tt)*
614    }) => {
615        $crate::standings_hydrations!(@ inline_structs [$($field_tt)*] $vis struct $name {})
616    };
617}
618
619/// Returns a [`StandingsResponse`].
620#[derive(Builder)]
621#[builder(derive(Into))]
622pub struct StandingsRequest<H: StandingsHydrations> {
623    #[builder(into)]
624    league_id: LeagueId,
625    #[builder(into, default)]
626    season: SeasonId,
627    standings_types: Option<Vec<StandingsType>>,
628    #[builder(into)]
629    date: Option<NaiveDate>,
630    #[builder(skip)]
631    _marker: PhantomData<H>,
632}
633
634impl<H: StandingsHydrations, S: standings_request_builder::State + standings_request_builder::IsComplete> RequestURLBuilderExt for StandingsRequestBuilder<H, S> {
635    type Built = StandingsRequest<H>;
636}
637
638impl<H: StandingsHydrations> Display for StandingsRequest<H> {
639    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
640        let hydrations = Some(H::hydration_text(&())).filter(|s| !s.is_empty());
641        write!(f, "http://statsapi.mlb.com/api/v1/standings{params}", params = gen_params! {
642            "leagueId": self.league_id,
643            "season": self.season,
644            "standingsTypes"?: self.standings_types.as_ref().map(|x| x.iter().copied().join(",")),
645            "date"?: self.date.map(|x| x.format(MLB_API_DATE_FORMAT)),
646            "hydrate"?: hydrations,
647        })
648    }
649}
650
651impl<H: StandingsHydrations> RequestURL for StandingsRequest<H> {
652    type Response = StandingsResponse<H>;
653}
654
655#[cfg(test)]
656mod tests {
657    use chrono::NaiveDate;
658    use crate::league::LeagueId;
659    use crate::request::RequestURLBuilderExt;
660    use crate::standings::StandingsRequest;
661    use crate::TEST_YEAR;
662
663    #[tokio::test]
664    async fn all_mlb_leagues() {
665        for league_id in [LeagueId::new(103), LeagueId::new(104)] {
666            let _ = StandingsRequest::<()>::builder().season(TEST_YEAR).league_id(league_id).build_and_get().await.unwrap();
667            let _ = StandingsRequest::<()>::builder().season(TEST_YEAR).date(NaiveDate::from_ymd_opt(TEST_YEAR as _, 9, 26).unwrap()).league_id(league_id).build_and_get().await.unwrap();
668        }
669    }
670}