From cfb847f2e589756dfcd417e1f3e9f2437bd8f6c5 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 13 Sep 2025 02:20:27 -0500 Subject: [PATCH] feat: holiday exclusion logic for ICS command --- src/bot/commands/ics.rs | 277 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 250 insertions(+), 27 deletions(-) diff --git a/src/bot/commands/ics.rs b/src/bot/commands/ics.rs index bfad61a..21e4e9b 100644 --- a/src/bot/commands/ics.rs +++ b/src/bot/commands/ics.rs @@ -2,10 +2,110 @@ use crate::banner::{Course, MeetingScheduleInfo}; use crate::bot::{Context, Error, utils}; -use chrono::Utc; +use chrono::{Datelike, NaiveDate, Utc}; use serenity::all::CreateAttachment; use tracing::info; +/// Represents a holiday or special day that should be excluded from class schedules +#[derive(Debug, Clone)] +enum Holiday { + /// A single-day holiday + Single { month: u32, day: u32 }, + /// A multi-day holiday range + Range { + month: u32, + start_day: u32, + end_day: u32, + }, +} + +impl Holiday { + /// Check if a specific date falls within this holiday + fn contains_date(&self, date: NaiveDate) -> bool { + match self { + Holiday::Single { month, day, .. } => date.month() == *month && date.day() == *day, + Holiday::Range { + month, + start_day, + end_day, + .. + } => date.month() == *month && date.day() >= *start_day && date.day() <= *end_day, + } + } + + /// Get all dates in this holiday for a given year + fn get_dates_for_year(&self, year: i32) -> Vec { + match self { + Holiday::Single { month, day, .. } => { + if let Some(date) = NaiveDate::from_ymd_opt(year, *month, *day) { + vec![date] + } else { + Vec::new() + } + } + Holiday::Range { + month, + start_day, + end_day, + .. + } => { + let mut dates = Vec::new(); + for day in *start_day..=*end_day { + if let Some(date) = NaiveDate::from_ymd_opt(year, *month, day) { + dates.push(date); + } + } + dates + } + } + } +} + +/// University holidays that should be excluded from class schedules +const UNIVERSITY_HOLIDAYS: &[(&'static str, Holiday)] = &[ + ("Labor Day", Holiday::Single { month: 9, day: 1 }), + ( + "Fall Break", + Holiday::Range { + month: 10, + start_day: 13, + end_day: 14, + }, + ), + ( + "Unspecified Holiday", + Holiday::Single { month: 11, day: 26 }, + ), + ( + "Thanksgiving", + Holiday::Range { + month: 11, + start_day: 28, + end_day: 29, + }, + ), + ("Student Study Day", Holiday::Single { month: 12, day: 5 }), + ( + "Winter Holiday", + Holiday::Range { + month: 12, + start_day: 23, + end_day: 31, + }, + ), + ("New Year's Day", Holiday::Single { month: 1, day: 1 }), + ("MLK Day", Holiday::Single { month: 1, day: 20 }), + ( + "Spring Break", + Holiday::Range { + month: 3, + start_day: 10, + end_day: 15, + }, + ), + ("Student Study Day", Holiday::Single { month: 5, day: 9 }), +]; + /// Generate an ICS file for a course #[poise::command(slash_command, prefix_command)] pub async fn ics( @@ -40,7 +140,8 @@ pub async fn ics( }); // Generate ICS content - let ics_content = generate_ics_content(&course, &term, &sorted_meeting_times)?; + let (ics_content, excluded_holidays) = + generate_ics_content(&course, &term, &sorted_meeting_times)?; // Create file attachment let filename = format!( @@ -52,28 +153,49 @@ pub async fn ics( let file = CreateAttachment::bytes(ics_content.into_bytes(), filename.clone()); + // Build response content + let mut response_content = format!( + "📅 Generated ICS calendar for **{}**\n\n**Meeting Times:**\n{}", + course.display_title(), + sorted_meeting_times + .iter() + .enumerate() + .map(|(i, m)| { + let time_info = match &m.time_range { + Some(range) => format!( + "{} {}", + m.days_string().unwrap_or("TBA".to_string()), + range.format_12hr() + ), + None => m.days_string().unwrap_or("TBA".to_string()), + }; + format!("{}. {}", i + 1, time_info) + }) + .collect::>() + .join("\n") + ); + + // Add holiday exclusion information + if !excluded_holidays.is_empty() { + let count = excluded_holidays.len(); + let count_text = if count == 1 { + "1 date was".to_string() + } else { + format!("{} dates were", count) + }; + response_content.push_str(&format!("\n\n{} excluded from the ICS file:\n", count_text)); + response_content.push_str( + &excluded_holidays + .iter() + .map(|s| format!("- {}", s)) + .collect::>() + .join("\n"), + ); + } + ctx.send( poise::CreateReply::default() - .content(format!( - "📅 Generated ICS calendar for **{}**\n\n**Meeting Times:**\n{}", - course.display_title(), - sorted_meeting_times - .iter() - .enumerate() - .map(|(i, m)| { - let time_info = match &m.time_range { - Some(range) => format!( - "{} {}", - m.days_string().unwrap_or("TBA".to_string()), - range.format_12hr() - ), - None => m.days_string().unwrap_or("TBA".to_string()), - }; - format!("{}. {}", i + 1, time_info) - }) - .collect::>() - .join("\n") - )) + .content(response_content) .attachment(file), ) .await?; @@ -87,8 +209,9 @@ fn generate_ics_content( course: &Course, term: &str, meeting_times: &[MeetingScheduleInfo], -) -> Result { +) -> Result<(String, Vec), anyhow::Error> { let mut ics_content = String::new(); + let mut excluded_holidays = Vec::new(); // ICS header ics_content.push_str("BEGIN:VCALENDAR\r\n"); @@ -106,14 +229,15 @@ fn generate_ics_content( // Generate events for each meeting time for (index, meeting_time) in meeting_times.iter().enumerate() { - let event_content = generate_event_content(course, meeting_time, index)?; + let (event_content, holidays) = generate_event_content(course, meeting_time, index)?; ics_content.push_str(&event_content); + excluded_holidays.extend(holidays); } // ICS footer ics_content.push_str("END:VCALENDAR\r\n"); - Ok(ics_content) + Ok((ics_content, excluded_holidays)) } /// Generate ICS event content for a single meeting time @@ -121,7 +245,7 @@ fn generate_event_content( course: &Course, meeting_time: &MeetingScheduleInfo, index: usize, -) -> Result { +) -> Result<(String, Vec), anyhow::Error> { let course_title = course.display_title(); let instructor_name = course.primary_instructor_name(); let location = meeting_time.place_string(); @@ -194,13 +318,112 @@ fn generate_event_content( by_day.join(","), until_date )); + + // Add holiday exceptions (EXDATE) if the class would meet on holiday dates + let holiday_exceptions = get_holiday_exceptions(meeting_time); + if let Some(exdate_property) = generate_exdate_property(&holiday_exceptions, start_utc) + { + event_content.push_str(&format!("{}\r\n", exdate_property)); + } + + // Collect holiday names for reporting + let mut holiday_names = Vec::new(); + for (holiday_name, holiday) in UNIVERSITY_HOLIDAYS { + for &exception_date in &holiday_exceptions { + if holiday.contains_date(exception_date) { + holiday_names.push(format!( + "{} ({})", + holiday_name, + exception_date.format("%a, %b %d") + )); + } + } + } + holiday_names.sort(); + holiday_names.dedup(); + + return Ok((event_content, holiday_names)); } } // Event footer event_content.push_str("END:VEVENT\r\n"); - Ok(event_content) + Ok((event_content, Vec::new())) +} + +/// Convert chrono::Weekday to the custom DayOfWeek enum +fn chrono_weekday_to_day_of_week(weekday: chrono::Weekday) -> crate::banner::meetings::DayOfWeek { + use crate::banner::meetings::DayOfWeek; + match weekday { + chrono::Weekday::Mon => DayOfWeek::Monday, + chrono::Weekday::Tue => DayOfWeek::Tuesday, + chrono::Weekday::Wed => DayOfWeek::Wednesday, + chrono::Weekday::Thu => DayOfWeek::Thursday, + chrono::Weekday::Fri => DayOfWeek::Friday, + chrono::Weekday::Sat => DayOfWeek::Saturday, + chrono::Weekday::Sun => DayOfWeek::Sunday, + } +} + +/// Check if a class meets on a specific date based on its meeting days +fn class_meets_on_date(meeting_time: &MeetingScheduleInfo, date: NaiveDate) -> bool { + let weekday = chrono_weekday_to_day_of_week(date.weekday()); + let meeting_days = meeting_time.days_of_week(); + + meeting_days.contains(&weekday) +} + +/// Get holiday dates that fall within the course date range and would conflict with class meetings +fn get_holiday_exceptions(meeting_time: &MeetingScheduleInfo) -> Vec { + let mut exceptions = Vec::new(); + + // Get the year range from the course date range + let start_year = meeting_time.date_range.start.year(); + let end_year = meeting_time.date_range.end.year(); + + for (_, holiday) in UNIVERSITY_HOLIDAYS { + // Check for the holiday in each year of the course + for year in start_year..=end_year { + let holiday_dates = holiday.get_dates_for_year(year); + + for holiday_date in holiday_dates { + // Check if the holiday falls within the course date range + if holiday_date >= meeting_time.date_range.start + && holiday_date <= meeting_time.date_range.end + { + // Check if the class would actually meet on this day + if class_meets_on_date(meeting_time, holiday_date) { + exceptions.push(holiday_date); + } + } + } + } + } + + exceptions +} + +/// Generate EXDATE property for holiday exceptions +fn generate_exdate_property( + exceptions: &[NaiveDate], + start_time: chrono::DateTime, +) -> Option { + if exceptions.is_empty() { + return None; + } + + let mut exdate_values = Vec::new(); + + for &exception_date in exceptions { + // Create a datetime for the exception using the same time as the start time + let exception_datetime = exception_date.and_time(start_time.time()).and_utc(); + + let exdate_str = exception_datetime.format("%Y%m%dT%H%M%SZ").to_string(); + exdate_values.push(exdate_str); + } + + Some(format!("EXDATE:{}", exdate_values.join(","))) } /// Escape text for ICS format