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:
27
src/app.rs
27
src/app.rs
@@ -138,16 +138,33 @@ fn CalendarView() -> Html {
|
|||||||
|
|
||||||
match calendar_service.refresh_event(&token, &uid).await {
|
match calendar_service.refresh_event(&token, &uid).await {
|
||||||
Ok(Some(refreshed_event)) => {
|
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();
|
let mut updated_events = (*events).clone();
|
||||||
|
|
||||||
|
// First, remove all existing occurrences of this event
|
||||||
for (_, day_events) in updated_events.iter_mut() {
|
for (_, day_events) in updated_events.iter_mut() {
|
||||||
for existing_event in day_events.iter_mut() {
|
day_events.retain(|e| e.uid != uid);
|
||||||
if existing_event.uid == uid {
|
|
||||||
*existing_event = refreshed_event.clone();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
events.set(updated_events);
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use chrono::{DateTime, Utc, NaiveDate};
|
use chrono::{DateTime, Utc, NaiveDate, Datelike, Weekday, Duration};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
use wasm_bindgen_futures::JsFuture;
|
use wasm_bindgen_futures::JsFuture;
|
||||||
@@ -183,7 +183,10 @@ impl CalendarService {
|
|||||||
pub fn group_events_by_date(events: Vec<CalendarEvent>) -> HashMap<NaiveDate, Vec<CalendarEvent>> {
|
pub fn group_events_by_date(events: Vec<CalendarEvent>) -> HashMap<NaiveDate, Vec<CalendarEvent>> {
|
||||||
let mut grouped = HashMap::new();
|
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();
|
let date = event.get_date();
|
||||||
|
|
||||||
grouped.entry(date)
|
grouped.entry(date)
|
||||||
@@ -194,6 +197,215 @@ impl CalendarService {
|
|||||||
grouped
|
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
|
/// Refresh a single event by UID from the CalDAV server
|
||||||
pub async fn refresh_event(&self, token: &str, uid: &str) -> Result<Option<CalendarEvent>, String> {
|
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")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
pub mod calendar_service;
|
pub mod calendar_service;
|
||||||
|
|
||||||
pub use calendar_service::{CalendarService, CalendarEvent, EventStatus, EventClass, EventReminder, ReminderAction};
|
pub use calendar_service::{CalendarService, CalendarEvent, EventReminder, ReminderAction};
|
||||||
Reference in New Issue
Block a user