Skip to main content

sofie_db/worker/
wall_clock.rs

1use sofie_core::variants::Variants as _;
2use sofie_core::{Error, Instant, Result, Timestamp};
3use sofie_values::{Date, TimeOfDay, TimeZone, Weekday};
4
5/// `#[every(daily_at = "03:00", zone = "Europe/Istanbul")]`; a worker refuses an unknown zone at start.
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub struct WallClock {
8    at: TimeOfDay,
9    on: Option<Weekday>,
10    zone: &'static str,
11}
12
13const DAYS_LOOKED_AHEAD: usize = 8;
14
15impl WallClock {
16    #[must_use]
17    pub const fn daily(at: TimeOfDay, zone: &'static str) -> Self {
18        Self { at, on: None, zone }
19    }
20
21    #[must_use]
22    pub const fn weekly(on: Weekday, at: TimeOfDay, zone: &'static str) -> Self {
23        Self {
24            at,
25            on: Some(on),
26            zone,
27        }
28    }
29
30    pub(crate) fn zone(self) -> Result<TimeZone> {
31        TimeZone::parse(self.zone).map_err(|_| {
32            Error::internal_message(format!(
33                "`{}` is not an IANA time zone such as Europe/Istanbul",
34                self.zone
35            ))
36        })
37    }
38
39    pub(crate) fn key(self) -> String {
40        match self.on {
41            None => format!("daily at {} {}", self.at, self.zone),
42            Some(weekday) => format!("weekly on {} at {} {}", weekday.name(), self.at, self.zone),
43        }
44    }
45
46    /// Counted in the zone's days, so a time its clocks show twice runs once, at the first.
47    pub(crate) fn next_after(self, now: Timestamp) -> Result<Timestamp> {
48        let zone = self.zone()?;
49        let mut day = Date::of(Instant::from(now), &zone);
50        for _ in 0..DAYS_LOOKED_AHEAD {
51            if self.on.is_none_or(|weekday| day.weekday() == weekday) {
52                let run = zone.instant_at(day, self.at)?.timestamp();
53                if run > now {
54                    return Ok(run);
55                }
56            }
57            day = day
58                .civil()
59                .tomorrow()
60                .map(Date::from)
61                .map_err(|error| Error::internal("finding a schedule's next day", error))?;
62        }
63        Err(Error::internal_message(format!(
64            "no run of {self:?} within a week after {now}"
65        )))
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    fn next(wall_clock: WallClock, now: &str) -> String {
74        wall_clock
75            .next_after(now.parse().unwrap())
76            .unwrap()
77            .to_string()
78    }
79
80    fn daily(at: &str, zone: &'static str) -> WallClock {
81        WallClock::daily(TimeOfDay::parse(at).unwrap(), zone)
82    }
83
84    #[test]
85    fn a_daily_run_is_today_until_it_passes_then_tomorrow() {
86        let three = daily("03:00", "Europe/Istanbul");
87
88        assert_eq!(next(three, "2026-10-20T23:59:00Z"), "2026-10-21T00:00:00Z");
89        assert_eq!(next(three, "2026-10-21T00:00:00Z"), "2026-10-22T00:00:00Z");
90    }
91
92    #[test]
93    fn a_skipped_time_runs_where_the_shift_ends() {
94        let half_past_two = daily("02:30", "Europe/Berlin");
95
96        assert_eq!(
97            next(half_past_two, "2026-03-28T12:00:00Z"),
98            "2026-03-29T01:00:00Z"
99        );
100        assert_eq!(
101            next(half_past_two, "2026-03-29T01:00:00Z"),
102            "2026-03-30T00:30:00Z"
103        );
104    }
105
106    #[test]
107    fn a_repeated_time_runs_once_at_its_first_occurrence() {
108        let half_past_two = daily("02:30", "Europe/Berlin");
109
110        assert_eq!(
111            next(half_past_two, "2026-10-24T12:00:00Z"),
112            "2026-10-25T00:30:00Z"
113        );
114        assert_eq!(
115            next(half_past_two, "2026-10-25T00:30:00Z"),
116            "2026-10-26T01:30:00Z"
117        );
118        assert_eq!(
119            next(half_past_two, "2026-10-25T01:30:00Z"),
120            "2026-10-26T01:30:00Z"
121        );
122    }
123
124    #[test]
125    fn a_weekly_run_waits_for_its_day() {
126        let monday = WallClock::weekly(
127            Weekday::Monday,
128            TimeOfDay::constant("09:00"),
129            "Europe/Berlin",
130        );
131
132        assert_eq!(next(monday, "2026-10-21T12:00:00Z"), "2026-10-26T08:00:00Z");
133        assert_eq!(next(monday, "2026-10-26T07:59:00Z"), "2026-10-26T08:00:00Z");
134        assert_eq!(next(monday, "2026-10-26T08:00:00Z"), "2026-11-02T08:00:00Z");
135    }
136
137    #[test]
138    fn an_unknown_zone_is_refused() {
139        let error = daily("03:00", "Mars/Olympus")
140            .next_after("2026-10-21T00:00:00Z".parse().unwrap())
141            .unwrap_err();
142
143        assert!(
144            error
145                .to_string()
146                .contains("`Mars/Olympus` is not an IANA time zone"),
147            "{error}"
148        );
149    }
150}