Implement comprehensive recurring event support

- Add RRULE parsing for DAILY, WEEKLY, MONTHLY, YEARLY frequencies
- Support INTERVAL, COUNT, and BYDAY recurrence parameters
- Generate event occurrences across 30 days past to 365 days future
- Update event refresh to regenerate all recurring occurrences
- Clean up unused imports for cleaner compilation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-28 17:59:23 -04:00
parent d945c46e5a
commit 0741afd0b2
3 changed files with 239 additions and 10 deletions

View File

@@ -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) => {

View File

@@ -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<CalendarEvent>) -> HashMap<NaiveDate, Vec<CalendarEvent>> {
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<CalendarEvent>) -> Vec<CalendarEvent> {
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<CalendarEvent> {
let mut occurrences = Vec::new();
// Parse RRULE components
let rrule_upper = rrule.to_uppercase();
let components: HashMap<String, String> = 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<Weekday> {
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<NaiveDate> {
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> {
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<Option<CalendarEvent>, String> {
let window = web_sys::window().ok_or("No global window exists")?;

View File

@@ -1,3 +1,3 @@
pub mod calendar_service;
pub use calendar_service::{CalendarService, CalendarEvent, EventStatus, EventClass, EventReminder, ReminderAction};
pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction};