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, Eq, 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, Eq, 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 #[allow(clippy::too_many_lines, reason = "low cognitive complexity")]
254 fn from(value: AnnualRecordStruct) -> Self {
255 let AnnualRecordStruct {
256 openings_total_away,
258 openings_total_home,
259 openings_total_lost,
260 games_away_total,
262 games_home_total,
263 year,
264 attendance_high,
268 attendance_high_date,
269 attendance_high_game,
270 attendance_low,
271 attendance_low_date,
272 attendance_low_game,
273 attendance_total_away,
276 attendance_total_home,
277 game_type,
278 } = value;
280
281 let single_opening_min_max = if let Some(((attendance_high, attendance_high_date), attendance_high_game)) = attendance_high
282 .zip(attendance_high_date).zip(attendance_high_game) {
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_date), attendance_low_game)) = attendance_low
291 .zip(attendance_low_date).zip(attendance_low_game) {
292 DatedAttendance {
293 value: attendance_low,
294 date: attendance_low_date.date(),
295 game: attendance_low_game,
296 }
297 } else {
298 max.clone()
299 }
300 };
301
302 Some((min, max))
303 } else {
304 None
305 };
306
307 Self {
308 total_openings: HomeAway {
309 home: openings_total_home,
310 away: openings_total_away,
311 },
312 total_openings_lost: openings_total_lost,
313 total_games: HomeAway {
314 home: games_home_total,
315 away: games_away_total,
316 },
317 season: year,
318 attendance_totals: HomeAway {
319 home: attendance_total_home.unwrap_or(0),
320 away: attendance_total_away.unwrap_or(0),
321 },
322 single_opening_min_max,
323 game_type,
324 }
325 }
326}
327
328#[derive(Debug, PartialEq, Eq, Clone)]
330pub struct DatedAttendance {
331 pub value: u32,
333 pub date: NaiveDate,
335 pub game: GameId,
337}
338
339impl PartialOrd<Self> for DatedAttendance {
340 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
341 Some(self.cmp(other))
342 }
343}
344
345impl Ord for DatedAttendance {
346 fn cmp(&self, other: &Self) -> Ordering {
347 self.value.cmp(&other.value)
348 }
349}
350
351#[derive(Builder)]
353#[builder(derive(Into))]
354pub struct AttendanceRequest {
355 #[doc(hidden)]
356 #[builder(setters(vis = "", name = __id_internal))]
357 id: Either<TeamId, LeagueId>,
358 #[builder(into)]
359 season: Option<SeasonWithMinorId>,
360 #[builder(into)]
361 date: Option<NaiveDate>,
362 #[builder(default)]
363 game_type: GameType,
364}
365
366impl<S: attendance_request_builder::State + attendance_request_builder::IsComplete> crate::request::RequestURLBuilderExt for AttendanceRequestBuilder<S> {
367 type Built = AttendanceRequest;
368}
369
370#[allow(dead_code, reason = "optionally used by the end user")]
371impl<S: attendance_request_builder::State> AttendanceRequestBuilder<S> {
372 #[doc = "_**Required.**_\n\n"]
373 pub fn team_id(self, id: impl Into<TeamId>) -> AttendanceRequestBuilder<attendance_request_builder::SetId<S>>
374 where
375 S::Id: attendance_request_builder::IsUnset,
376 {
377 self.__id_internal(Either::Left(id.into()))
378 }
379
380 #[doc = "_**Required.**_\n\n"]
381 pub fn league_id(self, id: impl Into<LeagueId>) -> AttendanceRequestBuilder<attendance_request_builder::SetId<S>>
382 where
383 S::Id: attendance_request_builder::IsUnset,
384 {
385 self.__id_internal(Either::Right(id.into()))
386 }
387}
388
389impl Display for AttendanceRequest {
390 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
391 write!(
392 f,
393 "http://statsapi.mlb.com/api/v1/attendance{}",
394 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) }
395 )
396 }
397}
398
399impl RequestURL for AttendanceRequest {
400 type Response = AttendanceResponse;
401}
402
403#[cfg(test)]
404mod tests {
405 use crate::attendance::AttendanceRequest;
406 use crate::request::{RequestURL, RequestURLBuilderExt};
407 use crate::team::TeamsRequest;
408 use crate::TEST_YEAR;
409
410 #[tokio::test]
411 #[cfg_attr(not(feature = "_heavy_tests"), ignore)]
412 async fn parse_all_teams_test_year() {
413 let mlb_teams = TeamsRequest::all_sports()
414 .season(TEST_YEAR)
415 .build_and_get()
416 .await
417 .unwrap()
418 .teams;
419 for team in mlb_teams {
420 let request = AttendanceRequest::builder()
421 .team_id(team.id)
422 .build();
423 let _response = request.get()
424 .await
425 .unwrap();
426 }
427 }
428}