1use crate::league::LeagueId;
14use crate::season::SeasonId;
15use crate::team::TeamId;
16use crate::{Copyright, HomeAway, MLB_API_DATE_FORMAT};
17use bon::Builder;
18use chrono::{Datelike, Local, NaiveDate, NaiveDateTime};
19use either::Either;
20use serde::de::Error;
21use serde::{Deserialize, Deserializer};
22use std::cmp::Ordering;
23use std::fmt::{Debug, Display, Formatter};
24use std::iter::Sum;
25use std::num::NonZeroU32;
26use std::ops::Add;
27use crate::game::GameId;
28use crate::meta::GameType;
29use crate::request::RequestURL;
30
31#[derive(Debug, Deserialize, PartialEq, Clone)]
36#[serde(from = "AttendanceResponseStruct")]
37pub struct AttendanceResponse {
38 pub copyright: Copyright,
39 #[serde(rename = "records")]
40 pub annual_records: Vec<AttendanceRecord>,
41}
42
43impl AttendanceResponse {
44 #[must_use]
46 pub fn into_aggregate(self) -> AttendanceRecord {
47 self.annual_records.into_iter().sum()
48 }
49}
50
51#[derive(Deserialize)]
52struct AttendanceResponseStruct {
53 copyright: Copyright,
54 records: Vec<AttendanceRecord>,
55}
56
57impl From<AttendanceResponseStruct> for AttendanceResponse {
58 fn from(value: AttendanceResponseStruct) -> Self {
59 let AttendanceResponseStruct { copyright, records } = value;
60 Self { copyright, annual_records: records }
61 }
62}
63
64#[derive(Debug, Deserialize, PartialEq, Clone)]
70#[serde(from = "AnnualRecordStruct")]
71pub struct AttendanceRecord {
72 pub total_openings: HomeAway<u32>,
73 pub total_openings_lost: u32,
74 pub total_games: HomeAway<u32>,
75 pub season: SeasonWithMinorId,
76 pub attendance_totals: HomeAway<u32>,
77 pub single_opening_min_max: Option<(DatedAttendance, DatedAttendance)>,
79 pub game_type: GameType,
80}
81
82impl Add for AttendanceRecord {
83 type Output = Self;
84
85 fn add(self, rhs: Self) -> Self::Output {
87 Self {
88 total_openings: HomeAway {
89 home: self.total_openings.home + rhs.total_openings.home,
90 away: self.total_openings.away + rhs.total_openings.away,
91 },
92 total_openings_lost: self.total_openings_lost + rhs.total_openings_lost,
93 total_games: HomeAway {
94 home: self.total_games.home + rhs.total_games.home,
95 away: self.total_games.away + rhs.total_games.away,
96 },
97 season: SeasonWithMinorId::max(self.season, rhs.season),
98 attendance_totals: HomeAway {
99 home: self.attendance_totals.home + rhs.attendance_totals.home,
100 away: self.attendance_totals.away + rhs.attendance_totals.away,
101 },
102 single_opening_min_max: match (self.single_opening_min_max, rhs.single_opening_min_max) {
103 (None, None) => None,
104 (Some(min_max), None) | (None, Some(min_max)) => Some(min_max),
105 (Some((a_min, a_max)), Some((b_min, b_max))) => Some((b_min.min(a_min), a_max.max(b_max))),
107 },
108 game_type: rhs.game_type,
109 }
110 }
111}
112
113impl Default for AttendanceRecord {
114 #[allow(clippy::cast_sign_loss, reason = "jesus is not alive")]
115 fn default() -> Self {
116 Self {
117 total_openings: HomeAway::new(0, 0),
118 total_openings_lost: 0,
119 total_games: HomeAway::new(0, 0),
120 season: (Local::now().year() as u32).into(),
121 attendance_totals: HomeAway::new(0, 0),
122 single_opening_min_max: None,
123 game_type: GameType::default(),
124 }
125 }
126}
127
128impl Sum for AttendanceRecord {
129 fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
130 iter.fold(Self::default(), |acc, x| acc + x)
131 }
132}
133
134impl AttendanceRecord {
135 #[must_use]
146 pub const fn average_attendance(&self) -> HomeAway<u32> {
147 let HomeAway { home, away } = self.attendance_totals;
148 let HomeAway { home: num_at_home, away: num_at_away } = self.total_openings;
149 HomeAway::new((home + num_at_home / 2) / num_at_home, (away + num_at_away / 2) / num_at_away)
150 }
151}
152
153#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Copy, Clone, Hash)]
157pub struct SeasonWithMinorId {
158 season: SeasonId,
159 minor: Option<NonZeroU32>,
160}
161
162impl From<SeasonId> for SeasonWithMinorId {
163 fn from(value: SeasonId) -> Self {
164 Self { season: value, minor: None }
165 }
166}
167
168impl From<u32> for SeasonWithMinorId {
169 fn from(value: u32) -> Self {
170 Self { season: value.into(), minor: None }
171 }
172}
173
174impl<'de> Deserialize<'de> for SeasonWithMinorId {
175 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
176 where
177 D: Deserializer<'de>
178 {
179 struct Visitor;
180
181 impl serde::de::Visitor<'_> for Visitor {
182 type Value = SeasonWithMinorId;
183
184 fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
185 formatter.write_str("a season id, or a string with a . denoting the minor")
186 }
187
188 fn visit_u32<E>(self, value: u32) -> Result<Self::Value, E>
189 where
190 E: Error
191 {
192 Ok(SeasonWithMinorId { season: SeasonId::from(value), minor: None })
193 }
194
195 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
196 where
197 E: Error,
198 {
199 if let Some((season, minor)) = v.split_once('.') {
200 let season = season.parse::<u32>().map_err(Error::custom)?;
201 let minor = minor.parse::<u32>().map_err(Error::custom)?;
202 let minor = NonZeroU32::try_from(minor).map_err(Error::custom)?;
203 Ok(SeasonWithMinorId { season: SeasonId::from(season), minor: Some(minor) })
204 } else {
205 Ok(v.parse::<u32>().map(|season| SeasonWithMinorId { season: SeasonId::from(season), minor: None }).map_err(Error::custom)?)
206 }
207 }
208 }
209
210 deserializer.deserialize_any(Visitor)
211 }
212}
213
214impl Display for SeasonWithMinorId {
215 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
216 write!(f, "{}", self.season)?;
217 if let Some(minor) = self.minor {
218 write!(f, ".{minor}")?;
219 }
220 Ok(())
221 }
222}
223
224#[derive(Debug, Deserialize, PartialEq, Clone)]
225#[serde(rename_all = "camelCase")]
226struct AnnualRecordStruct {
227 openings_total_away: u32,
229 openings_total_home: u32,
230 openings_total_lost: u32,
231 games_away_total: u32,
233 games_home_total: u32,
234 year: SeasonWithMinorId,
235 attendance_high: Option<u32>,
239 attendance_high_date: Option<NaiveDateTime>,
240 attendance_high_game: Option<GameId>,
241 attendance_low: Option<u32>,
242 attendance_low_date: Option<NaiveDateTime>,
243 attendance_low_game: Option<GameId>,
244 attendance_total_away: Option<u32>,
247 attendance_total_home: Option<u32>,
248 game_type: GameType,
249 }
251
252impl From<AnnualRecordStruct> for AttendanceRecord {
253 fn from(value: AnnualRecordStruct) -> Self {
254 let AnnualRecordStruct {
255 openings_total_away,
257 openings_total_home,
258 openings_total_lost,
259 games_away_total,
261 games_home_total,
262 year,
263 attendance_high,
267 attendance_high_date,
268 attendance_high_game,
269 attendance_low,
270 attendance_low_date,
271 attendance_low_game,
272 attendance_total_away,
275 attendance_total_home,
276 game_type,
277 } = value;
279 let single_opening_min_max = if let Some(attendance_high) = attendance_high
280 && let Some(attendance_high_date) = attendance_high_date
281 && let Some(attendance_high_game) = attendance_high_game
282 {
283 let max = DatedAttendance {
284 value: attendance_high,
285 date: attendance_high_date.date(),
286 game: attendance_high_game,
287 };
288
289 let min = {
290 if let Some(attendance_low) = attendance_low
291 && let Some(attendance_low_date) = attendance_low_date
292 && let Some(attendance_low_game) = attendance_low_game
293 {
294 DatedAttendance {
295 value: attendance_low,
296 date: attendance_low_date.date(),
297 game: attendance_low_game,
298 }
299 } else {
300 max.clone()
301 }
302 };
303
304 Some((min, max))
305 } else {
306 None
307 };
308 Self {
309 total_openings: HomeAway {
310 home: openings_total_home,
311 away: openings_total_away,
312 },
313 total_openings_lost: openings_total_lost,
314 total_games: HomeAway {
315 home: games_home_total,
316 away: games_away_total,
317 },
318 season: year,
319 attendance_totals: HomeAway {
320 home: attendance_total_home.unwrap_or(0),
321 away: attendance_total_away.unwrap_or(0),
322 },
323 single_opening_min_max,
324 game_type,
325 }
326 }
327}
328
329#[derive(Debug, PartialEq, Eq, Clone)]
331pub struct DatedAttendance {
332 pub value: u32,
334 pub date: NaiveDate,
336 pub game: GameId,
338}
339
340impl PartialOrd<Self> for DatedAttendance {
341 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
342 Some(self.cmp(other))
343 }
344}
345
346impl Ord for DatedAttendance {
347 fn cmp(&self, other: &Self) -> Ordering {
348 self.value.cmp(&other.value)
349 }
350}
351
352#[derive(Builder)]
354#[builder(derive(Into))]
355pub struct AttendanceRequest {
356 #[doc(hidden)]
357 #[builder(setters(vis = "", name = __id_internal))]
358 id: Either<TeamId, LeagueId>,
359 #[builder(into)]
360 season: Option<SeasonWithMinorId>,
361 #[builder(into)]
362 date: Option<NaiveDate>,
363 #[builder(default)]
364 game_type: GameType,
365}
366
367impl<S: attendance_request_builder::State + attendance_request_builder::IsComplete> crate::request::RequestURLBuilderExt for AttendanceRequestBuilder<S> {
368 type Built = AttendanceRequest;
369}
370
371#[allow(dead_code, reason = "optionally used by the end user")]
372impl<S: attendance_request_builder::State> AttendanceRequestBuilder<S> {
373 #[doc = "_**Required.**_\n\n"]
374 pub fn team_id(self, id: impl Into<TeamId>) -> AttendanceRequestBuilder<attendance_request_builder::SetId<S>>
375 where
376 S::Id: attendance_request_builder::IsUnset,
377 {
378 self.__id_internal(Either::Left(id.into()))
379 }
380
381 #[doc = "_**Required.**_\n\n"]
382 pub fn league_id(self, id: impl Into<LeagueId>) -> AttendanceRequestBuilder<attendance_request_builder::SetId<S>>
383 where
384 S::Id: attendance_request_builder::IsUnset,
385 {
386 self.__id_internal(Either::Right(id.into()))
387 }
388}
389
390impl Display for AttendanceRequest {
391 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
392 write!(
393 f,
394 "http://statsapi.mlb.com/api/v1/attendance{}",
395 gen_params! { "teamId"?: self.id.left(), "leagueId"?: self.id.right(), "season"?: self.season, "date"?: self.date.as_ref().map(|date| date.format(MLB_API_DATE_FORMAT)), "gameType": format!("{:?}", self.game_type) }
396 )
397 }
398}
399
400impl RequestURL for AttendanceRequest {
401 type Response = AttendanceResponse;
402}
403
404#[cfg(test)]
405mod tests {
406 use crate::attendance::AttendanceRequest;
407 use crate::request::{RequestURL, RequestURLBuilderExt};
408 use crate::team::TeamsRequest;
409 use crate::TEST_YEAR;
410
411 #[tokio::test]
412 #[cfg_attr(not(feature = "_heavy_tests"), ignore)]
413 async fn parse_all_teams_test_year() {
414 let mlb_teams = TeamsRequest::all_sports()
415 .season(TEST_YEAR)
416 .build_and_get()
417 .await
418 .unwrap()
419 .teams;
420 for team in mlb_teams {
421 let request = AttendanceRequest::builder()
422 .team_id(team.id)
423 .build();
424 let _response = request.get()
425 .await
426 .unwrap();
427 }
428 }
429}