rapport_temporal/recurrence/
weekly.rs1use nonempty::NonEmpty;
2use strum::EnumCount;
3
4use crate::date::{Date, Weekday};
5
6use super::{Interval, Schedule, TemporalUnit};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct WeeklyRecurrenceSchedule {
10 interval: Interval,
11 days: NonEmpty<Weekday>,
12}
13
14impl WeeklyRecurrenceSchedule {
15 pub fn new(interval: Interval, days: impl Into<NonEmpty<Weekday>>) -> Self {
16 let days = days.into();
17 Self { interval, days }
18 }
19
20 #[must_use]
21 pub fn for_weekday(interval: Interval, day: Weekday) -> Self {
22 Self::new(interval, NonEmpty::singleton(day))
23 }
24
25 #[allow(clippy::expect_used)]
31 #[must_use]
32 pub fn every_two_weeks(starting: Date) -> Self {
33 Self::new(Interval::two(), starting.weekday())
34 }
35
36 #[must_use]
37 pub fn weekly_on(day: Weekday) -> Self {
38 Self::for_weekday(Interval::one(), day)
39 }
40
41 pub(crate) fn weekly_on_same_day_as(date: Date) -> WeeklyRecurrenceSchedule {
42 Self::weekly_on(date.weekday())
43 }
44
45 #[must_use]
49 pub fn with_weekday_toggled(self, weekday: Weekday) -> Self {
50 let Self { interval, days } = self;
51 let days = super::toggle_value(days, weekday);
52 Self { interval, days }
53 }
54}
55
56impl Schedule for WeeklyRecurrenceSchedule {
57 fn interval(&self) -> Interval {
58 self.interval
59 }
60
61 fn next_occurrence_after(&self, from: Date) -> Date {
62 let next = Weekday::next_occurrence_after_days(&self.days, from);
64 let remaining_intervals = self.interval.minus_one();
66 let remaining_days = Weekday::COUNT.saturating_mul(remaining_intervals.into());
68 next.add_days(remaining_days)
69 }
70
71 fn into_string(self, starting: Date) -> String {
72 let WeeklyRecurrenceSchedule { interval, days } = self;
73 let printed = TemporalUnit::Week.print_interval(interval);
74 if days.len() == 1 && days.head == starting.weekday() {
75 printed
76 } else {
77 tracing::debug!(
78 "writing out days for days: {} and starting weekday: {}",
79 days.iter()
80 .map(ToString::to_string)
81 .collect::<Vec<_>>()
82 .join(", "),
83 starting.weekday()
84 );
85 let days_description = super::print_elements(&days);
86 format!(
87 "{printed} {} {days_description}",
88 super::parser::GrammarToken::On
89 )
90 }
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use rstest::rstest;
98
99 use crate::date::Weekday;
100 use claims::assert_some;
101 use nonempty::NonEmpty;
102
103 #[rstest]
104 #[case::weekly("2025-06-07", Interval::one(), &[Weekday::Saturday], "2025-06-14")]
105 #[case::different_day("2025-06-07", Interval::one(), &[Weekday::Tuesday], "2025-06-10")]
106 #[case::every_two_weeks("2025-06-07", Interval::two(), &[Weekday::Saturday], "2025-06-21")]
107 #[case::every_two_weeks_different_day("2025-06-07", Interval::two(), &[Weekday::Tuesday], "2025-06-17")]
108 #[case::multiple_days("2025-06-07", Interval::one(), &[Weekday::Monday, Weekday::Tuesday], "2025-06-09")]
109 fn next_occurrence_should_calculate_weekly(
110 #[case] from: &str,
111 #[case] interval: Interval,
112 #[case] days: &[Weekday],
113 #[case] expected: &str,
114 ) {
115 let recurrence =
116 WeeklyRecurrenceSchedule::new(interval, assert_some!(NonEmpty::from_slice(days)));
117
118 let from = Date::from_str_unchecked(from);
119 let expected_next = Date::from_str_unchecked(expected);
120
121 let actual_next = recurrence.next_occurrence_after(from);
122
123 assert_eq!(actual_next, expected_next);
124 }
125
126 #[rstest]
127 #[case::weekly_on_single_day("2025-06-07", Interval::one(), &[Weekday::Tuesday], "2025-06-10")]
128 #[case::every_two_weeks("2025-06-07", Interval::two(), &[Weekday::Tuesday], "2025-06-17")]
129 #[case::multiple_days_this_week("2025-06-07",Interval::one(), &[Weekday::Monday, Weekday::Tuesday], "2025-06-09")]
130 #[case::multiple_days_next_week("2025-06-07",Interval::one(), &[Weekday::Friday, Weekday::Thursday], "2025-06-12")]
131 #[case::multiple_days_every_two_weeks("2025-06-07",Interval::two(), &[Weekday::Friday, Weekday::Thursday], "2025-06-19")]
132 #[case::same_day_included("2025-06-07", Interval::one(),&[Weekday::Saturday, Weekday::Monday], "2025-06-09")]
133 #[case::all_next_week("2025-06-07", Interval::one(), &[Weekday::Wednesday, Weekday::Thursday, Weekday::Friday], "2025-06-11")]
134 #[case::next_week_is_next_month("2025-06-30", Interval::one(), &[Weekday::Monday], "2025-07-07")]
135 #[case::next_week_isnext_year("2025-12-30", Interval::one(), &[Weekday::Monday], "2026-01-05")]
136 fn weekly_next_occurrence_after(
137 #[case] from: &str,
138 #[case] interval: Interval,
139 #[case] days: &[Weekday],
140 #[case] expected: &str,
141 ) {
142 let from = Date::from_str_unchecked(from);
143 let expected = Date::from_str_unchecked(expected);
144 let days = assert_some!(NonEmpty::from_slice(days));
145
146 let recurrence = WeeklyRecurrenceSchedule::new(interval, days);
147
148 let actual = recurrence.next_occurrence_after(from);
149
150 assert_eq!(actual, expected);
151 }
152
153 #[rstest]
154 #[case::weekly(
155 WeeklyRecurrenceSchedule::weekly_on(Weekday::Monday),
156 "2025-06-09",
157 "weekly"
158 )]
159 #[case::every_three_weeks(
160 WeeklyRecurrenceSchedule::new(Interval::three(), NonEmpty::singleton(Weekday::Monday)),
161 "2025-06-09",
162 "every 3 weeks"
163 )]
164 #[case::every_three_weeks_on_another_day(
165 WeeklyRecurrenceSchedule::weekly_on(Weekday::Tuesday),
166 "2025-06-09",
167 "weekly on Tuesday"
168 )]
169 #[case::every_two_weeks(
170 WeeklyRecurrenceSchedule::new(Interval::two(), Weekday::Monday),
171 "2025-06-09",
172 "every 2 weeks"
173 )]
174 #[case::every_two_weeks_on_another_day(
175 WeeklyRecurrenceSchedule::new(Interval::two(), Weekday::Wednesday),
176 "2025-06-09",
177 "every 2 weeks on Wednesday"
178 )]
179 fn test_into_string(
180 #[case] schedule: WeeklyRecurrenceSchedule,
181 #[case] starting: &str,
182 #[case] expected: &str,
183 ) {
184 let starting = Date::from_str_unchecked(starting);
185
186 let actual = schedule.into_string(starting);
187
188 assert_eq!(actual, expected);
189 }
190
191 #[rstest]
192 #[case::toggle_on(&[Weekday::Monday], Weekday::Tuesday, &[Weekday::Monday, Weekday::Tuesday])]
193 #[case::toggle_on_from_multiple_days(&[Weekday::Monday, Weekday::Tuesday], Weekday::Wednesday, &[Weekday::Monday, Weekday::Tuesday, Weekday::Wednesday])]
194 #[case::toggle_off(&[Weekday::Monday, Weekday::Tuesday], Weekday::Tuesday, &[Weekday::Monday])]
195 #[case::toggle_does_nothing_for_singleton(&[Weekday::Monday], Weekday::Monday, &[Weekday::Monday])]
196 fn test_toggle_weekday(
197 #[case] from: &[Weekday],
198 #[case] toggle: Weekday,
199 #[case] expected: &[Weekday],
200 ) {
201 let from = assert_some!(NonEmpty::from_slice(from));
202 let expected = assert_some!(NonEmpty::from_slice(expected));
203
204 let schedule = WeeklyRecurrenceSchedule::new(Interval::one(), from);
205 let schedule = schedule.with_weekday_toggled(toggle);
206
207 let actual = schedule.days;
208
209 assert_eq!(
210 actual, expected,
211 "expecting toggle to have the expected effect"
212 );
213 }
214}