Skip to main content

max / goingson

38.5 KB · 975 lines History Blame Raw
1 //! Recurring task and event scheduling logic.
2 //!
3 //! Recurrence preserves the **local wall-clock time-of-day** in a target time
4 //! zone, not the UTC instant. A task set to recur "every day at 09:00" must stay
5 //! at 09:00 local across a daylight-saving transition; doing the arithmetic in
6 //! UTC would drift it by an hour twice a year (ultra-fuzz Run #28 MINOR). Every
7 //! computation therefore takes a `chrono_tz::Tz`: field extraction and result
8 //! construction happen in that zone, and only the boundaries convert to/from UTC.
9 //!
10 //! The `*_in_tz` functions are canonical. The legacy non-`tz` wrappers pass
11 //! `Tz::UTC` (no DST), preserving their original behavior for callers and tests
12 //! that operate purely in UTC; application code resolves the system zone and
13 //! calls the `*_in_tz` variants.
14
15 use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Timelike, Utc};
16 use chrono_tz::Tz;
17 use uuid::Uuid;
18 use crate::models::{Event, Recurrence, RecurrenceRule, MonthlySpec};
19
20 /// Calculate the next due date based on recurrence type (UTC; no DST handling).
21 ///
22 /// Thin wrapper over [`calculate_next_due_in_tz`] with `Tz::UTC`. Application
23 /// code should call the `_in_tz` form with the user's zone so a fixed local
24 /// time-of-day survives DST transitions.
25 pub fn calculate_next_due(
26 current_due: Option<&DateTime<Utc>>,
27 recurrence: &Recurrence,
28 ) -> Option<DateTime<Utc>> {
29 calculate_next_due_in_tz(current_due, recurrence, Tz::UTC)
30 }
31
32 /// Calculate the next due date based on recurrence type, in `tz`.
33 ///
34 /// The next occurrence is calculated from the original due date,
35 /// not from when the task was completed. This ensures consistent
36 /// scheduling (e.g., "every Monday" stays on Mondays) and keeps the
37 /// local time-of-day stable across DST.
38 pub fn calculate_next_due_in_tz(
39 current_due: Option<&DateTime<Utc>>,
40 recurrence: &Recurrence,
41 tz: Tz,
42 ) -> Option<DateTime<Utc>> {
43 // For monthly recurrence, detect end-of-month dates and preserve intent.
44 // If the due date falls on the last day of its month AND the day is >= 29,
45 // treat the target as day 31 so it snaps to end-of-month in longer months.
46 // The >= 29 guard prevents false positives: Feb 28 in a non-leap year is
47 // ambiguous (user may have meant "the 28th"), but days 29-30 at month-end
48 // clearly indicate end-of-month intent. Day/month are read in `tz`.
49 let target_day = if matches!(recurrence, Recurrence::Monthly) {
50 current_due.and_then(|dt| {
51 let local = dt.with_timezone(&tz);
52 let day = local.day();
53 let month_len = days_in_month(local.year(), local.month());
54 if day == month_len && (29..31).contains(&day) {
55 Some(31)
56 } else {
57 None
58 }
59 })
60 } else {
61 None
62 };
63 calculate_next_due_with_day_in_tz(current_due, recurrence, target_day, tz)
64 }
65
66 /// Like `calculate_next_due` but accepts an explicit target day-of-month
67 /// for monthly recurrence to prevent day drift across short months (UTC wrapper).
68 pub fn calculate_next_due_with_day(
69 current_due: Option<&DateTime<Utc>>,
70 recurrence: &Recurrence,
71 original_day: Option<u32>,
72 ) -> Option<DateTime<Utc>> {
73 calculate_next_due_with_day_in_tz(current_due, recurrence, original_day, Tz::UTC)
74 }
75
76 /// `calculate_next_due_with_day` evaluated in `tz`. Daily/weekly advance by whole
77 /// civil days in the zone (so the local time-of-day is stable across DST), not by
78 /// fixed 24h/168h UTC spans.
79 pub fn calculate_next_due_with_day_in_tz(
80 current_due: Option<&DateTime<Utc>>,
81 recurrence: &Recurrence,
82 original_day: Option<u32>,
83 tz: Tz,
84 ) -> Option<DateTime<Utc>> {
85 let base_date = current_due.copied().unwrap_or_else(Utc::now);
86
87 match recurrence {
88 Recurrence::Daily => Some(add_civil_days(base_date, 1, tz)),
89 Recurrence::Weekly => Some(add_civil_days(base_date, 7, tz)),
90 Recurrence::Monthly => {
91 let next = add_months(base_date, 1, original_day, tz);
92 Some(next)
93 }
94 Recurrence::None => None,
95 }
96 }
97
98 /// Advance `dt` by `days` whole **civil** days in `tz`, holding the local
99 /// wall-clock time-of-day fixed, then convert back to UTC. This is what keeps a
100 /// "every day at 09:00 local" task at 09:00 across a DST boundary (a fixed
101 /// `Duration::days` would shift it by the offset change). Falls back to absolute
102 /// addition if the reconstructed local time lands in a DST gap/fold.
103 fn add_civil_days(dt: DateTime<Utc>, days: i64, tz: Tz) -> DateTime<Utc> {
104 let local = dt.with_timezone(&tz);
105 let new_date = local.date_naive() + Duration::days(days);
106 tz.from_local_datetime(&new_date.and_time(local.time()))
107 .earliest() // earliest valid instant on a DST gap, rather than dropping the occurrence
108 .map(|t| t.with_timezone(&Utc))
109 .unwrap_or(dt + Duration::days(days))
110 }
111
112 /// Add months to a DateTime in `tz`, handling edge cases like month-end dates.
113 ///
114 /// Uses absolute month counting (year*12 + month) to add/subtract months, then
115 /// clamps the day to the target month's length. Examples:
116 /// Jan 31 + 1 month → Feb 28 (or 29 in a leap year)
117 /// Mar 31 + 1 month → Apr 30
118 ///
119 /// When `target_day` is provided, uses that as the intended day-of-month
120 /// instead of the zone-local day, preventing drift across short months:
121 /// Jan 31 (target=31) + 1 → Feb 28, then Feb 28 (target=31) + 1 → Mar 31
122 ///
123 /// Reads the date fields and rebuilds the result in `tz`, preserving the local
124 /// hour/minute/second. Falls back to the input datetime if the target local time
125 /// is ambiguous (e.g., a DST gap via `with_ymd_and_hms`).
126 fn add_months(dt: DateTime<Utc>, months: i32, target_day: Option<u32>, tz: Tz) -> DateTime<Utc> {
127 let local = dt.with_timezone(&tz);
128 let year = local.year();
129 let month = local.month() as i32;
130 let day = target_day.unwrap_or(local.day());
131
132 let total_months = year * 12 + month - 1 + months;
133 let new_year = total_months.div_euclid(12);
134 let new_month = (total_months.rem_euclid(12) + 1) as u32;
135
136 // Handle end-of-month edge cases (e.g., Jan 31 -> Feb 28)
137 let days_in_new_month = days_in_month(new_year, new_month);
138 let new_day = day.min(days_in_new_month);
139
140 tz.with_ymd_and_hms(new_year, new_month, new_day,
141 local.hour(), local.minute(), local.second())
142 .earliest() // earliest valid instant on a DST gap, rather than dropping the occurrence
143 .map(|t| t.with_timezone(&Utc))
144 .unwrap_or(dt)
145 }
146
147 /// Get the number of days in a month
148 fn days_in_month(year: i32, month: u32) -> u32 {
149 use chrono::NaiveDate;
150
151 // Get the first day of the next month, then go back one day
152 let next_month = if month == 12 {
153 NaiveDate::from_ymd_opt(year + 1, 1, 1)
154 } else {
155 NaiveDate::from_ymd_opt(year, month + 1, 1)
156 };
157
158 next_month
159 .map(|d| d.pred_opt().map(|p| p.day()).unwrap_or(28))
160 .unwrap_or(28)
161 }
162
163 /// Check if a task should recur
164 pub fn should_recur(recurrence: &Recurrence) -> bool {
165 !matches!(recurrence, Recurrence::None)
166 }
167
168 // ============ Rich Recurrence ============
169
170 /// Namespace UUID for generating deterministic v5 IDs for recurring instances.
171 const RECURRENCE_NS: Uuid = Uuid::from_bytes([
172 0x8a, 0x3f, 0xc7, 0x12, 0xe0, 0x4b, 0x4d, 0x9a,
173 0xb1, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
174 ]);
175
176 /// Calculate the next due date using a rich recurrence rule (UTC wrapper).
177 pub fn calculate_next_due_rich(
178 current_due: Option<&DateTime<Utc>>,
179 rule: &RecurrenceRule,
180 ) -> Option<DateTime<Utc>> {
181 calculate_next_due_rich_in_tz(current_due, rule, Tz::UTC)
182 }
183
184 /// Calculate the next due date using a rich recurrence rule, in `tz`.
185 ///
186 /// Handles intervals, weekday selection, and monthly specifications. Weekday and
187 /// date fields are read in `tz`, and daily/weekly advance by whole civil days so
188 /// the local time-of-day is stable across DST.
189 pub fn calculate_next_due_rich_in_tz(
190 current_due: Option<&DateTime<Utc>>,
191 rule: &RecurrenceRule,
192 tz: Tz,
193 ) -> Option<DateTime<Utc>> {
194 if matches!(rule.pattern, Recurrence::None) {
195 return None;
196 }
197 let base = current_due.copied().unwrap_or_else(Utc::now);
198 let local = base.with_timezone(&tz);
199 // Clamp the interval: rule.interval is untrusted (can arrive via sync or
200 // import). The upper bound keeps the i32 cast in the monthly arm and the
201 // civil-day arithmetic from overflowing on an absurd value.
202 let interval = rule.interval.clamp(1, 10_000) as i64;
203
204 match rule.pattern {
205 Recurrence::Daily => {
206 Some(add_civil_days(base, interval, tz))
207 }
208 Recurrence::Weekly => {
209 // Drop out-of-range weekday bytes (valid range is 0=Mon..6=Sun);
210 // a corrupt/imported value like 200 would otherwise drive a
211 // ~200-day civil jump through the `d > current_wd` branch below.
212 let mut sorted_days: Vec<u8> =
213 rule.weekdays.iter().copied().filter(|&d| d <= 6).collect();
214 sorted_days.sort_unstable();
215 sorted_days.dedup();
216 if sorted_days.is_empty() {
217 return Some(add_civil_days(base, interval * 7, tz));
218 }
219 // Find next matching weekday (weekday read in `tz`).
220 let current_wd = local.weekday().num_days_from_monday() as u8;
221
222 // Look for next day in the current week (after current weekday)
223 if let Some(&next_wd) = sorted_days.iter().find(|&&d| d > current_wd) {
224 let diff = (next_wd - current_wd) as i64;
225 return Some(add_civil_days(base, diff, tz));
226 }
227 // Wrap to first day of next interval-week
228 let first_wd = sorted_days[0];
229 let days_to_end = 7 - current_wd as i64;
230 let skip_weeks = (interval - 1) * 7;
231 let days = days_to_end + skip_weeks + first_wd as i64;
232 Some(add_civil_days(base, days, tz))
233 }
234 Recurrence::Monthly => {
235 match &rule.monthly_spec {
236 Some(MonthlySpec::DayOfMonth { day }) => {
237 let next = add_months(base, interval as i32, Some(*day), tz);
238 Some(next)
239 }
240 Some(MonthlySpec::NthWeekday { week, weekday }) => {
241 let next_base = add_months(base, interval as i32, None, tz);
242 let next_local = next_base.with_timezone(&tz);
243 let target = nth_weekday_in_month(
244 next_local.year(), next_local.month(),
245 *week, *weekday,
246 next_local.hour(), next_local.minute(), next_local.second(),
247 tz,
248 );
249 Some(target.unwrap_or(next_base))
250 }
251 None => {
252 // Same as legacy monthly (end-of-month intent read in `tz`)
253 let target_day = {
254 let day = local.day();
255 let month_len = days_in_month(local.year(), local.month());
256 if day == month_len && (29..31).contains(&day) { Some(31) } else { None }
257 };
258 Some(add_months(base, interval as i32, target_day, tz))
259 }
260 }
261 }
262 Recurrence::None => None,
263 }
264 }
265
266 /// Find the Nth weekday in a given month, constructing the result in `tz`.
267 /// `week`: 1-4 for ordinal, -1 for last.
268 /// `weekday`: 0=Mon..6=Sun.
269 /// `year`/`month`/`hour`/`minute`/`second` are civil fields in `tz`.
270 #[allow(clippy::too_many_arguments)] // civil date/time fields are clearer flat than boxed in a struct
271 fn nth_weekday_in_month(
272 year: i32, month: u32,
273 week: i8, weekday: u8,
274 hour: u32, minute: u32, second: u32,
275 tz: Tz,
276 ) -> Option<DateTime<Utc>> {
277 let weekday_chrono = match weekday {
278 0 => chrono::Weekday::Mon,
279 1 => chrono::Weekday::Tue,
280 2 => chrono::Weekday::Wed,
281 3 => chrono::Weekday::Thu,
282 4 => chrono::Weekday::Fri,
283 5 => chrono::Weekday::Sat,
284 6 => chrono::Weekday::Sun,
285 _ => return None,
286 };
287
288 if week == -1 {
289 // Last occurrence: start from end of month, walk backward
290 let last_day = days_in_month(year, month);
291 let end = NaiveDate::from_ymd_opt(year, month, last_day)?;
292 let mut d = end;
293 while d.weekday() != weekday_chrono {
294 d = d.pred_opt()?;
295 }
296 tz.with_ymd_and_hms(year, month, d.day(), hour, minute, second)
297 .earliest() // earliest valid instant on a DST gap, rather than dropping the occurrence
298 .map(|t| t.with_timezone(&Utc))
299 } else if (1..=5).contains(&week) {
300 // Nth occurrence: start from day 1, find first matching weekday, skip N-1
301 let first = NaiveDate::from_ymd_opt(year, month, 1)?;
302 let mut d = first;
303 while d.weekday() != weekday_chrono {
304 d = d.succ_opt()?;
305 }
306 // d is the 1st occurrence; advance (week-1) weeks
307 d = d.checked_add_signed(chrono::TimeDelta::weeks((week - 1) as i64))?;
308 if d.month() != month {
309 return None; // e.g., 5th Monday doesn't exist
310 }
311 tz.with_ymd_and_hms(year, month, d.day(), hour, minute, second)
312 .earliest() // earliest valid instant on a DST gap, rather than dropping the occurrence
313 .map(|t| t.with_timezone(&Utc))
314 } else {
315 None
316 }
317 }
318
319 /// Expand a recurring event into virtual instances within a date range.
320 ///
321 /// Returns clones of the parent event with adjusted times and synthetic IDs.
322 /// The parent event itself is NOT included unless its `start_time` falls in range.
323 /// Caps expansion at 500 iterations to prevent runaway loops.
324 pub fn expand_recurrence(
325 event: &Event,
326 range_start: DateTime<Utc>,
327 range_end: DateTime<Utc>,
328 ) -> Vec<Event> {
329 expand_recurrence_in_tz(event, range_start, range_end, Tz::UTC)
330 }
331
332 /// Expand a recurring event into virtual instances within a date range, advancing
333 /// occurrences in `tz` so the local time-of-day is stable across DST.
334 pub fn expand_recurrence_in_tz(
335 event: &Event,
336 range_start: DateTime<Utc>,
337 range_end: DateTime<Utc>,
338 tz: Tz,
339 ) -> Vec<Event> {
340 let rule = match event.effective_recurrence_rule() {
341 Some(r) => r,
342 None => return vec![],
343 };
344
345 let event_duration = event.end_time
346 .map(|e| e - event.start_time)
347 .unwrap_or_else(|| Duration::hours(1));
348
349 let mut instances = Vec::new();
350 let mut cursor = event.start_time;
351
352 // Seek forward to the first occurrence that could overlap the window before
353 // spending the bounded expansion budget. Without this, a daily event whose
354 // start_time is >500 days before range_start burns all 500 iterations on
355 // occurrences long before the window and renders empty. The seek is cheap
356 // (no clone/push) and separately capped so a degenerate rule can't spin.
357 let seek_cap = 100_000;
358 let mut seeked = 0;
359 while cursor + event_duration < range_start && seeked < seek_cap {
360 match calculate_next_due_rich_in_tz(Some(&cursor), &rule, tz) {
361 Some(next) if next > cursor => cursor = next,
362 _ => break,
363 }
364 seeked += 1;
365 }
366
367 let max_iterations = 500;
368
369 for _ in 0..max_iterations {
370 if cursor > range_end {
371 break;
372 }
373
374 let instance_end = cursor + event_duration;
375
376 // Check if this occurrence overlaps the range
377 if instance_end >= range_start && cursor <= range_end {
378 // Skip the original event (it exists in DB as-is)
379 if cursor != event.start_time {
380 let synthetic_id = generate_instance_id(event.id, cursor);
381 let mut instance = event.clone();
382 instance.id = synthetic_id;
383 instance.start_time = cursor;
384 instance.end_time = Some(instance_end);
385 instance.is_recurring_instance = true;
386 instance.recurrence_parent_id = Some(event.id);
387 instances.push(instance);
388 }
389 }
390
391 // Advance to next occurrence
392 match calculate_next_due_rich_in_tz(Some(&cursor), &rule, tz) {
393 Some(next) if next > cursor => cursor = next,
394 _ => break, // prevent infinite loop
395 }
396 }
397
398 instances
399 }
400
401 /// Generate a deterministic synthetic ID for a recurring event instance.
402 fn generate_instance_id(parent_id: crate::id_types::EventId, occurrence_time: DateTime<Utc>) -> crate::id_types::EventId {
403 let mut name = parent_id.as_uuid().as_bytes().to_vec();
404 name.extend_from_slice(&occurrence_time.timestamp().to_le_bytes());
405 let id = Uuid::new_v5(&RECURRENCE_NS, &name);
406 crate::id_types::EventId::from_uuid(id)
407 }
408
409 #[cfg(test)]
410 mod tests {
411 use super::*;
412
413 #[test]
414 fn test_daily_recurrence() {
415 let now = Utc.with_ymd_and_hms(2026, 2, 4, 10, 0, 0).unwrap();
416 let next = calculate_next_due(Some(&now), &Recurrence::Daily).unwrap();
417 assert_eq!(next.day(), 5);
418 }
419
420 #[test]
421 fn test_weekly_recurrence() {
422 let now = Utc.with_ymd_and_hms(2026, 2, 4, 10, 0, 0).unwrap();
423 let next = calculate_next_due(Some(&now), &Recurrence::Weekly).unwrap();
424 assert_eq!(next.day(), 11);
425 }
426
427 #[test]
428 fn test_monthly_recurrence() {
429 let now = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
430 let next = calculate_next_due(Some(&now), &Recurrence::Monthly).unwrap();
431 assert_eq!(next.month(), 2);
432 assert_eq!(next.day(), 15);
433 }
434
435 #[test]
436 fn test_monthly_end_of_month() {
437 // Jan 31 -> Feb 28 (or 29 in leap year)
438 let jan_31 = Utc.with_ymd_and_hms(2026, 1, 31, 10, 0, 0).unwrap();
439 let next = calculate_next_due(Some(&jan_31), &Recurrence::Monthly).unwrap();
440 assert_eq!(next.month(), 2);
441 // 2026 is not a leap year, so Feb has 28 days
442 assert_eq!(next.day(), 28);
443 }
444
445 #[test]
446 fn test_no_recurrence() {
447 let now = Utc::now();
448 let next = calculate_next_due(Some(&now), &Recurrence::None);
449 assert!(next.is_none());
450 }
451
452 #[test]
453 fn test_should_recur() {
454 assert!(should_recur(&Recurrence::Daily));
455 assert!(should_recur(&Recurrence::Weekly));
456 assert!(should_recur(&Recurrence::Monthly));
457 assert!(!should_recur(&Recurrence::None));
458 }
459
460 #[test]
461 fn test_monthly_recurrence_preserves_time() {
462 let original = Utc.with_ymd_and_hms(2026, 1, 15, 14, 30, 0).unwrap();
463 let next = calculate_next_due(Some(&original), &Recurrence::Monthly).unwrap();
464 assert_eq!(next.hour(), 14);
465 assert_eq!(next.minute(), 30);
466 }
467
468 #[test]
469 fn test_daily_recurrence_preserves_time() {
470 let original = Utc.with_ymd_and_hms(2026, 2, 14, 9, 15, 30).unwrap();
471 let next = calculate_next_due(Some(&original), &Recurrence::Daily).unwrap();
472 assert_eq!(next.hour(), 9);
473 assert_eq!(next.minute(), 15);
474 assert_eq!(next.second(), 30);
475 }
476
477 #[test]
478 fn test_weekly_recurrence_preserves_time() {
479 let original = Utc.with_ymd_and_hms(2026, 3, 10, 17, 0, 0).unwrap();
480 let next = calculate_next_due(Some(&original), &Recurrence::Weekly).unwrap();
481 assert_eq!(next.hour(), 17);
482 assert_eq!(next.minute(), 0);
483 }
484
485 #[test]
486 fn test_monthly_december_to_january() {
487 // Dec 15, 2026 -> Jan 15, 2027
488 let dec_15 = Utc.with_ymd_and_hms(2026, 12, 15, 10, 0, 0).unwrap();
489 let next = calculate_next_due(Some(&dec_15), &Recurrence::Monthly).unwrap();
490 assert_eq!(next.year(), 2027);
491 assert_eq!(next.month(), 1);
492 assert_eq!(next.day(), 15);
493 }
494
495 #[test]
496 fn test_monthly_leap_year() {
497 // Jan 31, 2028 (leap year) -> Feb 29, 2028
498 let jan_31 = Utc.with_ymd_and_hms(2028, 1, 31, 10, 0, 0).unwrap();
499 let next = calculate_next_due(Some(&jan_31), &Recurrence::Monthly).unwrap();
500 assert_eq!(next.month(), 2);
501 assert_eq!(next.day(), 29); // 2028 is a leap year
502 }
503
504 #[test]
505 fn test_monthly_feb_28_no_snap() {
506 // Feb 28 in a non-leap year: day < 29, so no end-of-month snap.
507 // User who chose the 28th gets Mar 28, not Mar 31.
508 let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
509 let next = calculate_next_due(Some(&feb_28), &Recurrence::Monthly).unwrap();
510 assert_eq!(next.month(), 3);
511 assert_eq!(next.day(), 28);
512 }
513
514 #[test]
515 fn test_monthly_feb_28_explicit_day_28() {
516 // With explicit target day 28, Feb 28 -> Mar 28 (no end-of-month heuristic)
517 let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
518 let next = calculate_next_due_with_day(Some(&feb_28), &Recurrence::Monthly, Some(28)).unwrap();
519 assert_eq!(next.month(), 3);
520 assert_eq!(next.day(), 28);
521 }
522
523 #[test]
524 fn test_monthly_march_31_to_april() {
525 // Mar 31 -> Apr 30 (April only has 30 days)
526 let mar_31 = Utc.with_ymd_and_hms(2026, 3, 31, 10, 0, 0).unwrap();
527 let next = calculate_next_due(Some(&mar_31), &Recurrence::Monthly).unwrap();
528 assert_eq!(next.month(), 4);
529 assert_eq!(next.day(), 30);
530 }
531
532 #[test]
533 fn test_daily_year_boundary() {
534 // Dec 31, 2026 -> Jan 1, 2027
535 let dec_31 = Utc.with_ymd_and_hms(2026, 12, 31, 10, 0, 0).unwrap();
536 let next = calculate_next_due(Some(&dec_31), &Recurrence::Daily).unwrap();
537 assert_eq!(next.year(), 2027);
538 assert_eq!(next.month(), 1);
539 assert_eq!(next.day(), 1);
540 }
541
542 #[test]
543 fn test_weekly_month_boundary() {
544 // Jan 28, 2026 -> Feb 4, 2026
545 let jan_28 = Utc.with_ymd_and_hms(2026, 1, 28, 10, 0, 0).unwrap();
546 let next = calculate_next_due(Some(&jan_28), &Recurrence::Weekly).unwrap();
547 assert_eq!(next.month(), 2);
548 assert_eq!(next.day(), 4);
549 }
550
551 #[test]
552 fn test_recurrence_with_no_due_date() {
553 // When no due date provided, should use current time as base
554 let next = calculate_next_due(None, &Recurrence::Daily);
555 assert!(next.is_some());
556
557 let next_date = next.unwrap();
558 let now = Utc::now();
559 // Next due should be approximately 1 day from now
560 let diff = next_date - now;
561 assert!(diff.num_hours() >= 23 && diff.num_hours() <= 25);
562 }
563
564 #[test]
565 fn test_days_in_month_helper() {
566 assert_eq!(days_in_month(2026, 1), 31); // January
567 assert_eq!(days_in_month(2026, 2), 28); // February (non-leap)
568 assert_eq!(days_in_month(2028, 2), 29); // February (leap year)
569 assert_eq!(days_in_month(2026, 4), 30); // April
570 assert_eq!(days_in_month(2026, 12), 31); // December
571 }
572
573 #[test]
574 fn test_recurring_task_fresh_urgency_after_completion() {
575 use crate::models::{Priority, TaskStatus};
576 use crate::urgency::calculate_urgency;
577
578 // Simulate an overdue recurring weekly task:
579 // Original due date was 3 days ago, so it had high urgency from the overdue penalty.
580 let overdue_due = Utc::now() - Duration::days(3);
581 let old_created = Utc::now() - Duration::days(10);
582 let tags: Vec<String> = vec![];
583
584 let old_urgency = calculate_urgency(
585 &Priority::Medium,
586 &TaskStatus::Pending,
587 Some(&overdue_due),
588 &old_created,
589 &tags,
590 );
591
592 // Old task should have overdue urgency (12.0 from overdue + priority + age)
593 assert!(old_urgency > 15.0, "Overdue task should have high urgency, got: {}", old_urgency);
594
595 // When completing and creating the next instance, we calculate next_due
596 let next_due = calculate_next_due(Some(&overdue_due), &Recurrence::Weekly).unwrap();
597 let new_created = Utc::now();
598
599 let new_urgency = calculate_urgency(
600 &Priority::Medium,
601 &TaskStatus::Pending,
602 Some(&next_due),
603 &new_created,
604 &tags,
605 );
606
607 // The new instance should NOT be overdue (due date is in the future)
608 // and should have much lower urgency than the old overdue one
609 assert!(
610 new_urgency < old_urgency,
611 "New recurring instance should have lower urgency ({}) than the completed overdue one ({})",
612 new_urgency, old_urgency
613 );
614
615 // Specifically, it should NOT have the overdue penalty
616 assert!(
617 new_urgency < 12.0,
618 "New recurring instance should not have overdue penalty, got urgency: {}",
619 new_urgency
620 );
621 }
622
623 // ============ Rich Recurrence Tests ============
624
625 #[test]
626 fn test_rich_daily_interval() {
627 let now = Utc.with_ymd_and_hms(2026, 3, 1, 9, 0, 0).unwrap();
628 let rule = RecurrenceRule {
629 pattern: Recurrence::Daily,
630 interval: 3,
631 weekdays: vec![],
632 monthly_spec: None,
633 };
634 let next = calculate_next_due_rich(Some(&now), &rule).unwrap();
635 assert_eq!(next.day(), 4); // 3 days later
636 assert_eq!(next.hour(), 9);
637 }
638
639 #[test]
640 fn test_rich_weekly_weekdays() {
641 // Monday, requesting Mon/Wed/Fri
642 let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday
643 let rule = RecurrenceRule {
644 pattern: Recurrence::Weekly,
645 interval: 1,
646 weekdays: vec![0, 2, 4], // Mon, Wed, Fri
647 monthly_spec: None,
648 };
649 // Next after Monday should be Wednesday
650 let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
651 assert_eq!(next.weekday(), chrono::Weekday::Wed);
652 assert_eq!(next.day(), 4);
653
654 // Next after Wednesday should be Friday
655 let next2 = calculate_next_due_rich(Some(&next), &rule).unwrap();
656 assert_eq!(next2.weekday(), chrono::Weekday::Fri);
657 assert_eq!(next2.day(), 6);
658
659 // Next after Friday should be Monday of next week
660 let next3 = calculate_next_due_rich(Some(&next2), &rule).unwrap();
661 assert_eq!(next3.weekday(), chrono::Weekday::Mon);
662 assert_eq!(next3.day(), 9);
663 }
664
665 #[test]
666 fn test_rich_weekly_ignores_out_of_range_weekdays() {
667 // A corrupt/imported weekday byte (200) must not drive a ~200-day jump;
668 // out-of-range values are dropped, leaving the valid weekday (Wed).
669 let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday
670 let rule = RecurrenceRule {
671 pattern: Recurrence::Weekly,
672 interval: 1,
673 weekdays: vec![200, 2], // garbage + Wed
674 monthly_spec: None,
675 };
676 let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
677 assert_eq!(next.weekday(), chrono::Weekday::Wed);
678 assert!((next - mon).num_days() < 7, "must not jump far past one week");
679 }
680
681 #[test]
682 fn test_rich_weekly_all_invalid_weekdays_falls_back() {
683 // If every weekday byte is invalid, advance by the interval-week instead
684 // of panicking on an empty sorted list.
685 let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
686 let rule = RecurrenceRule {
687 pattern: Recurrence::Weekly,
688 interval: 1,
689 weekdays: vec![99, 200],
690 monthly_spec: None,
691 };
692 let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
693 assert_eq!((next - mon).num_days(), 7);
694 }
695
696 #[test]
697 fn test_rich_interval_clamped() {
698 // An absurd interval must not overflow the i32 month cast or civil math.
699 let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
700 let rule = RecurrenceRule {
701 pattern: Recurrence::Daily,
702 interval: u32::MAX,
703 weekdays: vec![],
704 monthly_spec: None,
705 };
706 // Clamped to 10_000 days; just assert it produces a finite future date.
707 let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
708 assert!(next > mon);
709 }
710
711 #[test]
712 fn test_rich_weekly_interval_2() {
713 // Friday, every 2 weeks on Mon/Fri
714 let fri = Utc.with_ymd_and_hms(2026, 3, 6, 10, 0, 0).unwrap(); // Friday
715 let rule = RecurrenceRule {
716 pattern: Recurrence::Weekly,
717 interval: 2,
718 weekdays: vec![0, 4], // Mon, Fri
719 monthly_spec: None,
720 };
721 // Next after Friday: wrap to Mon of 2-weeks-later
722 let next = calculate_next_due_rich(Some(&fri), &rule).unwrap();
723 assert_eq!(next.weekday(), chrono::Weekday::Mon);
724 assert_eq!(next.day(), 16); // 2 weeks later, Monday
725 }
726
727 #[test]
728 fn test_rich_monthly_day_of_month() {
729 let jan = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
730 let rule = RecurrenceRule {
731 pattern: Recurrence::Monthly,
732 interval: 1,
733 weekdays: vec![],
734 monthly_spec: Some(MonthlySpec::DayOfMonth { day: 15 }),
735 };
736 let next = calculate_next_due_rich(Some(&jan), &rule).unwrap();
737 assert_eq!(next.month(), 2);
738 assert_eq!(next.day(), 15);
739 }
740
741 #[test]
742 fn test_rich_monthly_nth_weekday() {
743 // 2nd Friday of January 2026 is Jan 9... let me compute
744 // Jan 2026: 1=Thu, 2=Fri (1st Fri), 9=Fri (2nd Fri)
745 let jan = Utc.with_ymd_and_hms(2026, 1, 9, 10, 0, 0).unwrap();
746 let rule = RecurrenceRule {
747 pattern: Recurrence::Monthly,
748 interval: 1,
749 weekdays: vec![],
750 monthly_spec: Some(MonthlySpec::NthWeekday { week: 2, weekday: 4 }), // 2nd Friday
751 };
752 let next = calculate_next_due_rich(Some(&jan), &rule).unwrap();
753 // Feb 2026: 1=Sun, 6=Fri (1st Fri), 13=Fri (2nd Fri)
754 assert_eq!(next.month(), 2);
755 assert_eq!(next.day(), 13);
756 }
757
758 #[test]
759 fn test_rich_monthly_last_weekday() {
760 let jan = Utc.with_ymd_and_hms(2026, 1, 26, 10, 0, 0).unwrap();
761 let rule = RecurrenceRule {
762 pattern: Recurrence::Monthly,
763 interval: 1,
764 weekdays: vec![],
765 monthly_spec: Some(MonthlySpec::NthWeekday { week: -1, weekday: 0 }), // Last Monday
766 };
767 let next = calculate_next_due_rich(Some(&jan), &rule).unwrap();
768 // Feb 2026: last Monday is Feb 23
769 assert_eq!(next.month(), 2);
770 assert_eq!(next.day(), 23);
771 }
772
773 #[test]
774 fn test_expand_recurrence_weekly() {
775 let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday
776 let event = Event {
777 id: crate::id_types::EventId::new(),
778 user_id: None,
779 project_id: None,
780 project_name: None,
781 contact_id: None,
782 contact_name: None,
783 title: "Weekly meeting".to_string(),
784 description: String::new(),
785 start_time: start,
786 end_time: Some(start + Duration::hours(1)),
787 location: None,
788 linked_task_id: None,
789 recurrence: Recurrence::Weekly,
790 recurrence_rule: Some(RecurrenceRule {
791 pattern: Recurrence::Weekly,
792 interval: 1,
793 weekdays: vec![],
794 monthly_spec: None,
795 }),
796 recurrence_parent_id: None,
797 is_recurring_instance: false,
798 block_type: None,
799 external_source: None,
800 external_id: None,
801 is_read_only: false,
802 snoozed_until: None,
803 reminder_offsets_seconds: Vec::new(),
804 };
805
806 let range_start = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap();
807 let range_end = Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap();
808
809 let instances = expand_recurrence(&event, range_start, range_end);
810 // Original is March 2 (Mon). Instances: Mar 9, 16, 23, 30 = 4 expanded
811 assert_eq!(instances.len(), 4);
812 assert_eq!(instances[0].start_time.day(), 9);
813 assert_eq!(instances[1].start_time.day(), 16);
814 assert_eq!(instances[2].start_time.day(), 23);
815 assert_eq!(instances[3].start_time.day(), 30);
816
817 // All should be marked as recurring instances
818 assert!(instances.iter().all(|e| e.is_recurring_instance));
819 // All should have unique deterministic IDs
820 let ids: std::collections::HashSet<_> = instances.iter().map(|e| e.id).collect();
821 assert_eq!(ids.len(), 4);
822 }
823
824 #[test]
825 fn test_expand_recurrence_far_past_start_still_renders() {
826 // A daily event whose start_time is well over 500 occurrences before the
827 // window used to render empty: the 500-iteration budget was spent on
828 // occurrences long before range_start. The seek must fast-forward into
829 // the window so today's occurrences appear.
830 let start = Utc.with_ymd_and_hms(2022, 1, 1, 9, 0, 0).unwrap(); // ~4 years prior
831 let event = Event {
832 id: crate::id_types::EventId::new(),
833 user_id: None,
834 project_id: None,
835 project_name: None,
836 contact_id: None,
837 contact_name: None,
838 title: "Daily standup".to_string(),
839 description: String::new(),
840 start_time: start,
841 end_time: Some(start + Duration::minutes(15)),
842 location: None,
843 linked_task_id: None,
844 recurrence: Recurrence::Daily,
845 recurrence_rule: Some(RecurrenceRule {
846 pattern: Recurrence::Daily,
847 interval: 1,
848 weekdays: vec![],
849 monthly_spec: None,
850 }),
851 recurrence_parent_id: None,
852 is_recurring_instance: false,
853 block_type: None,
854 external_source: None,
855 external_id: None,
856 is_read_only: false,
857 snoozed_until: None,
858 reminder_offsets_seconds: Vec::new(),
859 };
860
861 let range_start = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap();
862 let range_end = Utc.with_ymd_and_hms(2026, 3, 7, 23, 59, 59).unwrap();
863
864 let instances = expand_recurrence(&event, range_start, range_end);
865 // Seven days in the window, each with a daily occurrence.
866 assert_eq!(instances.len(), 7, "old daily event must still render in the current window");
867 assert!(instances.iter().all(|e| e.start_time >= range_start && e.start_time <= range_end));
868 }
869
870 #[test]
871 fn test_expand_recurrence_deterministic_ids() {
872 let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
873 let event = Event {
874 id: crate::id_types::EventId::new(),
875 user_id: None,
876 project_id: None,
877 project_name: None,
878 contact_id: None,
879 contact_name: None,
880 title: "Test".to_string(),
881 description: String::new(),
882 start_time: start,
883 end_time: Some(start + Duration::hours(1)),
884 location: None,
885 linked_task_id: None,
886 recurrence: Recurrence::Daily,
887 recurrence_rule: None,
888 recurrence_parent_id: None,
889 is_recurring_instance: false,
890 block_type: None,
891 external_source: None,
892 external_id: None,
893 is_read_only: false,
894 snoozed_until: None,
895 reminder_offsets_seconds: Vec::new(),
896 };
897
898 let range_start = Utc.with_ymd_and_hms(2026, 3, 3, 0, 0, 0).unwrap();
899 let range_end = Utc.with_ymd_and_hms(2026, 3, 5, 23, 59, 59).unwrap();
900
901 let instances1 = expand_recurrence(&event, range_start, range_end);
902 let instances2 = expand_recurrence(&event, range_start, range_end);
903 // Same inputs produce same IDs
904 assert_eq!(instances1.len(), instances2.len());
905 for (a, b) in instances1.iter().zip(instances2.iter()) {
906 assert_eq!(a.id, b.id);
907 }
908 }
909
910 // ============ DST / time-zone-aware recurrence (Run #28) ============
911
912 #[test]
913 fn test_daily_recurrence_holds_local_time_across_spring_forward() {
914 use chrono_tz::America::New_York;
915 // 2026-03-08 is US spring-forward (02:00 -> 03:00). A task at 09:00 local on
916 // Mar 7 must land at 09:00 local on Mar 8 — not 10:00 as fixed-24h-UTC would give.
917 let start = New_York.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).single().unwrap()
918 .with_timezone(&Utc);
919 let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Daily, New_York).unwrap();
920 let next_local = next.with_timezone(&New_York);
921 assert_eq!(next_local.day(), 8);
922 assert_eq!(next_local.hour(), 9, "local hour must stay 09:00 across DST");
923 // The UTC instant shifts by 23h (a short civil day), proving DST was honored.
924 assert_eq!((next - start).num_hours(), 23);
925 }
926
927 #[test]
928 fn test_daily_recurrence_holds_local_time_across_fall_back() {
929 use chrono_tz::America::New_York;
930 // 2026-11-01 is US fall-back (02:00 -> 01:00). 09:00 local Oct 31 -> 09:00 local Nov 1.
931 let start = New_York.with_ymd_and_hms(2026, 10, 31, 9, 0, 0).single().unwrap()
932 .with_timezone(&Utc);
933 let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Daily, New_York).unwrap();
934 let next_local = next.with_timezone(&New_York);
935 assert_eq!(next_local.day(), 1);
936 assert_eq!(next_local.hour(), 9);
937 assert_eq!((next - start).num_hours(), 25, "a long civil day spans the fall-back");
938 }
939
940 #[test]
941 fn test_weekly_recurrence_holds_local_time_across_dst() {
942 use chrono_tz::America::New_York;
943 // Mar 5 (Thu) 08:00 local -> Mar 12, still 08:00 local, despite the Mar 8 transition.
944 let start = New_York.with_ymd_and_hms(2026, 3, 5, 8, 0, 0).single().unwrap()
945 .with_timezone(&Utc);
946 let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Weekly, New_York).unwrap();
947 let next_local = next.with_timezone(&New_York);
948 assert_eq!(next_local.day(), 12);
949 assert_eq!(next_local.hour(), 8);
950 }
951
952 #[test]
953 fn test_monthly_recurrence_holds_local_time_across_dst() {
954 use chrono_tz::America::New_York;
955 // Feb 20 09:00 local -> Mar 20 09:00 local, crossing the Mar 8 spring-forward.
956 let start = New_York.with_ymd_and_hms(2026, 2, 20, 9, 0, 0).single().unwrap()
957 .with_timezone(&Utc);
958 let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Monthly, New_York).unwrap();
959 let next_local = next.with_timezone(&New_York);
960 assert_eq!(next_local.month(), 3);
961 assert_eq!(next_local.day(), 20);
962 assert_eq!(next_local.hour(), 9);
963 }
964
965 #[test]
966 fn test_utc_wrapper_unaffected_by_dst_logic() {
967 // The legacy UTC entry points must still add a fixed 24h (no zone involved),
968 // so existing callers and instants are unchanged.
969 let start = Utc.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).unwrap();
970 let next = calculate_next_due(Some(&start), &Recurrence::Daily).unwrap();
971 assert_eq!((next - start).num_hours(), 24);
972 assert_eq!(next.hour(), 9);
973 }
974 }
975