2 Commits

Author SHA1 Message Date
Connor Johnstone
811cceae52 Add weekday selection for weekly recurrence and fix RRULE generation
- Add weekday selection UI for weekly recurring events with checkboxes
- Implement BYDAY parameter generation in RRULE based on selected days
- Fix missing RRULE generation in iCalendar output
- Convert reminder durations to proper EventReminder structs
- Add responsive CSS styling for weekday selection interface

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 22:54:56 -04:00
Connor Johnstone
34461640af Add comprehensive iCal properties support to event creation modal
Enhanced the create event modal to include all major iCalendar properties:
- Event status (confirmed/tentative/cancelled)
- Privacy classification (public/private/confidential)
- Priority levels (0-9 numeric scale)
- Organizer email field
- Attendees list (comma-separated emails)
- Categories (comma-separated tags)
- Reminder options (none to 1 week before)
- Recurrence patterns (none/daily/weekly/monthly/yearly)

Updated backend to parse and handle all new fields, with proper enum conversion
and comma-separated list parsing. Events now generate complete iCal data with
STATUS, CLASS, PRIORITY, ORGANIZER, ATTENDEE, CATEGORIES, VALARM, and RRULE properties.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-28 22:43:03 -04:00
9 changed files with 629 additions and 12 deletions

View File

@@ -7,7 +7,24 @@ edition = "2021"
[dependencies]
yew = { version = "0.21", features = ["csr"] }
web-sys = "0.3"
web-sys = { version = "0.3", features = [
"console",
"HtmlSelectElement",
"HtmlInputElement",
"HtmlTextAreaElement",
"Event",
"MouseEvent",
"InputEvent",
"Element",
"Document",
"Window",
"Location",
"Headers",
"Request",
"RequestInit",
"RequestMode",
"Response",
] }
wasm-bindgen = "0.2"
# HTTP client for CalDAV requests

View File

@@ -866,6 +866,11 @@ impl CalDAVClient {
ical.push_str("END:VALARM\r\n");
}
// Recurrence rule
if let Some(rrule) = &event.recurrence_rule {
ical.push_str(&format!("RRULE:{}\r\n", rrule));
}
ical.push_str("END:VEVENT\r\n");
ical.push_str("END:VCALENDAR\r\n");

View File

@@ -415,6 +415,124 @@ pub async fn create_event(
// Generate a unique UID for the event
let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp());
// Parse status
let status = match request.status.to_lowercase().as_str() {
"tentative" => crate::calendar::EventStatus::Tentative,
"cancelled" => crate::calendar::EventStatus::Cancelled,
_ => crate::calendar::EventStatus::Confirmed,
};
// Parse class
let class = match request.class.to_lowercase().as_str() {
"private" => crate::calendar::EventClass::Private,
"confidential" => crate::calendar::EventClass::Confidential,
_ => crate::calendar::EventClass::Public,
};
// Parse attendees (comma-separated email list)
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
Vec::new()
} else {
request.attendees
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
// Parse categories (comma-separated list)
let categories: Vec<String> = if request.categories.trim().is_empty() {
Vec::new()
} else {
request.categories
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
// Parse reminders and convert to EventReminder structs
let reminders: Vec<crate::calendar::EventReminder> = match request.reminder.to_lowercase().as_str() {
"15min" => vec![crate::calendar::EventReminder {
minutes_before: 15,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"30min" => vec![crate::calendar::EventReminder {
minutes_before: 30,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1hour" => vec![crate::calendar::EventReminder {
minutes_before: 60,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"2hours" => vec![crate::calendar::EventReminder {
minutes_before: 120,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1day" => vec![crate::calendar::EventReminder {
minutes_before: 1440, // 24 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"2days" => vec![crate::calendar::EventReminder {
minutes_before: 2880, // 48 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1week" => vec![crate::calendar::EventReminder {
minutes_before: 10080, // 7 * 24 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
_ => Vec::new(),
};
// Parse recurrence with BYDAY support for weekly recurrence
let recurrence_rule = match request.recurrence.to_lowercase().as_str() {
"daily" => Some("FREQ=DAILY".to_string()),
"weekly" => {
// Handle weekly recurrence with optional BYDAY parameter
let mut rrule = "FREQ=WEEKLY".to_string();
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
if request.recurrence_days.len() == 7 {
let selected_days: Vec<&str> = request.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
_ => return None,
})
} else {
None
}
})
.collect();
if !selected_days.is_empty() {
rrule.push_str(&format!(";BYDAY={}", selected_days.join(",")));
}
}
Some(rrule)
},
"monthly" => Some("FREQ=MONTHLY".to_string()),
"yearly" => Some("FREQ=YEARLY".to_string()),
_ => None,
};
// Create the CalendarEvent struct
let event = crate::calendar::CalendarEvent {
uid,
@@ -431,17 +549,21 @@ pub async fn create_event(
} else {
Some(request.location.clone())
},
status: crate::calendar::EventStatus::Confirmed,
class: crate::calendar::EventClass::Public,
priority: None,
organizer: None,
attendees: Vec::new(),
categories: Vec::new(),
status,
class,
priority: request.priority,
organizer: if request.organizer.trim().is_empty() {
None
} else {
Some(request.organizer.clone())
},
attendees,
categories,
created: Some(chrono::Utc::now()),
last_modified: Some(chrono::Utc::now()),
recurrence_rule: None,
recurrence_rule,
all_day: request.all_day,
reminders: Vec::new(),
reminders,
etag: None,
href: None,
calendar_path: Some(calendar_path.clone()),

View File

@@ -80,6 +80,15 @@ pub struct CreateEventRequest {
pub end_time: String, // HH:MM format
pub location: String,
pub all_day: bool,
pub status: String, // confirmed, tentative, cancelled
pub class: String, // public, private, confidential
pub priority: Option<u8>, // 0-9 priority level
pub organizer: String, // organizer email
pub attendees: String, // comma-separated attendee emails
pub categories: String, // comma-separated categories
pub reminder: String, // reminder type
pub recurrence: String, // recurrence type
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
}

View File

@@ -2,7 +2,7 @@ use yew::prelude::*;
use yew_router::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use web_sys::MouseEvent;
use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler};
use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType};
use crate::services::{CalendarService, calendar_service::{UserInfo, CalendarEvent}};
use chrono::NaiveDate;
@@ -221,6 +221,38 @@ pub fn App() -> Html {
let end_date = event_data.end_date.format("%Y-%m-%d").to_string();
let end_time = event_data.end_time.format("%H:%M").to_string();
// Convert enums to strings for backend
let status_str = match event_data.status {
EventStatus::Tentative => "tentative",
EventStatus::Cancelled => "cancelled",
_ => "confirmed",
}.to_string();
let class_str = match event_data.class {
EventClass::Private => "private",
EventClass::Confidential => "confidential",
_ => "public",
}.to_string();
let reminder_str = match event_data.reminder {
ReminderType::Minutes15 => "15min",
ReminderType::Minutes30 => "30min",
ReminderType::Hour1 => "1hour",
ReminderType::Hours2 => "2hours",
ReminderType::Day1 => "1day",
ReminderType::Days2 => "2days",
ReminderType::Week1 => "1week",
_ => "none",
}.to_string();
let recurrence_str = match event_data.recurrence {
RecurrenceType::Daily => "daily",
RecurrenceType::Weekly => "weekly",
RecurrenceType::Monthly => "monthly",
RecurrenceType::Yearly => "yearly",
_ => "none",
}.to_string();
match calendar_service.create_event(
&token,
&password,
@@ -232,6 +264,15 @@ pub fn App() -> Html {
end_time,
event_data.location,
event_data.all_day,
status_str,
class_str,
event_data.priority,
event_data.organizer,
event_data.attendees,
event_data.categories,
reminder_str,
recurrence_str,
event_data.recurrence_days,
None // Let backend use first available calendar
).await {
Ok(_) => {

View File

@@ -1,5 +1,5 @@
use yew::prelude::*;
use web_sys::{HtmlInputElement, HtmlTextAreaElement};
use web_sys::{HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement};
use chrono::{NaiveDate, NaiveTime};
#[derive(Properties, PartialEq)]
@@ -10,6 +10,65 @@ pub struct CreateEventModalProps {
pub on_create: Callback<EventCreationData>,
}
#[derive(Clone, PartialEq, Debug)]
pub enum EventStatus {
Tentative,
Confirmed,
Cancelled,
}
impl Default for EventStatus {
fn default() -> Self {
EventStatus::Confirmed
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum EventClass {
Public,
Private,
Confidential,
}
impl Default for EventClass {
fn default() -> Self {
EventClass::Public
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum ReminderType {
None,
Minutes15,
Minutes30,
Hour1,
Hours2,
Day1,
Days2,
Week1,
}
impl Default for ReminderType {
fn default() -> Self {
ReminderType::None
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum RecurrenceType {
None,
Daily,
Weekly,
Monthly,
Yearly,
}
impl Default for RecurrenceType {
fn default() -> Self {
RecurrenceType::None
}
}
#[derive(Clone, PartialEq, Debug)]
pub struct EventCreationData {
pub title: String,
@@ -20,6 +79,15 @@ pub struct EventCreationData {
pub end_time: NaiveTime,
pub location: String,
pub all_day: bool,
pub status: EventStatus,
pub class: EventClass,
pub priority: Option<u8>,
pub organizer: String,
pub attendees: String, // Comma-separated list
pub categories: String, // Comma-separated list
pub reminder: ReminderType,
pub recurrence: RecurrenceType,
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
}
impl Default for EventCreationData {
@@ -37,6 +105,15 @@ impl Default for EventCreationData {
end_time,
location: String::new(),
all_day: false,
status: EventStatus::default(),
class: EventClass::default(),
priority: None,
organizer: String::new(),
attendees: String::new(),
categories: String::new(),
reminder: ReminderType::default(),
recurrence: RecurrenceType::default(),
recurrence_days: vec![false; 7], // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] - all false by default
}
}
}
@@ -109,6 +186,135 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
})
};
let on_organizer_input = {
let event_data = event_data.clone();
Callback::from(move |e: InputEvent| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone();
data.organizer = input.value();
event_data.set(data);
}
})
};
let on_attendees_input = {
let event_data = event_data.clone();
Callback::from(move |e: InputEvent| {
if let Some(textarea) = e.target_dyn_into::<HtmlTextAreaElement>() {
let mut data = (*event_data).clone();
data.attendees = textarea.value();
event_data.set(data);
}
})
};
let on_categories_input = {
let event_data = event_data.clone();
Callback::from(move |e: InputEvent| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone();
data.categories = input.value();
event_data.set(data);
}
})
};
let on_status_change = {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
let mut data = (*event_data).clone();
data.status = match select.value().as_str() {
"tentative" => EventStatus::Tentative,
"cancelled" => EventStatus::Cancelled,
_ => EventStatus::Confirmed,
};
event_data.set(data);
}
})
};
let on_class_change = {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
let mut data = (*event_data).clone();
data.class = match select.value().as_str() {
"private" => EventClass::Private,
"confidential" => EventClass::Confidential,
_ => EventClass::Public,
};
event_data.set(data);
}
})
};
let on_priority_input = {
let event_data = event_data.clone();
Callback::from(move |e: InputEvent| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone();
data.priority = input.value().parse::<u8>().ok().filter(|&p| p <= 9);
event_data.set(data);
}
})
};
let on_reminder_change = {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
let mut data = (*event_data).clone();
data.reminder = match select.value().as_str() {
"15min" => ReminderType::Minutes15,
"30min" => ReminderType::Minutes30,
"1hour" => ReminderType::Hour1,
"2hours" => ReminderType::Hours2,
"1day" => ReminderType::Day1,
"2days" => ReminderType::Days2,
"1week" => ReminderType::Week1,
_ => ReminderType::None,
};
event_data.set(data);
}
})
};
let on_recurrence_change = {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
let mut data = (*event_data).clone();
data.recurrence = match select.value().as_str() {
"daily" => RecurrenceType::Daily,
"weekly" => RecurrenceType::Weekly,
"monthly" => RecurrenceType::Monthly,
"yearly" => RecurrenceType::Yearly,
_ => RecurrenceType::None,
};
// Reset recurrence days when changing recurrence type
data.recurrence_days = vec![false; 7];
event_data.set(data);
}
})
};
let on_weekday_change = {
let event_data = event_data.clone();
move |day_index: usize| {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone();
if day_index < data.recurrence_days.len() {
data.recurrence_days[day_index] = input.checked();
event_data.set(data);
}
}
})
}
};
let on_start_date_change = {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
@@ -302,6 +508,148 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
placeholder="Enter event location"
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="event-status">{"Status"}</label>
<select
id="event-status"
class="form-input"
onchange={on_status_change}
>
<option value="confirmed" selected={matches!(data.status, EventStatus::Confirmed)}>{"Confirmed"}</option>
<option value="tentative" selected={matches!(data.status, EventStatus::Tentative)}>{"Tentative"}</option>
<option value="cancelled" selected={matches!(data.status, EventStatus::Cancelled)}>{"Cancelled"}</option>
</select>
</div>
<div class="form-group">
<label for="event-class">{"Privacy"}</label>
<select
id="event-class"
class="form-input"
onchange={on_class_change}
>
<option value="public" selected={matches!(data.class, EventClass::Public)}>{"Public"}</option>
<option value="private" selected={matches!(data.class, EventClass::Private)}>{"Private"}</option>
<option value="confidential" selected={matches!(data.class, EventClass::Confidential)}>{"Confidential"}</option>
</select>
</div>
</div>
<div class="form-group">
<label for="event-priority">{"Priority (0-9, optional)"}</label>
<input
type="number"
id="event-priority"
class="form-input"
value={data.priority.map(|p| p.to_string()).unwrap_or_default()}
oninput={on_priority_input}
placeholder="0-9 priority level"
min="0"
max="9"
/>
</div>
<div class="form-group">
<label for="event-organizer">{"Organizer Email"}</label>
<input
type="email"
id="event-organizer"
class="form-input"
value={data.organizer.clone()}
oninput={on_organizer_input}
placeholder="organizer@example.com"
/>
</div>
<div class="form-group">
<label for="event-attendees">{"Attendees (comma-separated emails)"}</label>
<textarea
id="event-attendees"
class="form-input"
value={data.attendees.clone()}
oninput={on_attendees_input}
placeholder="attendee1@example.com, attendee2@example.com"
rows="2"
></textarea>
</div>
<div class="form-group">
<label for="event-categories">{"Categories (comma-separated)"}</label>
<input
type="text"
id="event-categories"
class="form-input"
value={data.categories.clone()}
oninput={on_categories_input}
placeholder="work, meeting, personal"
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="event-reminder">{"Reminder"}</label>
<select
id="event-reminder"
class="form-input"
onchange={on_reminder_change}
>
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"None"}</option>
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes"}</option>
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes"}</option>
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour"}</option>
<option value="2hours" selected={matches!(data.reminder, ReminderType::Hours2)}>{"2 hours"}</option>
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day"}</option>
<option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days"}</option>
<option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week"}</option>
</select>
</div>
<div class="form-group">
<label for="event-recurrence">{"Recurrence"}</label>
<select
id="event-recurrence"
class="form-input"
onchange={on_recurrence_change}
>
<option value="none" selected={matches!(data.recurrence, RecurrenceType::None)}>{"None"}</option>
<option value="daily" selected={matches!(data.recurrence, RecurrenceType::Daily)}>{"Daily"}</option>
<option value="weekly" selected={matches!(data.recurrence, RecurrenceType::Weekly)}>{"Weekly"}</option>
<option value="monthly" selected={matches!(data.recurrence, RecurrenceType::Monthly)}>{"Monthly"}</option>
<option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option>
</select>
</div>
</div>
// Show weekday selection only when weekly recurrence is selected
if matches!(data.recurrence, RecurrenceType::Weekly) {
<div class="form-group">
<label>{"Repeat on"}</label>
<div class="weekday-selection">
{
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
.iter()
.enumerate()
.map(|(i, day)| {
let day_checked = data.recurrence_days.get(i).cloned().unwrap_or(false);
let on_change = on_weekday_change(i);
html! {
<label key={i} class="weekday-checkbox">
<input
type="checkbox"
checked={day_checked}
onchange={on_change}
/>
<span class="weekday-label">{day}</span>
</label>
}
})
.collect::<Html>()
}
</div>
</div>
}
</div>
<div class="modal-footer">

View File

@@ -17,7 +17,7 @@ pub use create_calendar_modal::CreateCalendarModal;
pub use context_menu::ContextMenu;
pub use event_context_menu::EventContextMenu;
pub use calendar_context_menu::CalendarContextMenu;
pub use create_event_modal::{CreateEventModal, EventCreationData};
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
pub use sidebar::Sidebar;
pub use calendar_list_item::CalendarListItem;
pub use route_handler::RouteHandler;

View File

@@ -599,6 +599,15 @@ impl CalendarService {
end_time: String,
location: String,
all_day: bool,
status: String,
class: String,
priority: Option<u8>,
organizer: String,
attendees: String,
categories: String,
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
calendar_path: Option<String>
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
@@ -616,6 +625,15 @@ impl CalendarService {
"end_time": end_time,
"location": location,
"all_day": all_day,
"status": status,
"class": class,
"priority": priority,
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"calendar_path": calendar_path
});

View File

@@ -1271,6 +1271,54 @@ body {
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
/* Weekday selection styles */
.weekday-selection {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.weekday-checkbox {
display: flex;
align-items: center;
cursor: pointer;
padding: 0.5rem 0.75rem;
border: 1px solid #ced4da;
border-radius: 6px;
background: white;
transition: all 0.2s ease;
user-select: none;
min-width: 3rem;
justify-content: center;
}
.weekday-checkbox:hover {
border-color: #667eea;
background: #f8f9ff;
}
.weekday-checkbox input[type="checkbox"] {
display: none;
}
.weekday-checkbox input[type="checkbox"]:checked + .weekday-label {
color: white;
}
.weekday-checkbox:has(input[type="checkbox"]:checked) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
color: white;
}
.weekday-label {
font-size: 0.85rem;
font-weight: 500;
color: #495057;
transition: color 0.2s ease;
}
/* Mobile adjustments for event creation modal */
@media (max-width: 768px) {
.create-event-modal .form-row {
@@ -1287,4 +1335,13 @@ body {
.create-event-modal .btn {
width: 100%;
}
.weekday-selection {
gap: 0.25rem;
}
.weekday-checkbox {
min-width: 2.5rem;
padding: 0.4rem 0.6rem;
}
}