Skip to main content

max / goingson

6.6 KB · 210 lines History Blame Raw
1 //! Day planning business logic.
2 //!
3 //! Contains pure functions for timeline conflict detection.
4 //! These are used by the command layer but are pure logic with no I/O.
5
6 use chrono::{DateTime, Utc};
7 use serde::Serialize;
8 use uuid::Uuid;
9
10 /// A scheduled item on the day timeline.
11 #[derive(Debug, Serialize)]
12 #[serde(rename_all = "camelCase")]
13 pub struct TimelineItem {
14 pub id: Uuid,
15 pub item_type: String,
16 pub title: String,
17 pub start_time: DateTime<Utc>,
18 pub end_time: Option<DateTime<Utc>>,
19 pub duration: Option<i32>,
20 pub project_id: Option<Uuid>,
21 pub project_name: Option<String>,
22 pub priority: Option<String>,
23 pub status: Option<String>,
24 pub block_type: Option<String>,
25 }
26
27 /// A detected overlap between two timeline items.
28 #[derive(Debug, Serialize)]
29 #[serde(rename_all = "camelCase")]
30 pub struct Conflict {
31 pub item1_id: Uuid,
32 pub item2_id: Uuid,
33 pub overlap_start: DateTime<Utc>,
34 pub overlap_end: DateTime<Utc>,
35 }
36
37 /// Detects scheduling conflicts between timeline items.
38 ///
39 /// Compares all pairs of items and returns any overlapping time ranges.
40 /// Items without an explicit end time use their duration (defaulting to 30 minutes).
41 pub fn detect_conflicts(items: &[TimelineItem]) -> Vec<Conflict> {
42 let mut conflicts = Vec::new();
43
44 for (i, item1) in items.iter().enumerate() {
45 let end1 = item1.end_time.unwrap_or_else(|| {
46 item1.start_time + chrono::Duration::minutes(item1.duration.unwrap_or(30).max(0) as i64)
47 });
48
49 for item2 in items.iter().skip(i + 1) {
50 let end2 = item2.end_time.unwrap_or_else(|| {
51 item2.start_time + chrono::Duration::minutes(item2.duration.unwrap_or(30).max(0) as i64)
52 });
53
54 // Standard interval overlap test: two intervals [s1,e1) and [s2,e2)
55 // overlap iff s1 < e2 AND s2 < e1. The overlap region is [max(s1,s2), min(e1,e2)).
56 if item1.start_time < end2 && item2.start_time < end1 {
57 let overlap_start = item1.start_time.max(item2.start_time);
58 let overlap_end = end1.min(end2);
59 conflicts.push(Conflict {
60 item1_id: item1.id,
61 item2_id: item2.id,
62 overlap_start,
63 overlap_end,
64 });
65 }
66 }
67 }
68
69 conflicts
70 }
71
72 #[cfg(test)]
73 mod tests {
74 use super::*;
75 use chrono::{Duration, TimeZone};
76
77 fn make_timeline_item(hour: u32, minute: u32, duration_mins: i32) -> TimelineItem {
78 let start = Utc.with_ymd_and_hms(2026, 3, 15, hour, minute, 0).unwrap();
79 TimelineItem {
80 id: Uuid::new_v4(),
81 item_type: "event".to_string(),
82 title: format!("Event at {}:{:02}", hour, minute),
83 start_time: start,
84 end_time: Some(start + Duration::minutes(duration_mins as i64)),
85 duration: Some(duration_mins),
86 project_id: None,
87 project_name: None,
88 priority: None,
89 status: None,
90 block_type: None,
91 }
92 }
93
94 #[test]
95 fn test_no_conflicts_non_overlapping() {
96 let items = vec![
97 make_timeline_item(9, 0, 60),
98 make_timeline_item(10, 0, 60),
99 make_timeline_item(11, 0, 60),
100 ];
101 assert!(detect_conflicts(&items).is_empty());
102 }
103
104 #[test]
105 fn test_direct_overlap() {
106 let items = vec![
107 make_timeline_item(9, 0, 60),
108 make_timeline_item(9, 30, 60),
109 ];
110 let conflicts = detect_conflicts(&items);
111 assert_eq!(conflicts.len(), 1);
112 let overlap_duration = conflicts[0].overlap_end - conflicts[0].overlap_start;
113 assert_eq!(overlap_duration.num_minutes(), 30);
114 }
115
116 #[test]
117 fn test_complete_containment() {
118 let items = vec![
119 make_timeline_item(9, 0, 120),
120 make_timeline_item(9, 30, 30),
121 ];
122 let conflicts = detect_conflicts(&items);
123 assert_eq!(conflicts.len(), 1);
124 let overlap_duration = conflicts[0].overlap_end - conflicts[0].overlap_start;
125 assert_eq!(overlap_duration.num_minutes(), 30);
126 }
127
128 #[test]
129 fn test_multiple_conflicts() {
130 let items = vec![
131 make_timeline_item(9, 0, 90),
132 make_timeline_item(9, 30, 60),
133 make_timeline_item(10, 0, 60),
134 ];
135 assert_eq!(detect_conflicts(&items).len(), 3);
136 }
137
138 #[test]
139 fn test_adjacent_no_conflict() {
140 let items = vec![
141 make_timeline_item(9, 0, 60),
142 make_timeline_item(10, 0, 60),
143 ];
144 assert!(detect_conflicts(&items).is_empty());
145 }
146
147 #[test]
148 fn test_default_duration_for_missing_end_time() {
149 let start1 = Utc.with_ymd_and_hms(2026, 3, 15, 9, 0, 0).unwrap();
150 let start2 = Utc.with_ymd_and_hms(2026, 3, 15, 9, 15, 0).unwrap();
151
152 let items = vec![
153 TimelineItem {
154 id: Uuid::new_v4(),
155 item_type: "task".to_string(),
156 title: "Task 1".to_string(),
157 start_time: start1,
158 end_time: None,
159 duration: None,
160 project_id: None,
161 project_name: None,
162 priority: None,
163 status: None,
164 block_type: None,
165 },
166 TimelineItem {
167 id: Uuid::new_v4(),
168 item_type: "task".to_string(),
169 title: "Task 2".to_string(),
170 start_time: start2,
171 end_time: None,
172 duration: None,
173 project_id: None,
174 project_name: None,
175 priority: None,
176 status: None,
177 block_type: None,
178 },
179 ];
180
181 let conflicts = detect_conflicts(&items);
182 assert_eq!(conflicts.len(), 1);
183 let overlap_duration = conflicts[0].overlap_end - conflicts[0].overlap_start;
184 assert_eq!(overlap_duration.num_minutes(), 15);
185 }
186
187 #[test]
188 fn test_empty_items() {
189 assert!(detect_conflicts(&Vec::<TimelineItem>::new()).is_empty());
190 }
191
192 #[test]
193 fn test_single_item() {
194 assert!(detect_conflicts(&[make_timeline_item(9, 0, 60)]).is_empty());
195 }
196
197 #[test]
198 fn test_conflict_ids_correct() {
199 let item1 = make_timeline_item(9, 0, 60);
200 let item2 = make_timeline_item(9, 30, 60);
201 let id1 = item1.id;
202 let id2 = item2.id;
203
204 let conflicts = detect_conflicts(&[item1, item2]);
205 assert_eq!(conflicts.len(), 1);
206 assert_eq!(conflicts[0].item1_id, id1);
207 assert_eq!(conflicts[0].item2_id, id2);
208 }
209 }
210