Skip to main content

aven_core/recurrence/
schedule.rs

1use std::fmt;
2
3use chrono::{
4    DateTime, Datelike, Days, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc,
5};
6use chrono_tz::{GapInfo, Tz};
7
8use super::rule::{RecurrenceFrequency, RecurrenceRule, last_day_of_month};
9use super::{RecurrenceDuePolicy, RecurrenceSchedule, RecurrenceSlot};
10
11pub fn is_slot(rule: &RecurrenceRule, start_on: NaiveDate, date: NaiveDate) -> bool {
12    rule.matches(start_on, date)
13}
14
15pub fn next_slot_after(
16    rule: &RecurrenceRule,
17    start_on: NaiveDate,
18    date: NaiveDate,
19) -> Option<NaiveDate> {
20    slot_on_or_after(rule, start_on, date.checked_add_days(Days::new(1))?)
21}
22
23pub fn live_slot_on(
24    rule: &RecurrenceRule,
25    start_on: NaiveDate,
26    now: DateTime<Utc>,
27    timezone: &super::TimeZoneId,
28) -> Option<NaiveDate> {
29    let zone = timezone.timezone();
30    let local_today = now.with_timezone(&zone).date_naive();
31    let mut slot = slot_on_or_before(rule, start_on, local_today)?;
32    loop {
33        if resolve_local(zone, slot.and_time(NaiveTime::MIN)).ok()? <= now {
34            return Some(slot);
35        }
36        let before = slot.checked_sub_days(Days::new(1))?;
37        slot = slot_on_or_before(rule, start_on, before)?;
38    }
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 slot = slot_on_or_after(self.rule, self.start_on, self.candidate?)?;
111        self.candidate = slot.checked_add_days(Days::new(1));
112        Some(slot)
113    }
114}
115
116pub(crate) fn slot_on_or_after(
117    rule: &RecurrenceRule,
118    start_on: NaiveDate,
119    date: NaiveDate,
120) -> Option<NaiveDate> {
121    let target = date.max(start_on);
122    match rule.frequency() {
123        RecurrenceFrequency::Daily => daily_slot_on_or_after(rule.interval(), start_on, target),
124        RecurrenceFrequency::Weekly => weekly_slot_on_or_after(rule, start_on, target),
125        RecurrenceFrequency::Monthly => {
126            month_slot_on_or_after(i64::from(rule.interval()), start_on, target)
127        }
128        RecurrenceFrequency::Yearly => {
129            month_slot_on_or_after(i64::from(rule.interval()) * 12, start_on, target)
130        }
131    }
132}
133
134pub(crate) fn slot_on_or_before(
135    rule: &RecurrenceRule,
136    start_on: NaiveDate,
137    date: NaiveDate,
138) -> Option<NaiveDate> {
139    if date < start_on {
140        return None;
141    }
142    match rule.frequency() {
143        RecurrenceFrequency::Daily => daily_slot_on_or_before(rule.interval(), start_on, date),
144        RecurrenceFrequency::Weekly => weekly_slot_on_or_before(rule, start_on, date),
145        RecurrenceFrequency::Monthly => {
146            month_slot_on_or_before(i64::from(rule.interval()), start_on, date)
147        }
148        RecurrenceFrequency::Yearly => {
149            month_slot_on_or_before(i64::from(rule.interval()) * 12, start_on, date)
150        }
151    }
152}
153
154fn daily_slot_on_or_after(
155    interval: u32,
156    start_on: NaiveDate,
157    date: NaiveDate,
158) -> Option<NaiveDate> {
159    let elapsed = date.signed_duration_since(start_on).num_days();
160    let interval = i64::from(interval);
161    let periods = elapsed.checked_add(interval - 1)?.checked_div(interval)?;
162    add_days(start_on, periods.checked_mul(interval)?)
163}
164
165fn daily_slot_on_or_before(
166    interval: u32,
167    start_on: NaiveDate,
168    date: NaiveDate,
169) -> Option<NaiveDate> {
170    let elapsed = date.signed_duration_since(start_on).num_days();
171    let interval = i64::from(interval);
172    add_days(
173        start_on,
174        elapsed.checked_div(interval)?.checked_mul(interval)?,
175    )
176}
177
178fn month_slot_on_or_after(period: i64, start_on: NaiveDate, date: NaiveDate) -> Option<NaiveDate> {
179    let difference = month_ordinal(date).checked_sub(month_ordinal(start_on))?;
180    let mut index = difference.checked_div(period)?;
181    let mut slot = month_slot(start_on, period, index)?;
182    if slot < date {
183        index = index.checked_add(1)?;
184        slot = month_slot(start_on, period, index)?;
185    }
186    Some(slot)
187}
188
189fn month_slot_on_or_before(period: i64, start_on: NaiveDate, date: NaiveDate) -> Option<NaiveDate> {
190    let difference = month_ordinal(date).checked_sub(month_ordinal(start_on))?;
191    let mut index = difference.checked_div(period)?;
192    let mut slot = month_slot(start_on, period, index)?;
193    if slot > date {
194        index = index.checked_sub(1)?;
195        if index < 0 {
196            return None;
197        }
198        slot = month_slot(start_on, period, index)?;
199    }
200    Some(slot)
201}
202
203fn month_slot(start_on: NaiveDate, period: i64, index: i64) -> Option<NaiveDate> {
204    let ordinal = month_ordinal(start_on).checked_add(period.checked_mul(index)?)?;
205    let year = i32::try_from(ordinal.div_euclid(12)).ok()?;
206    let month = u32::try_from(ordinal.rem_euclid(12) + 1).ok()?;
207    let first = NaiveDate::from_ymd_opt(year, month, 1)?;
208    NaiveDate::from_ymd_opt(year, month, start_on.day().min(last_day_of_month(first)))
209}
210
211fn weekly_slot_on_or_after(
212    rule: &RecurrenceRule,
213    start_on: NaiveDate,
214    date: NaiveDate,
215) -> Option<NaiveDate> {
216    let anchor_monday = monday_of(start_on)?;
217    let target_monday = monday_of(date)?;
218    let target_week = target_monday
219        .signed_duration_since(anchor_monday)
220        .num_days()
221        .checked_div(7)?;
222    let interval = i64::from(rule.interval());
223    let mut block = target_week.checked_div(interval)?;
224    loop {
225        let monday = add_weeks(anchor_monday, block.checked_mul(interval)?)?;
226        for weekday in rule.weekdays_set().iter() {
227            let candidate = add_days(monday, i64::from(weekday.num_days_from_monday()))?;
228            if candidate >= start_on && candidate >= date {
229                return Some(candidate);
230            }
231        }
232        block = block.checked_add(1)?;
233    }
234}
235
236fn weekly_slot_on_or_before(
237    rule: &RecurrenceRule,
238    start_on: NaiveDate,
239    date: NaiveDate,
240) -> Option<NaiveDate> {
241    let anchor_monday = monday_of(start_on)?;
242    let target_monday = monday_of(date)?;
243    let target_week = target_monday
244        .signed_duration_since(anchor_monday)
245        .num_days()
246        .checked_div(7)?;
247    let interval = i64::from(rule.interval());
248    let mut block = target_week.checked_div(interval)?;
249    loop {
250        let monday = add_weeks(anchor_monday, block.checked_mul(interval)?)?;
251        for weekday in rule.weekdays_set().iter().rev() {
252            if let Some(candidate) = add_days(monday, i64::from(weekday.num_days_from_monday()))
253                && candidate >= start_on
254                && candidate <= date
255            {
256                return Some(candidate);
257            }
258        }
259        block = block.checked_sub(1)?;
260        if block < 0 {
261            return None;
262        }
263    }
264}
265
266fn month_ordinal(date: NaiveDate) -> i64 {
267    i64::from(date.year()) * 12 + i64::from(date.month0())
268}
269
270fn monday_of(date: NaiveDate) -> Option<NaiveDate> {
271    date.checked_sub_days(Days::new(u64::from(date.weekday().num_days_from_monday())))
272}
273
274fn add_weeks(date: NaiveDate, weeks: i64) -> Option<NaiveDate> {
275    add_days(date, weeks.checked_mul(7)?)
276}
277
278fn add_days(date: NaiveDate, days: i64) -> Option<NaiveDate> {
279    date.checked_add_days(Days::new(u64::try_from(days).ok()?))
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum RecurrenceScheduleError {
284    NotScheduledSlot,
285    DateOverflow,
286    UnresolvableLocalTime,
287}
288
289impl fmt::Display for RecurrenceScheduleError {
290    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
291        formatter.write_str(match self {
292            Self::NotScheduledSlot => "date is not a slot in the recurrence schedule",
293            Self::DateOverflow => "recurrence schedule exceeds the supported date range",
294            Self::UnresolvableLocalTime => {
295                "recurrence local time is outside the time-zone rule range"
296            }
297        })
298    }
299}
300
301impl std::error::Error for RecurrenceScheduleError {}
302
303fn require_slot(
304    schedule: &RecurrenceSchedule,
305    slot_on: NaiveDate,
306) -> Result<(), RecurrenceScheduleError> {
307    if is_slot(&schedule.rule, schedule.start_on, slot_on) {
308        Ok(())
309    } else {
310        Err(RecurrenceScheduleError::NotScheduledSlot)
311    }
312}
313
314fn resolve_local(zone: Tz, local: NaiveDateTime) -> Result<DateTime<Utc>, RecurrenceScheduleError> {
315    let zoned = match zone.from_local_datetime(&local) {
316        LocalResult::Single(value) => value,
317        LocalResult::Ambiguous(first, second) => first.min(second),
318        LocalResult::None => GapInfo::new(&local, &zone)
319            .and_then(|gap| gap.end)
320            .ok_or(RecurrenceScheduleError::UnresolvableLocalTime)?,
321    };
322    Ok(zoned.with_timezone(&Utc))
323}
324
325fn format_utc(value: DateTime<Utc>) -> String {
326    value.format("%Y-%m-%dT%H:%M:%SZ").to_string()
327}