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#[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 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 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 (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_away: u32,
201 openings_total_home: u32,
202 openings_total_lost: u32,
203 games_away_total: u32,
205 games_home_total: u32,
206 year: SeasonWithMinorId,
207 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_total_away: Option<u32>,
219 attendance_total_home: Option<u32>,
220 game_type: GameType,
221 }
223
224impl From<AnnualRecordStruct> for AttendanceRecord {
225 fn from(value: AnnualRecordStruct) -> Self {
226 let AnnualRecordStruct {
227 openings_total_away,
229 openings_total_home,
230 openings_total_lost,
231 games_away_total,
233 games_home_total,
234 year,
235 attendance_high,
239 attendance_high_date,
240 attendance_high_game,
241 attendance_low,
242 attendance_low_date,
243 attendance_low_game,
244 attendance_total_away,
247 attendance_total_home,
248 game_type,
249 } = 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}