Trying very hard not to miss calendar events
0
fork

Configure Feed

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

Accept datetimes for filter

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

+54 -25
+38 -18
crates/alarma-cli/src/commands/events.rs
··· 7 7 /// Arguments for listing events 8 8 pub struct ListEventsArgs { 9 9 pub source: Option<String>, 10 - pub date_from: Option<String>, 11 - pub date_until: Option<String>, 10 + pub datetime_from: Option<String>, 11 + pub datetime_until: Option<String>, 12 12 pub format: OutputFormat, 13 13 } 14 14 15 15 /// Executes the events list command 16 16 pub fn execute_list(repository: &dyn CalendarRepository, args: ListEventsArgs) -> Result<()> { 17 17 // Parse date range 18 - let range = parse_date_range(&args.date_from, &args.date_until)?; 18 + let range = parse_datetime_range(&args.datetime_from, &args.datetime_until)?; 19 19 20 20 // Get events 21 21 let mut events = if let Some(source_uid) = &args.source { ··· 33 33 Ok(()) 34 34 } 35 35 36 - /// Parses date range from string arguments 37 - fn parse_date_range(date_from: &Option<String>, date_until: &Option<String>) -> Result<DateRange> { 38 - let start = if let Some(date_str) = date_from { 39 - parse_date_to_timestamp(date_str)? 36 + /// Parses datetime range from string arguments 37 + fn parse_datetime_range( 38 + datetime_from: &Option<String>, 39 + datetime_until: &Option<String>, 40 + ) -> Result<DateRange> { 41 + let start = if let Some(datetime_str) = datetime_from { 42 + parse_datetime_to_timestamp(datetime_str)? 40 43 } else { 41 44 Local::now() 42 45 }; 43 46 44 - let end = if let Some(date_str) = date_until { 45 - parse_date_to_timestamp(date_str)? 47 + let end = if let Some(datetime_str) = datetime_until { 48 + parse_datetime_to_timestamp(datetime_str)? 46 49 } else { 47 50 start + chrono::Duration::days(60) 48 51 }; ··· 50 53 DateRange::new(start, end) 51 54 } 52 55 53 - /// Parses a date string (YYYY-MM-DD) to a DateTime 54 - fn parse_date_to_timestamp(date_str: &str) -> Result<chrono::DateTime<Local>> { 55 - let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")?; 56 + /// Parses a datetime string (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS) to a DateTime 57 + fn parse_datetime_to_timestamp(datetime_str: &str) -> Result<chrono::DateTime<Local>> { 58 + // Try parsing as full datetime first 59 + if let Ok(datetime) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") { 60 + return datetime.and_local_timezone(Local).single().ok_or_else(|| { 61 + alarma_core::AlarmaError::InvalidDateTime { 62 + input: datetime_str.to_string(), 63 + expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS".to_string()), 64 + } 65 + }); 66 + } 67 + 68 + // Fall back to date only (with time 00:00:00) 69 + let date = NaiveDate::parse_from_str(datetime_str, "%Y-%m-%d")?; 56 70 let datetime = 57 71 date.and_hms_opt(0, 0, 0) 58 72 .ok_or_else(|| alarma_core::AlarmaError::InvalidDateTime { 59 - input: date_str.to_string(), 60 - expected_format: Some("YYYY-MM-DD".to_string()), 73 + input: datetime_str.to_string(), 74 + expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS".to_string()), 61 75 })?; 62 76 63 77 datetime.and_local_timezone(Local).single().ok_or_else(|| { 64 78 alarma_core::AlarmaError::InvalidDateTime { 65 - input: date_str.to_string(), 66 - expected_format: Some("YYYY-MM-DD".to_string()), 79 + input: datetime_str.to_string(), 80 + expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS".to_string()), 67 81 } 68 82 }) 69 83 } ··· 74 88 75 89 #[test] 76 90 fn test_parse_date() { 77 - let result = parse_date_to_timestamp("2026-01-15"); 91 + let result = parse_datetime_to_timestamp("2026-01-15"); 92 + assert!(result.is_ok()); 93 + } 94 + 95 + #[test] 96 + fn test_parse_datetime() { 97 + let result = parse_datetime_to_timestamp("2026-01-15T14:30:00"); 78 98 assert!(result.is_ok()); 79 99 } 80 100 81 101 #[test] 82 102 fn test_parse_invalid_date() { 83 - let result = parse_date_to_timestamp("invalid-date"); 103 + let result = parse_datetime_to_timestamp("invalid-date"); 84 104 assert!(result.is_err()); 85 105 } 86 106 }
+6 -6
crates/alarma-cli/src/main.rs
··· 66 66 #[arg(short, long)] 67 67 source: Option<String>, 68 68 69 - /// Start date (YYYY-MM-DD format). Defaults to now 69 + /// Start datetime (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS format). Defaults to now 70 70 #[arg(long)] 71 - date_from: Option<String>, 71 + datetime_from: Option<String>, 72 72 73 - /// End date (YYYY-MM-DD format). Defaults to 60 days from now 73 + /// End datetime (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS format). Defaults to 60 days from now 74 74 #[arg(long)] 75 - date_until: Option<String>, 75 + datetime_until: Option<String>, 76 76 77 77 /// Output format (table, json, simple) 78 78 #[arg(short, long, default_value = "table")] ··· 102 102 let format = parse_output_format(&args.format)?; 103 103 let cmd_args = commands::events::ListEventsArgs { 104 104 source: args.source, 105 - date_from: args.date_from, 106 - date_until: args.date_until, 105 + datetime_from: args.datetime_from, 106 + datetime_until: args.datetime_until, 107 107 format, 108 108 }; 109 109 commands::events::execute_list(repository.as_ref(), cmd_args)?;
+10 -1
crates/alarma-eds/src/repository.rs
··· 98 98 // Query events 99 99 let components = client.list_events(range.start_timestamp(), range.end_timestamp())?; 100 100 101 - // Convert to domain events 101 + // Convert to domain events and filter by actual start time 102 + // EDS may return recurring event masters that are outside the range, 103 + // so we need to filter by the actual event start time 104 + let range_start = range.start_timestamp(); 105 + let range_end = range.end_timestamp(); 106 + 102 107 Ok(components 103 108 .iter() 104 109 .filter_map(|comp| Self::ical_to_event(comp, source_uid.to_string())) 110 + .filter(|event| { 111 + let event_start = event.start.timestamp(); 112 + event_start >= range_start && event_start < range_end 113 + }) 105 114 .collect()) 106 115 } 107 116 }