//! Day planning business logic. //! //! Contains pure functions for timeline conflict detection. //! These are used by the command layer but are pure logic with no I/O. use chrono::{DateTime, Utc}; use serde::Serialize; use uuid::Uuid; /// A scheduled item on the day timeline. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct TimelineItem { pub id: Uuid, pub item_type: String, pub title: String, pub start_time: DateTime, pub end_time: Option>, pub duration: Option, pub project_id: Option, pub project_name: Option, pub priority: Option, pub status: Option, pub block_type: Option, } /// A detected overlap between two timeline items. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct Conflict { pub item1_id: Uuid, pub item2_id: Uuid, pub overlap_start: DateTime, pub overlap_end: DateTime, } /// Detects scheduling conflicts between timeline items. /// /// Compares all pairs of items and returns any overlapping time ranges. /// Items without an explicit end time use their duration (defaulting to 30 minutes). pub fn detect_conflicts(items: &[TimelineItem]) -> Vec { let mut conflicts = Vec::new(); for (i, item1) in items.iter().enumerate() { let end1 = item1.end_time.unwrap_or_else(|| { item1.start_time + chrono::Duration::minutes(item1.duration.unwrap_or(30).max(0) as i64) }); for item2 in items.iter().skip(i + 1) { let end2 = item2.end_time.unwrap_or_else(|| { item2.start_time + chrono::Duration::minutes(item2.duration.unwrap_or(30).max(0) as i64) }); // Standard interval overlap test: two intervals [s1,e1) and [s2,e2) // overlap iff s1 < e2 AND s2 < e1. The overlap region is [max(s1,s2), min(e1,e2)). if item1.start_time < end2 && item2.start_time < end1 { let overlap_start = item1.start_time.max(item2.start_time); let overlap_end = end1.min(end2); conflicts.push(Conflict { item1_id: item1.id, item2_id: item2.id, overlap_start, overlap_end, }); } } } conflicts } #[cfg(test)] mod tests { use super::*; use chrono::{Duration, TimeZone}; fn make_timeline_item(hour: u32, minute: u32, duration_mins: i32) -> TimelineItem { let start = Utc.with_ymd_and_hms(2026, 3, 15, hour, minute, 0).unwrap(); TimelineItem { id: Uuid::new_v4(), item_type: "event".to_string(), title: format!("Event at {}:{:02}", hour, minute), start_time: start, end_time: Some(start + Duration::minutes(duration_mins as i64)), duration: Some(duration_mins), project_id: None, project_name: None, priority: None, status: None, block_type: None, } } #[test] fn test_no_conflicts_non_overlapping() { let items = vec![ make_timeline_item(9, 0, 60), make_timeline_item(10, 0, 60), make_timeline_item(11, 0, 60), ]; assert!(detect_conflicts(&items).is_empty()); } #[test] fn test_direct_overlap() { let items = vec![ make_timeline_item(9, 0, 60), make_timeline_item(9, 30, 60), ]; let conflicts = detect_conflicts(&items); assert_eq!(conflicts.len(), 1); let overlap_duration = conflicts[0].overlap_end - conflicts[0].overlap_start; assert_eq!(overlap_duration.num_minutes(), 30); } #[test] fn test_complete_containment() { let items = vec![ make_timeline_item(9, 0, 120), make_timeline_item(9, 30, 30), ]; let conflicts = detect_conflicts(&items); assert_eq!(conflicts.len(), 1); let overlap_duration = conflicts[0].overlap_end - conflicts[0].overlap_start; assert_eq!(overlap_duration.num_minutes(), 30); } #[test] fn test_multiple_conflicts() { let items = vec![ make_timeline_item(9, 0, 90), make_timeline_item(9, 30, 60), make_timeline_item(10, 0, 60), ]; assert_eq!(detect_conflicts(&items).len(), 3); } #[test] fn test_adjacent_no_conflict() { let items = vec![ make_timeline_item(9, 0, 60), make_timeline_item(10, 0, 60), ]; assert!(detect_conflicts(&items).is_empty()); } #[test] fn test_default_duration_for_missing_end_time() { let start1 = Utc.with_ymd_and_hms(2026, 3, 15, 9, 0, 0).unwrap(); let start2 = Utc.with_ymd_and_hms(2026, 3, 15, 9, 15, 0).unwrap(); let items = vec![ TimelineItem { id: Uuid::new_v4(), item_type: "task".to_string(), title: "Task 1".to_string(), start_time: start1, end_time: None, duration: None, project_id: None, project_name: None, priority: None, status: None, block_type: None, }, TimelineItem { id: Uuid::new_v4(), item_type: "task".to_string(), title: "Task 2".to_string(), start_time: start2, end_time: None, duration: None, project_id: None, project_name: None, priority: None, status: None, block_type: None, }, ]; let conflicts = detect_conflicts(&items); assert_eq!(conflicts.len(), 1); let overlap_duration = conflicts[0].overlap_end - conflicts[0].overlap_start; assert_eq!(overlap_duration.num_minutes(), 15); } #[test] fn test_empty_items() { assert!(detect_conflicts(&Vec::::new()).is_empty()); } #[test] fn test_single_item() { assert!(detect_conflicts(&[make_timeline_item(9, 0, 60)]).is_empty()); } #[test] fn test_conflict_ids_correct() { let item1 = make_timeline_item(9, 0, 60); let item2 = make_timeline_item(9, 30, 60); let id1 = item1.id; let id2 = item2.id; let conflicts = detect_conflicts(&[item1, item2]); assert_eq!(conflicts.len(), 1); assert_eq!(conflicts[0].item1_id, id1); assert_eq!(conflicts[0].item2_id, id2); } }