Skip to main content

wavekat_flow/
hours.rs

1//! Evaluation of the `hours` node's schedule — the one v1 component with
2//! real branching logic, kept pure so it is directly unit-testable (feed a
3//! fixed instant, assert open/closed) without a call or a clock. The
4//! TypeScript twin is `packages/flow-schema/src/hours.ts`; a config is valid
5//! on one side iff it is on the other (pinned by the conformance corpus).
6//!
7//! A flow is authored in one place and may run on a device in another
8//! timezone, so the schedule is always evaluated in the flow's declared IANA
9//! zone (doc 48), never the device's local time. `time-tz` bundles the tz
10//! database and does the UTC→zone conversion.
11
12use time::macros::format_description;
13use time::{Date, OffsetDateTime, Time, Weekday};
14use time_tz::{timezones, OffsetDateTimeExt, Tz};
15
16use crate::model::{HoursException, Node, TimeRange, WeeklySchedule};
17
18/// Result of evaluating an `hours` node: which named exit to follow.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum HoursResult {
21    Open,
22    Closed,
23}
24
25impl HoursResult {
26    /// The exit name this result follows.
27    pub fn exit(self) -> &'static str {
28        match self {
29            HoursResult::Open => "open",
30            HoursResult::Closed => "closed",
31        }
32    }
33}
34
35/// Why an `hours` node could not be evaluated or validated. All of these are
36/// caught at load by [`crate::validate`], so a validated flow never produces
37/// one at run time — the engine treats it as a defect.
38#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39pub enum HoursError {
40    #[error("unknown IANA timezone {0:?}")]
41    UnknownTimezone(String),
42    #[error("time {0:?} is not HH:MM")]
43    BadTime(String),
44    #[error("date {0:?} is not YYYY-MM-DD")]
45    BadDate(String),
46    #[error("range open {open:?} is not before close {close:?}")]
47    NonPositiveRange { open: String, close: String },
48}
49
50impl HoursError {
51    /// Stable snake_case code, shared with the TypeScript validator
52    /// (`hours.ts`) and the conformance corpus. The Rust validator surfaces
53    /// these through [`crate::validate::ValidationError::code`] so both
54    /// languages report the same hours code for the same defect.
55    pub fn code(&self) -> &'static str {
56        match self {
57            HoursError::UnknownTimezone(_) => "unknown_timezone",
58            HoursError::BadTime(_) => "bad_time",
59            HoursError::BadDate(_) => "bad_date",
60            HoursError::NonPositiveRange { .. } => "non_positive_range",
61        }
62    }
63}
64
65/// Resolve an IANA zone name, or [`HoursError::UnknownTimezone`].
66pub fn resolve_tz(name: &str) -> Result<&'static Tz, HoursError> {
67    timezones::get_by_name(name).ok_or_else(|| HoursError::UnknownTimezone(name.to_string()))
68}
69
70/// Parse `"HH:MM"` (24-hour) into a [`Time`].
71pub fn parse_time(s: &str) -> Result<Time, HoursError> {
72    let fmt = format_description!("[hour]:[minute]");
73    Time::parse(s, fmt).map_err(|_| HoursError::BadTime(s.to_string()))
74}
75
76/// Parse `"YYYY-MM-DD"` into a [`Date`].
77pub fn parse_date(s: &str) -> Result<Date, HoursError> {
78    let fmt = format_description!("[year]-[month]-[day]");
79    Date::parse(s, fmt).map_err(|_| HoursError::BadDate(s.to_string()))
80}
81
82/// Check every time/date string in the config parses and every range is
83/// positive (open strictly before close). Called by validation so a
84/// malformed schedule is rejected at publish, not discovered mid-call.
85/// Overnight ranges (close ≤ open) are **not** a v1 feature — they fail here
86/// rather than silently never matching.
87pub fn validate_config(
88    schedule: &WeeklySchedule,
89    timezone: &str,
90    exceptions: &[HoursException],
91) -> Result<(), HoursError> {
92    resolve_tz(timezone)?;
93    for range in weekdays(schedule).into_iter().flatten() {
94        check_range(range)?;
95    }
96    for ex in exceptions {
97        parse_date(&ex.date)?;
98        for range in &ex.ranges {
99            check_range(range)?;
100        }
101    }
102    Ok(())
103}
104
105fn check_range(range: &TimeRange) -> Result<(), HoursError> {
106    let open = parse_time(&range.open)?;
107    let close = parse_time(&range.close)?;
108    if open >= close {
109        return Err(HoursError::NonPositiveRange {
110            open: range.open.clone(),
111            close: range.close.clone(),
112        });
113    }
114    Ok(())
115}
116
117/// Evaluate an `hours` node against an instant (any offset; the evaluation
118/// converts it to the flow's zone). Returns which exit to follow. Assumes a
119/// validated config; a parse failure here is a defect surfaced as an error
120/// rather than a panic. Called only for `hours` nodes; any other kind is a
121/// programming error surfaced (not hidden as "closed").
122pub fn evaluate(node: &Node, now: OffsetDateTime) -> Result<HoursResult, HoursError> {
123    let Node::Hours {
124        schedule,
125        timezone,
126        exceptions,
127        ..
128    } = node
129    else {
130        return Err(HoursError::UnknownTimezone(String::new()));
131    };
132
133    let tz = resolve_tz(timezone)?;
134    let local = now.to_timezone(tz);
135    let today = local.date();
136    let now_time = local.time();
137
138    // A dated exception fully overrides the weekly schedule for that day —
139    // the whole point of a holiday override.
140    for ex in exceptions {
141        if parse_date(&ex.date)? == today {
142            if ex.closed {
143                return Ok(HoursResult::Closed);
144            }
145            return open_if_within(&ex.ranges, now_time);
146        }
147    }
148
149    let ranges = ranges_for(schedule, local.weekday());
150    open_if_within(ranges, now_time)
151}
152
153fn open_if_within(ranges: &[TimeRange], now: Time) -> Result<HoursResult, HoursError> {
154    for range in ranges {
155        let open = parse_time(&range.open)?;
156        let close = parse_time(&range.close)?;
157        if now >= open && now < close {
158            return Ok(HoursResult::Open);
159        }
160    }
161    Ok(HoursResult::Closed)
162}
163
164fn ranges_for(schedule: &WeeklySchedule, day: Weekday) -> &[TimeRange] {
165    match day {
166        Weekday::Monday => &schedule.mon,
167        Weekday::Tuesday => &schedule.tue,
168        Weekday::Wednesday => &schedule.wed,
169        Weekday::Thursday => &schedule.thu,
170        Weekday::Friday => &schedule.fri,
171        Weekday::Saturday => &schedule.sat,
172        Weekday::Sunday => &schedule.sun,
173    }
174}
175
176fn weekdays(schedule: &WeeklySchedule) -> [&[TimeRange]; 7] {
177    [
178        &schedule.mon,
179        &schedule.tue,
180        &schedule.wed,
181        &schedule.thu,
182        &schedule.fri,
183        &schedule.sat,
184        &schedule.sun,
185    ]
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::model::Node;
192    use time::macros::datetime;
193
194    fn open_11_to_22() -> WeeklySchedule {
195        let range = TimeRange {
196            open: "11:00".into(),
197            close: "22:00".into(),
198        };
199        WeeklySchedule {
200            tue: vec![range.clone()],
201            wed: vec![range.clone()],
202            thu: vec![range.clone()],
203            fri: vec![range.clone()],
204            sat: vec![range.clone()],
205            sun: vec![range],
206            ..Default::default()
207        }
208    }
209
210    fn hours(exceptions: Vec<HoursException>) -> Node {
211        Node::Hours {
212            schedule: open_11_to_22(),
213            timezone: "America/New_York".into(),
214            exceptions,
215            exits: None,
216        }
217    }
218
219    #[test]
220    fn open_during_business_hours() {
221        // 2026-07-07 is a Tuesday. 15:00 America/New_York is 19:00 UTC
222        // (EDT, -04:00). Well inside 11:00–22:00 local.
223        let now = datetime!(2026-07-07 19:00 UTC);
224        assert_eq!(evaluate(&hours(vec![]), now).unwrap(), HoursResult::Open);
225    }
226
227    #[test]
228    fn closed_before_opening() {
229        // 2026-07-07 Tue, 10:00 local = 14:00 UTC — before 11:00 open.
230        let now = datetime!(2026-07-07 14:00 UTC);
231        assert_eq!(evaluate(&hours(vec![]), now).unwrap(), HoursResult::Closed);
232    }
233
234    #[test]
235    fn closed_at_exactly_close_time() {
236        // Half-open [open, close): 22:00 local is closed. = 02:00 UTC the
237        // next calendar day.
238        let now = datetime!(2026-07-08 02:00 UTC);
239        assert_eq!(evaluate(&hours(vec![]), now).unwrap(), HoursResult::Closed);
240    }
241
242    #[test]
243    fn closed_on_a_day_with_no_ranges() {
244        // Monday has no ranges in this schedule. 2026-07-06 is Monday.
245        let now = datetime!(2026-07-06 19:00 UTC);
246        assert_eq!(evaluate(&hours(vec![]), now).unwrap(), HoursResult::Closed);
247    }
248
249    #[test]
250    fn timezone_is_the_flows_not_utc() {
251        // 2026-07-07 Tue, 23:30 UTC. In UTC that's past 22:00 and would read
252        // "closed" if we ignored the zone — but in New York it's 19:30 EDT,
253        // firmly open. This is the whole reason `hours` carries a timezone.
254        let now = datetime!(2026-07-07 23:30 UTC);
255        assert_eq!(evaluate(&hours(vec![]), now).unwrap(), HoursResult::Open);
256    }
257
258    #[test]
259    fn holiday_exception_forces_closed() {
260        // Would be open (Tue 15:00 local) but for the holiday override.
261        let now = datetime!(2026-07-07 19:00 UTC);
262        let closed = hours(vec![HoursException {
263            date: "2026-07-07".into(),
264            closed: true,
265            ranges: vec![],
266        }]);
267        assert_eq!(evaluate(&closed, now).unwrap(), HoursResult::Closed);
268    }
269
270    #[test]
271    fn special_hours_exception_overrides_weekly() {
272        // Monday (normally closed all day) but a one-off open window.
273        let now = datetime!(2026-07-06 15:00 UTC); // 11:00 EDT
274        let special = hours(vec![HoursException {
275            date: "2026-07-06".into(),
276            closed: false,
277            ranges: vec![TimeRange {
278                open: "10:00".into(),
279                close: "14:00".into(),
280            }],
281        }]);
282        assert_eq!(evaluate(&special, now).unwrap(), HoursResult::Open);
283
284        // ...and closed outside that special window, ignoring the (empty)
285        // Monday weekly schedule.
286        let later = datetime!(2026-07-06 19:00 UTC); // 15:00 EDT
287        assert_eq!(evaluate(&special, later).unwrap(), HoursResult::Closed);
288    }
289
290    #[test]
291    fn validate_rejects_bad_timezone() {
292        let err = validate_config(&open_11_to_22(), "Mars/Olympus", &[]).unwrap_err();
293        assert!(matches!(err, HoursError::UnknownTimezone(_)));
294    }
295
296    #[test]
297    fn validate_rejects_overnight_range() {
298        let overnight = WeeklySchedule {
299            fri: vec![TimeRange {
300                open: "22:00".into(),
301                close: "02:00".into(),
302            }],
303            ..Default::default()
304        };
305        let err = validate_config(&overnight, "America/New_York", &[]).unwrap_err();
306        assert!(matches!(err, HoursError::NonPositiveRange { .. }));
307    }
308
309    #[test]
310    fn validate_rejects_malformed_time_and_date() {
311        let bad_time = WeeklySchedule {
312            mon: vec![TimeRange {
313                open: "9am".into(),
314                close: "17:00".into(),
315            }],
316            ..Default::default()
317        };
318        assert!(matches!(
319            validate_config(&bad_time, "UTC", &[]).unwrap_err(),
320            HoursError::BadTime(_)
321        ));
322
323        let bad_date = vec![HoursException {
324            date: "July 7".into(),
325            closed: true,
326            ranges: vec![],
327        }];
328        assert!(matches!(
329            validate_config(&open_11_to_22(), "UTC", &bad_date).unwrap_err(),
330            HoursError::BadDate(_)
331        ));
332    }
333}