Skip to main content

ravenrs_widgets/
recurrence.rs

1//! How something repeats: a rule, with no UI, that answers on which days.
2
3use std::fmt;
4
5use crate::date::{Date, days_in_month};
6
7/// How often a [`RecurrenceRule`] comes back.
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
9#[non_exhaustive]
10#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
11pub enum Frequency {
12    /// Only on its first day.
13    Once,
14    /// Every `interval` days.
15    Daily,
16    /// Every `interval` weeks, on the chosen weekdays.
17    Weekly,
18    /// Every `interval` months, on the first day's date.
19    #[default]
20    Monthly,
21    /// Every `interval` years, on the first day's date.
22    Yearly,
23}
24
25#[cfg_attr(feature = "live", ravenrs_live::expose)]
26impl Frequency {
27    /// Every frequency, in the order an editor offers them.
28    pub const ALL: [Self; 5] = [Self::Once, Self::Daily, Self::Weekly, Self::Monthly, Self::Yearly];
29}
30
31/// When a [`RecurrenceRule`] stops.
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
33#[non_exhaustive]
34#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
35pub enum RecurrenceEnd {
36    /// It goes on.
37    #[default]
38    Never,
39    /// Its last day can be this one.
40    Until(Date),
41    /// After this many occurrences, counting the first.
42    After(u32),
43}
44
45/// A repeating schedule, starting from a date given when asking for its days.
46/// A date past the end of a shorter month falls on that month's last day and
47/// returns to its own day after: the 31st is February 28, then March 31.
48///
49/// ```
50/// use ravenrs_widgets::{Date, Frequency, RecurrenceEnd, RecurrenceRule};
51///
52/// let start = Date::new(2026, 1, 31).unwrap();
53/// let rule = RecurrenceRule::new(Frequency::Monthly).end(RecurrenceEnd::After(3));
54/// let days = rule.occurrences(start, start, start.add_months(12));
55/// assert_eq!(days, [start, Date::new(2026, 2, 28).unwrap(), Date::new(2026, 3, 31).unwrap()]);
56///
57/// // It can be stored as text, in the format of iCalendar.
58/// assert_eq!(rule.to_text(), "FREQ=MONTHLY;COUNT=3");
59/// assert_eq!(RecurrenceRule::parse(&rule.to_text()), Some(rule));
60/// ```
61#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
62#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
63pub struct RecurrenceRule {
64    /// How often.
65    pub frequency: Frequency,
66    /// Every how many days, weeks, months or years; at least 1.
67    pub interval: u32,
68    /// For weekly rules, the weekdays from Sunday to Saturday. With none chosen,
69    /// the first day's weekday.
70    pub weekdays: [bool; 7],
71    /// When it stops.
72    pub end: RecurrenceEnd,
73}
74
75impl Default for RecurrenceRule {
76    fn default() -> Self {
77        Self::new(Frequency::default())
78    }
79}
80
81/// iCalendar's names, from Sunday.
82const WEEKDAY_CODES: [&str; 7] = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
83
84#[cfg_attr(feature = "live", ravenrs_live::expose)]
85impl RecurrenceRule {
86    /// A rule at this frequency, every time, with no end.
87    pub fn new(frequency: Frequency) -> Self {
88        Self { frequency, interval: 1, weekdays: [false; 7], end: RecurrenceEnd::Never }
89    }
90
91    /// Sets the interval; below 1 counts as 1.
92    pub fn every(mut self, interval: u32) -> Self {
93        self.interval = interval.max(1);
94        self
95    }
96
97    /// Sets the weekdays of a weekly rule, 0 = Sunday … 6 = Saturday.
98    pub fn on_weekdays(mut self, days: impl IntoIterator<Item = u8>) -> Self {
99        self.weekdays = [false; 7];
100        for day in days {
101            if let Some(chosen) = self.weekdays.get_mut(usize::from(day)) {
102                *chosen = true;
103            }
104        }
105        self
106    }
107
108    /// Sets when it stops.
109    pub fn end(mut self, end: RecurrenceEnd) -> Self {
110        self.end = end;
111        self
112    }
113
114    /// The days from `start` on, in order. It never ends by itself when the rule
115    /// does not, so limit it, as [`Self::occurrences`] does.
116    pub fn days_from(&self, start: Date) -> impl Iterator<Item = Date> + use<> {
117        let rule = *self;
118        let mut produced = 0_u32;
119        Occurrences { rule, start, step: 0, weekday: 0 }.take_while(move |day| {
120            produced += 1;
121            match rule.end {
122                RecurrenceEnd::Never => true,
123                RecurrenceEnd::Until(last) => *day <= last,
124                RecurrenceEnd::After(count) => produced <= count,
125            }
126        })
127    }
128
129    /// The days of a rule starting at `start` that fall in `from..=to`, in order.
130    pub fn occurrences(&self, start: Date, from: Date, to: Date) -> Vec<Date> {
131        self.days_from(start).take_while(|day| *day <= to).filter(|day| *day >= from).collect()
132    }
133
134    /// The rule as text, the part of iCalendar's `RRULE` it covers.
135    pub fn to_text(&self) -> String {
136        let name = match self.frequency {
137            Frequency::Once => return "FREQ=ONCE".to_owned(),
138            Frequency::Daily => "DAILY",
139            Frequency::Weekly => "WEEKLY",
140            Frequency::Monthly => "MONTHLY",
141            Frequency::Yearly => "YEARLY",
142        };
143        let mut text = format!("FREQ={name}");
144        if self.interval > 1 {
145            text.push_str(&format!(";INTERVAL={}", self.interval));
146        }
147        if self.frequency == Frequency::Weekly && self.weekdays.contains(&true) {
148            let days: Vec<&str> = (0..7).filter(|d| self.weekdays[*d]).map(|d| WEEKDAY_CODES[d]).collect();
149            text.push_str(&format!(";BYDAY={}", days.join(",")));
150        }
151        match self.end {
152            RecurrenceEnd::Never => {}
153            RecurrenceEnd::Until(day) => {
154                text.push_str(&format!(";UNTIL={:04}{:02}{:02}", day.year(), day.month(), day.day()));
155            }
156            RecurrenceEnd::After(count) => text.push_str(&format!(";COUNT={count}")),
157        }
158        text
159    }
160
161    /// Reads what [`Self::to_text`] writes; `None` when it is not a rule.
162    pub fn parse(text: &str) -> Option<Self> {
163        let mut rule = Self::new(Frequency::Once);
164        let mut has_frequency = false;
165        for part in text.trim().split(';') {
166            let (key, value) = part.split_once('=')?;
167            match key {
168                "FREQ" => {
169                    has_frequency = true;
170                    rule.frequency = match value {
171                        "ONCE" => Frequency::Once,
172                        "DAILY" => Frequency::Daily,
173                        "WEEKLY" => Frequency::Weekly,
174                        "MONTHLY" => Frequency::Monthly,
175                        "YEARLY" => Frequency::Yearly,
176                        _ => return None,
177                    };
178                }
179                "INTERVAL" => rule.interval = value.parse::<u32>().ok()?.max(1),
180                "BYDAY" => {
181                    for code in value.split(',') {
182                        let day = WEEKDAY_CODES.iter().position(|c| *c == code)?;
183                        rule.weekdays[day] = true;
184                    }
185                }
186                "UNTIL" => {
187                    let (year, month, day) = (value.get(..4)?, value.get(4..6)?, value.get(6..8)?);
188                    let date = Date::new(year.parse().ok()?, month.parse().ok()?, day.parse().ok()?)?;
189                    rule.end = RecurrenceEnd::Until(date);
190                }
191                "COUNT" => rule.end = RecurrenceEnd::After(value.parse().ok()?),
192                _ => return None,
193            }
194        }
195        has_frequency.then_some(rule)
196    }
197}
198
199impl fmt::Display for RecurrenceRule {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        f.write_str(&self.to_text())
202    }
203}
204
205struct Occurrences {
206    rule: RecurrenceRule,
207    start: Date,
208    /// Periods since the start.
209    step: i64,
210    /// For weekly rules, the next weekday to look at in this week.
211    weekday: u8,
212}
213
214/// `start`, `months` later, on its own day or on the last day of a shorter month.
215fn months_after(start: Date, months: i64) -> Option<Date> {
216    let months = i32::try_from(months).ok()?;
217    let first = start.first_of_month().add_months(months);
218    let day = start.day().min(days_in_month(first.year(), first.month()));
219    Date::new(first.year(), first.month(), day)
220}
221
222impl Iterator for Occurrences {
223    type Item = Date;
224
225    fn next(&mut self) -> Option<Date> {
226        let interval = i64::from(self.rule.interval.max(1));
227        match self.rule.frequency {
228            Frequency::Once => {
229                self.step += 1;
230                (self.step == 1).then_some(self.start)
231            }
232            Frequency::Daily => {
233                let day = Date::from_days(self.start.to_days() + self.step * interval);
234                self.step += 1;
235                Some(day)
236            }
237            Frequency::Monthly | Frequency::Yearly => {
238                let per_step = if self.rule.frequency == Frequency::Yearly { 12 } else { 1 };
239                let day = months_after(self.start, self.step * interval * per_step);
240                self.step += 1;
241                day
242            }
243            Frequency::Weekly => {
244                let own = usize::from(self.start.weekday());
245                let chosen = self.rule.weekdays;
246                let any = chosen.contains(&true);
247                let week_start = self.start.to_days() - own as i64;
248                // At most one empty pass over a week, since some weekday is always chosen.
249                loop {
250                    if self.weekday > 6 {
251                        self.weekday = 0;
252                        self.step += 1;
253                    }
254                    let weekday = usize::from(self.weekday);
255                    self.weekday += 1;
256                    if if any { chosen[weekday] } else { weekday == own } {
257                        let day = Date::from_days(week_start + self.step * interval * 7 + weekday as i64);
258                        // The first week may have chosen days before the start.
259                        if day >= self.start {
260                            return Some(day);
261                        }
262                    }
263                }
264            }
265        }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn date(y: i32, m: u8, d: u8) -> Date {
274        Date::new(y, m, d).unwrap_or_else(|| panic!("invalid date"))
275    }
276
277    fn in_a_year(rule: RecurrenceRule, start: Date) -> Vec<Date> {
278        rule.occurrences(start, start, start.add_months(12))
279    }
280
281    #[test]
282    fn once_is_only_the_first_day() {
283        let start = date(2026, 3, 10);
284        assert_eq!(in_a_year(RecurrenceRule::new(Frequency::Once), start), [start]);
285    }
286
287    #[test]
288    fn daily_counts_by_interval() {
289        let start = date(2026, 12, 30);
290        let days = RecurrenceRule::new(Frequency::Daily).every(2).occurrences(start, start, date(2027, 1, 3));
291        assert_eq!(days, [date(2026, 12, 30), date(2027, 1, 1), date(2027, 1, 3)]);
292    }
293
294    #[test]
295    fn weekly_falls_on_the_chosen_days_and_skips_weeks() {
296        // A Wednesday; Mondays and Fridays, every other week.
297        let start = date(2026, 11, 4);
298        let rule = RecurrenceRule::new(Frequency::Weekly).every(2).on_weekdays([1, 5]);
299        let days = rule.occurrences(start, start, date(2026, 11, 30));
300        assert_eq!(days, [date(2026, 11, 6), date(2026, 11, 16), date(2026, 11, 20), date(2026, 11, 30)]);
301    }
302
303    #[test]
304    fn weekly_with_no_day_chosen_keeps_its_own() {
305        let start = date(2026, 11, 4);
306        let days = RecurrenceRule::new(Frequency::Weekly).occurrences(start, start, date(2026, 11, 18));
307        assert_eq!(days, [date(2026, 11, 4), date(2026, 11, 11), date(2026, 11, 18)]);
308    }
309
310    #[test]
311    fn a_long_month_day_returns_after_a_short_month() {
312        let start = date(2026, 1, 31);
313        let days = RecurrenceRule::new(Frequency::Monthly).occurrences(start, start, date(2026, 4, 30));
314        assert_eq!(days, [start, date(2026, 2, 28), date(2026, 3, 31), date(2026, 4, 30)]);
315    }
316
317    #[test]
318    fn february_29_comes_back_in_leap_years() {
319        let start = date(2024, 2, 29);
320        let days = RecurrenceRule::new(Frequency::Yearly).occurrences(start, start, date(2028, 3, 1));
321        assert_eq!(days, [start, date(2025, 2, 28), date(2026, 2, 28), date(2027, 2, 28), date(2028, 2, 29)]);
322    }
323
324    #[test]
325    fn the_end_counts_from_the_start_whatever_range_is_asked() {
326        let start = date(2026, 1, 10);
327        let rule = RecurrenceRule::new(Frequency::Monthly).end(RecurrenceEnd::After(3));
328        assert_eq!(rule.occurrences(start, date(2026, 3, 1), date(2027, 1, 1)), [date(2026, 3, 10)]);
329        assert!(rule.occurrences(start, date(2026, 4, 1), date(2027, 1, 1)).is_empty());
330        let until = RecurrenceRule::new(Frequency::Monthly).end(RecurrenceEnd::Until(date(2026, 2, 10)));
331        assert_eq!(in_a_year(until, start), [start, date(2026, 2, 10)]);
332    }
333
334    #[test]
335    fn nothing_falls_before_the_start() {
336        let start = date(2026, 6, 15);
337        let rule = RecurrenceRule::new(Frequency::Monthly);
338        assert_eq!(rule.occurrences(start, date(2026, 1, 1), date(2026, 6, 14)), []);
339    }
340
341    #[test]
342    fn rules_survive_being_stored_as_text() {
343        for rule in [
344            RecurrenceRule::new(Frequency::Once),
345            RecurrenceRule::new(Frequency::Daily).every(3),
346            RecurrenceRule::new(Frequency::Weekly).on_weekdays([0, 3, 6]).end(RecurrenceEnd::After(9)),
347            RecurrenceRule::new(Frequency::Monthly).every(2).end(RecurrenceEnd::Until(date(2030, 12, 31))),
348            RecurrenceRule::new(Frequency::Yearly),
349        ] {
350            assert_eq!(RecurrenceRule::parse(&rule.to_text()), Some(rule), "{rule}");
351        }
352        for broken in ["", "nope", "FREQ=SOMETIMES", "INTERVAL=2", "FREQ=DAILY;UNTIL=2026", "FREQ=DAILY;X=1"]
353        {
354            assert_eq!(RecurrenceRule::parse(broken), None, "{broken}");
355        }
356    }
357
358    #[test]
359    fn a_zero_interval_cannot_hang() {
360        let mut rule = RecurrenceRule::new(Frequency::Daily);
361        rule.interval = 0;
362        let start = date(2026, 1, 1);
363        assert_eq!(rule.occurrences(start, start, date(2026, 1, 3)).len(), 3);
364    }
365}