Skip to main content

bloop_server_framework/evaluator/
time.rs

1use crate::achievement::AchievementContext;
2use crate::evaluator::{EvalResult, Evaluator};
3use chrono::{LocalResult, NaiveTime, Timelike};
4use std::fmt::Debug;
5use std::ops::Add;
6use std::time::Duration;
7use thiserror::Error;
8
9/// Evaluates whether the current bloop's timestamp falls within a specified
10/// time window.
11///
12/// The `TimeEvaluator` checks if the `recorded_at` time of a bloop, converted
13/// to a configured timezone, matches the given hour and/or minute, allowing for
14/// an optional leeway duration.
15///
16/// # Behavior
17///
18/// - If `hour` is `Some`, it checks if the time's hour equals.
19/// - If `minute` is `Some`, it checks if the time's minute equals.
20/// - If either `hour` or `minute` is `None`, that component is ignored
21///   (i.e., any hour or minute matches).
22/// - The time window starts at the exact configured hour/minute (or current
23///   hour/minute if `None`),
24///   and extends forward by the `leeway` duration.
25/// - Handles daylight saving time ambiguities by selecting the earliest
26///   matching local time.
27/// - If the configured time does not exist on the day (e.g., during a DST gap),
28///   evaluation returns false.
29#[derive(Debug)]
30pub struct TimeEvaluator {
31    hour: Option<u32>,
32    minute: Option<u32>,
33    timezone: chrono_tz::Tz,
34    leeway: Duration,
35}
36
37#[derive(Debug, Error)]
38pub enum Error {
39    #[error("hour must be between 0 and 23")]
40    InvalidHour(u32),
41    #[error("minute must be between 0 and 59")]
42    InvalidMinute(u32),
43}
44
45type Result<T> = std::result::Result<T, Error>;
46
47impl TimeEvaluator {
48    /// Creates a new `TimeEvaluator`.
49    ///
50    /// # Examples
51    ///
52    /// ```
53    /// use std::time::Duration;
54    /// use bloop_server_framework::evaluator::time::TimeEvaluator;
55    ///
56    /// let evaluator = TimeEvaluator::new(
57    ///     Some(12),
58    ///     None,
59    ///     chrono_tz::Europe::Berlin,
60    ///     Some(Duration::from_secs(60))
61    /// ).unwrap();
62    /// ```
63    pub fn new(
64        hour: Option<u32>,
65        minute: Option<u32>,
66        timezone: chrono_tz::Tz,
67        leeway: Option<Duration>,
68    ) -> Result<Self> {
69        if let Some(hour) = hour
70            && hour > 23
71        {
72            return Err(Error::InvalidHour(hour));
73        }
74
75        if let Some(minute) = minute
76            && minute > 59
77        {
78            return Err(Error::InvalidMinute(minute));
79        }
80
81        Ok(Self {
82            hour,
83            minute,
84            timezone,
85            leeway: leeway.unwrap_or_default(),
86        })
87    }
88}
89
90const MINUTE: Duration = Duration::from_secs(60);
91const HOUR: Duration = Duration::from_secs(60 * 60);
92
93impl<Player, Metadata, Trigger> Evaluator<Player, Metadata, Trigger> for TimeEvaluator {
94    fn evaluate(
95        &self,
96        ctx: &AchievementContext<Player, Metadata, Trigger>,
97    ) -> impl Into<EvalResult> {
98        let now = ctx.current_bloop.recorded_at.with_timezone(&self.timezone);
99
100        let from = now.with_time(
101            NaiveTime::from_hms_opt(
102                self.hour.unwrap_or(now.hour()),
103                self.minute.unwrap_or(now.minute()),
104                0,
105            )
106            .unwrap(),
107        );
108
109        let (from, to) = match from {
110            LocalResult::Single(target_time) => (target_time, target_time),
111            LocalResult::Ambiguous(earliest, latest) => (earliest, latest),
112            LocalResult::None => return false,
113        };
114
115        let to = to.add(if self.minute.is_some() { MINUTE } else { HOUR });
116
117        now >= from && now < to + self.leeway
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::bloop::Bloop;
125    use crate::evaluator::{EvalResult, Evaluator};
126    use crate::test_utils::{MockPlayer, TestCtxBuilder};
127    use chrono::{DateTime, TimeZone, Utc};
128    use chrono_tz::Europe::Berlin;
129    use std::time::Duration;
130
131    fn make_ctx_builder_for_time(dt: DateTime<Utc>) -> TestCtxBuilder<MockPlayer, (), ()> {
132        let (player, _) = MockPlayer::builder().build();
133        let bloop = Bloop::new(player.clone(), "client1", dt);
134        TestCtxBuilder::new(bloop)
135    }
136
137    #[test]
138    fn matches_exact_hour_minute() {
139        let time = Berlin
140            .with_ymd_and_hms(2024, 10, 1, 14, 30, 0)
141            .unwrap()
142            .with_timezone(&Utc);
143        let mut builder = make_ctx_builder_for_time(time);
144        let evaluator = TimeEvaluator::new(Some(14), Some(30), Berlin, None).unwrap();
145        assert_eq!(
146            evaluator.evaluate(&builder.build()).into(),
147            EvalResult::AwardSelf
148        );
149    }
150
151    #[test]
152    fn within_leeway() {
153        let time = Berlin
154            .with_ymd_and_hms(2024, 10, 1, 14, 30, 30)
155            .unwrap()
156            .with_timezone(&Utc);
157        let mut builder = make_ctx_builder_for_time(time);
158        let evaluator =
159            TimeEvaluator::new(Some(14), Some(30), Berlin, Some(Duration::from_secs(60))).unwrap();
160        assert_eq!(
161            evaluator.evaluate(&builder.build()).into(),
162            EvalResult::AwardSelf
163        );
164    }
165
166    #[test]
167    fn outside_leeway_fails() {
168        let time = Berlin
169            .with_ymd_and_hms(2024, 10, 1, 14, 32, 0)
170            .unwrap()
171            .with_timezone(&Utc);
172        let mut builder = make_ctx_builder_for_time(time);
173        let evaluator =
174            TimeEvaluator::new(Some(14), Some(30), Berlin, Some(Duration::from_secs(60))).unwrap();
175        assert_eq!(
176            evaluator.evaluate(&builder.build()).into(),
177            EvalResult::NoAward
178        );
179    }
180
181    #[test]
182    fn with_only_hour() {
183        let time = Berlin
184            .with_ymd_and_hms(2024, 10, 1, 9, 15, 0)
185            .unwrap()
186            .with_timezone(&Utc);
187        let mut builder = make_ctx_builder_for_time(time);
188        let evaluator = TimeEvaluator::new(Some(9), None, Berlin, None).unwrap();
189        assert_eq!(
190            evaluator.evaluate(&builder.build()).into(),
191            EvalResult::AwardSelf
192        );
193    }
194
195    #[test]
196    fn with_only_minute() {
197        let time = Berlin
198            .with_ymd_and_hms(2024, 10, 1, 22, 45, 0)
199            .unwrap()
200            .with_timezone(&Utc);
201        let mut builder = make_ctx_builder_for_time(time);
202        let evaluator = TimeEvaluator::new(None, Some(45), Berlin, None).unwrap();
203        assert_eq!(
204            evaluator.evaluate(&builder.build()).into(),
205            EvalResult::AwardSelf
206        );
207    }
208
209    #[test]
210    fn dst_gap_returns_false() {
211        let time = Utc.with_ymd_and_hms(2024, 3, 31, 1, 30, 0).unwrap();
212        let mut builder = make_ctx_builder_for_time(time);
213        let evaluator = TimeEvaluator::new(Some(2), Some(30), Berlin, None).unwrap();
214        assert_eq!(
215            evaluator.evaluate(&builder.build()).into(),
216            EvalResult::NoAward
217        );
218    }
219
220    #[test]
221    fn handles_ambiguous_earliest_time() {
222        let time = Utc.with_ymd_and_hms(2024, 10, 27, 0, 30, 0).unwrap();
223        let mut builder = make_ctx_builder_for_time(time);
224        let evaluator = TimeEvaluator::new(Some(2), Some(30), Berlin, None).unwrap();
225        assert_eq!(
226            evaluator.evaluate(&builder.build()).into(),
227            EvalResult::AwardSelf
228        );
229    }
230
231    #[test]
232    fn handles_ambiguous_latest_time() {
233        let time = Utc.with_ymd_and_hms(2024, 10, 27, 1, 30, 0).unwrap();
234        let mut builder = make_ctx_builder_for_time(time);
235        let evaluator = TimeEvaluator::new(Some(2), Some(30), Berlin, None).unwrap();
236        assert_eq!(
237            evaluator.evaluate(&builder.build()).into(),
238            EvalResult::AwardSelf
239        );
240    }
241}