Add comprehensive reminder/alarm support to calendar events

- Backend: Parse VALARM components from CalDAV iCalendar data
- Backend: Add EventReminder struct with minutes_before, action, and description
- Backend: Support Display, Email, and Audio reminder types
- Backend: Parse ISO 8601 duration triggers (-PT15M, -P1D, etc.)
- Frontend: Add reminders field to CalendarEvent structure
- Frontend: Display reminders in event modal with human-readable formatting
- Frontend: Show reminder timing (15 minutes before, 1 day before) and action type
- Fix: Update Trunk.toml to properly copy CSS files to dist directory

🤖 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:37:30 -04:00
parent 01a21cb869
commit 1c4857ccad
8 changed files with 458 additions and 20 deletions

View File

@@ -1,6 +1,6 @@
use yew::prelude::*;
use chrono::{DateTime, Utc};
use crate::services::CalendarEvent;
use crate::services::{CalendarEvent, EventReminder, ReminderAction};
#[derive(Properties, PartialEq)]
pub struct EventModalProps {
@@ -39,6 +39,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
<strong>{"Title:"}</strong>
<span>{event.get_title()}</span>
</div>
{
if let Some(ref description) = event.description {
html! {
@@ -51,22 +52,12 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {}
}
}
{
if let Some(ref location) = event.location {
html! {
<div class="event-detail">
<strong>{"Location:"}</strong>
<span>{location}</span>
</div>
}
} else {
html! {}
}
}
<div class="event-detail">
<strong>{"Start:"}</strong>
<span>{format_datetime(&event.start, event.all_day)}</span>
</div>
{
if let Some(ref end) = event.end {
html! {
@@ -79,10 +70,140 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {}
}
}
<div class="event-detail">
<strong>{"All Day:"}</strong>
<span>{if event.all_day { "Yes" } else { "No" }}</span>
</div>
{
if let Some(ref location) = event.location {
html! {
<div class="event-detail">
<strong>{"Location:"}</strong>
<span>{location}</span>
</div>
}
} else {
html! {}
}
}
<div class="event-detail">
<strong>{"Status:"}</strong>
<span>{&event.status}</span>
<span>{event.get_status_display()}</span>
</div>
<div class="event-detail">
<strong>{"Privacy:"}</strong>
<span>{event.get_class_display()}</span>
</div>
<div class="event-detail">
<strong>{"Priority:"}</strong>
<span>{event.get_priority_display()}</span>
</div>
{
if let Some(ref organizer) = event.organizer {
html! {
<div class="event-detail">
<strong>{"Organizer:"}</strong>
<span>{organizer}</span>
</div>
}
} else {
html! {}
}
}
{
if !event.attendees.is_empty() {
html! {
<div class="event-detail">
<strong>{"Attendees:"}</strong>
<span>{event.attendees.join(", ")}</span>
</div>
}
} else {
html! {}
}
}
{
if !event.categories.is_empty() {
html! {
<div class="event-detail">
<strong>{"Categories:"}</strong>
<span>{event.categories.join(", ")}</span>
</div>
}
} else {
html! {}
}
}
{
if let Some(ref recurrence) = event.recurrence_rule {
html! {
<div class="event-detail">
<strong>{"Repeats:"}</strong>
<span>{format_recurrence_rule(recurrence)}</span>
</div>
}
} else {
html! {
<div class="event-detail">
<strong>{"Repeats:"}</strong>
<span>{"No"}</span>
</div>
}
}
}
{
if !event.reminders.is_empty() {
html! {
<div class="event-detail">
<strong>{"Reminders:"}</strong>
<span>{format_reminders(&event.reminders)}</span>
</div>
}
} else {
html! {
<div class="event-detail">
<strong>{"Reminders:"}</strong>
<span>{"None"}</span>
</div>
}
}
}
{
if let Some(ref created) = event.created {
html! {
<div class="event-detail">
<strong>{"Created:"}</strong>
<span>{format_datetime(created, false)}</span>
</div>
}
} else {
html! {}
}
}
{
if let Some(ref modified) = event.last_modified {
html! {
<div class="event-detail">
<strong>{"Last Modified:"}</strong>
<span>{format_datetime(modified, false)}</span>
</div>
}
} else {
html! {}
}
}
</div>
</div>
</div>
@@ -98,4 +219,71 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
} else {
dt.format("%B %d, %Y at %I:%M %p").to_string()
}
}
fn format_recurrence_rule(rrule: &str) -> String {
// Basic parsing of RRULE to display user-friendly text
if rrule.contains("FREQ=DAILY") {
"Daily".to_string()
} else if rrule.contains("FREQ=WEEKLY") {
"Weekly".to_string()
} else if rrule.contains("FREQ=MONTHLY") {
"Monthly".to_string()
} else if rrule.contains("FREQ=YEARLY") {
"Yearly".to_string()
} else {
// Show the raw rule if we can't parse it
format!("Custom ({})", rrule)
}
}
fn format_reminders(reminders: &[EventReminder]) -> String {
if reminders.is_empty() {
return "None".to_string();
}
let formatted_reminders: Vec<String> = reminders
.iter()
.map(|reminder| {
let time_text = if reminder.minutes_before == 0 {
"At event time".to_string()
} else if reminder.minutes_before < 60 {
format!("{} minutes before", reminder.minutes_before)
} else if reminder.minutes_before == 60 {
"1 hour before".to_string()
} else if reminder.minutes_before % 60 == 0 {
format!("{} hours before", reminder.minutes_before / 60)
} else if reminder.minutes_before < 1440 {
let hours = reminder.minutes_before / 60;
let minutes = reminder.minutes_before % 60;
format!("{}h {}m before", hours, minutes)
} else if reminder.minutes_before == 1440 {
"1 day before".to_string()
} else if reminder.minutes_before % 1440 == 0 {
format!("{} days before", reminder.minutes_before / 1440)
} else {
let days = reminder.minutes_before / 1440;
let remaining_minutes = reminder.minutes_before % 1440;
let hours = remaining_minutes / 60;
let minutes = remaining_minutes % 60;
if hours > 0 {
format!("{}d {}h before", days, hours)
} else if minutes > 0 {
format!("{}d {}m before", days, minutes)
} else {
format!("{} days before", days)
}
};
let action_text = match reminder.action {
ReminderAction::Display => "notification",
ReminderAction::Email => "email",
ReminderAction::Audio => "sound",
};
format!("{} ({})", time_text, action_text)
})
.collect();
formatted_reminders.join(", ")
}

View File

@@ -5,6 +5,26 @@ use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EventReminder {
pub minutes_before: i32,
pub action: ReminderAction,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ReminderAction {
Display,
Email,
Audio,
}
impl Default for ReminderAction {
fn default() -> Self {
ReminderAction::Display
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CalendarEvent {
pub uid: String,
@@ -13,8 +33,45 @@ pub struct CalendarEvent {
pub start: DateTime<Utc>,
pub end: Option<DateTime<Utc>>,
pub location: Option<String>,
pub status: String,
pub status: EventStatus,
pub class: EventClass,
pub priority: Option<u8>,
pub organizer: Option<String>,
pub attendees: Vec<String>,
pub categories: Vec<String>,
pub created: Option<DateTime<Utc>>,
pub last_modified: Option<DateTime<Utc>>,
pub recurrence_rule: Option<String>,
pub all_day: bool,
pub reminders: Vec<EventReminder>,
pub etag: Option<String>,
pub href: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventStatus {
Tentative,
Confirmed,
Cancelled,
}
impl Default for EventStatus {
fn default() -> Self {
EventStatus::Confirmed
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventClass {
Public,
Private,
Confidential,
}
impl Default for EventClass {
fn default() -> Self {
EventClass::Public
}
}
impl CalendarEvent {
@@ -31,6 +88,38 @@ impl CalendarEvent {
pub fn get_title(&self) -> String {
self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string())
}
/// Get display string for status
pub fn get_status_display(&self) -> &'static str {
match self.status {
EventStatus::Tentative => "Tentative",
EventStatus::Confirmed => "Confirmed",
EventStatus::Cancelled => "Cancelled",
}
}
/// Get display string for class
pub fn get_class_display(&self) -> &'static str {
match self.class {
EventClass::Public => "Public",
EventClass::Private => "Private",
EventClass::Confidential => "Confidential",
}
}
/// Get display string for priority
pub fn get_priority_display(&self) -> String {
match self.priority {
None => "Not set".to_string(),
Some(0) => "Undefined".to_string(),
Some(1) => "High".to_string(),
Some(p) if p <= 4 => "High".to_string(),
Some(5) => "Medium".to_string(),
Some(p) if p <= 8 => "Low".to_string(),
Some(9) => "Low".to_string(),
Some(p) => format!("Priority {}", p),
}
}
}
pub struct CalendarService {

View File

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