Trying very hard not to miss calendar events
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix "until" filtering

Co-authored-by: Claude <noreply@anthropic.com>

+83 -15
+79 -13
crates/alarma-cli/src/commands/events.rs
··· 46 46 datetime_until: &Option<String>, 47 47 ) -> Result<DateRange> { 48 48 let start = if let Some(datetime_str) = datetime_from { 49 - parse_datetime_to_timestamp(datetime_str)? 49 + parse_datetime_to_timestamp(datetime_str, true)? 50 50 } else { 51 51 Local::now() 52 52 }; 53 53 54 54 let end = if let Some(datetime_str) = datetime_until { 55 - parse_datetime_to_timestamp(datetime_str)? 55 + parse_datetime_to_timestamp(datetime_str, false)? 56 56 } else { 57 57 start + chrono::Duration::days(60) 58 58 }; ··· 61 61 } 62 62 63 63 /// Parses a datetime string (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS) to a DateTime 64 - fn parse_datetime_to_timestamp(datetime_str: &str) -> Result<chrono::DateTime<Local>> { 64 + /// If `is_start` is true and only date is provided, uses 00:00:00 of that day 65 + /// If `is_start` is false and only date is provided, uses 00:00:00 of the next day 66 + /// (to be used with strict < comparison) 67 + fn parse_datetime_to_timestamp( 68 + datetime_str: &str, 69 + is_start: bool, 70 + ) -> Result<chrono::DateTime<Local>> { 65 71 // Try parsing as full datetime first 66 72 if let Ok(datetime) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") { 67 73 return datetime.and_local_timezone(Local).single().ok_or_else(|| { ··· 72 78 }); 73 79 } 74 80 75 - // Fall back to date only (with time 00:00:00) 81 + // Fall back to date only 76 82 let date = NaiveDate::parse_from_str(datetime_str, "%Y-%m-%d")?; 77 - let datetime = 83 + let datetime = if is_start { 84 + // For start datetime, use 00:00:00 of that day 78 85 date.and_hms_opt(0, 0, 0) 79 - .ok_or_else(|| alarma_core::AlarmaError::InvalidDateTime { 80 - input: datetime_str.to_string(), 81 - expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS".to_string()), 82 - })?; 86 + } else { 87 + // For end datetime, use 00:00:00 of the next day 88 + // (to be used with strict < comparison, catching all events on the given day) 89 + let next_day = 90 + date.succ_opt() 91 + .ok_or_else(|| alarma_core::AlarmaError::InvalidDateTime { 92 + input: datetime_str.to_string(), 93 + expected_format: Some("Date overflow".to_string()), 94 + })?; 95 + next_day.and_hms_opt(0, 0, 0) 96 + } 97 + .ok_or_else(|| alarma_core::AlarmaError::InvalidDateTime { 98 + input: datetime_str.to_string(), 99 + expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS".to_string()), 100 + })?; 83 101 84 102 datetime.and_local_timezone(Local).single().ok_or_else(|| { 85 103 alarma_core::AlarmaError::InvalidDateTime { ··· 92 110 #[cfg(test)] 93 111 mod tests { 94 112 use super::*; 113 + use chrono::{Datelike, Timelike}; 95 114 96 115 #[test] 97 116 fn test_parse_date() { 98 - let result = parse_datetime_to_timestamp("2026-01-15"); 117 + let result = parse_datetime_to_timestamp("2026-01-15", true); 99 118 assert!(result.is_ok()); 100 119 } 101 120 102 121 #[test] 103 122 fn test_parse_datetime() { 104 - let result = parse_datetime_to_timestamp("2026-01-15T14:30:00"); 123 + let result = parse_datetime_to_timestamp("2026-01-15T14:30:00", true); 105 124 assert!(result.is_ok()); 106 125 } 107 126 108 127 #[test] 109 - fn test_parse_invalid_date() { 110 - let result = parse_datetime_to_timestamp("invalid-date"); 128 + fn test_parse_invalid_datetime() { 129 + let result = parse_datetime_to_timestamp("invalid-date", true); 111 130 assert!(result.is_err()); 131 + } 132 + 133 + #[test] 134 + fn test_parse_date_start_time() { 135 + let result = parse_datetime_to_timestamp("2026-01-15", true).unwrap(); 136 + assert_eq!(result.time().hour(), 0); 137 + assert_eq!(result.time().minute(), 0); 138 + assert_eq!(result.time().second(), 0); 139 + } 140 + 141 + #[test] 142 + fn test_parse_date_end_time() { 143 + let result = parse_datetime_to_timestamp("2026-01-15", false).unwrap(); 144 + // Should be 00:00:00 of the next day (2026-01-16) 145 + assert_eq!(result.date_naive().day(), 16); 146 + assert_eq!(result.time().hour(), 0); 147 + assert_eq!(result.time().minute(), 0); 148 + assert_eq!(result.time().second(), 0); 149 + } 150 + 151 + #[test] 152 + fn test_date_end_time_boundary_condition() { 153 + // For --datetime-until with date-only input, we use next day at 00:00:00 154 + let end = parse_datetime_to_timestamp("2026-01-15", false).unwrap(); 155 + 156 + // Create a theoretical event at 23:59:59.999... on 2026-01-15 157 + let late_event = chrono::NaiveDate::from_ymd_opt(2026, 1, 15) 158 + .unwrap() 159 + .and_hms_opt(23, 59, 59) 160 + .unwrap() 161 + .and_local_timezone(chrono::Local) 162 + .single() 163 + .unwrap(); 164 + 165 + // With strict < comparison, this event should be included 166 + assert!(late_event.timestamp() < end.timestamp()); 167 + 168 + // And an event exactly at midnight of next day should be excluded 169 + let next_day_event = chrono::NaiveDate::from_ymd_opt(2026, 1, 16) 170 + .unwrap() 171 + .and_hms_opt(0, 0, 0) 172 + .unwrap() 173 + .and_local_timezone(chrono::Local) 174 + .single() 175 + .unwrap(); 176 + 177 + assert!(next_day_event.timestamp() >= end.timestamp()); 112 178 } 113 179 }
+4 -2
crates/alarma-cli/tests/output_tests.rs
··· 50 50 "source-1".to_string(), 51 51 )]; 52 52 53 - let result = print_events(&events, OutputFormat::Table); 53 + let source_names = std::collections::HashMap::new(); 54 + let result = print_events(&events, &source_names, OutputFormat::Table); 54 55 assert!(result.is_ok()); 55 56 } 56 57 ··· 64 65 "source-1".to_string(), 65 66 )]; 66 67 67 - let result = print_events(&events, OutputFormat::Json); 68 + let source_names = std::collections::HashMap::new(); 69 + let result = print_events(&events, &source_names, OutputFormat::Json); 68 70 assert!(result.is_ok()); 69 71 } 70 72