Skip to main content

max / goingson

26.2 KB · 714 lines History Blame Raw
1 //! Recurring task and event scheduling logic.
2
3 use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc};
4 use uuid::Uuid;
5 use crate::models::{Event, Recurrence, RecurrenceRule, MonthlySpec};
6
7 /// Calculate the next due date based on recurrence type.
8 ///
9 /// The next occurrence is calculated from the original due date,
10 /// not from when the task was completed. This ensures consistent
11 /// scheduling (e.g., "every Monday" stays on Mondays).
12 ///
13 /// For monthly recurrence, `original_day` can specify the intended
14 /// day-of-month to prevent drift when months have fewer days
15 /// (e.g., Jan 31 -> Feb 28 -> Mar 31 instead of Mar 28).
16 pub fn calculate_next_due(
17 current_due: Option<&DateTime<Utc>>,
18 recurrence: &Recurrence,
19 ) -> Option<DateTime<Utc>> {
20 // For monthly recurrence, detect end-of-month dates and preserve intent.
21 // If the due date falls on the last day of its month AND the day is >= 29,
22 // treat the target as day 31 so it snaps to end-of-month in longer months.
23 // The >= 29 guard prevents false positives: Feb 28 in a non-leap year is
24 // ambiguous (user may have meant "the 28th"), but days 29-30 at month-end
25 // clearly indicate end-of-month intent.
26 let target_day = if matches!(recurrence, Recurrence::Monthly) {
27 current_due.and_then(|dt| {
28 let day = dt.day();
29 let month_len = days_in_month(dt.year(), dt.month());
30 if day == month_len && day >= 29 && day < 31 {
31 Some(31)
32 } else {
33 None
34 }
35 })
36 } else {
37 None
38 };
39 calculate_next_due_with_day(current_due, recurrence, target_day)
40 }
41
42 /// Like `calculate_next_due` but accepts an explicit target day-of-month
43 /// for monthly recurrence to prevent day drift across short months.
44 pub fn calculate_next_due_with_day(
45 current_due: Option<&DateTime<Utc>>,
46 recurrence: &Recurrence,
47 original_day: Option<u32>,
48 ) -> Option<DateTime<Utc>> {
49 let base_date = current_due.copied().unwrap_or_else(Utc::now);
50
51 match recurrence {
52 Recurrence::Daily => Some(base_date + Duration::days(1)),
53 Recurrence::Weekly => Some(base_date + Duration::weeks(1)),
54 Recurrence::Monthly => {
55 let next = add_months(base_date, 1, original_day);
56 Some(next)
57 }
58 Recurrence::None => None,
59 }
60 }
61
62 /// Add months to a DateTime, handling edge cases like month-end dates.
63 ///
64 /// Uses absolute month counting (year*12 + month) to add/subtract months, then
65 /// clamps the day to the target month's length. Examples:
66 /// Jan 31 + 1 month → Feb 28 (or 29 in a leap year)
67 /// Mar 31 + 1 month → Apr 30
68 ///
69 /// When `target_day` is provided, uses that as the intended day-of-month
70 /// instead of `dt.day()`, preventing drift across short months:
71 /// Jan 31 (target=31) + 1 → Feb 28, then Feb 28 (target=31) + 1 → Mar 31
72 ///
73 /// Preserves the original hour/minute/second. Falls back to the input datetime
74 /// if the target date is ambiguous (e.g., DST gap via `with_ymd_and_hms`).
75 fn add_months(dt: DateTime<Utc>, months: i32, target_day: Option<u32>) -> DateTime<Utc> {
76
77 let year = dt.year();
78 let month = dt.month() as i32;
79 let day = target_day.unwrap_or(dt.day());
80
81 let total_months = year * 12 + month - 1 + months;
82 let new_year = total_months.div_euclid(12);
83 let new_month = (total_months.rem_euclid(12) + 1) as u32;
84
85 // Handle end-of-month edge cases (e.g., Jan 31 -> Feb 28)
86 let days_in_new_month = days_in_month(new_year, new_month);
87 let new_day = day.min(days_in_new_month);
88
89 Utc.with_ymd_and_hms(new_year, new_month, new_day,
90 dt.hour(), dt.minute(), dt.second())
91 .single()
92 .unwrap_or(dt)
93 }
94
95 /// Get the number of days in a month
96 fn days_in_month(year: i32, month: u32) -> u32 {
97 use chrono::NaiveDate;
98
99 // Get the first day of the next month, then go back one day
100 let next_month = if month == 12 {
101 NaiveDate::from_ymd_opt(year + 1, 1, 1)
102 } else {
103 NaiveDate::from_ymd_opt(year, month + 1, 1)
104 };
105
106 next_month
107 .map(|d| d.pred_opt().map(|p| p.day()).unwrap_or(28))
108 .unwrap_or(28)
109 }
110
111 /// Check if a task should recur
112 pub fn should_recur(recurrence: &Recurrence) -> bool {
113 !matches!(recurrence, Recurrence::None)
114 }
115
116 // ============ Rich Recurrence ============
117
118 /// Namespace UUID for generating deterministic v5 IDs for recurring instances.
119 const RECURRENCE_NS: Uuid = Uuid::from_bytes([
120 0x8a, 0x3f, 0xc7, 0x12, 0xe0, 0x4b, 0x4d, 0x9a,
121 0xb1, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
122 ]);
123
124 /// Calculate the next due date using a rich recurrence rule.
125 ///
126 /// Handles intervals, weekday selection, and monthly specifications.
127 /// Falls back to simple recurrence for rules with interval=1 and no extras.
128 pub fn calculate_next_due_rich(
129 current_due: Option<&DateTime<Utc>>,
130 rule: &RecurrenceRule,
131 ) -> Option<DateTime<Utc>> {
132 if matches!(rule.pattern, Recurrence::None) {
133 return None;
134 }
135 let base = current_due.copied().unwrap_or_else(Utc::now);
136 let interval = rule.interval.max(1) as i64;
137
138 match rule.pattern {
139 Recurrence::Daily => {
140 Some(base + Duration::days(interval))
141 }
142 Recurrence::Weekly => {
143 if rule.weekdays.is_empty() {
144 return Some(base + Duration::weeks(interval));
145 }
146 // Find next matching weekday
147 let current_wd = base.weekday().num_days_from_monday() as u8;
148 let mut sorted_days = rule.weekdays.clone();
149 sorted_days.sort_unstable();
150 sorted_days.dedup();
151
152 // Look for next day in the current week (after current weekday)
153 if let Some(&next_wd) = sorted_days.iter().find(|&&d| d > current_wd) {
154 let diff = (next_wd - current_wd) as i64;
155 return Some(base + Duration::days(diff));
156 }
157 // Wrap to first day of next interval-week
158 let first_wd = sorted_days[0];
159 let days_to_end = 7 - current_wd as i64;
160 let skip_weeks = (interval - 1) * 7;
161 let days = days_to_end + skip_weeks + first_wd as i64;
162 Some(base + Duration::days(days))
163 }
164 Recurrence::Monthly => {
165 match &rule.monthly_spec {
166 Some(MonthlySpec::DayOfMonth { day }) => {
167 let next = add_months(base, interval as i32, Some(*day));
168 Some(next)
169 }
170 Some(MonthlySpec::NthWeekday { week, weekday }) => {
171 let next_base = add_months(base, interval as i32, None);
172 let target = nth_weekday_in_month(
173 next_base.year(), next_base.month(),
174 *week, *weekday,
175 next_base.hour(), next_base.minute(), next_base.second(),
176 );
177 Some(target.unwrap_or(next_base))
178 }
179 None => {
180 // Same as legacy monthly
181 let target_day = {
182 let day = base.day();
183 let month_len = days_in_month(base.year(), base.month());
184 if day == month_len && day >= 29 && day < 31 { Some(31) } else { None }
185 };
186 Some(add_months(base, interval as i32, target_day))
187 }
188 }
189 }
190 Recurrence::None => None,
191 }
192 }
193
194 /// Find the Nth weekday in a given month.
195 /// `week`: 1-4 for ordinal, -1 for last.
196 /// `weekday`: 0=Mon..6=Sun.
197 fn nth_weekday_in_month(
198 year: i32, month: u32,
199 week: i8, weekday: u8,
200 hour: u32, minute: u32, second: u32,
201 ) -> Option<DateTime<Utc>> {
202 use chrono::NaiveDate;
203
204 let weekday_chrono = match weekday {
205 0 => chrono::Weekday::Mon,
206 1 => chrono::Weekday::Tue,
207 2 => chrono::Weekday::Wed,
208 3 => chrono::Weekday::Thu,
209 4 => chrono::Weekday::Fri,
210 5 => chrono::Weekday::Sat,
211 6 => chrono::Weekday::Sun,
212 _ => return None,
213 };
214
215 if week == -1 {
216 // Last occurrence: start from end of month, walk backward
217 let last_day = days_in_month(year, month);
218 let end = NaiveDate::from_ymd_opt(year, month, last_day)?;
219 let mut d = end;
220 while d.weekday() != weekday_chrono {
221 d = d.pred_opt()?;
222 }
223 Utc.with_ymd_and_hms(year, month, d.day(), hour, minute, second).single()
224 } else if (1..=5).contains(&week) {
225 // Nth occurrence: start from day 1, find first matching weekday, skip N-1
226 let first = NaiveDate::from_ymd_opt(year, month, 1)?;
227 let mut d = first;
228 while d.weekday() != weekday_chrono {
229 d = d.succ_opt()?;
230 }
231 // d is the 1st occurrence; advance (week-1) weeks
232 d = d.checked_add_signed(chrono::TimeDelta::weeks((week - 1) as i64))?;
233 if d.month() != month {
234 return None; // e.g., 5th Monday doesn't exist
235 }
236 Utc.with_ymd_and_hms(year, month, d.day(), hour, minute, second).single()
237 } else {
238 None
239 }
240 }
241
242 /// Expand a recurring event into virtual instances within a date range.
243 ///
244 /// Returns clones of the parent event with adjusted times and synthetic IDs.
245 /// The parent event itself is NOT included unless its `start_time` falls in range.
246 /// Caps expansion at 500 iterations to prevent runaway loops.
247 pub fn expand_recurrence(
248 event: &Event,
249 range_start: DateTime<Utc>,
250 range_end: DateTime<Utc>,
251 ) -> Vec<Event> {
252 let rule = match event.effective_recurrence_rule() {
253 Some(r) => r,
254 None => return vec![],
255 };
256
257 let event_duration = event.end_time
258 .map(|e| e - event.start_time)
259 .unwrap_or_else(|| Duration::hours(1));
260
261 let mut instances = Vec::new();
262 let mut cursor = event.start_time;
263 let max_iterations = 500;
264
265 for _ in 0..max_iterations {
266 if cursor > range_end {
267 break;
268 }
269
270 let instance_end = cursor + event_duration;
271
272 // Check if this occurrence overlaps the range
273 if instance_end >= range_start && cursor <= range_end {
274 // Skip the original event (it exists in DB as-is)
275 if cursor != event.start_time {
276 let synthetic_id = generate_instance_id(event.id, cursor);
277 let mut instance = event.clone();
278 instance.id = synthetic_id;
279 instance.start_time = cursor;
280 instance.end_time = Some(instance_end);
281 instance.is_recurring_instance = true;
282 instance.recurrence_parent_id = Some(event.id);
283 instances.push(instance);
284 }
285 }
286
287 // Advance to next occurrence
288 match calculate_next_due_rich(Some(&cursor), &rule) {
289 Some(next) if next > cursor => cursor = next,
290 _ => break, // prevent infinite loop
291 }
292 }
293
294 instances
295 }
296
297 /// Generate a deterministic synthetic ID for a recurring event instance.
298 fn generate_instance_id(parent_id: crate::id_types::EventId, occurrence_time: DateTime<Utc>) -> crate::id_types::EventId {
299 let mut name = parent_id.as_uuid().as_bytes().to_vec();
300 name.extend_from_slice(&occurrence_time.timestamp().to_le_bytes());
301 let id = Uuid::new_v5(&RECURRENCE_NS, &name);
302 crate::id_types::EventId::from_uuid(id)
303 }
304
305 #[cfg(test)]
306 mod tests {
307 use super::*;
308
309 #[test]
310 fn test_daily_recurrence() {
311 let now = Utc.with_ymd_and_hms(2026, 2, 4, 10, 0, 0).unwrap();
312 let next = calculate_next_due(Some(&now), &Recurrence::Daily).unwrap();
313 assert_eq!(next.day(), 5);
314 }
315
316 #[test]
317 fn test_weekly_recurrence() {
318 let now = Utc.with_ymd_and_hms(2026, 2, 4, 10, 0, 0).unwrap();
319 let next = calculate_next_due(Some(&now), &Recurrence::Weekly).unwrap();
320 assert_eq!(next.day(), 11);
321 }
322
323 #[test]
324 fn test_monthly_recurrence() {
325 let now = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
326 let next = calculate_next_due(Some(&now), &Recurrence::Monthly).unwrap();
327 assert_eq!(next.month(), 2);
328 assert_eq!(next.day(), 15);
329 }
330
331 #[test]
332 fn test_monthly_end_of_month() {
333 // Jan 31 -> Feb 28 (or 29 in leap year)
334 let jan_31 = Utc.with_ymd_and_hms(2026, 1, 31, 10, 0, 0).unwrap();
335 let next = calculate_next_due(Some(&jan_31), &Recurrence::Monthly).unwrap();
336 assert_eq!(next.month(), 2);
337 // 2026 is not a leap year, so Feb has 28 days
338 assert_eq!(next.day(), 28);
339 }
340
341 #[test]
342 fn test_no_recurrence() {
343 let now = Utc::now();
344 let next = calculate_next_due(Some(&now), &Recurrence::None);
345 assert!(next.is_none());
346 }
347
348 #[test]
349 fn test_should_recur() {
350 assert!(should_recur(&Recurrence::Daily));
351 assert!(should_recur(&Recurrence::Weekly));
352 assert!(should_recur(&Recurrence::Monthly));
353 assert!(!should_recur(&Recurrence::None));
354 }
355
356 #[test]
357 fn test_monthly_recurrence_preserves_time() {
358 let original = Utc.with_ymd_and_hms(2026, 1, 15, 14, 30, 0).unwrap();
359 let next = calculate_next_due(Some(&original), &Recurrence::Monthly).unwrap();
360 assert_eq!(next.hour(), 14);
361 assert_eq!(next.minute(), 30);
362 }
363
364 #[test]
365 fn test_daily_recurrence_preserves_time() {
366 let original = Utc.with_ymd_and_hms(2026, 2, 14, 9, 15, 30).unwrap();
367 let next = calculate_next_due(Some(&original), &Recurrence::Daily).unwrap();
368 assert_eq!(next.hour(), 9);
369 assert_eq!(next.minute(), 15);
370 assert_eq!(next.second(), 30);
371 }
372
373 #[test]
374 fn test_weekly_recurrence_preserves_time() {
375 let original = Utc.with_ymd_and_hms(2026, 3, 10, 17, 0, 0).unwrap();
376 let next = calculate_next_due(Some(&original), &Recurrence::Weekly).unwrap();
377 assert_eq!(next.hour(), 17);
378 assert_eq!(next.minute(), 0);
379 }
380
381 #[test]
382 fn test_monthly_december_to_january() {
383 // Dec 15, 2026 -> Jan 15, 2027
384 let dec_15 = Utc.with_ymd_and_hms(2026, 12, 15, 10, 0, 0).unwrap();
385 let next = calculate_next_due(Some(&dec_15), &Recurrence::Monthly).unwrap();
386 assert_eq!(next.year(), 2027);
387 assert_eq!(next.month(), 1);
388 assert_eq!(next.day(), 15);
389 }
390
391 #[test]
392 fn test_monthly_leap_year() {
393 // Jan 31, 2028 (leap year) -> Feb 29, 2028
394 let jan_31 = Utc.with_ymd_and_hms(2028, 1, 31, 10, 0, 0).unwrap();
395 let next = calculate_next_due(Some(&jan_31), &Recurrence::Monthly).unwrap();
396 assert_eq!(next.month(), 2);
397 assert_eq!(next.day(), 29); // 2028 is a leap year
398 }
399
400 #[test]
401 fn test_monthly_feb_28_no_snap() {
402 // Feb 28 in a non-leap year: day < 29, so no end-of-month snap.
403 // User who chose the 28th gets Mar 28, not Mar 31.
404 let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
405 let next = calculate_next_due(Some(&feb_28), &Recurrence::Monthly).unwrap();
406 assert_eq!(next.month(), 3);
407 assert_eq!(next.day(), 28);
408 }
409
410 #[test]
411 fn test_monthly_feb_28_explicit_day_28() {
412 // With explicit target day 28, Feb 28 -> Mar 28 (no end-of-month heuristic)
413 let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
414 let next = calculate_next_due_with_day(Some(&feb_28), &Recurrence::Monthly, Some(28)).unwrap();
415 assert_eq!(next.month(), 3);
416 assert_eq!(next.day(), 28);
417 }
418
419 #[test]
420 fn test_monthly_march_31_to_april() {
421 // Mar 31 -> Apr 30 (April only has 30 days)
422 let mar_31 = Utc.with_ymd_and_hms(2026, 3, 31, 10, 0, 0).unwrap();
423 let next = calculate_next_due(Some(&mar_31), &Recurrence::Monthly).unwrap();
424 assert_eq!(next.month(), 4);
425 assert_eq!(next.day(), 30);
426 }
427
428 #[test]
429 fn test_daily_year_boundary() {
430 // Dec 31, 2026 -> Jan 1, 2027
431 let dec_31 = Utc.with_ymd_and_hms(2026, 12, 31, 10, 0, 0).unwrap();
432 let next = calculate_next_due(Some(&dec_31), &Recurrence::Daily).unwrap();
433 assert_eq!(next.year(), 2027);
434 assert_eq!(next.month(), 1);
435 assert_eq!(next.day(), 1);
436 }
437
438 #[test]
439 fn test_weekly_month_boundary() {
440 // Jan 28, 2026 -> Feb 4, 2026
441 let jan_28 = Utc.with_ymd_and_hms(2026, 1, 28, 10, 0, 0).unwrap();
442 let next = calculate_next_due(Some(&jan_28), &Recurrence::Weekly).unwrap();
443 assert_eq!(next.month(), 2);
444 assert_eq!(next.day(), 4);
445 }
446
447 #[test]
448 fn test_recurrence_with_no_due_date() {
449 // When no due date provided, should use current time as base
450 let next = calculate_next_due(None, &Recurrence::Daily);
451 assert!(next.is_some());
452
453 let next_date = next.unwrap();
454 let now = Utc::now();
455 // Next due should be approximately 1 day from now
456 let diff = next_date - now;
457 assert!(diff.num_hours() >= 23 && diff.num_hours() <= 25);
458 }
459
460 #[test]
461 fn test_days_in_month_helper() {
462 assert_eq!(days_in_month(2026, 1), 31); // January
463 assert_eq!(days_in_month(2026, 2), 28); // February (non-leap)
464 assert_eq!(days_in_month(2028, 2), 29); // February (leap year)
465 assert_eq!(days_in_month(2026, 4), 30); // April
466 assert_eq!(days_in_month(2026, 12), 31); // December
467 }
468
469 #[test]
470 fn test_recurring_task_fresh_urgency_after_completion() {
471 use crate::models::{Priority, TaskStatus};
472 use crate::urgency::calculate_urgency;
473
474 // Simulate an overdue recurring weekly task:
475 // Original due date was 3 days ago, so it had high urgency from the overdue penalty.
476 let overdue_due = Utc::now() - Duration::days(3);
477 let old_created = Utc::now() - Duration::days(10);
478 let tags: Vec<String> = vec![];
479
480 let old_urgency = calculate_urgency(
481 &Priority::Medium,
482 &TaskStatus::Pending,
483 Some(&overdue_due),
484 &old_created,
485 &tags,
486 );
487
488 // Old task should have overdue urgency (12.0 from overdue + priority + age)
489 assert!(old_urgency > 15.0, "Overdue task should have high urgency, got: {}", old_urgency);
490
491 // When completing and creating the next instance, we calculate next_due
492 let next_due = calculate_next_due(Some(&overdue_due), &Recurrence::Weekly).unwrap();
493 let new_created = Utc::now();
494
495 let new_urgency = calculate_urgency(
496 &Priority::Medium,
497 &TaskStatus::Pending,
498 Some(&next_due),
499 &new_created,
500 &tags,
501 );
502
503 // The new instance should NOT be overdue (due date is in the future)
504 // and should have much lower urgency than the old overdue one
505 assert!(
506 new_urgency < old_urgency,
507 "New recurring instance should have lower urgency ({}) than the completed overdue one ({})",
508 new_urgency, old_urgency
509 );
510
511 // Specifically, it should NOT have the overdue penalty
512 assert!(
513 new_urgency < 12.0,
514 "New recurring instance should not have overdue penalty, got urgency: {}",
515 new_urgency
516 );
517 }
518
519 // ============ Rich Recurrence Tests ============
520
521 #[test]
522 fn test_rich_daily_interval() {
523 let now = Utc.with_ymd_and_hms(2026, 3, 1, 9, 0, 0).unwrap();
524 let rule = RecurrenceRule {
525 pattern: Recurrence::Daily,
526 interval: 3,
527 weekdays: vec![],
528 monthly_spec: None,
529 };
530 let next = calculate_next_due_rich(Some(&now), &rule).unwrap();
531 assert_eq!(next.day(), 4); // 3 days later
532 assert_eq!(next.hour(), 9);
533 }
534
535 #[test]
536 fn test_rich_weekly_weekdays() {
537 // Monday, requesting Mon/Wed/Fri
538 let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday
539 let rule = RecurrenceRule {
540 pattern: Recurrence::Weekly,
541 interval: 1,
542 weekdays: vec![0, 2, 4], // Mon, Wed, Fri
543 monthly_spec: None,
544 };
545 // Next after Monday should be Wednesday
546 let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
547 assert_eq!(next.weekday(), chrono::Weekday::Wed);
548 assert_eq!(next.day(), 4);
549
550 // Next after Wednesday should be Friday
551 let next2 = calculate_next_due_rich(Some(&next), &rule).unwrap();
552 assert_eq!(next2.weekday(), chrono::Weekday::Fri);
553 assert_eq!(next2.day(), 6);
554
555 // Next after Friday should be Monday of next week
556 let next3 = calculate_next_due_rich(Some(&next2), &rule).unwrap();
557 assert_eq!(next3.weekday(), chrono::Weekday::Mon);
558 assert_eq!(next3.day(), 9);
559 }
560
561 #[test]
562 fn test_rich_weekly_interval_2() {
563 // Friday, every 2 weeks on Mon/Fri
564 let fri = Utc.with_ymd_and_hms(2026, 3, 6, 10, 0, 0).unwrap(); // Friday
565 let rule = RecurrenceRule {
566 pattern: Recurrence::Weekly,
567 interval: 2,
568 weekdays: vec![0, 4], // Mon, Fri
569 monthly_spec: None,
570 };
571 // Next after Friday: wrap to Mon of 2-weeks-later
572 let next = calculate_next_due_rich(Some(&fri), &rule).unwrap();
573 assert_eq!(next.weekday(), chrono::Weekday::Mon);
574 assert_eq!(next.day(), 16); // 2 weeks later, Monday
575 }
576
577 #[test]
578 fn test_rich_monthly_day_of_month() {
579 let jan = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
580 let rule = RecurrenceRule {
581 pattern: Recurrence::Monthly,
582 interval: 1,
583 weekdays: vec![],
584 monthly_spec: Some(MonthlySpec::DayOfMonth { day: 15 }),
585 };
586 let next = calculate_next_due_rich(Some(&jan), &rule).unwrap();
587 assert_eq!(next.month(), 2);
588 assert_eq!(next.day(), 15);
589 }
590
591 #[test]
592 fn test_rich_monthly_nth_weekday() {
593 // 2nd Friday of January 2026 is Jan 9... let me compute
594 // Jan 2026: 1=Thu, 2=Fri (1st Fri), 9=Fri (2nd Fri)
595 let jan = Utc.with_ymd_and_hms(2026, 1, 9, 10, 0, 0).unwrap();
596 let rule = RecurrenceRule {
597 pattern: Recurrence::Monthly,
598 interval: 1,
599 weekdays: vec![],
600 monthly_spec: Some(MonthlySpec::NthWeekday { week: 2, weekday: 4 }), // 2nd Friday
601 };
602 let next = calculate_next_due_rich(Some(&jan), &rule).unwrap();
603 // Feb 2026: 1=Sun, 6=Fri (1st Fri), 13=Fri (2nd Fri)
604 assert_eq!(next.month(), 2);
605 assert_eq!(next.day(), 13);
606 }
607
608 #[test]
609 fn test_rich_monthly_last_weekday() {
610 let jan = Utc.with_ymd_and_hms(2026, 1, 26, 10, 0, 0).unwrap();
611 let rule = RecurrenceRule {
612 pattern: Recurrence::Monthly,
613 interval: 1,
614 weekdays: vec![],
615 monthly_spec: Some(MonthlySpec::NthWeekday { week: -1, weekday: 0 }), // Last Monday
616 };
617 let next = calculate_next_due_rich(Some(&jan), &rule).unwrap();
618 // Feb 2026: last Monday is Feb 23
619 assert_eq!(next.month(), 2);
620 assert_eq!(next.day(), 23);
621 }
622
623 #[test]
624 fn test_expand_recurrence_weekly() {
625 let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday
626 let event = Event {
627 id: crate::id_types::EventId::new(),
628 user_id: None,
629 project_id: None,
630 project_name: None,
631 contact_id: None,
632 contact_name: None,
633 title: "Weekly meeting".to_string(),
634 description: String::new(),
635 start_time: start,
636 end_time: Some(start + Duration::hours(1)),
637 location: None,
638 linked_task_id: None,
639 recurrence: Recurrence::Weekly,
640 recurrence_rule: Some(RecurrenceRule {
641 pattern: Recurrence::Weekly,
642 interval: 1,
643 weekdays: vec![],
644 monthly_spec: None,
645 }),
646 recurrence_parent_id: None,
647 is_recurring_instance: false,
648 block_type: None,
649 external_source: None,
650 external_id: None,
651 is_read_only: false,
652 snoozed_until: None,
653 reminder_offsets_seconds: Vec::new(),
654 };
655
656 let range_start = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap();
657 let range_end = Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap();
658
659 let instances = expand_recurrence(&event, range_start, range_end);
660 // Original is March 2 (Mon). Instances: Mar 9, 16, 23, 30 = 4 expanded
661 assert_eq!(instances.len(), 4);
662 assert_eq!(instances[0].start_time.day(), 9);
663 assert_eq!(instances[1].start_time.day(), 16);
664 assert_eq!(instances[2].start_time.day(), 23);
665 assert_eq!(instances[3].start_time.day(), 30);
666
667 // All should be marked as recurring instances
668 assert!(instances.iter().all(|e| e.is_recurring_instance));
669 // All should have unique deterministic IDs
670 let ids: std::collections::HashSet<_> = instances.iter().map(|e| e.id).collect();
671 assert_eq!(ids.len(), 4);
672 }
673
674 #[test]
675 fn test_expand_recurrence_deterministic_ids() {
676 let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
677 let event = Event {
678 id: crate::id_types::EventId::new(),
679 user_id: None,
680 project_id: None,
681 project_name: None,
682 contact_id: None,
683 contact_name: None,
684 title: "Test".to_string(),
685 description: String::new(),
686 start_time: start,
687 end_time: Some(start + Duration::hours(1)),
688 location: None,
689 linked_task_id: None,
690 recurrence: Recurrence::Daily,
691 recurrence_rule: None,
692 recurrence_parent_id: None,
693 is_recurring_instance: false,
694 block_type: None,
695 external_source: None,
696 external_id: None,
697 is_read_only: false,
698 snoozed_until: None,
699 reminder_offsets_seconds: Vec::new(),
700 };
701
702 let range_start = Utc.with_ymd_and_hms(2026, 3, 3, 0, 0, 0).unwrap();
703 let range_end = Utc.with_ymd_and_hms(2026, 3, 5, 23, 59, 59).unwrap();
704
705 let instances1 = expand_recurrence(&event, range_start, range_end);
706 let instances2 = expand_recurrence(&event, range_start, range_end);
707 // Same inputs produce same IDs
708 assert_eq!(instances1.len(), instances2.len());
709 for (a, b) in instances1.iter().zip(instances2.iter()) {
710 assert_eq!(a.id, b.id);
711 }
712 }
713 }
714