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