//! Recurring task and event scheduling logic. use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc}; use uuid::Uuid; use crate::models::{Event, Recurrence, RecurrenceRule, MonthlySpec}; /// Calculate the next due date based on recurrence type. /// /// The next occurrence is calculated from the original due date, /// not from when the task was completed. This ensures consistent /// scheduling (e.g., "every Monday" stays on Mondays). /// /// For monthly recurrence, `original_day` can specify the intended /// day-of-month to prevent drift when months have fewer days /// (e.g., Jan 31 -> Feb 28 -> Mar 31 instead of Mar 28). pub fn calculate_next_due( current_due: Option<&DateTime>, recurrence: &Recurrence, ) -> Option> { // For monthly recurrence, detect end-of-month dates and preserve intent. // If the due date falls on the last day of its month AND the day is >= 29, // treat the target as day 31 so it snaps to end-of-month in longer months. // The >= 29 guard prevents false positives: Feb 28 in a non-leap year is // ambiguous (user may have meant "the 28th"), but days 29-30 at month-end // clearly indicate end-of-month intent. let target_day = if matches!(recurrence, Recurrence::Monthly) { current_due.and_then(|dt| { let day = dt.day(); let month_len = days_in_month(dt.year(), dt.month()); if day == month_len && day >= 29 && day < 31 { Some(31) } else { None } }) } else { None }; calculate_next_due_with_day(current_due, recurrence, target_day) } /// Like `calculate_next_due` but accepts an explicit target day-of-month /// for monthly recurrence to prevent day drift across short months. pub fn calculate_next_due_with_day( current_due: Option<&DateTime>, recurrence: &Recurrence, original_day: Option, ) -> Option> { let base_date = current_due.copied().unwrap_or_else(Utc::now); match recurrence { Recurrence::Daily => Some(base_date + Duration::days(1)), Recurrence::Weekly => Some(base_date + Duration::weeks(1)), Recurrence::Monthly => { let next = add_months(base_date, 1, original_day); Some(next) } Recurrence::None => None, } } /// Add months to a DateTime, handling edge cases like month-end dates. /// /// Uses absolute month counting (year*12 + month) to add/subtract months, then /// clamps the day to the target month's length. Examples: /// Jan 31 + 1 month → Feb 28 (or 29 in a leap year) /// Mar 31 + 1 month → Apr 30 /// /// When `target_day` is provided, uses that as the intended day-of-month /// instead of `dt.day()`, preventing drift across short months: /// Jan 31 (target=31) + 1 → Feb 28, then Feb 28 (target=31) + 1 → Mar 31 /// /// Preserves the original hour/minute/second. Falls back to the input datetime /// if the target date is ambiguous (e.g., DST gap via `with_ymd_and_hms`). fn add_months(dt: DateTime, months: i32, target_day: Option) -> DateTime { let year = dt.year(); let month = dt.month() as i32; let day = target_day.unwrap_or(dt.day()); let total_months = year * 12 + month - 1 + months; let new_year = total_months.div_euclid(12); let new_month = (total_months.rem_euclid(12) + 1) as u32; // Handle end-of-month edge cases (e.g., Jan 31 -> Feb 28) let days_in_new_month = days_in_month(new_year, new_month); let new_day = day.min(days_in_new_month); Utc.with_ymd_and_hms(new_year, new_month, new_day, dt.hour(), dt.minute(), dt.second()) .single() .unwrap_or(dt) } /// Get the number of days in a month fn days_in_month(year: i32, month: u32) -> u32 { use chrono::NaiveDate; // Get the first day of the next month, then go back one day let next_month = if month == 12 { NaiveDate::from_ymd_opt(year + 1, 1, 1) } else { NaiveDate::from_ymd_opt(year, month + 1, 1) }; next_month .map(|d| d.pred_opt().map(|p| p.day()).unwrap_or(28)) .unwrap_or(28) } /// Check if a task should recur pub fn should_recur(recurrence: &Recurrence) -> bool { !matches!(recurrence, Recurrence::None) } // ============ Rich Recurrence ============ /// Namespace UUID for generating deterministic v5 IDs for recurring instances. const RECURRENCE_NS: Uuid = Uuid::from_bytes([ 0x8a, 0x3f, 0xc7, 0x12, 0xe0, 0x4b, 0x4d, 0x9a, 0xb1, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, ]); /// Calculate the next due date using a rich recurrence rule. /// /// Handles intervals, weekday selection, and monthly specifications. /// Falls back to simple recurrence for rules with interval=1 and no extras. pub fn calculate_next_due_rich( current_due: Option<&DateTime>, rule: &RecurrenceRule, ) -> Option> { if matches!(rule.pattern, Recurrence::None) { return None; } let base = current_due.copied().unwrap_or_else(Utc::now); let interval = rule.interval.max(1) as i64; match rule.pattern { Recurrence::Daily => { Some(base + Duration::days(interval)) } Recurrence::Weekly => { if rule.weekdays.is_empty() { return Some(base + Duration::weeks(interval)); } // Find next matching weekday let current_wd = base.weekday().num_days_from_monday() as u8; let mut sorted_days = rule.weekdays.clone(); sorted_days.sort_unstable(); sorted_days.dedup(); // Look for next day in the current week (after current weekday) if let Some(&next_wd) = sorted_days.iter().find(|&&d| d > current_wd) { let diff = (next_wd - current_wd) as i64; return Some(base + Duration::days(diff)); } // Wrap to first day of next interval-week let first_wd = sorted_days[0]; let days_to_end = 7 - current_wd as i64; let skip_weeks = (interval - 1) * 7; let days = days_to_end + skip_weeks + first_wd as i64; Some(base + Duration::days(days)) } Recurrence::Monthly => { match &rule.monthly_spec { Some(MonthlySpec::DayOfMonth { day }) => { let next = add_months(base, interval as i32, Some(*day)); Some(next) } Some(MonthlySpec::NthWeekday { week, weekday }) => { let next_base = add_months(base, interval as i32, None); let target = nth_weekday_in_month( next_base.year(), next_base.month(), *week, *weekday, next_base.hour(), next_base.minute(), next_base.second(), ); Some(target.unwrap_or(next_base)) } None => { // Same as legacy monthly let target_day = { let day = base.day(); let month_len = days_in_month(base.year(), base.month()); if day == month_len && day >= 29 && day < 31 { Some(31) } else { None } }; Some(add_months(base, interval as i32, target_day)) } } } Recurrence::None => None, } } /// Find the Nth weekday in a given month. /// `week`: 1-4 for ordinal, -1 for last. /// `weekday`: 0=Mon..6=Sun. fn nth_weekday_in_month( year: i32, month: u32, week: i8, weekday: u8, hour: u32, minute: u32, second: u32, ) -> Option> { use chrono::NaiveDate; let weekday_chrono = match weekday { 0 => chrono::Weekday::Mon, 1 => chrono::Weekday::Tue, 2 => chrono::Weekday::Wed, 3 => chrono::Weekday::Thu, 4 => chrono::Weekday::Fri, 5 => chrono::Weekday::Sat, 6 => chrono::Weekday::Sun, _ => return None, }; if week == -1 { // Last occurrence: start from end of month, walk backward let last_day = days_in_month(year, month); let end = NaiveDate::from_ymd_opt(year, month, last_day)?; let mut d = end; while d.weekday() != weekday_chrono { d = d.pred_opt()?; } Utc.with_ymd_and_hms(year, month, d.day(), hour, minute, second).single() } else if (1..=5).contains(&week) { // Nth occurrence: start from day 1, find first matching weekday, skip N-1 let first = NaiveDate::from_ymd_opt(year, month, 1)?; let mut d = first; while d.weekday() != weekday_chrono { d = d.succ_opt()?; } // d is the 1st occurrence; advance (week-1) weeks d = d.checked_add_signed(chrono::TimeDelta::weeks((week - 1) as i64))?; if d.month() != month { return None; // e.g., 5th Monday doesn't exist } Utc.with_ymd_and_hms(year, month, d.day(), hour, minute, second).single() } else { None } } /// Expand a recurring event into virtual instances within a date range. /// /// Returns clones of the parent event with adjusted times and synthetic IDs. /// The parent event itself is NOT included unless its `start_time` falls in range. /// Caps expansion at 500 iterations to prevent runaway loops. pub fn expand_recurrence( event: &Event, range_start: DateTime, range_end: DateTime, ) -> Vec { let rule = match event.effective_recurrence_rule() { Some(r) => r, None => return vec![], }; let event_duration = event.end_time .map(|e| e - event.start_time) .unwrap_or_else(|| Duration::hours(1)); let mut instances = Vec::new(); let mut cursor = event.start_time; let max_iterations = 500; for _ in 0..max_iterations { if cursor > range_end { break; } let instance_end = cursor + event_duration; // Check if this occurrence overlaps the range if instance_end >= range_start && cursor <= range_end { // Skip the original event (it exists in DB as-is) if cursor != event.start_time { let synthetic_id = generate_instance_id(event.id, cursor); let mut instance = event.clone(); instance.id = synthetic_id; instance.start_time = cursor; instance.end_time = Some(instance_end); instance.is_recurring_instance = true; instance.recurrence_parent_id = Some(event.id); instances.push(instance); } } // Advance to next occurrence match calculate_next_due_rich(Some(&cursor), &rule) { Some(next) if next > cursor => cursor = next, _ => break, // prevent infinite loop } } instances } /// Generate a deterministic synthetic ID for a recurring event instance. fn generate_instance_id(parent_id: crate::id_types::EventId, occurrence_time: DateTime) -> crate::id_types::EventId { let mut name = parent_id.as_uuid().as_bytes().to_vec(); name.extend_from_slice(&occurrence_time.timestamp().to_le_bytes()); let id = Uuid::new_v5(&RECURRENCE_NS, &name); crate::id_types::EventId::from_uuid(id) } #[cfg(test)] mod tests { use super::*; #[test] fn test_daily_recurrence() { let now = Utc.with_ymd_and_hms(2026, 2, 4, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&now), &Recurrence::Daily).unwrap(); assert_eq!(next.day(), 5); } #[test] fn test_weekly_recurrence() { let now = Utc.with_ymd_and_hms(2026, 2, 4, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&now), &Recurrence::Weekly).unwrap(); assert_eq!(next.day(), 11); } #[test] fn test_monthly_recurrence() { let now = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&now), &Recurrence::Monthly).unwrap(); assert_eq!(next.month(), 2); assert_eq!(next.day(), 15); } #[test] fn test_monthly_end_of_month() { // Jan 31 -> Feb 28 (or 29 in leap year) let jan_31 = Utc.with_ymd_and_hms(2026, 1, 31, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&jan_31), &Recurrence::Monthly).unwrap(); assert_eq!(next.month(), 2); // 2026 is not a leap year, so Feb has 28 days assert_eq!(next.day(), 28); } #[test] fn test_no_recurrence() { let now = Utc::now(); let next = calculate_next_due(Some(&now), &Recurrence::None); assert!(next.is_none()); } #[test] fn test_should_recur() { assert!(should_recur(&Recurrence::Daily)); assert!(should_recur(&Recurrence::Weekly)); assert!(should_recur(&Recurrence::Monthly)); assert!(!should_recur(&Recurrence::None)); } #[test] fn test_monthly_recurrence_preserves_time() { let original = Utc.with_ymd_and_hms(2026, 1, 15, 14, 30, 0).unwrap(); let next = calculate_next_due(Some(&original), &Recurrence::Monthly).unwrap(); assert_eq!(next.hour(), 14); assert_eq!(next.minute(), 30); } #[test] fn test_daily_recurrence_preserves_time() { let original = Utc.with_ymd_and_hms(2026, 2, 14, 9, 15, 30).unwrap(); let next = calculate_next_due(Some(&original), &Recurrence::Daily).unwrap(); assert_eq!(next.hour(), 9); assert_eq!(next.minute(), 15); assert_eq!(next.second(), 30); } #[test] fn test_weekly_recurrence_preserves_time() { let original = Utc.with_ymd_and_hms(2026, 3, 10, 17, 0, 0).unwrap(); let next = calculate_next_due(Some(&original), &Recurrence::Weekly).unwrap(); assert_eq!(next.hour(), 17); assert_eq!(next.minute(), 0); } #[test] fn test_monthly_december_to_january() { // Dec 15, 2026 -> Jan 15, 2027 let dec_15 = Utc.with_ymd_and_hms(2026, 12, 15, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&dec_15), &Recurrence::Monthly).unwrap(); assert_eq!(next.year(), 2027); assert_eq!(next.month(), 1); assert_eq!(next.day(), 15); } #[test] fn test_monthly_leap_year() { // Jan 31, 2028 (leap year) -> Feb 29, 2028 let jan_31 = Utc.with_ymd_and_hms(2028, 1, 31, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&jan_31), &Recurrence::Monthly).unwrap(); assert_eq!(next.month(), 2); assert_eq!(next.day(), 29); // 2028 is a leap year } #[test] fn test_monthly_feb_28_no_snap() { // Feb 28 in a non-leap year: day < 29, so no end-of-month snap. // User who chose the 28th gets Mar 28, not Mar 31. let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&feb_28), &Recurrence::Monthly).unwrap(); assert_eq!(next.month(), 3); assert_eq!(next.day(), 28); } #[test] fn test_monthly_feb_28_explicit_day_28() { // With explicit target day 28, Feb 28 -> Mar 28 (no end-of-month heuristic) let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap(); let next = calculate_next_due_with_day(Some(&feb_28), &Recurrence::Monthly, Some(28)).unwrap(); assert_eq!(next.month(), 3); assert_eq!(next.day(), 28); } #[test] fn test_monthly_march_31_to_april() { // Mar 31 -> Apr 30 (April only has 30 days) let mar_31 = Utc.with_ymd_and_hms(2026, 3, 31, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&mar_31), &Recurrence::Monthly).unwrap(); assert_eq!(next.month(), 4); assert_eq!(next.day(), 30); } #[test] fn test_daily_year_boundary() { // Dec 31, 2026 -> Jan 1, 2027 let dec_31 = Utc.with_ymd_and_hms(2026, 12, 31, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&dec_31), &Recurrence::Daily).unwrap(); assert_eq!(next.year(), 2027); assert_eq!(next.month(), 1); assert_eq!(next.day(), 1); } #[test] fn test_weekly_month_boundary() { // Jan 28, 2026 -> Feb 4, 2026 let jan_28 = Utc.with_ymd_and_hms(2026, 1, 28, 10, 0, 0).unwrap(); let next = calculate_next_due(Some(&jan_28), &Recurrence::Weekly).unwrap(); assert_eq!(next.month(), 2); assert_eq!(next.day(), 4); } #[test] fn test_recurrence_with_no_due_date() { // When no due date provided, should use current time as base let next = calculate_next_due(None, &Recurrence::Daily); assert!(next.is_some()); let next_date = next.unwrap(); let now = Utc::now(); // Next due should be approximately 1 day from now let diff = next_date - now; assert!(diff.num_hours() >= 23 && diff.num_hours() <= 25); } #[test] fn test_days_in_month_helper() { assert_eq!(days_in_month(2026, 1), 31); // January assert_eq!(days_in_month(2026, 2), 28); // February (non-leap) assert_eq!(days_in_month(2028, 2), 29); // February (leap year) assert_eq!(days_in_month(2026, 4), 30); // April assert_eq!(days_in_month(2026, 12), 31); // December } #[test] fn test_recurring_task_fresh_urgency_after_completion() { use crate::models::{Priority, TaskStatus}; use crate::urgency::calculate_urgency; // Simulate an overdue recurring weekly task: // Original due date was 3 days ago, so it had high urgency from the overdue penalty. let overdue_due = Utc::now() - Duration::days(3); let old_created = Utc::now() - Duration::days(10); let tags: Vec = vec![]; let old_urgency = calculate_urgency( &Priority::Medium, &TaskStatus::Pending, Some(&overdue_due), &old_created, &tags, ); // Old task should have overdue urgency (12.0 from overdue + priority + age) assert!(old_urgency > 15.0, "Overdue task should have high urgency, got: {}", old_urgency); // When completing and creating the next instance, we calculate next_due let next_due = calculate_next_due(Some(&overdue_due), &Recurrence::Weekly).unwrap(); let new_created = Utc::now(); let new_urgency = calculate_urgency( &Priority::Medium, &TaskStatus::Pending, Some(&next_due), &new_created, &tags, ); // The new instance should NOT be overdue (due date is in the future) // and should have much lower urgency than the old overdue one assert!( new_urgency < old_urgency, "New recurring instance should have lower urgency ({}) than the completed overdue one ({})", new_urgency, old_urgency ); // Specifically, it should NOT have the overdue penalty assert!( new_urgency < 12.0, "New recurring instance should not have overdue penalty, got urgency: {}", new_urgency ); } // ============ Rich Recurrence Tests ============ #[test] fn test_rich_daily_interval() { let now = Utc.with_ymd_and_hms(2026, 3, 1, 9, 0, 0).unwrap(); let rule = RecurrenceRule { pattern: Recurrence::Daily, interval: 3, weekdays: vec![], monthly_spec: None, }; let next = calculate_next_due_rich(Some(&now), &rule).unwrap(); assert_eq!(next.day(), 4); // 3 days later assert_eq!(next.hour(), 9); } #[test] fn test_rich_weekly_weekdays() { // Monday, requesting Mon/Wed/Fri let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday let rule = RecurrenceRule { pattern: Recurrence::Weekly, interval: 1, weekdays: vec![0, 2, 4], // Mon, Wed, Fri monthly_spec: None, }; // Next after Monday should be Wednesday let next = calculate_next_due_rich(Some(&mon), &rule).unwrap(); assert_eq!(next.weekday(), chrono::Weekday::Wed); assert_eq!(next.day(), 4); // Next after Wednesday should be Friday let next2 = calculate_next_due_rich(Some(&next), &rule).unwrap(); assert_eq!(next2.weekday(), chrono::Weekday::Fri); assert_eq!(next2.day(), 6); // Next after Friday should be Monday of next week let next3 = calculate_next_due_rich(Some(&next2), &rule).unwrap(); assert_eq!(next3.weekday(), chrono::Weekday::Mon); assert_eq!(next3.day(), 9); } #[test] fn test_rich_weekly_interval_2() { // Friday, every 2 weeks on Mon/Fri let fri = Utc.with_ymd_and_hms(2026, 3, 6, 10, 0, 0).unwrap(); // Friday let rule = RecurrenceRule { pattern: Recurrence::Weekly, interval: 2, weekdays: vec![0, 4], // Mon, Fri monthly_spec: None, }; // Next after Friday: wrap to Mon of 2-weeks-later let next = calculate_next_due_rich(Some(&fri), &rule).unwrap(); assert_eq!(next.weekday(), chrono::Weekday::Mon); assert_eq!(next.day(), 16); // 2 weeks later, Monday } #[test] fn test_rich_monthly_day_of_month() { let jan = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap(); let rule = RecurrenceRule { pattern: Recurrence::Monthly, interval: 1, weekdays: vec![], monthly_spec: Some(MonthlySpec::DayOfMonth { day: 15 }), }; let next = calculate_next_due_rich(Some(&jan), &rule).unwrap(); assert_eq!(next.month(), 2); assert_eq!(next.day(), 15); } #[test] fn test_rich_monthly_nth_weekday() { // 2nd Friday of January 2026 is Jan 9... let me compute // Jan 2026: 1=Thu, 2=Fri (1st Fri), 9=Fri (2nd Fri) let jan = Utc.with_ymd_and_hms(2026, 1, 9, 10, 0, 0).unwrap(); let rule = RecurrenceRule { pattern: Recurrence::Monthly, interval: 1, weekdays: vec![], monthly_spec: Some(MonthlySpec::NthWeekday { week: 2, weekday: 4 }), // 2nd Friday }; let next = calculate_next_due_rich(Some(&jan), &rule).unwrap(); // Feb 2026: 1=Sun, 6=Fri (1st Fri), 13=Fri (2nd Fri) assert_eq!(next.month(), 2); assert_eq!(next.day(), 13); } #[test] fn test_rich_monthly_last_weekday() { let jan = Utc.with_ymd_and_hms(2026, 1, 26, 10, 0, 0).unwrap(); let rule = RecurrenceRule { pattern: Recurrence::Monthly, interval: 1, weekdays: vec![], monthly_spec: Some(MonthlySpec::NthWeekday { week: -1, weekday: 0 }), // Last Monday }; let next = calculate_next_due_rich(Some(&jan), &rule).unwrap(); // Feb 2026: last Monday is Feb 23 assert_eq!(next.month(), 2); assert_eq!(next.day(), 23); } #[test] fn test_expand_recurrence_weekly() { let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday let event = Event { id: crate::id_types::EventId::new(), user_id: None, project_id: None, project_name: None, contact_id: None, contact_name: None, title: "Weekly meeting".to_string(), description: String::new(), start_time: start, end_time: Some(start + Duration::hours(1)), location: None, linked_task_id: None, recurrence: Recurrence::Weekly, recurrence_rule: Some(RecurrenceRule { pattern: Recurrence::Weekly, interval: 1, weekdays: vec![], monthly_spec: None, }), recurrence_parent_id: None, is_recurring_instance: false, block_type: None, external_source: None, external_id: None, is_read_only: false, snoozed_until: None, reminder_offsets_seconds: Vec::new(), }; let range_start = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(); let range_end = Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(); let instances = expand_recurrence(&event, range_start, range_end); // Original is March 2 (Mon). Instances: Mar 9, 16, 23, 30 = 4 expanded assert_eq!(instances.len(), 4); assert_eq!(instances[0].start_time.day(), 9); assert_eq!(instances[1].start_time.day(), 16); assert_eq!(instances[2].start_time.day(), 23); assert_eq!(instances[3].start_time.day(), 30); // All should be marked as recurring instances assert!(instances.iter().all(|e| e.is_recurring_instance)); // All should have unique deterministic IDs let ids: std::collections::HashSet<_> = instances.iter().map(|e| e.id).collect(); assert_eq!(ids.len(), 4); } #[test] fn test_expand_recurrence_deterministic_ids() { let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); let event = Event { id: crate::id_types::EventId::new(), user_id: None, project_id: None, project_name: None, contact_id: None, contact_name: None, title: "Test".to_string(), description: String::new(), start_time: start, end_time: Some(start + Duration::hours(1)), location: None, linked_task_id: None, recurrence: Recurrence::Daily, recurrence_rule: None, recurrence_parent_id: None, is_recurring_instance: false, block_type: None, external_source: None, external_id: None, is_read_only: false, snoozed_until: None, reminder_offsets_seconds: Vec::new(), }; let range_start = Utc.with_ymd_and_hms(2026, 3, 3, 0, 0, 0).unwrap(); let range_end = Utc.with_ymd_and_hms(2026, 3, 5, 23, 59, 59).unwrap(); let instances1 = expand_recurrence(&event, range_start, range_end); let instances2 = expand_recurrence(&event, range_start, range_end); // Same inputs produce same IDs assert_eq!(instances1.len(), instances2.len()); for (a, b) in instances1.iter().zip(instances2.iter()) { assert_eq!(a.id, b.id); } } }