Skip to main content

aven_core/recurrence/
schedule.rs

1use std::fmt;
2
3use chrono::{DateTime, Days, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
4use chrono_tz::{GapInfo, Tz};
5
6use super::rule::RecurrenceRule;
7use super::{RecurrenceDuePolicy, RecurrenceSchedule, RecurrenceSlot};
8
9pub fn is_slot(rule: &RecurrenceRule, start_on: NaiveDate, date: NaiveDate) -> bool {
10    rule.matches(start_on, date)
11}
12
13pub fn next_slot_after(
14    rule: &RecurrenceRule,
15    start_on: NaiveDate,
16    date: NaiveDate,
17) -> Option<NaiveDate> {
18    let next = date.checked_add_days(Days::new(1))?;
19    RecurrenceSlotIter::new(rule, start_on, next).next()
20}
21
22pub fn live_slot_on(
23    rule: &RecurrenceRule,
24    start_on: NaiveDate,
25    now: DateTime<Utc>,
26    timezone: &super::TimeZoneId,
27) -> Option<NaiveDate> {
28    let zone = timezone.timezone();
29    let mut date = now.with_timezone(&zone).date_naive();
30    while date >= start_on {
31        if is_slot(rule, start_on, date)
32            && resolve_local(zone, date.and_time(NaiveTime::MIN)).ok()? <= now
33        {
34            return Some(date);
35        }
36        date = date.checked_sub_days(Days::new(1))?;
37    }
38    None
39}
40
41pub fn projection_slot_at(
42    schedule: &RecurrenceSchedule,
43    at: DateTime<Utc>,
44) -> Result<NaiveDate, RecurrenceScheduleError> {
45    if let Some(slot) = live_slot_on(&schedule.rule, schedule.start_on, at, &schedule.timezone) {
46        return Ok(slot);
47    }
48    schedule
49        .slots_on_or_after(schedule.start_on)
50        .next()
51        .ok_or(RecurrenceScheduleError::DateOverflow)
52}
53
54pub fn slot_cutoff(
55    schedule: &RecurrenceSchedule,
56    slot_on: NaiveDate,
57) -> Result<DateTime<Utc>, RecurrenceScheduleError> {
58    require_slot(schedule, slot_on)?;
59    let next = next_slot_after(&schedule.rule, schedule.start_on, slot_on)
60        .ok_or(RecurrenceScheduleError::DateOverflow)?;
61    resolve_local(schedule.timezone.timezone(), next.and_time(NaiveTime::MIN))
62}
63
64pub fn slot_values(
65    schedule: &RecurrenceSchedule,
66    slot_on: NaiveDate,
67) -> Result<RecurrenceSlot, RecurrenceScheduleError> {
68    require_slot(schedule, slot_on)?;
69    let zone = schedule.timezone.timezone();
70    let boundary = resolve_local(zone, slot_on.and_time(NaiveTime::MIN))?;
71    let available_local_time = schedule.available_local_time.unwrap_or(NaiveTime::MIN);
72    let available_at = resolve_local(zone, slot_on.and_time(available_local_time))?;
73    let due_on = match schedule.due_policy {
74        RecurrenceDuePolicy::SameDay => Some(slot_on.format("%Y-%m-%d").to_string()),
75        RecurrenceDuePolicy::None => None,
76    };
77    Ok(RecurrenceSlot {
78        scheduled_on: slot_on,
79        boundary_at: format_utc(boundary),
80        available_at: format_utc(available_at),
81        due_on,
82    })
83}
84
85#[derive(Debug, Clone)]
86pub struct RecurrenceSlotIter<'a> {
87    rule: &'a RecurrenceRule,
88    start_on: NaiveDate,
89    candidate: Option<NaiveDate>,
90}
91
92impl<'a> RecurrenceSlotIter<'a> {
93    pub(crate) fn new(
94        rule: &'a RecurrenceRule,
95        start_on: NaiveDate,
96        on_or_after: NaiveDate,
97    ) -> Self {
98        Self {
99            rule,
100            start_on,
101            candidate: Some(on_or_after.max(start_on)),
102        }
103    }
104}
105
106impl Iterator for RecurrenceSlotIter<'_> {
107    type Item = NaiveDate;
108
109    fn next(&mut self) -> Option<Self::Item> {
110        let mut candidate = self.candidate?;
111        loop {
112            let following = candidate.checked_add_days(Days::new(1));
113            if is_slot(self.rule, self.start_on, candidate) {
114                self.candidate = following;
115                return Some(candidate);
116            }
117            candidate = following?;
118        }
119    }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum RecurrenceScheduleError {
124    NotScheduledSlot,
125    DateOverflow,
126    UnresolvableLocalTime,
127}
128
129impl fmt::Display for RecurrenceScheduleError {
130    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131        formatter.write_str(match self {
132            Self::NotScheduledSlot => "date is not a slot in the recurrence schedule",
133            Self::DateOverflow => "recurrence schedule exceeds the supported date range",
134            Self::UnresolvableLocalTime => {
135                "recurrence local time is outside the time-zone rule range"
136            }
137        })
138    }
139}
140
141impl std::error::Error for RecurrenceScheduleError {}
142
143fn require_slot(
144    schedule: &RecurrenceSchedule,
145    slot_on: NaiveDate,
146) -> Result<(), RecurrenceScheduleError> {
147    if is_slot(&schedule.rule, schedule.start_on, slot_on) {
148        Ok(())
149    } else {
150        Err(RecurrenceScheduleError::NotScheduledSlot)
151    }
152}
153
154fn resolve_local(zone: Tz, local: NaiveDateTime) -> Result<DateTime<Utc>, RecurrenceScheduleError> {
155    let zoned = match zone.from_local_datetime(&local) {
156        LocalResult::Single(value) => value,
157        LocalResult::Ambiguous(first, second) => first.min(second),
158        LocalResult::None => GapInfo::new(&local, &zone)
159            .and_then(|gap| gap.end)
160            .ok_or(RecurrenceScheduleError::UnresolvableLocalTime)?,
161    };
162    Ok(zoned.with_timezone(&Utc))
163}
164
165fn format_utc(value: DateTime<Utc>) -> String {
166    value.format("%Y-%m-%dT%H:%M:%SZ").to_string()
167}