Fix recurring event RRULE INTERVAL and COUNT parameter loss

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 <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-31 00:28:41 -04:00
parent d0aa6fda08
commit c599598390
4 changed files with 509 additions and 58 deletions

View File

@@ -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<chrono::DateTime<chrono::Utc>>,
count: usize,
) -> Vec<VEvent> {
let mut occurrences = Vec::new();
// Parse BYMONTHDAY (e.g., "15" or "1,15,31")
let monthdays: Vec<u32> = 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<chrono::DateTime<chrono::Utc>>,
count: usize,
) -> Vec<VEvent> {
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<chrono::DateTime<chrono::Utc>>,
count: usize,
) -> Vec<VEvent> {
let mut occurrences = Vec::new();
// Parse BYMONTH (e.g., "1,3,6" -> [January, March, June])
let months: Vec<u32> = 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::<i8>().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<NaiveDate> {
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
}
}