diff --git a/src/app.rs b/src/app.rs index 2844549..8fe6201 100644 --- a/src/app.rs +++ b/src/app.rs @@ -138,16 +138,33 @@ fn CalendarView() -> Html { match calendar_service.refresh_event(&token, &uid).await { Ok(Some(refreshed_event)) => { - // Update the event in the existing events map + // If this is a recurring event, we need to regenerate all occurrences let mut updated_events = (*events).clone(); + + // First, remove all existing occurrences of this event for (_, day_events) in updated_events.iter_mut() { - for existing_event in day_events.iter_mut() { - if existing_event.uid == uid { - *existing_event = refreshed_event.clone(); - break; - } - } + day_events.retain(|e| e.uid != uid); } + + // Then, if it's a recurring event, generate new occurrences + if refreshed_event.recurrence_rule.is_some() { + let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_event.clone()]); + + // Add all new occurrences to the appropriate dates + for occurrence in new_occurrences { + let date = occurrence.get_date(); + updated_events.entry(date) + .or_insert_with(Vec::new) + .push(occurrence); + } + } else { + // Non-recurring event, just add it to the appropriate date + let date = refreshed_event.get_date(); + updated_events.entry(date) + .or_insert_with(Vec::new) + .push(refreshed_event); + } + events.set(updated_events); } Ok(None) => { diff --git a/src/services/calendar_service.rs b/src/services/calendar_service.rs index fafb27e..935f776 100644 --- a/src/services/calendar_service.rs +++ b/src/services/calendar_service.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, Utc, NaiveDate}; +use chrono::{DateTime, Utc, NaiveDate, Datelike, Weekday, Duration}; use serde::{Deserialize, Serialize}; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; @@ -183,7 +183,10 @@ impl CalendarService { pub fn group_events_by_date(events: Vec) -> HashMap> { let mut grouped = HashMap::new(); - for event in events { + // Expand recurring events first + let expanded_events = Self::expand_recurring_events(events); + + for event in expanded_events { let date = event.get_date(); grouped.entry(date) @@ -194,6 +197,215 @@ impl CalendarService { grouped } + /// Expand recurring events to show on all occurrence dates within a reasonable range + pub fn expand_recurring_events(events: Vec) -> Vec { + let mut expanded_events = Vec::new(); + let today = chrono::Utc::now().date_naive(); + let start_range = today - Duration::days(30); // Show past 30 days + let end_range = today + Duration::days(365); // Show next 365 days + + for event in events { + if let Some(ref rrule) = event.recurrence_rule { + // Generate occurrences for recurring events + let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range); + expanded_events.extend(occurrences); + } else { + // Non-recurring event - add as-is + expanded_events.push(event); + } + } + + expanded_events + } + + /// Generate occurrence dates for a recurring event based on RRULE + fn generate_occurrences( + base_event: &CalendarEvent, + rrule: &str, + start_range: NaiveDate, + end_range: NaiveDate, + ) -> Vec { + let mut occurrences = Vec::new(); + + // Parse RRULE components + let rrule_upper = rrule.to_uppercase(); + let components: HashMap = rrule_upper + .split(';') + .filter_map(|part| { + let mut split = part.split('='); + if let (Some(key), Some(value)) = (split.next(), split.next()) { + Some((key.to_string(), value.to_string())) + } else { + None + } + }) + .collect(); + + // Get frequency + let freq = components.get("FREQ").map(|s| s.as_str()).unwrap_or("DAILY"); + + // Get interval (default 1) + let interval: i32 = components.get("INTERVAL") + .and_then(|s| s.parse().ok()) + .unwrap_or(1); + + // Get count limit (default 100 to prevent infinite loops) + let count: usize = components.get("COUNT") + .and_then(|s| s.parse().ok()) + .unwrap_or(100) + .min(365); // Cap at 365 occurrences for performance + + let start_date = base_event.start.date_naive(); + let mut current_date = start_date; + let mut occurrence_count = 0; + + // Generate occurrences based on frequency + while current_date <= end_range && occurrence_count < count { + if current_date >= start_range { + // Create occurrence event + let mut occurrence_event = base_event.clone(); + + // Adjust dates + let days_diff = current_date.signed_duration_since(start_date).num_days(); + occurrence_event.start = base_event.start + Duration::days(days_diff); + + if let Some(end) = base_event.end { + occurrence_event.end = Some(end + Duration::days(days_diff)); + } + + occurrences.push(occurrence_event); + } + + // Calculate next occurrence date + match freq { + "DAILY" => { + current_date = current_date + Duration::days(interval as i64); + } + "WEEKLY" => { + if let Some(byday) = components.get("BYDAY") { + // Handle specific days of week + current_date = Self::next_weekday_occurrence(current_date, byday, interval); + } else { + current_date = current_date + Duration::weeks(interval as i64); + } + } + "MONTHLY" => { + // 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; + } else { + break; // Invalid date + } + } + _ => break, // Unsupported frequency + } + + occurrence_count += 1; + } + + occurrences + } + + /// Calculate next weekday occurrence for WEEKLY frequency with BYDAY + fn next_weekday_occurrence(current_date: NaiveDate, byday: &str, interval: i32) -> NaiveDate { + let weekdays = Self::parse_byday(byday); + if weekdays.is_empty() { + return current_date + Duration::weeks(interval as i64); + } + + let current_weekday = current_date.weekday(); + + // Find next occurrence within current week + for &target_weekday in &weekdays { + let days_until = Self::days_until_weekday(current_weekday, target_weekday); + if days_until > 0 { + return current_date + Duration::days(days_until as i64); + } + } + + // No more occurrences this week, move to next interval + let next_week_start = current_date + Duration::weeks(interval as i64) - Duration::days(current_weekday.num_days_from_monday() as i64); + next_week_start + Duration::days(weekdays[0].num_days_from_monday() as i64) + } + + /// Parse BYDAY parameter (e.g., "MO,WE,FR" -> [Monday, Wednesday, Friday]) + fn parse_byday(byday: &str) -> Vec { + byday + .split(',') + .filter_map(|day| match day { + "MO" => Some(Weekday::Mon), + "TU" => Some(Weekday::Tue), + "WE" => Some(Weekday::Wed), + "TH" => Some(Weekday::Thu), + "FR" => Some(Weekday::Fri), + "SA" => Some(Weekday::Sat), + "SU" => Some(Weekday::Sun), + _ => None, + }) + .collect() + } + + /// Calculate days until target weekday + fn days_until_weekday(from: Weekday, to: Weekday) -> i32 { + let from_num = from.num_days_from_monday(); + let to_num = to.num_days_from_monday(); + + if to_num > from_num { + (to_num - from_num) as i32 + } else if to_num < from_num { + (7 + to_num - from_num) as i32 + } else { + 0 // Same day + } + } + + /// Add months to a date (handling month boundary issues) + fn add_months(date: NaiveDate, months: i32) -> Option { + let mut year = date.year(); + let mut month = date.month() as i32 + months; + + while month > 12 { + year += 1; + month -= 12; + } + while month < 1 { + year -= 1; + month += 12; + } + + // Handle day overflow (e.g., Jan 31 -> Feb 28) + let day = date.day().min(Self::days_in_month(year, month as u32)); + + NaiveDate::from_ymd_opt(year, month as u32, day) + } + + /// Add years to a date + fn add_years(date: NaiveDate, years: i32) -> Option { + NaiveDate::from_ymd_opt(date.year() + years, date.month(), date.day()) + } + + /// Get number of days in a month + fn days_in_month(year: i32, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => if Self::is_leap_year(year) { 29 } else { 28 }, + _ => 30, + } + } + + /// Check if year is leap year + fn is_leap_year(year: i32) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) + } + /// Refresh a single event by UID from the CalDAV server pub async fn refresh_event(&self, token: &str, uid: &str) -> Result, String> { let window = web_sys::window().ok_or("No global window exists")?; diff --git a/src/services/mod.rs b/src/services/mod.rs index ba16cd9..b98a1b3 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,3 +1,3 @@ pub mod calendar_service; -pub use calendar_service::{CalendarService, CalendarEvent, EventStatus, EventClass, EventReminder, ReminderAction}; \ No newline at end of file +pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction}; \ No newline at end of file