diff --git a/frontend/src/components/create_event_modal.rs b/frontend/src/components/create_event_modal.rs index f6813ed..082042a 100644 --- a/frontend/src/components/create_event_modal.rs +++ b/frontend/src/components/create_event_modal.rs @@ -1,7 +1,7 @@ use yew::prelude::*; use web_sys::{HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement}; use wasm_bindgen::JsCast; -use chrono::{NaiveDate, NaiveTime, Local, TimeZone, Utc}; +use chrono::{NaiveDate, NaiveTime, Local, TimeZone, Utc, Datelike}; use crate::services::calendar_service::CalendarInfo; use crate::models::ical::VEvent; @@ -93,6 +93,224 @@ impl RecurrenceType { } } +/// Parse RRULE string into recurrence components +/// Example RRULE: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;UNTIL=20231215T000000Z" +#[derive(Debug, Default, Clone)] +struct ParsedRrule { + pub freq: RecurrenceType, + pub interval: u32, + pub until: Option, + pub count: Option, + pub byday: Vec, + pub bymonthday: Option, + pub bymonth: Vec, +} + +fn parse_rrule(rrule: Option<&str>) -> ParsedRrule { + let mut parsed = ParsedRrule { + interval: 1, // Default interval is 1 + ..Default::default() + }; + + let Some(rrule_str) = rrule else { + return parsed; + }; + + // Split RRULE into parts: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE" + for part in rrule_str.split(';') { + let mut key_value = part.split('='); + let key = key_value.next().unwrap_or(""); + let value = key_value.next().unwrap_or(""); + + match key { + "FREQ" => { + parsed.freq = match value { + "DAILY" => RecurrenceType::Daily, + "WEEKLY" => RecurrenceType::Weekly, + "MONTHLY" => RecurrenceType::Monthly, + "YEARLY" => RecurrenceType::Yearly, + _ => RecurrenceType::None, + }; + } + "INTERVAL" => { + if let Ok(interval) = value.parse::() { + parsed.interval = interval.max(1); + } + } + "UNTIL" => { + // Parse UNTIL date: "20231215T000000Z" -> NaiveDate + if value.len() >= 8 { + let date_part = &value[..8]; // Extract YYYYMMDD + if let Ok(until_date) = NaiveDate::parse_from_str(date_part, "%Y%m%d") { + parsed.until = Some(until_date); + } + } + } + "COUNT" => { + if let Ok(count) = value.parse::() { + parsed.count = Some(count); + } + } + "BYDAY" => { + // Parse BYDAY: "MO,WE,FR" or "1MO,-1SU" (with position) + parsed.byday = value.split(',') + .map(|s| s.trim().to_uppercase()) + .collect(); + } + "BYMONTHDAY" => { + // Parse BYMONTHDAY: "15" or "1,15,31" + if let Some(first_day) = value.split(',').next() { + if let Ok(day) = first_day.parse::() { + if day >= 1 && day <= 31 { + parsed.bymonthday = Some(day); + } + } + } + } + "BYMONTH" => { + // Parse BYMONTH: "1,3,5" (January, March, May) + parsed.bymonth = value.split(',') + .filter_map(|m| m.trim().parse::().ok()) + .filter(|&m| m >= 1 && m <= 12) + .collect(); + } + _ => {} // Ignore unknown parameters + } + } + + parsed +} + +/// Convert BYDAY values to weekday boolean array +/// Maps RFC 5545 day codes to [Sun, Mon, Tue, Wed, Thu, Fri, Sat] +fn byday_to_weekday_array(byday: &[String]) -> Vec { + let mut weekdays = vec![false; 7]; + + for day_spec in byday { + // Handle both simple days (MO, TU) and positioned days (1MO, -1SU) + let day_code = if day_spec.len() > 2 { + // Extract last 2 characters for positioned days like "1MO" -> "MO" + &day_spec[day_spec.len()-2..] + } else { + day_spec + }; + + let index = match day_code { + "SU" => 0, // Sunday + "MO" => 1, // Monday + "TU" => 2, // Tuesday + "WE" => 3, // Wednesday + "TH" => 4, // Thursday + "FR" => 5, // Friday + "SA" => 6, // Saturday + _ => continue, + }; + + weekdays[index] = true; + } + + weekdays +} + +/// Convert BYMONTH values to monthly boolean array +/// Maps month numbers to [Jan, Feb, Mar, ..., Dec] +fn bymonth_to_monthly_array(bymonth: &[u8]) -> Vec { + let mut months = vec![false; 12]; + + for &month in bymonth { + if month >= 1 && month <= 12 { + months[(month - 1) as usize] = true; + } + } + + months +} + +/// Extract positioned weekday from BYDAY for monthly recurrence +/// Examples: "1MO" -> Some("1MO"), "2TU" -> Some("2TU"), "-1SU" -> Some("-1SU") +fn extract_monthly_byday(byday: &[String]) -> Option { + byday.iter() + .find(|day| day.len() > 2) // Positioned days have length > 2 + .cloned() +} + +#[cfg(test)] +mod rrule_tests { + use super::*; + + #[test] + fn test_parse_simple_weekly() { + let parsed = parse_rrule(Some("FREQ=WEEKLY;BYDAY=MO,WE,FR")); + assert_eq!(parsed.freq, RecurrenceType::Weekly); + assert_eq!(parsed.interval, 1); + assert_eq!(parsed.byday, vec!["MO", "WE", "FR"]); + } + + #[test] + fn test_parse_complex_monthly() { + let parsed = parse_rrule(Some("FREQ=MONTHLY;INTERVAL=2;BYDAY=1MO;UNTIL=20241231T000000Z")); + assert_eq!(parsed.freq, RecurrenceType::Monthly); + assert_eq!(parsed.interval, 2); + assert_eq!(parsed.byday, vec!["1MO"]); + assert!(parsed.until.is_some()); + } + + #[test] + fn test_byday_to_weekday_array() { + let weekdays = byday_to_weekday_array(&["MO".to_string(), "WE".to_string(), "FR".to_string()]); + // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] + assert_eq!(weekdays, vec![false, true, false, true, false, true, false]); + } + + #[test] + fn test_extract_monthly_byday() { + let byday = vec!["1MO".to_string(), "WE".to_string()]; + assert_eq!(extract_monthly_byday(&byday), Some("1MO".to_string())); + + let byday_simple = vec!["MO".to_string(), "TU".to_string()]; + assert_eq!(extract_monthly_byday(&byday_simple), None); + } + + #[test] + fn test_build_rrule_weekly() { + let mut data = EventCreationData::default(); + data.recurrence = RecurrenceType::Weekly; + data.recurrence_interval = 2; + data.recurrence_days = vec![false, true, false, true, false, true, false]; // Mon, Wed, Fri + data.recurrence_count = Some(10); + + let rrule = data.build_rrule(); + assert!(rrule.contains("FREQ=WEEKLY")); + assert!(rrule.contains("INTERVAL=2")); + assert!(rrule.contains("BYDAY=MO,WE,FR")); + assert!(rrule.contains("COUNT=10")); + } + + #[test] + fn test_build_rrule_monthly_by_day() { + let mut data = EventCreationData::default(); + data.recurrence = RecurrenceType::Monthly; + data.monthly_by_day = Some("1MO".to_string()); + data.recurrence_until = Some(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()); + + let rrule = data.build_rrule(); + assert!(rrule.contains("FREQ=MONTHLY")); + assert!(rrule.contains("BYDAY=1MO")); + assert!(rrule.contains("UNTIL=20241231T000000Z")); + } + + #[test] + fn test_build_rrule_yearly() { + let mut data = EventCreationData::default(); + data.recurrence = RecurrenceType::Yearly; + data.yearly_by_month = vec![false, false, true, false, true, false, false, false, false, false, false, false]; // March, May + + let rrule = data.build_rrule(); + assert!(rrule.contains("FREQ=YEARLY")); + assert!(rrule.contains("BYMONTH=3,5")); + } +} + #[derive(Clone, PartialEq, Debug)] pub struct EventCreationData { pub title: String, @@ -113,6 +331,14 @@ pub struct EventCreationData { pub recurrence: RecurrenceType, pub recurrence_days: Vec, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence pub selected_calendar: Option, // Calendar path + + // Advanced recurrence fields + pub recurrence_interval: u32, // INTERVAL - every N (days/weeks/months/years) + pub recurrence_until: Option, // UNTIL date + pub recurrence_count: Option, // COUNT - number of occurrences + pub monthly_by_day: Option, // For monthly: "1MO" = first Monday, "2TU" = second Tuesday, etc. + pub monthly_by_monthday: Option, // For monthly: day of month (1-31) + pub yearly_by_month: Vec, // For yearly: [Jan, Feb, Mar, ..., Dec] } impl Default for EventCreationData { @@ -140,11 +366,105 @@ impl Default for EventCreationData { recurrence: RecurrenceType::default(), recurrence_days: vec![false; 7], // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] - all false by default selected_calendar: None, + + // Advanced recurrence defaults + recurrence_interval: 1, + recurrence_until: None, + recurrence_count: None, + monthly_by_day: None, + monthly_by_monthday: None, + yearly_by_month: vec![false; 12], // [Jan, Feb, ..., Dec] - all false by default } } } impl EventCreationData { + /// Build a complete RRULE string from recurrence fields + fn build_rrule(&self) -> String { + if matches!(self.recurrence, RecurrenceType::None) { + return String::new(); + } + + let mut parts = Vec::new(); + + // Add frequency (required) + match self.recurrence { + RecurrenceType::Daily => parts.push("FREQ=DAILY".to_string()), + RecurrenceType::Weekly => parts.push("FREQ=WEEKLY".to_string()), + RecurrenceType::Monthly => parts.push("FREQ=MONTHLY".to_string()), + RecurrenceType::Yearly => parts.push("FREQ=YEARLY".to_string()), + RecurrenceType::None => return String::new(), + } + + // Add interval if not 1 + if self.recurrence_interval > 1 { + parts.push(format!("INTERVAL={}", self.recurrence_interval)); + } + + // Add frequency-specific rules + match self.recurrence { + RecurrenceType::Weekly => { + // Add BYDAY for weekly recurrence + let selected_days: Vec<&str> = self.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 + _ => "", + }) + } else { + None + }) + .filter(|s| !s.is_empty()) + .collect(); + + if !selected_days.is_empty() { + parts.push(format!("BYDAY={}", selected_days.join(","))); + } + }, + RecurrenceType::Monthly => { + // Add BYDAY or BYMONTHDAY for monthly recurrence + if let Some(ref by_day) = self.monthly_by_day { + parts.push(format!("BYDAY={}", by_day)); + } else if let Some(by_monthday) = self.monthly_by_monthday { + parts.push(format!("BYMONTHDAY={}", by_monthday)); + } + }, + RecurrenceType::Yearly => { + // Add BYMONTH for yearly recurrence + let selected_months: Vec = self.yearly_by_month.iter() + .enumerate() + .filter_map(|(i, &selected)| if selected { + Some((i + 1).to_string()) // Convert 0-based index to 1-based month + } else { + None + }) + .collect(); + + if !selected_months.is_empty() { + parts.push(format!("BYMONTH={}", selected_months.join(","))); + } + }, + _ => {} + } + + // Add end condition (UNTIL or COUNT) + if let Some(until_date) = self.recurrence_until { + // Format as UNTIL=YYYYMMDDTHHMMSSZ + parts.push(format!("UNTIL={}T000000Z", until_date.format("%Y%m%d"))); + } else if let Some(count) = self.recurrence_count { + parts.push(format!("COUNT={}", count)); + } + + parts.join(";") + } + 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) { // Convert local date/time to UTC let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single() @@ -188,13 +508,7 @@ impl EventCreationData { ReminderType::Days2 => "2880".to_string(), ReminderType::Week1 => "10080".to_string(), }, - match self.recurrence { - RecurrenceType::None => "".to_string(), - RecurrenceType::Daily => "DAILY".to_string(), - RecurrenceType::Weekly => "WEEKLY".to_string(), - RecurrenceType::Monthly => "MONTHLY".to_string(), - RecurrenceType::Yearly => "YEARLY".to_string(), - }, + self.build_rrule(), // Use the comprehensive RRULE builder self.recurrence_days.clone(), self.selected_calendar.clone() ) @@ -207,6 +521,9 @@ impl EventCreationData { // All events (including temporary drag events) now have proper UTC times // Convert to local time for display in the modal + // Parse RRULE once for efficiency + let parsed_rrule = parse_rrule(event.rrule.as_deref()); + Self { title: event.summary.clone().unwrap_or_default(), description: event.description.clone().unwrap_or_default(), @@ -231,9 +548,33 @@ impl EventCreationData { attendees: event.attendees.iter().map(|a| a.cal_address.clone()).collect::>().join(", "), categories: event.categories.join(", "), reminder: ReminderType::default(), // TODO: Convert from event reminders - recurrence: RecurrenceType::from_rrule(event.rrule.as_deref()), - recurrence_days: vec![false; 7], // TODO: Parse from RRULE + recurrence: parsed_rrule.freq.clone(), + recurrence_days: if parsed_rrule.freq == RecurrenceType::Weekly { + byday_to_weekday_array(&parsed_rrule.byday) + } else { + vec![false; 7] + }, selected_calendar: event.calendar_path.clone(), + + // Advanced recurrence fields from parsed RRULE + recurrence_interval: parsed_rrule.interval, + recurrence_until: parsed_rrule.until, + recurrence_count: parsed_rrule.count, + monthly_by_day: if parsed_rrule.freq == RecurrenceType::Monthly { + extract_monthly_byday(&parsed_rrule.byday) + } else { + None + }, + monthly_by_monthday: if parsed_rrule.freq == RecurrenceType::Monthly { + parsed_rrule.bymonthday + } else { + None + }, + yearly_by_month: if parsed_rrule.freq == RecurrenceType::Yearly { + bymonth_to_monthly_array(&parsed_rrule.bymonth) + } else { + vec![false; 12] + }, } } @@ -463,13 +804,112 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html { "yearly" => RecurrenceType::Yearly, _ => RecurrenceType::None, }; - // Reset recurrence days when changing recurrence type + // Reset recurrence-related fields when changing recurrence type data.recurrence_days = vec![false; 7]; + data.recurrence_interval = 1; + data.recurrence_until = None; + data.recurrence_count = None; + data.monthly_by_day = None; + data.monthly_by_monthday = None; + data.yearly_by_month = vec![false; 12]; event_data.set(data); } }) }; + let on_recurrence_interval_change = { + let event_data = event_data.clone(); + Callback::from(move |e: Event| { + if let Some(input) = e.target_dyn_into::() { + if let Ok(interval) = input.value().parse::() { + let mut data = (*event_data).clone(); + data.recurrence_interval = interval.max(1); + event_data.set(data); + } + } + }) + }; + + let on_recurrence_until_change = { + let event_data = event_data.clone(); + Callback::from(move |e: Event| { + if let Some(input) = e.target_dyn_into::() { + let mut data = (*event_data).clone(); + if input.value().is_empty() { + data.recurrence_until = None; + } else if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") { + data.recurrence_until = Some(date); + } + event_data.set(data); + } + }) + }; + + let on_recurrence_count_change = { + let event_data = event_data.clone(); + Callback::from(move |e: Event| { + if let Some(input) = e.target_dyn_into::() { + let mut data = (*event_data).clone(); + if input.value().is_empty() { + data.recurrence_count = None; + } else if let Ok(count) = input.value().parse::() { + data.recurrence_count = Some(count.max(1)); + } + event_data.set(data); + } + }) + }; + + let on_monthly_by_monthday_change = { + let event_data = event_data.clone(); + Callback::from(move |e: Event| { + if let Some(input) = e.target_dyn_into::() { + let mut data = (*event_data).clone(); + if input.value().is_empty() { + data.monthly_by_monthday = None; + } else if let Ok(day) = input.value().parse::() { + if day >= 1 && day <= 31 { + data.monthly_by_monthday = Some(day); + data.monthly_by_day = None; // Clear the other option + } + } + event_data.set(data); + } + }) + }; + + let on_monthly_by_day_change = { + let event_data = event_data.clone(); + Callback::from(move |e: Event| { + if let Some(select) = e.target_dyn_into::() { + let mut data = (*event_data).clone(); + if select.value().is_empty() || select.value() == "none" { + data.monthly_by_day = None; + } else { + data.monthly_by_day = Some(select.value()); + data.monthly_by_monthday = None; // Clear the other option + } + event_data.set(data); + } + }) + }; + + let on_yearly_month_change = { + let event_data = event_data.clone(); + move |month_index: usize| { + let event_data = event_data.clone(); + Callback::from(move |e: Event| { + if let Some(input) = e.target_dyn_into::() { + let mut data = (*event_data).clone(); + if month_index < data.yearly_by_month.len() { + data.yearly_by_month[month_index] = input.checked(); + event_data.set(data); + } + } + }) + } + }; + let on_weekday_change = { let event_data = event_data.clone(); move |day_index: usize| { @@ -852,6 +1292,249 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html { } + + // Show additional recurrence options for all recurring events + if !matches!(data.recurrence, RecurrenceType::None) { +
+
+
+ +
+ + + {match data.recurrence { + RecurrenceType::Daily => if data.recurrence_interval == 1 { "day" } else { "days" }, + RecurrenceType::Weekly => if data.recurrence_interval == 1 { "week" } else { "weeks" }, + RecurrenceType::Monthly => if data.recurrence_interval == 1 { "month" } else { "months" }, + RecurrenceType::Yearly => if data.recurrence_interval == 1 { "year" } else { "years" }, + RecurrenceType::None => "", + }} + +
+
+ +
+ +
+
+ +
+ +
+ + +
+ +
+ + + {"occurrences"} +
+
+
+
+ + // Monthly-specific options + if matches!(data.recurrence, RecurrenceType::Monthly) { +
+ +
+
+ + +
+ +
+ + +
+
+
+ } + + // Yearly-specific options + if matches!(data.recurrence, RecurrenceType::Yearly) { +
+ +
+ { + ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + .iter() + .enumerate() + .map(|(i, month)| { + let month_checked = data.yearly_by_month.get(i).cloned().unwrap_or(false); + let on_change = on_yearly_month_change(i); + html! { + + } + }) + .collect::() + } +
+
+ } +
+ } }, ModalTab::Advanced => html! { diff --git a/frontend/styles.css b/frontend/styles.css index 8b818e3..9e72b94 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -3327,3 +3327,151 @@ body { [data-theme="mint"] .app-sidebar { background: var(--sidebar-bg); } + +/* Recurrence Options Styling */ +.recurrence-options { + margin-top: 1.5rem; + padding: 1rem; + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 8px; +} + +.interval-input { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.interval-input input { + width: 80px; + flex-shrink: 0; +} + +.interval-unit { + color: #6c757d; + font-size: 0.9rem; +} + +.end-options { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.end-option { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.end-option .radio-label { + display: flex; + align-items: center; + gap: 0.25rem; + margin-right: 0.5rem; + white-space: nowrap; + cursor: pointer; +} + +.end-option input[type="date"], +.end-option input[type="number"] { + width: 120px; +} + +.count-input { + width: 80px !important; +} + +.count-unit { + color: #6c757d; + font-size: 0.9rem; +} + +.monthly-options { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.monthly-option { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.monthly-option .radio-label { + display: flex; + align-items: center; + gap: 0.25rem; + margin-right: 0.5rem; + white-space: nowrap; + cursor: pointer; +} + +.day-input { + width: 80px !important; +} + +.yearly-months { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + margin-top: 0.5rem; +} + +.month-checkbox { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border: 1px solid #e9ecef; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.month-checkbox:hover { + background-color: #f8f9fa; + border-color: #ced4da; +} + +.month-checkbox input[type="checkbox"] { + width: auto !important; + margin: 0 !important; +} + +.month-label { + font-size: 0.9rem; + user-select: none; +} + +/* Radio button styling */ +.radio-label input[type="radio"] { + width: auto !important; + margin: 0 !important; + margin-right: 0.25rem !important; +} + +/* Mobile responsive adjustments */ +@media (max-width: 768px) { + .end-options { + gap: 0.5rem; + } + + .end-option { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .monthly-option { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .yearly-months { + grid-template-columns: repeat(2, 1fr); + } +}