From c5995983903370bfacb2bab930088c223b646eac Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Sun, 31 Aug 2025 00:28:41 -0400 Subject: [PATCH] Fix recurring event RRULE INTERVAL and COUNT parameter loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes a critical bug where INTERVAL and COUNT parameters were being stripped from recurring events during backend processing. Frontend was correctly generating complete RRULE strings like: FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,FR;COUNT=6 But backend was ignoring the complete RRULE and rebuilding from scratch, resulting in simplified RRULEs like: FREQ=WEEKLY;BYDAY=TU,FR (missing INTERVAL and COUNT) Changes: - Modified both events and series handlers to detect complete RRULE strings - Added logic to use frontend RRULE directly when it starts with "FREQ=" - Maintained backwards compatibility with simple recurrence types - Added comprehensive debug logging for RRULE generation - Fixed weekly BYDAY occurrence counting to respect COUNT parameter - Enhanced frontend RRULE generation with detailed logging This ensures all RFC 5545 RRULE parameters are preserved from frontend creation through CalDAV storage and retrieval. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- backend/src/handlers/events.rs | 72 +-- backend/src/handlers/series.rs | 12 +- frontend/src/components/create_event_modal.rs | 15 +- frontend/src/services/calendar_service.rs | 468 +++++++++++++++++- 4 files changed, 509 insertions(+), 58 deletions(-) diff --git a/backend/src/handlers/events.rs b/backend/src/handlers/events.rs index a8f097f..a476701 100644 --- a/backend/src/handlers/events.rs +++ b/backend/src/handlers/events.rs @@ -384,29 +384,44 @@ pub async fn create_event( } }; - // Parse recurrence with BYDAY support for weekly recurrence - let rrule = match request.recurrence.to_uppercase().as_str() { - "DAILY" => Some("FREQ=DAILY".to_string()), - "WEEKLY" => { - // Handle weekly recurrence with optional BYDAY parameter - let mut rrule = "FREQ=WEEKLY".to_string(); - - // Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]) - if request.recurrence_days.len() == 7 { - let selected_days: Vec<&str> = request.recurrence_days - .iter() - .enumerate() - .filter_map(|(i, &selected)| { - if selected { - Some(match i { - 0 => "SU", // Sunday - 1 => "MO", // Monday - 2 => "TU", // Tuesday - 3 => "WE", // Wednesday - 4 => "TH", // Thursday - 5 => "FR", // Friday - 6 => "SA", // Saturday - _ => return None, + // DEBUG: Log the recurrence field to see what we're receiving + println!("🔍 DEBUG: Received recurrence field: '{}'", request.recurrence); + println!("🔍 DEBUG: Starts with FREQ=? {}", request.recurrence.starts_with("FREQ=")); + + // Check if recurrence is already a full RRULE or just a simple type + let rrule = if request.recurrence.starts_with("FREQ=") { + // Frontend sent a complete RRULE string, use it directly + println!("🎯 Using complete RRULE from frontend: {}", request.recurrence); + if request.recurrence.is_empty() { + None + } else { + Some(request.recurrence.clone()) + } + } else { + // Legacy path: Parse recurrence with BYDAY support for weekly recurrence + println!("🔄 Building RRULE from simple recurrence type: {}", request.recurrence); + match request.recurrence.to_uppercase().as_str() { + "DAILY" => Some("FREQ=DAILY".to_string()), + "WEEKLY" => { + // Handle weekly recurrence with optional BYDAY parameter + let mut rrule = "FREQ=WEEKLY".to_string(); + + // Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]) + if request.recurrence_days.len() == 7 { + let selected_days: Vec<&str> = request.recurrence_days + .iter() + .enumerate() + .filter_map(|(i, &selected)| { + if selected { + Some(match i { + 0 => "SU", // Sunday + 1 => "MO", // Monday + 2 => "TU", // Tuesday + 3 => "WE", // Wednesday + 4 => "TH", // Thursday + 5 => "FR", // Friday + 6 => "SA", // Saturday + _ => return None, }) } else { None @@ -419,11 +434,12 @@ pub async fn create_event( } } - Some(rrule) - }, - "MONTHLY" => Some("FREQ=MONTHLY".to_string()), - "YEARLY" => Some("FREQ=YEARLY".to_string()), - _ => None, + Some(rrule) + }, + "MONTHLY" => Some("FREQ=MONTHLY".to_string()), + "YEARLY" => Some("FREQ=YEARLY".to_string()), + _ => None, + } }; // Create the VEvent struct (RFC 5545 compliant) diff --git a/backend/src/handlers/series.rs b/backend/src/handlers/series.rs index 23663a8..d169821 100644 --- a/backend/src/handlers/series.rs +++ b/backend/src/handlers/series.rs @@ -161,8 +161,16 @@ pub async fn create_event_series( // Set priority event.priority = request.priority; - // Generate the RRULE for recurrence - let rrule = build_series_rrule_with_freq(&request, recurrence_freq)?; + // Check if recurrence is already a full RRULE or just a simple type + let rrule = if request.recurrence.starts_with("FREQ=") { + // Frontend sent a complete RRULE string, use it directly + println!("🎯 SERIES: Using complete RRULE from frontend: {}", request.recurrence); + request.recurrence.clone() + } else { + // Legacy path: Generate the RRULE for recurrence + println!("🔄 SERIES: Building RRULE from simple recurrence type: {}", request.recurrence); + build_series_rrule_with_freq(&request, recurrence_freq)? + }; event.rrule = Some(rrule); println!("🔁 Generated RRULE: {:?}", event.rrule); diff --git a/frontend/src/components/create_event_modal.rs b/frontend/src/components/create_event_modal.rs index 082042a..5a5e3b8 100644 --- a/frontend/src/components/create_event_modal.rs +++ b/frontend/src/components/create_event_modal.rs @@ -306,9 +306,11 @@ mod rrule_tests { data.yearly_by_month = vec![false, false, true, false, true, false, false, false, false, false, false, false]; // March, May let rrule = data.build_rrule(); + println!("YEARLY RRULE: {}", rrule); assert!(rrule.contains("FREQ=YEARLY")); assert!(rrule.contains("BYMONTH=3,5")); } + } #[derive(Clone, PartialEq, Debug)] @@ -385,6 +387,8 @@ impl EventCreationData { return String::new(); } + web_sys::console::log_1(&format!("🔧 Building RRULE with interval: {}, count: {:?}", self.recurrence_interval, self.recurrence_count).into()); + let mut parts = Vec::new(); // Add frequency (required) @@ -399,6 +403,9 @@ impl EventCreationData { // Add interval if not 1 if self.recurrence_interval > 1 { parts.push(format!("INTERVAL={}", self.recurrence_interval)); + web_sys::console::log_1(&format!("➕ Added INTERVAL={}", self.recurrence_interval).into()); + } else { + web_sys::console::log_1(&format!("⏭️ Skipped INTERVAL (value is {})", self.recurrence_interval).into()); } // Add frequency-specific rules @@ -458,11 +465,17 @@ impl EventCreationData { if let Some(until_date) = self.recurrence_until { // Format as UNTIL=YYYYMMDDTHHMMSSZ parts.push(format!("UNTIL={}T000000Z", until_date.format("%Y%m%d"))); + web_sys::console::log_1(&format!("➕ Added UNTIL={}", until_date).into()); } else if let Some(count) = self.recurrence_count { parts.push(format!("COUNT={}", count)); + web_sys::console::log_1(&format!("➕ Added COUNT={}", count).into()); + } else { + web_sys::console::log_1(&format!("⏭️ No COUNT or UNTIL specified").into()); } - parts.join(";") + let rrule = parts.join(";"); + web_sys::console::log_1(&format!("🎯 Final RRULE: {}", rrule).into()); + rrule } pub fn to_create_event_params(&self) -> (String, String, String, String, String, String, String, bool, String, String, Option, String, String, String, String, String, Vec, Option) { diff --git a/frontend/src/services/calendar_service.rs b/frontend/src/services/calendar_service.rs index 0108aab..0e4c15e 100644 --- a/frontend/src/services/calendar_service.rs +++ b/frontend/src/services/calendar_service.rs @@ -328,6 +328,8 @@ impl CalendarService { .and_then(|s| s.parse().ok()) .unwrap_or(100) .min(365); // Cap at 365 occurrences for performance + + web_sys::console::log_1(&format!("📊 RRULE parsed - FREQ: {}, INTERVAL: {}, COUNT: {}", freq, interval, count).into()); // Get UNTIL date if specified let until_date = components.get("UNTIL") @@ -398,6 +400,7 @@ impl CalendarService { } occurrences.push(occurrence_event); + occurrence_count += 1; // Only count when we actually add an occurrence } } @@ -424,24 +427,61 @@ impl CalendarService { } } "MONTHLY" => { - // Simple monthly increment (same day of month) - if let Some(next_month) = Self::add_months(current_date, interval) { - current_date = next_month; + // Handle MONTHLY with BYMONTHDAY or BYDAY + if let Some(bymonthday) = components.get("BYMONTHDAY") { + // Monthly by day of month (e.g., 15th of every month) + return Self::generate_monthly_bymonthday_occurrences( + base_event, + bymonthday, + interval, + start_range, + end_range, + until_date, + count + ); + } else if let Some(byday) = components.get("BYDAY") { + // Monthly by weekday position (e.g., first Monday) + return Self::generate_monthly_byday_occurrences( + base_event, + byday, + interval, + start_range, + end_range, + until_date, + count + ); } else { - break; // Invalid date + // Simple monthly increment (same day of month) + if let Some(next_month) = Self::add_months(current_date, interval) { + current_date = next_month; + } else { + break; // Invalid date + } } } "YEARLY" => { - if let Some(next_year) = Self::add_years(current_date, interval) { - current_date = next_year; + if let Some(bymonth) = components.get("BYMONTH") { + // Yearly with specific months (e.g., January, March, June) + return Self::generate_yearly_bymonth_occurrences( + base_event, + bymonth, + interval, + start_range, + end_range, + until_date, + count + ); } else { - break; // Invalid date + // Simple yearly increment (same date each year) + if let Some(next_year) = Self::add_years(current_date, interval) { + current_date = next_year; + } else { + break; // Invalid date + } } } _ => break, // Unsupported frequency } - - occurrence_count += 1; } occurrences @@ -464,26 +504,46 @@ impl CalendarService { return occurrences; } - web_sys::console::log_1(&format!("🗓️ Generating WEEKLY BYDAY occurrences for days: {:?}", weekdays).into()); + web_sys::console::log_1(&format!("🗓️ Generating WEEKLY BYDAY occurrences for days: {:?}, interval: {}, count: {}", weekdays, interval, count).into()); let start_date = base_event.dtstart.date_naive(); - let mut current_week_start = start_date - Duration::days(start_date.weekday().num_days_from_monday() as i64); - let mut total_occurrences = 0; + + // Find the Monday of the week containing the start_date (reference week) + let reference_week_start = start_date - Duration::days(start_date.weekday().num_days_from_monday() as i64); + + let mut total_events_generated = 0; // Count of actual events generated (matches RFC 5545 COUNT) + let mut week_interval_number = 0; // Which interval week we're processing (0, 1, 2, ...) + let max_weeks = (count * 10).min(520); // Prevent infinite loops - max 10 years + + web_sys::console::log_1(&format!("📅 Reference week start: {}, event start: {}, INTERVAL: {}", reference_week_start, start_date, interval).into()); - // Generate occurrences week by week - while current_week_start <= end_range && total_occurrences < count { + // Continue generating until we reach count or date limits + while total_events_generated < count && week_interval_number < max_weeks { + // Calculate the actual week we're processing: original + (interval_number * interval) weeks + let current_week_start = reference_week_start + Duration::weeks((week_interval_number as i32 * interval) as i64); + + // Stop if we've gone past the end range + if current_week_start > end_range { + web_sys::console::log_1(&format!("🛑 Week start {} exceeds end range {}", current_week_start, end_range).into()); + break; + } + + web_sys::console::log_1(&format!("🔄 Processing interval week {} (week start: {}, total events so far: {})", week_interval_number, current_week_start, total_events_generated).into()); + // Generate occurrences for all matching weekdays in this week for &weekday in &weekdays { let occurrence_date = current_week_start + Duration::days(weekday.num_days_from_monday() as i64); // Skip if occurrence is before start_range or after end_range if occurrence_date < start_range || occurrence_date > end_range { + web_sys::console::log_1(&format!("⏭️ Skipping {} (outside range {}-{})", occurrence_date, start_range, end_range).into()); continue; } - - // Skip if we've reached the count limit - if total_occurrences >= count { - break; + + // Skip if this occurrence is before the original event date + if occurrence_date < start_date { + web_sys::console::log_1(&format!("⏭️ Skipping {} (before event start {})", occurrence_date, start_date).into()); + continue; } // Check UNTIL constraint @@ -496,11 +556,6 @@ impl CalendarService { } } - // Skip if this occurrence is before the original event date - if occurrence_date < start_date { - continue; - } - // Calculate the occurrence datetime let days_diff = occurrence_date.signed_duration_since(start_date).num_days(); let occurrence_datetime = base_event.dtstart + Duration::days(days_diff); @@ -529,20 +584,308 @@ impl CalendarService { occurrence_event.dtend = Some(end + Duration::days(days_diff)); } - web_sys::console::log_1(&format!("📅 Generated weekly occurrence on {}", occurrence_date).into()); + total_events_generated += 1; + web_sys::console::log_1(&format!("✅ Generated weekly occurrence on {} (event {} of {})", occurrence_date, total_events_generated, count).into()); occurrences.push(occurrence_event); - total_occurrences += 1; + + // Stop if we've reached the count limit + if total_events_generated >= count { + web_sys::console::log_1(&format!("🎯 Reached COUNT limit of {} events", count).into()); + return occurrences; + } } } - // Move to next week interval - current_week_start = current_week_start + Duration::weeks(interval as i64); + // Move to the next interval week + week_interval_number += 1; } web_sys::console::log_1(&format!("✅ Generated {} total weekly BYDAY occurrences", occurrences.len()).into()); occurrences } + /// Generate occurrences for MONTHLY frequency with BYMONTHDAY + fn generate_monthly_bymonthday_occurrences( + base_event: &VEvent, + bymonthday: &str, + interval: i32, + start_range: NaiveDate, + end_range: NaiveDate, + until_date: Option>, + count: usize, + ) -> Vec { + let mut occurrences = Vec::new(); + + // Parse BYMONTHDAY (e.g., "15" or "1,15,31") + let monthdays: Vec = bymonthday + .split(',') + .filter_map(|day| day.trim().parse().ok()) + .filter(|&day| day >= 1 && day <= 31) + .collect(); + + if monthdays.is_empty() { + return occurrences; + } + + web_sys::console::log_1(&format!("📅 Generating MONTHLY BYMONTHDAY occurrences for days: {:?}", monthdays).into()); + + let start_date = base_event.dtstart.date_naive(); + let mut current_month_start = NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap(); + let mut total_occurrences = 0; + let mut months_processed = 0; + let max_months = (count * 12).min(120); // Prevent infinite loops - max 10 years + + web_sys::console::log_1(&format!("📅 Starting monthly BYMONTHDAY generation from {} with count {} and interval {}", current_month_start, count, interval).into()); + + // Generate occurrences month by month + while current_month_start <= end_range && total_occurrences < count && months_processed < max_months { + // Generate occurrences for all matching days in this month + for &day in &monthdays { + // Try to create the date, skip if invalid (e.g., Feb 31) + if let Some(occurrence_date) = NaiveDate::from_ymd_opt(current_month_start.year(), current_month_start.month(), day) { + // Skip if occurrence is before start_range or after end_range + if occurrence_date < start_range || occurrence_date > end_range { + continue; + } + + // Skip if we've reached the count limit + if total_occurrences >= count { + break; + } + + // Skip if this occurrence is before the original event date + if occurrence_date < start_date { + continue; + } + + // Check UNTIL constraint + if let Some(until) = until_date { + let days_diff = occurrence_date.signed_duration_since(start_date).num_days(); + let occurrence_datetime = base_event.dtstart + Duration::days(days_diff); + if occurrence_datetime > until { + web_sys::console::log_1(&format!("🛑 Stopping at {} due to UNTIL {}", occurrence_datetime, until).into()); + return occurrences; + } + } + + // Calculate the occurrence datetime + let days_diff = occurrence_date.signed_duration_since(start_date).num_days(); + let occurrence_datetime = base_event.dtstart + Duration::days(days_diff); + + // Check if this occurrence is in the exception dates (EXDATE) + let is_exception = base_event.exdate.iter().any(|exception_date| { + let exception_naive = exception_date.naive_utc(); + let occurrence_naive = occurrence_datetime.naive_utc(); + let diff = occurrence_naive - exception_naive; + diff.num_seconds().abs() < 60 + }); + + if !is_exception { + // Create occurrence event + let mut occurrence_event = base_event.clone(); + occurrence_event.dtstart = occurrence_datetime; + occurrence_event.dtstamp = chrono::Utc::now(); + + if let Some(end) = base_event.dtend { + occurrence_event.dtend = Some(end + Duration::days(days_diff)); + } + + web_sys::console::log_1(&format!("📅 Generated monthly BYMONTHDAY occurrence on {}", occurrence_date).into()); + occurrences.push(occurrence_event); + total_occurrences += 1; + } + } + } + + // Move to next month interval + months_processed += 1; + if let Some(next_month_start) = Self::add_months(current_month_start, interval) { + current_month_start = NaiveDate::from_ymd_opt(next_month_start.year(), next_month_start.month(), 1).unwrap(); + web_sys::console::log_1(&format!("📅 Advanced to month {} (processed {} months, {} occurrences so far)", current_month_start, months_processed, total_occurrences).into()); + } else { + break; + } + } + + web_sys::console::log_1(&format!("✅ Generated {} total monthly BYMONTHDAY occurrences", occurrences.len()).into()); + occurrences + } + + /// Generate occurrences for MONTHLY frequency with BYDAY (e.g., "1MO" = first Monday) + fn generate_monthly_byday_occurrences( + base_event: &VEvent, + byday: &str, + interval: i32, + start_range: NaiveDate, + end_range: NaiveDate, + until_date: Option>, + count: usize, + ) -> Vec { + let mut occurrences = Vec::new(); + + web_sys::console::log_1(&format!("📅 Generating MONTHLY BYDAY occurrences for: {}", byday).into()); + + // Parse BYDAY for monthly (e.g., "1MO" = first Monday, "-1FR" = last Friday) + if let Some((position, weekday)) = Self::parse_monthly_byday(byday) { + let start_date = base_event.dtstart.date_naive(); + let mut current_month_start = NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap(); + let mut total_occurrences = 0; + + // Generate occurrences month by month + while current_month_start <= end_range && total_occurrences < count { + if let Some(occurrence_date) = Self::find_nth_weekday_in_month(current_month_start, position, weekday) { + // Skip if occurrence is before start_range or after end_range + if occurrence_date < start_range || occurrence_date > end_range { + } else if occurrence_date >= start_date && total_occurrences < count { + // Check UNTIL constraint + if let Some(until) = until_date { + let days_diff = occurrence_date.signed_duration_since(start_date).num_days(); + let occurrence_datetime = base_event.dtstart + Duration::days(days_diff); + if occurrence_datetime > until { + return occurrences; + } + } + + // Calculate the occurrence datetime + let days_diff = occurrence_date.signed_duration_since(start_date).num_days(); + let occurrence_datetime = base_event.dtstart + Duration::days(days_diff); + + // Check EXDATE + let is_exception = base_event.exdate.iter().any(|exception_date| { + let exception_naive = exception_date.naive_utc(); + let occurrence_naive = occurrence_datetime.naive_utc(); + let diff = occurrence_naive - exception_naive; + diff.num_seconds().abs() < 60 + }); + + if !is_exception { + let mut occurrence_event = base_event.clone(); + occurrence_event.dtstart = occurrence_datetime; + occurrence_event.dtstamp = chrono::Utc::now(); + + if let Some(end) = base_event.dtend { + occurrence_event.dtend = Some(end + Duration::days(days_diff)); + } + + occurrences.push(occurrence_event); + total_occurrences += 1; + } + } + } + + // Move to next month interval + if let Some(next_month) = Self::add_months(current_month_start, interval) { + current_month_start = NaiveDate::from_ymd_opt(next_month.year(), next_month.month(), 1).unwrap(); + } else { + break; + } + } + } + + web_sys::console::log_1(&format!("✅ Generated {} total monthly BYDAY occurrences", occurrences.len()).into()); + occurrences + } + + /// Generate occurrences for YEARLY frequency with BYMONTH + fn generate_yearly_bymonth_occurrences( + base_event: &VEvent, + bymonth: &str, + interval: i32, + start_range: NaiveDate, + end_range: NaiveDate, + until_date: Option>, + count: usize, + ) -> Vec { + let mut occurrences = Vec::new(); + + // Parse BYMONTH (e.g., "1,3,6" -> [January, March, June]) + let months: Vec = bymonth + .split(',') + .filter_map(|month| month.trim().parse().ok()) + .filter(|&month| month >= 1 && month <= 12) + .collect(); + + if months.is_empty() { + return occurrences; + } + + web_sys::console::log_1(&format!("📅 Generating YEARLY BYMONTH occurrences for months: {:?}", months).into()); + + let start_date = base_event.dtstart.date_naive(); + let mut current_year = start_date.year(); + let mut total_occurrences = 0; + + // Generate occurrences year by year + while total_occurrences < count { + // Generate occurrences for all matching months in this year + for &month in &months { + // Create the date for this year/month/day + if let Some(occurrence_date) = NaiveDate::from_ymd_opt(current_year, month, start_date.day()) { + // Skip if occurrence is before start_range or after end_range + if occurrence_date < start_range || occurrence_date > end_range { + continue; + } + + // Skip if we've reached the count limit + if total_occurrences >= count { + break; + } + + // Skip if this occurrence is before the original event date + if occurrence_date < start_date { + continue; + } + + // Check UNTIL constraint + if let Some(until) = until_date { + let days_diff = occurrence_date.signed_duration_since(start_date).num_days(); + let occurrence_datetime = base_event.dtstart + Duration::days(days_diff); + if occurrence_datetime > until { + return occurrences; + } + } + + // Calculate the occurrence datetime + let days_diff = occurrence_date.signed_duration_since(start_date).num_days(); + let occurrence_datetime = base_event.dtstart + Duration::days(days_diff); + + // Check EXDATE + let is_exception = base_event.exdate.iter().any(|exception_date| { + let exception_naive = exception_date.naive_utc(); + let occurrence_naive = occurrence_datetime.naive_utc(); + let diff = occurrence_naive - exception_naive; + diff.num_seconds().abs() < 60 + }); + + if !is_exception { + let mut occurrence_event = base_event.clone(); + occurrence_event.dtstart = occurrence_datetime; + occurrence_event.dtstamp = chrono::Utc::now(); + + if let Some(end) = base_event.dtend { + occurrence_event.dtend = Some(end + Duration::days(days_diff)); + } + + web_sys::console::log_1(&format!("📅 Generated yearly BYMONTH occurrence on {}", occurrence_date).into()); + occurrences.push(occurrence_event); + total_occurrences += 1; + } + } + } + + // Move to next year interval + current_year += interval; + + // Stop if we've gone beyond reasonable range + if current_year > end_range.year() + 10 { + break; + } + } + + web_sys::console::log_1(&format!("✅ Generated {} total yearly BYMONTH occurrences", occurrences.len()).into()); + occurrences + } + /// Calculate next weekday occurrence for WEEKLY frequency with BYDAY fn next_weekday_occurrence(current_date: NaiveDate, byday: &str, interval: i32) -> NaiveDate { @@ -1275,5 +1618,76 @@ impl CalendarService { } } + /// Parse monthly BYDAY (e.g., "1MO" -> (1, Monday), "-1FR" -> (-1, Friday)) + fn parse_monthly_byday(byday: &str) -> Option<(i8, chrono::Weekday)> { + if byday.len() < 3 { + return None; + } + + // Extract position and weekday + let (position_str, weekday_str) = if byday.starts_with('-') { + (&byday[..2], &byday[2..]) + } else { + (&byday[..1], &byday[1..]) + }; + + let position = position_str.parse::().ok()?; + let weekday = match weekday_str { + "MO" => chrono::Weekday::Mon, + "TU" => chrono::Weekday::Tue, + "WE" => chrono::Weekday::Wed, + "TH" => chrono::Weekday::Thu, + "FR" => chrono::Weekday::Fri, + "SA" => chrono::Weekday::Sat, + "SU" => chrono::Weekday::Sun, + _ => return None, + }; + + Some((position, weekday)) + } + + /// Find nth weekday in a month (e.g., 1st Monday, 2nd Tuesday, -1 = last) + fn find_nth_weekday_in_month(month_start: NaiveDate, position: i8, weekday: chrono::Weekday) -> Option { + let year = month_start.year(); + let month = month_start.month(); + + if position > 0 { + // Find nth occurrence from beginning of month + let mut current = NaiveDate::from_ymd_opt(year, month, 1)?; + let mut count = 0; + + while current.month() == month { + if current.weekday() == weekday { + count += 1; + if count == position as u8 { + return Some(current); + } + } + current = current.succ_opt()?; + } + } else if position < 0 { + // Find nth occurrence from end of month + let next_month = if month == 12 { + NaiveDate::from_ymd_opt(year + 1, 1, 1)? + } else { + NaiveDate::from_ymd_opt(year, month + 1, 1)? + }; + let mut current = next_month.pred_opt()?; // Last day of current month + let mut count = 0; + + while current.month() == month { + if current.weekday() == weekday { + count += 1; + if count == (-position) as u8 { + return Some(current); + } + } + current = current.pred_opt()?; + } + } + + None + } + }