Skip to main content

nautilus_common/
calendar.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Timezone-aware recurring schedules expressed as UTC timer deadlines.
17
18use std::num::NonZeroU64;
19
20use jiff::{SignedDuration, Span, Zoned};
21use nautilus_core::{UnixNanos, datetime::try_datetime_to_unix_nanos};
22
23use crate::timer::{TimerInterval, TimerSchedule};
24
25/// A recurring calendar schedule anchored to its original local date and time.
26///
27/// Days, weeks, months, and years use the origin's timezone. Time-only spans use elapsed time.
28/// Each occurrence is calculated from the original origin, so DST adjustments and month-end
29/// clamping do not shift later occurrences. The schedule supplies UTC deadlines and owns no clock,
30/// callbacks, or timer lifecycle state.
31#[derive(Debug, Clone)]
32pub struct CalendarSchedule {
33    origin: Zoned,
34    span: Span,
35    start_time_ns: UnixNanos,
36}
37
38impl CalendarSchedule {
39    /// Creates a schedule with a positive span and a timezone-aware origin.
40    ///
41    /// The origin retains its civil date, wall-clock time, timezone, and selected offset in a DST
42    /// fold. Jiff's compatible disambiguation resolves later DST gaps and folds.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if:
47    /// - The span is not positive.
48    /// - The origin is outside the [`UnixNanos`] range.
49    pub fn new(origin: &Zoned, span: Span) -> anyhow::Result<Self> {
50        anyhow::ensure!(
51            span.is_positive(),
52            "Calendar interval {span} must be positive"
53        );
54
55        let start_time_ns = try_datetime_to_unix_nanos(origin.timestamp())?;
56
57        Ok(Self {
58            origin: origin.clone(),
59            span,
60            start_time_ns,
61        })
62    }
63
64    fn scheduled_time(&self, index: u64) -> Option<UnixNanos> {
65        let offset = self.span.checked_mul(i64::try_from(index).ok()?).ok()?;
66        let scheduled = self.origin.checked_add(offset).ok()?;
67        try_datetime_to_unix_nanos(scheduled.timestamp()).ok()
68    }
69}
70
71impl TimerSchedule for CalendarSchedule {
72    fn start_time_ns(&self) -> UnixNanos {
73        self.start_time_ns
74    }
75
76    fn next_after(&self, time_ns: UnixNanos, index: &mut u64) -> Option<UnixNanos> {
77        if let Ok(duration) = SignedDuration::try_from(self.span) {
78            let interval_ns = NonZeroU64::new(u64::try_from(duration.as_nanos()).ok()?)?;
79            return TimerInterval::Fixed(interval_ns).next_time_after(
80                self.start_time_ns,
81                time_ns,
82                index,
83            );
84        }
85
86        loop {
87            *index = index.checked_add(1)?;
88            let scheduled_time_ns = self.scheduled_time(*index)?;
89            if scheduled_time_ns > time_ns {
90                return Some(scheduled_time_ns);
91            }
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use jiff::{Span, Timestamp, Zoned};
99    use nautilus_core::{UnixNanos, datetime::get_timezone};
100    use rstest::rstest;
101
102    use super::CalendarSchedule;
103    use crate::timer::TimerSchedule;
104
105    fn utc_nanos(time: &str) -> UnixNanos {
106        UnixNanos::from(time.parse::<Timestamp>().unwrap())
107    }
108
109    #[rstest]
110    fn test_calendar_schedule_next_time_after_skips_to_local_grid() {
111        let interval = CalendarSchedule::new(
112            &"2026-10-30T09:30:00[America/New_York]"
113                .parse::<Zoned>()
114                .unwrap(),
115            Span::new().days(1),
116        )
117        .unwrap();
118
119        let mut index = 0;
120
121        let next_time_ns = interval.next_after(utc_nanos("2026-11-01T20:00:00Z"), &mut index);
122
123        assert_eq!(next_time_ns, Some(utc_nanos("2026-11-02T14:30:00Z")));
124        assert_eq!(index, 3);
125    }
126
127    #[rstest]
128    #[case::nanosecond(Span::new().nanoseconds(1), 10_000_000_000, 10_000_000_001, 10_000_000_001)]
129    #[case::second(Span::new().seconds(1), 10_000_000_000, 11_000_000_000, 11)]
130    fn test_calendar_schedule_time_only_skips_to_grid(
131        #[case] span: Span,
132        #[case] time_ns: u64,
133        #[case] expected: u64,
134        #[case] expected_index: u64,
135    ) {
136        let interval = CalendarSchedule::new(
137            &Timestamp::UNIX_EPOCH.to_zoned(get_timezone("America/New_York").unwrap()),
138            span,
139        )
140        .unwrap();
141
142        let mut index = 0;
143
144        let next_time_ns = interval.next_after(UnixNanos::from(time_ns), &mut index);
145
146        assert_eq!(next_time_ns, Some(UnixNanos::from(expected)));
147        assert_eq!(index, expected_index);
148    }
149
150    #[rstest]
151    #[case::fall_back_keeps_local_time(
152        "2026-10-30T09:30:00[America/New_York]",
153        "1d",
154        ["2026-10-30T13:30:00Z", "2026-10-31T13:30:00Z", "2026-11-01T14:30:00Z", "2026-11-02T14:30:00Z"],
155    )]
156    #[case::spring_forward_keeps_local_time(
157        "2026-03-06T09:30:00[America/New_York]",
158        "1d",
159        ["2026-03-06T14:30:00Z", "2026-03-07T14:30:00Z", "2026-03-08T13:30:00Z", "2026-03-09T13:30:00Z"],
160    )]
161    #[case::spring_forward_gap_does_not_shift_later_days(
162        "2026-03-07T02:30:00[America/New_York]",
163        "1d",
164        ["2026-03-07T07:30:00Z", "2026-03-08T07:30:00Z", "2026-03-09T06:30:00Z", "2026-03-10T06:30:00Z"],
165    )]
166    #[case::month_end_clamp_does_not_shift_later_months(
167        "2026-01-31T00:00:00[UTC]",
168        "1mo",
169        ["2026-01-31T00:00:00Z", "2026-02-28T00:00:00Z", "2026-03-31T00:00:00Z", "2026-04-30T00:00:00Z"],
170    )]
171    #[case::hours_stay_exact_across_fall_back(
172        "2026-11-01T00:00:00[America/New_York]",
173        "12h",
174        ["2026-11-01T04:00:00Z", "2026-11-01T16:00:00Z", "2026-11-02T04:00:00Z", "2026-11-02T16:00:00Z"],
175    )]
176    fn test_calendar_schedule_nominal_origin(
177        #[case] start: &str,
178        #[case] span: &str,
179        #[case] expected: [&str; 4],
180    ) {
181        let start = start.parse::<Zoned>().unwrap();
182        let schedule = CalendarSchedule::new(&start, span.parse::<Span>().unwrap()).unwrap();
183        let mut index = 0;
184        let mut actual = vec![schedule.start_time_ns()];
185        for _ in 0..3 {
186            actual.push(
187                schedule
188                    .next_after(*actual.last().unwrap(), &mut index)
189                    .unwrap(),
190            );
191        }
192
193        assert_eq!(
194            actual,
195            expected.into_iter().map(utc_nanos).collect::<Vec<_>>()
196        );
197        assert_eq!(index, 3);
198    }
199
200    #[rstest]
201    #[case::zero(Span::new(), "Calendar interval PT0S must be positive")]
202    #[case::negative(Span::new().days(-1), "Calendar interval -P1D must be positive")]
203    fn test_calendar_schedule_rejects_non_positive_span(
204        #[case] span: Span,
205        #[case] expected: &str,
206    ) {
207        let error = CalendarSchedule::new(
208            &Timestamp::UNIX_EPOCH.to_zoned(jiff::tz::TimeZone::UTC),
209            span,
210        )
211        .unwrap_err();
212        assert_eq!(error.to_string(), expected);
213    }
214
215    #[rstest]
216    fn test_calendar_schedule_exhausts_unrepresentable_deadline() {
217        let origin = UnixNanos::from(u64::MAX)
218            .to_datetime_utc()
219            .to_zoned(jiff::tz::TimeZone::UTC);
220        let schedule = CalendarSchedule::new(&origin, Span::new().days(1)).unwrap();
221        let mut index = 0;
222        assert_eq!(
223            schedule.next_after(UnixNanos::from(u64::MAX), &mut index),
224            None
225        );
226    }
227}