Skip to main content

mlb_api/requests/
attendance.rs

1//! Attendance for games and seasons.
2//!
3//! Typically only seasonal [`AttendanceRecord`]s are accessible so some extra work is needed to get a specific game's attendance.
4//!
5//! Within regards to attendance, the term frequently used is "Opening" over "Game";
6//! this is for reasons including but not limited to: single ticket double headers,
7//! and rescheduled games.
8//!
9//! Averages are calculated with respect to the # of openings on the sample, not the number of games the team played as either "home" or "away".
10//!
11//! Since the 2020 season had 0 total attendance, the 'peak attendance game' has its default value of [`NaiveDate::MIN`]
12
13use 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/// Response from the `attendance` endpoint.
32/// Returns a [`Vec`] of [`AttendanceRecord`].
33///
34/// Example: <http://statsapi.mlb.com/api/v1/attendance?teamId=141>
35#[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	/// Combines all the attendance records into one for all the recorded openings.
45	#[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/// A record of attendance.
65///
66/// Does not represent a single opening, those opening-by-opening requests require a little more MacGyver-ing with the date.
67///
68/// Represents a full season of attendance data (segmented by [`GameType`]).
69#[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	/// Minimum at an opening, then maximum at an opening
78	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	/// Since the [`AttendanceRecord::default()`] value has some "worse"-er defaults (high and low attendance records have the epoch start time as their dates), we always take the later values in case of ties.
86	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				// ties go to rhs in `min` and `max` calls
106				(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	/// Calculates the average attendance.
136	///
137	/// # Examples
138	/// ```
139	/// assert_eq!(AttendanceRecord {
140	///     total_openings: (2, 2).into(),
141	///     attendance_totals: (200, 200).into(),
142	///     ..Default::default(),
143	/// }.average_attendance(), (100, 100).into());
144	/// ```
145	#[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/// Season with an optional minor part
154///
155/// Some seasons are duplicated since there might be multiple in the same year, because of that we get stuff like `2018.2`.
156#[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: u32,
228	openings_total_away: u32,
229	openings_total_home: u32,
230	openings_total_lost: u32,
231	// games_total: u32,
232	games_away_total: u32,
233	games_home_total: u32,
234	year: SeasonWithMinorId,
235	// attendance_average_away: u32,
236	// attendance_average_home: u32,
237	// attendance_average_ytd: u32,
238	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_opening_average: u32,
245	// attendance_total: u32,
246	attendance_total_away: Option<u32>,
247	attendance_total_home: Option<u32>,
248	game_type: GameType,
249	// team: Team,
250}
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,
257			openings_total_away,
258			openings_total_home,
259			openings_total_lost,
260			// games_total,
261			games_away_total,
262			games_home_total,
263			year,
264			// attendance_average_away,
265			// attendance_average_home,
266			// attendance_average_ytd,
267			attendance_high,
268			attendance_high_date,
269			attendance_high_game,
270			attendance_low,
271			attendance_low_date,
272			attendance_low_game,
273			// attendance_opening_average,
274			// attendance_total,
275			attendance_total_away,
276			attendance_total_home,
277			game_type,
278			// team,
279		} = 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/// An attendance record of a single game.
329#[derive(Debug, PartialEq, Eq, Clone)]
330pub struct DatedAttendance {
331	/// Attendance quantity
332	pub value: u32,
333	/// Date of attendance
334	pub date: NaiveDate,
335	/// Game in which people attended
336	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/// Returns [`AttendanceResponse`]
352#[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}