mlb_api/endpoints/attendance/
mod.rs

1use crate::endpoints::game::Game;
2use crate::endpoints::league::LeagueId;
3use crate::endpoints::teams::team::TeamId;
4use crate::endpoints::{GameType, StatsAPIUrl};
5use crate::gen_params;
6use crate::types::{Copyright, HomeAwaySplits, MLB_API_DATE_FORMAT};
7use chrono::{Datelike, Local, NaiveDate, NaiveDateTime};
8use serde::Deserialize;
9use serde_with::DisplayFromStr;
10use serde_with::serde_as;
11use std::cmp::Ordering;
12use std::fmt::{Debug, Display, Formatter};
13use std::iter::Sum;
14use std::ops::Add;
15
16/// Within regards to attendance, the term frequently used is "Opening" over "Game"; this is for reasons including but not limited to: single ticket double headers, and partially cancelled/rescheduled games.
17///
18/// Averages are canculated with respect to the # of openings on the sample, not the number of games the team played as either "home" or "away".
19///
20/// Since the 2020 season had 0 total attendance, the 'peak attendance game' has its default value of [`NaiveDate::MIN`]
21#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
22#[serde(from = "AttendanceResponseStruct")]
23pub struct AttendanceResponse {
24	pub copyright: Copyright,
25	#[serde(rename = "records")]
26	pub annual_records: Vec<AttendanceRecord>,
27}
28
29impl AttendanceResponse {
30	#[must_use]
31	pub fn into_aggregate(self) -> AttendanceRecord {
32		self.annual_records.into_iter().sum()
33	}
34}
35
36#[derive(Deserialize)]
37struct AttendanceResponseStruct {
38	copyright: Copyright,
39	records: Vec<AttendanceRecord>,
40}
41
42impl From<AttendanceResponseStruct> for AttendanceResponse {
43	fn from(value: AttendanceResponseStruct) -> Self {
44		let AttendanceResponseStruct { copyright, records } = value;
45		Self { copyright, annual_records: records }
46	}
47}
48
49#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
50#[serde(from = "AnnualRecordStruct")]
51pub struct AttendanceRecord {
52	pub total_openings: HomeAwaySplits<u32>,
53	pub total_openings_lost: u32,
54	pub total_games: HomeAwaySplits<u32>,
55	pub season: u16,
56	pub attendance_totals: HomeAwaySplits<u32>,
57	pub single_opening_high: DatedAttendance,
58	pub single_opening_low: DatedAttendance,
59	pub game_type: GameType,
60}
61
62impl Add for AttendanceRecord {
63	type Output = Self;
64
65	/// 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.
66	fn add(self, rhs: Self) -> Self::Output {
67		Self {
68			total_openings: HomeAwaySplits {
69				home: self.total_openings.home + rhs.total_openings.home,
70				away: self.total_openings.away + rhs.total_openings.away,
71			},
72			total_openings_lost: self.total_openings_lost + rhs.total_openings_lost,
73			total_games: HomeAwaySplits {
74				home: self.total_games.home + rhs.total_games.home,
75				away: self.total_games.away + rhs.total_games.away,
76			},
77			season: u16::max(self.season, rhs.season),
78			attendance_totals: HomeAwaySplits {
79				home: self.attendance_totals.home + rhs.attendance_totals.home,
80				away: self.attendance_totals.away + rhs.attendance_totals.away,
81			},
82			single_opening_high: self.single_opening_high.max(rhs.single_opening_high), // ties go to rhs
83			single_opening_low: self.single_opening_low.min(rhs.single_opening_low),    // ties go to rhs
84			game_type: rhs.game_type,
85		}
86	}
87}
88
89impl Default for AttendanceRecord {
90	fn default() -> Self {
91		Self {
92			total_openings: HomeAwaySplits::new(0, 0),
93			total_openings_lost: 0,
94			total_games: HomeAwaySplits::new(0, 0),
95			season: Local::now().year() as _,
96			attendance_totals: HomeAwaySplits::new(0, 0),
97			single_opening_high: DatedAttendance::default(),
98			single_opening_low: DatedAttendance::default(),
99			game_type: GameType::default(),
100		}
101	}
102}
103
104impl Sum for AttendanceRecord {
105	fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
106		iter.fold(Self::default(), |acc, x| acc + x)
107	}
108}
109
110impl AttendanceRecord {
111	#[must_use]
112	pub fn average_attendance(&self) -> HomeAwaySplits<u32> {
113		let HomeAwaySplits { home, away } = self.attendance_totals;
114		let HomeAwaySplits { home: num_at_home, away: num_at_away } = self.total_openings;
115		HomeAwaySplits::new((home + num_at_home / 2) / num_at_home, (away + num_at_away / 2) / num_at_away)
116	}
117}
118
119#[serde_as]
120#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
121#[serde(rename_all = "camelCase")]
122struct AnnualRecordStruct {
123	// openings_total: u32,
124	openings_total_away: u32,
125	openings_total_home: u32,
126	openings_total_lost: u32,
127	// games_total: u32,
128	games_away_total: u32,
129	games_home_total: u32,
130	#[serde_as(as = "DisplayFromStr")]
131	year: u16,
132	// attendance_average_away: u32,
133	// attendance_average_home: u32,
134	// attendance_average_ytd: u32,
135	attendance_high: u32,
136	attendance_high_date: NaiveDateTime,
137	attendance_high_game: Game,
138	attendance_low: Option<u32>,
139	attendance_low_date: Option<NaiveDateTime>,
140	attendance_low_game: Option<Game>,
141	// attendance_opening_average: u32,
142	// attendance_total: u32,
143	attendance_total_away: u32,
144	attendance_total_home: u32,
145	game_type: GameType,
146	// team: Team,
147}
148
149impl From<AnnualRecordStruct> for AttendanceRecord {
150	fn from(value: AnnualRecordStruct) -> Self {
151		let AnnualRecordStruct {
152			// openings_total,
153			openings_total_away,
154			openings_total_home,
155			openings_total_lost,
156			// games_total,
157			games_away_total,
158			games_home_total,
159			year,
160			// attendance_average_away,
161			// attendance_average_home,
162			// attendance_average_ytd,
163			attendance_high,
164			attendance_high_date,
165			attendance_high_game,
166			attendance_low,
167			attendance_low_date,
168			attendance_low_game,
169			// attendance_opening_average,
170			// attendance_total,
171			attendance_total_away,
172			attendance_total_home,
173			game_type,
174			// team,
175		} = value;
176		let high = DatedAttendance {
177			value: attendance_high,
178			date: attendance_high_date.date(),
179			game: attendance_high_game,
180		};
181		Self {
182			total_openings: HomeAwaySplits {
183				home: openings_total_home,
184				away: openings_total_away,
185			},
186			total_openings_lost: openings_total_lost,
187			total_games: HomeAwaySplits {
188				home: games_home_total,
189				away: games_away_total,
190			},
191			season: year,
192			attendance_totals: HomeAwaySplits {
193				home: attendance_total_home,
194				away: attendance_total_away,
195			},
196			single_opening_low: {
197				if let Some(attendance_low) = attendance_low
198					&& let Some(attendance_low_date) = attendance_low_date
199					&& let Some(attendance_low_game) = attendance_low_game
200				{
201					DatedAttendance {
202						value: attendance_low,
203						date: attendance_low_date.date(),
204						game: attendance_low_game,
205					}
206				} else {
207					high.clone()
208				}
209			},
210			single_opening_high: high,
211			game_type,
212		}
213	}
214}
215
216#[derive(Debug, PartialEq, Eq, Clone)]
217pub struct DatedAttendance {
218	pub value: u32,
219	pub date: NaiveDate,
220	pub game: Game,
221}
222
223impl Default for DatedAttendance {
224	fn default() -> Self {
225		Self {
226			value: 0,
227			date: NaiveDate::default(),
228			game: Game::default(),
229		}
230	}
231}
232
233impl PartialOrd<Self> for DatedAttendance {
234	fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
235		Some(self.cmp(other))
236	}
237}
238
239impl Ord for DatedAttendance {
240	fn cmp(&self, other: &Self) -> Ordering {
241		self.value.cmp(&other.value)
242	}
243}
244
245pub struct AttendanceEndpointUrl {
246	pub id: Result<TeamId, LeagueId>,
247	pub season: Option<u16>,
248	pub date: Option<NaiveDate>,
249	pub game_type: GameType,
250}
251
252impl Display for AttendanceEndpointUrl {
253	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
254		write!(
255			f,
256			"http://statsapi.mlb.com/api/v1/attendance{}",
257			gen_params! { "teamId"?: self.id.clone().ok(), "leagueId"?: self.id.clone().err(), "date"?: self.date.as_ref().map(|date| date.format(MLB_API_DATE_FORMAT)), "gameType": format!("{:?}", self.game_type) }
258		)
259	}
260}
261
262impl StatsAPIUrl for AttendanceEndpointUrl { type Response = AttendanceResponse; }
263
264#[cfg(test)]
265mod tests {
266	use crate::endpoints::attendance::AttendanceEndpointUrl;
267	use crate::endpoints::sports::SportId;
268	use crate::endpoints::teams::TeamsEndpointUrl;
269	use crate::endpoints::{GameType, StatsAPIUrl};
270
271	#[tokio::test]
272	#[cfg_attr(not(feature = "_heavy_tests"), ignore)]
273	async fn parse_all_mlb_teams_2025() {
274		let mlb_teams = TeamsEndpointUrl {
275			sport_id: Some(SportId::MLB),
276			season: Some(2025),
277		}
278		.get()
279		.await
280		.unwrap()
281		.teams;
282		for team in mlb_teams {
283			let _response = AttendanceEndpointUrl {
284				id: Ok(team.id),
285				season: None,
286				date: None,
287				game_type: GameType::RegularSeason,
288			}
289			.get()
290			.await
291			.unwrap();
292		}
293	}
294}