sofie_db/worker/
wall_clock.rs1use sofie_core::variants::Variants as _;
2use sofie_core::{Error, Instant, Result, Timestamp};
3use sofie_values::{Date, TimeOfDay, TimeZone, Weekday};
4
5#[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 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}