Skip to main content

mlb_api/requests/
attendance.rs

1use crate::league::LeagueId;
2use crate::season::SeasonId;
3use crate::team::TeamId;
4use crate::types::{Copyright, HomeAwaySplits, MLB_API_DATE_FORMAT};
5use bon::Builder;
6use chrono::{Datelike, Local, NaiveDate, NaiveDateTime};
7use either::Either;
8use serde::de::Error;
9use serde::{Deserialize, Deserializer};
10use std::cmp::Ordering;
11use std::fmt::{Debug, Display, Formatter};
12use std::iter::Sum;
13use std::num::NonZeroU32;
14use std::ops::Add;
15use crate::game::GameId;
16use crate::game_types::GameType;
17use crate::request::RequestURL;
18
19/// Within regards to attendance, the term frequently used is "Opening" over "Game";
20/// this is for reasons including but not limited to: single ticket double headers,
21/// and rescheduled games.
22///
23/// 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".
24///
25/// Since the 2020 season had 0 total attendance, the 'peak attendance game' has its default value of [`NaiveDate::MIN`]
26#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
27#[serde(from = "AttendanceResponseStruct")]
28pub struct AttendanceResponse {
29	pub copyright: Copyright,
30	#[serde(rename = "records")]
31	pub annual_records: Vec<AttendanceRecord>,
32}
33
34impl AttendanceResponse {
35	#[must_use]
36	pub fn into_aggregate(self) -> AttendanceRecord {
37		self.annual_records.into_iter().sum()
38	}
39}
40
41#[derive(Deserialize)]
42struct AttendanceResponseStruct {
43	copyright: Copyright,
44	records: Vec<AttendanceRecord>,
45}
46
47impl From<AttendanceResponseStruct> for AttendanceResponse {
48	fn from(value: AttendanceResponseStruct) -> Self {
49		let AttendanceResponseStruct { copyright, records } = value;
50		Self { copyright, annual_records: records }
51	}
52}
53
54#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
55#[serde(from = "AnnualRecordStruct")]
56pub struct AttendanceRecord {
57	pub total_openings: HomeAwaySplits<u32>,
58	pub total_openings_lost: u32,
59	pub total_games: HomeAwaySplits<u32>,
60	pub season: SeasonWithMinorId,
61	pub attendance_totals: HomeAwaySplits<u32>,
62	/// Minimum at an opening, then maximum at an opening
63	pub single_opening_min_max: Option<(DatedAttendance, DatedAttendance)>,
64	pub game_type: GameType,
65}
66
67impl Add for AttendanceRecord {
68	type Output = Self;
69
70	/// 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.
71	fn add(self, rhs: Self) -> Self::Output {
72		Self {
73			total_openings: HomeAwaySplits {
74				home: self.total_openings.home + rhs.total_openings.home,
75				away: self.total_openings.away + rhs.total_openings.away,
76			},
77			total_openings_lost: self.total_openings_lost + rhs.total_openings_lost,
78			total_games: HomeAwaySplits {
79				home: self.total_games.home + rhs.total_games.home,
80				away: self.total_games.away + rhs.total_games.away,
81			},
82			season: SeasonWithMinorId::max(self.season, rhs.season),
83			attendance_totals: HomeAwaySplits {
84				home: self.attendance_totals.home + rhs.attendance_totals.home,
85				away: self.attendance_totals.away + rhs.attendance_totals.away,
86			},
87			single_opening_min_max: match (self.single_opening_min_max, rhs.single_opening_min_max) {
88				(None, None) => None,
89				(Some(min_max), None) | (None, Some(min_max)) => Some(min_max),
90				// ties go to rhs in `min` and `max` calls
91				(Some((a_min, a_max)), Some((b_min, b_max))) => Some((b_min.min(a_min), a_max.max(b_max))),
92			},
93			game_type: rhs.game_type,
94		}
95	}
96}
97
98impl Default for AttendanceRecord {
99	#[allow(clippy::cast_sign_loss, reason = "jesus is not alive")]
100	fn default() -> Self {
101		Self {
102			total_openings: HomeAwaySplits::new(0, 0),
103			total_openings_lost: 0,
104			total_games: HomeAwaySplits::new(0, 0),
105			season: (Local::now().year() as u32).into(),
106			attendance_totals: HomeAwaySplits::new(0, 0),
107			single_opening_min_max: None,
108			game_type: GameType::default(),
109		}
110	}
111}
112
113impl Sum for AttendanceRecord {
114	fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
115		iter.fold(Self::default(), |acc, x| acc + x)
116	}
117}
118
119impl AttendanceRecord {
120	#[must_use]
121	pub const fn average_attendance(&self) -> HomeAwaySplits<u32> {
122		let HomeAwaySplits { home, away } = self.attendance_totals;
123		let HomeAwaySplits { home: num_at_home, away: num_at_away } = self.total_openings;
124		HomeAwaySplits::new((home + num_at_home / 2) / num_at_home, (away + num_at_away / 2) / num_at_away)
125	}
126}
127
128#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Copy, Clone, Hash)]
129pub struct SeasonWithMinorId {
130	season: SeasonId,
131	minor: Option<NonZeroU32>,
132}
133
134impl From<SeasonId> for SeasonWithMinorId {
135	fn from(value: SeasonId) -> Self {
136		Self { season: value, minor: None }
137	}
138}
139
140impl From<u32> for SeasonWithMinorId {
141	fn from(value: u32) -> Self {
142		Self { season: value.into(), minor: None }
143	}
144}
145
146impl<'de> Deserialize<'de> for SeasonWithMinorId {
147	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
148	where
149		D: Deserializer<'de>
150	{
151		struct Visitor;
152
153		impl serde::de::Visitor<'_> for Visitor {
154			type Value = SeasonWithMinorId;
155
156			fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
157				formatter.write_str("a season id, or a string with a . denoting the minor")
158			}
159
160			fn visit_u32<E>(self, value: u32) -> Result<Self::Value, E>
161			where
162				E: Error
163			{
164				Ok(SeasonWithMinorId { season: SeasonId::from(value), minor: None })
165			}
166
167			fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
168			where
169				E: Error,
170			{
171				if let Some((season, minor)) = v.split_once('.') {
172					let season = season.parse::<u32>().map_err(Error::custom)?;
173					let minor = minor.parse::<u32>().map_err(Error::custom)?;
174					let minor = NonZeroU32::try_from(minor).map_err(Error::custom)?;
175					Ok(SeasonWithMinorId { season: SeasonId::from(season), minor: Some(minor) })
176				} else {
177					Ok(v.parse::<u32>().map(|season| SeasonWithMinorId { season: SeasonId::from(season), minor: None }).map_err(Error::custom)?)
178				}
179			}
180		}
181
182		deserializer.deserialize_any(Visitor)
183	}
184}
185
186impl Display for SeasonWithMinorId {
187	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
188		write!(f, "{}", self.season)?;
189		if let Some(minor) = self.minor {
190			write!(f, ".{minor}")?;
191		}
192		Ok(())
193	}
194}
195
196#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
197#[serde(rename_all = "camelCase")]
198struct AnnualRecordStruct {
199	// openings_total: u32,
200	openings_total_away: u32,
201	openings_total_home: u32,
202	openings_total_lost: u32,
203	// games_total: u32,
204	games_away_total: u32,
205	games_home_total: u32,
206	year: SeasonWithMinorId,
207	// attendance_average_away: u32,
208	// attendance_average_home: u32,
209	// attendance_average_ytd: u32,
210	attendance_high: Option<u32>,
211	attendance_high_date: Option<NaiveDateTime>,
212	attendance_high_game: Option<GameId>,
213	attendance_low: Option<u32>,
214	attendance_low_date: Option<NaiveDateTime>,
215	attendance_low_game: Option<GameId>,
216	// attendance_opening_average: u32,
217	// attendance_total: u32,
218	attendance_total_away: Option<u32>,
219	attendance_total_home: Option<u32>,
220	game_type: GameType,
221	// team: Team,
222}
223
224impl From<AnnualRecordStruct> for AttendanceRecord {
225	fn from(value: AnnualRecordStruct) -> Self {
226		let AnnualRecordStruct {
227			// openings_total,
228			openings_total_away,
229			openings_total_home,
230			openings_total_lost,
231			// games_total,
232			games_away_total,
233			games_home_total,
234			year,
235			// attendance_average_away,
236			// attendance_average_home,
237			// attendance_average_ytd,
238			attendance_high,
239			attendance_high_date,
240			attendance_high_game,
241			attendance_low,
242			attendance_low_date,
243			attendance_low_game,
244			// attendance_opening_average,
245			// attendance_total,
246			attendance_total_away,
247			attendance_total_home,
248			game_type,
249			// team,
250		} = value;
251		let single_opening_min_max = if let Some(attendance_high) = attendance_high
252			&& let Some(attendance_high_date) = attendance_high_date
253			&& let Some(attendance_high_game) = attendance_high_game
254		{
255			let max = DatedAttendance {
256				value: attendance_high,
257				date: attendance_high_date.date(),
258				game: attendance_high_game,
259			};
260
261			let min = {
262				if let Some(attendance_low) = attendance_low
263					&& let Some(attendance_low_date) = attendance_low_date
264					&& let Some(attendance_low_game) = attendance_low_game
265				{
266					DatedAttendance {
267						value: attendance_low,
268						date: attendance_low_date.date(),
269						game: attendance_low_game,
270					}
271				} else {
272					max.clone()
273				}
274			};
275
276			Some((min, max))
277		} else {
278			None
279		};
280		Self {
281			total_openings: HomeAwaySplits {
282				home: openings_total_home,
283				away: openings_total_away,
284			},
285			total_openings_lost: openings_total_lost,
286			total_games: HomeAwaySplits {
287				home: games_home_total,
288				away: games_away_total,
289			},
290			season: year,
291			attendance_totals: HomeAwaySplits {
292				home: attendance_total_home.unwrap_or(0),
293				away: attendance_total_away.unwrap_or(0),
294			},
295			single_opening_min_max,
296			game_type,
297		}
298	}
299}
300
301#[derive(Debug, PartialEq, Eq, Clone)]
302pub struct DatedAttendance {
303	pub value: u32,
304	pub date: NaiveDate,
305	pub game: GameId,
306}
307
308impl PartialOrd<Self> for DatedAttendance {
309	fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
310		Some(self.cmp(other))
311	}
312}
313
314impl Ord for DatedAttendance {
315	fn cmp(&self, other: &Self) -> Ordering {
316		self.value.cmp(&other.value)
317	}
318}
319
320#[derive(Builder)]
321#[builder(derive(Into))]
322pub struct AttendanceRequest {
323	#[doc(hidden)]
324	#[builder(setters(vis = "", name = __id_internal))]
325	id: Either<TeamId, LeagueId>,
326	#[builder(into)]
327	season: Option<SeasonWithMinorId>,
328	#[builder(into)]
329	date: Option<NaiveDate>,
330	#[builder(default)]
331	game_type: GameType,
332}
333
334impl<S: attendance_request_builder::State + attendance_request_builder::IsComplete> crate::request::RequestURLBuilderExt for AttendanceRequestBuilder<S> {
335    type Built = AttendanceRequest;
336}
337
338#[allow(dead_code)]
339impl<S: attendance_request_builder::State> AttendanceRequestBuilder<S> {
340	#[doc = "_**Required.**_\n\n"]
341	pub fn team_id(self, id: impl Into<TeamId>) -> AttendanceRequestBuilder<attendance_request_builder::SetId<S>>
342	where
343		S::Id: attendance_request_builder::IsUnset,
344	{
345		self.__id_internal(Either::Left(id.into()))
346	}
347
348	#[doc = "_**Required.**_\n\n"]
349	pub fn league_id(self, id: impl Into<LeagueId>) -> AttendanceRequestBuilder<attendance_request_builder::SetId<S>>
350	where
351		S::Id: attendance_request_builder::IsUnset,
352	{
353		self.__id_internal(Either::Right(id.into()))
354	}
355}
356
357impl Display for AttendanceRequest {
358	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
359		write!(
360			f,
361			"http://statsapi.mlb.com/api/v1/attendance{}",
362			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) }
363		)
364	}
365}
366
367impl RequestURL for AttendanceRequest {
368	type Response = AttendanceResponse;
369}
370
371#[cfg(test)]
372mod tests {
373	use crate::attendance::AttendanceRequest;
374	use crate::request::{RequestURL, RequestURLBuilderExt};
375	use crate::team::teams::TeamsRequest;
376	use crate::TEST_YEAR;
377
378	#[tokio::test]
379	#[cfg_attr(not(feature = "_heavy_tests"), ignore)]
380	async fn parse_all_teams_test_year() {
381		let mlb_teams = TeamsRequest::all_sports()
382			.season(TEST_YEAR)
383			.build_and_get()
384		.await
385		.unwrap()
386		.teams;
387		for team in mlb_teams {
388			let request = AttendanceRequest::builder()
389				.team_id(team.id)
390				.build();
391			let _response = request.get()
392				.await
393				.unwrap();
394		}
395	}
396}