Compare commits
24 Commits
890940fe31
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933d7a8c1b | ||
|
|
c938f25951 | ||
|
|
c612f567b4 | ||
|
|
b5b53bb23a | ||
|
|
7e058ba972 | ||
|
|
1f86ea9f71 | ||
|
|
ce9914e388 | ||
|
|
faf5ce2cfd | ||
|
|
2fee7a15f9 | ||
|
|
7caf3539f7 | ||
|
|
1538869f4a | ||
|
|
7ce7d4c9d9 | ||
|
|
037b733d48 | ||
|
|
cb1bb23132 | ||
|
|
5c406569af | ||
|
|
4aca6c7fae | ||
|
|
fd80624429 | ||
|
|
b530dcaa69 | ||
|
|
0821573041 | ||
|
|
703c9ee2f5 | ||
|
|
5854ad291d | ||
|
|
ac1164fd81 | ||
|
|
a6092d13ce | ||
|
|
acc5ced551 |
@@ -39,19 +39,13 @@ impl AuthService {
|
|||||||
request.username.clone(),
|
request.username.clone(),
|
||||||
request.password.clone(),
|
request.password.clone(),
|
||||||
);
|
);
|
||||||
println!("📝 Created CalDAV config");
|
|
||||||
|
|
||||||
// Test authentication against CalDAV server
|
// Test authentication against CalDAV server
|
||||||
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
let caldav_client = CalDAVClient::new(caldav_config.clone());
|
||||||
println!("🔗 Created CalDAV client, attempting to discover calendars...");
|
|
||||||
|
|
||||||
// Try to discover calendars as an authentication test
|
// Try to discover calendars as an authentication test
|
||||||
match caldav_client.discover_calendars().await {
|
match caldav_client.discover_calendars().await {
|
||||||
Ok(calendars) => {
|
Ok(_calendars) => {
|
||||||
println!(
|
|
||||||
"✅ Authentication successful! Found {} calendars",
|
|
||||||
calendars.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find or create user in database
|
// Find or create user in database
|
||||||
let user_repo = UserRepository::new(&self.db);
|
let user_repo = UserRepository::new(&self.db);
|
||||||
|
|||||||
@@ -167,8 +167,6 @@ impl CalDAVClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let basic_auth = self.config.get_basic_auth();
|
let basic_auth = self.config.get_basic_auth();
|
||||||
println!("🔑 REPORT Basic Auth: Basic {}", basic_auth);
|
|
||||||
println!("🌐 REPORT URL: {}", url);
|
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.http_client
|
||||||
@@ -349,6 +347,8 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
full_prop.push_str(&format!(":{}", prop_value));
|
full_prop.push_str(&format!(":{}", prop_value));
|
||||||
|
|
||||||
|
|
||||||
full_properties.insert(prop_name, full_prop);
|
full_properties.insert(prop_name, full_prop);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,27 +358,30 @@ impl CalDAVClient {
|
|||||||
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
|
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
|
// Determine if it's an all-day event FIRST by checking for VALUE=DATE parameter per RFC 5545
|
||||||
|
let empty_string = String::new();
|
||||||
|
let dtstart_raw = full_properties.get("DTSTART").unwrap_or(&empty_string);
|
||||||
|
let dtstart_value = properties.get("DTSTART").unwrap_or(&empty_string);
|
||||||
|
let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_value.contains("T") && dtstart_value.len() == 8);
|
||||||
|
|
||||||
|
|
||||||
// Parse start time (required)
|
// Parse start time (required)
|
||||||
let start = properties
|
let start_prop = properties
|
||||||
.get("DTSTART")
|
.get("DTSTART")
|
||||||
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
|
||||||
let start = self.parse_datetime(start, full_properties.get("DTSTART"))?;
|
let (start_naive, start_tzid) = self.parse_datetime_with_tz(start_prop, full_properties.get("DTSTART"))?;
|
||||||
|
|
||||||
// Parse end time (optional - use start time if not present)
|
// Parse end time (optional - use start time if not present)
|
||||||
let end = if let Some(dtend) = properties.get("DTEND") {
|
let (end_naive, end_tzid) = if let Some(dtend) = properties.get("DTEND") {
|
||||||
Some(self.parse_datetime(dtend, full_properties.get("DTEND"))?)
|
let (end_dt, end_tz) = self.parse_datetime_with_tz(dtend, full_properties.get("DTEND"))?;
|
||||||
|
(Some(end_dt), end_tz)
|
||||||
} else if let Some(_duration) = properties.get("DURATION") {
|
} else if let Some(_duration) = properties.get("DURATION") {
|
||||||
// TODO: Parse duration and add to start time
|
// TODO: Parse duration and add to start time
|
||||||
Some(start)
|
(Some(start_naive), start_tzid.clone())
|
||||||
} else {
|
} else {
|
||||||
None
|
(None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if it's an all-day event by checking for VALUE=DATE parameter
|
|
||||||
let empty_string = String::new();
|
|
||||||
let dtstart_raw = properties.get("DTSTART").unwrap_or(&empty_string);
|
|
||||||
let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_raw.contains("T") && dtstart_raw.len() == 8);
|
|
||||||
|
|
||||||
// Parse status
|
// Parse status
|
||||||
let status = properties
|
let status = properties
|
||||||
.get("STATUS")
|
.get("STATUS")
|
||||||
@@ -411,23 +414,35 @@ impl CalDAVClient {
|
|||||||
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
|
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Parse dates
|
// Parse dates with timezone information
|
||||||
let created = properties
|
let (created_naive, created_tzid) = if let Some(created_str) = properties.get("CREATED") {
|
||||||
.get("CREATED")
|
match self.parse_datetime_with_tz(created_str, None) {
|
||||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
Ok((dt, tz)) => (Some(dt), tz),
|
||||||
|
Err(_) => (None, None)
|
||||||
let last_modified = properties
|
}
|
||||||
.get("LAST-MODIFIED")
|
} else {
|
||||||
.and_then(|s| self.parse_datetime(s, None).ok());
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (last_modified_naive, last_modified_tzid) = if let Some(modified_str) = properties.get("LAST-MODIFIED") {
|
||||||
|
match self.parse_datetime_with_tz(modified_str, None) {
|
||||||
|
Ok((dt, tz)) => (Some(dt), tz),
|
||||||
|
Err(_) => (None, None)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
// Parse exception dates (EXDATE)
|
// Parse exception dates (EXDATE)
|
||||||
let exdate = self.parse_exdate(&event);
|
let exdate = self.parse_exdate(&event);
|
||||||
|
|
||||||
// Create VEvent with required fields
|
// Create VEvent with parsed naive datetime and timezone info
|
||||||
let mut vevent = VEvent::new(uid, start);
|
let mut vevent = VEvent::new(uid, start_naive);
|
||||||
|
|
||||||
// Set optional fields
|
// Set optional fields with timezone information
|
||||||
vevent.dtend = end;
|
vevent.dtend = end_naive;
|
||||||
|
vevent.dtstart_tzid = start_tzid;
|
||||||
|
vevent.dtend_tzid = end_tzid;
|
||||||
vevent.summary = properties.get("SUMMARY").cloned();
|
vevent.summary = properties.get("SUMMARY").cloned();
|
||||||
vevent.description = properties.get("DESCRIPTION").cloned();
|
vevent.description = properties.get("DESCRIPTION").cloned();
|
||||||
vevent.location = properties.get("LOCATION").cloned();
|
vevent.location = properties.get("LOCATION").cloned();
|
||||||
@@ -450,10 +465,13 @@ impl CalDAVClient {
|
|||||||
vevent.attendees = Vec::new();
|
vevent.attendees = Vec::new();
|
||||||
|
|
||||||
vevent.categories = categories;
|
vevent.categories = categories;
|
||||||
vevent.created = created;
|
vevent.created = created_naive;
|
||||||
vevent.last_modified = last_modified;
|
vevent.created_tzid = created_tzid;
|
||||||
|
vevent.last_modified = last_modified_naive;
|
||||||
|
vevent.last_modified_tzid = last_modified_tzid;
|
||||||
vevent.rrule = properties.get("RRULE").cloned();
|
vevent.rrule = properties.get("RRULE").cloned();
|
||||||
vevent.exdate = exdate;
|
vevent.exdate = exdate.into_iter().map(|dt| dt.naive_utc()).collect();
|
||||||
|
vevent.exdate_tzid = None; // TODO: Parse timezone info for EXDATE
|
||||||
vevent.all_day = all_day;
|
vevent.all_day = all_day;
|
||||||
|
|
||||||
// Parse alarms
|
// Parse alarms
|
||||||
@@ -566,11 +584,9 @@ impl CalDAVClient {
|
|||||||
pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> {
|
pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> {
|
||||||
// First, try to discover user calendars if we have a calendar path in config
|
// First, try to discover user calendars if we have a calendar path in config
|
||||||
if let Some(calendar_path) = &self.config.calendar_path {
|
if let Some(calendar_path) = &self.config.calendar_path {
|
||||||
println!("Using configured calendar path: {}", calendar_path);
|
|
||||||
return Ok(vec![calendar_path.clone()]);
|
return Ok(vec![calendar_path.clone()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("No calendar path configured, discovering calendars...");
|
|
||||||
|
|
||||||
// Try different common CalDAV discovery paths
|
// Try different common CalDAV discovery paths
|
||||||
// Note: paths should be relative to the server URL base
|
// Note: paths should be relative to the server URL base
|
||||||
@@ -583,20 +599,16 @@ impl CalDAVClient {
|
|||||||
let mut has_valid_caldav_response = false;
|
let mut has_valid_caldav_response = false;
|
||||||
|
|
||||||
for path in discovery_paths {
|
for path in discovery_paths {
|
||||||
println!("Trying discovery path: {}", path);
|
|
||||||
match self.discover_calendars_at_path(&path).await {
|
match self.discover_calendars_at_path(&path).await {
|
||||||
Ok(calendars) => {
|
Ok(calendars) => {
|
||||||
println!("Found {} calendar(s) at {}", calendars.len(), path);
|
|
||||||
has_valid_caldav_response = true;
|
has_valid_caldav_response = true;
|
||||||
all_calendars.extend(calendars);
|
all_calendars.extend(calendars);
|
||||||
}
|
}
|
||||||
Err(CalDAVError::ServerError(status)) => {
|
Err(CalDAVError::ServerError(_status)) => {
|
||||||
// HTTP error - this might be expected for some paths, continue trying
|
// HTTP error - this might be expected for some paths, continue trying
|
||||||
println!("Discovery path {} returned HTTP {}, trying next path", path, status);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Network or other error - this suggests the server isn't reachable or isn't CalDAV
|
// Network or other error - this suggests the server isn't reachable or isn't CalDAV
|
||||||
println!("Discovery failed for path {}: {:?}", path, e);
|
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -653,7 +665,6 @@ impl CalDAVClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body = response.text().await.map_err(CalDAVError::RequestError)?;
|
let body = response.text().await.map_err(CalDAVError::RequestError)?;
|
||||||
println!("Discovery response for {}: {}", path, body);
|
|
||||||
|
|
||||||
let mut calendar_paths = Vec::new();
|
let mut calendar_paths = Vec::new();
|
||||||
|
|
||||||
@@ -664,7 +675,6 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
// Extract href first
|
// Extract href first
|
||||||
if let Some(href) = self.extract_xml_content(response_content, "href") {
|
if let Some(href) = self.extract_xml_content(response_content, "href") {
|
||||||
println!("🔍 Checking resource: {}", href);
|
|
||||||
|
|
||||||
// Check if this is a calendar collection by looking for supported-calendar-component-set
|
// Check if this is a calendar collection by looking for supported-calendar-component-set
|
||||||
// This indicates it's an actual calendar that can contain events
|
// This indicates it's an actual calendar that can contain events
|
||||||
@@ -688,14 +698,10 @@ impl CalDAVClient {
|
|||||||
&& !href.ends_with("/calendars/")
|
&& !href.ends_with("/calendars/")
|
||||||
&& href.ends_with('/')
|
&& href.ends_with('/')
|
||||||
{
|
{
|
||||||
println!("📅 Found calendar collection: {}", href);
|
|
||||||
calendar_paths.push(href);
|
calendar_paths.push(href);
|
||||||
} else {
|
} else {
|
||||||
println!("❌ Skipping system/root directory: {}", href);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("ℹ️ Not a calendar collection: {} (is_calendar: {}, has_collection: {})",
|
|
||||||
href, is_calendar, has_collection);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -704,6 +710,99 @@ impl CalDAVClient {
|
|||||||
Ok(calendar_paths)
|
Ok(calendar_paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse iCal datetime format and return NaiveDateTime + timezone info
|
||||||
|
/// According to RFC 5545: if no TZID parameter is provided, treat as UTC
|
||||||
|
fn parse_datetime_with_tz(
|
||||||
|
&self,
|
||||||
|
datetime_str: &str,
|
||||||
|
original_property: Option<&String>,
|
||||||
|
) -> Result<(chrono::NaiveDateTime, Option<String>), CalDAVError> {
|
||||||
|
// Extract timezone information from the original property if available
|
||||||
|
let mut timezone_id: Option<String> = None;
|
||||||
|
if let Some(prop) = original_property {
|
||||||
|
// Look for TZID parameter in the property
|
||||||
|
// Format: DTSTART;TZID=America/Denver:20231225T090000
|
||||||
|
if let Some(tzid_start) = prop.find("TZID=") {
|
||||||
|
let tzid_part = &prop[tzid_start + 5..];
|
||||||
|
if let Some(tzid_end) = tzid_part.find(':') {
|
||||||
|
timezone_id = Some(tzid_part[..tzid_end].to_string());
|
||||||
|
} else if let Some(tzid_end) = tzid_part.find(';') {
|
||||||
|
timezone_id = Some(tzid_part[..tzid_end].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the datetime string - remove any TZID prefix if present
|
||||||
|
let cleaned = datetime_str.replace("TZID=", "").trim().to_string();
|
||||||
|
|
||||||
|
// Split on colon to separate TZID from datetime if format is "TZID=America/Denver:20231225T090000"
|
||||||
|
let datetime_part = if let Some(colon_pos) = cleaned.find(':') {
|
||||||
|
&cleaned[colon_pos + 1..]
|
||||||
|
} else {
|
||||||
|
&cleaned
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try different parsing formats
|
||||||
|
let formats = [
|
||||||
|
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
|
||||||
|
"%Y%m%dT%H%M%S", // Local format: 20231225T120000
|
||||||
|
"%Y%m%d", // Date only: 20231225
|
||||||
|
];
|
||||||
|
|
||||||
|
for format in &formats {
|
||||||
|
// Try parsing as UTC format (with Z suffix)
|
||||||
|
if datetime_part.ends_with('Z') {
|
||||||
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&datetime_part[..datetime_part.len()-1], "%Y%m%dT%H%M%S") {
|
||||||
|
// Z suffix means UTC, ignore any TZID parameter
|
||||||
|
return Ok((dt, Some("UTC".to_string())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing with timezone offset (e.g., 20231225T120000-0500)
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y%m%dT%H%M%S%z") {
|
||||||
|
// Convert to naive UTC time and return UTC timezone
|
||||||
|
return Ok((dt.naive_utc(), Some("UTC".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ISO format with timezone offset (e.g., 2023-12-25T12:00:00-05:00)
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%S%z") {
|
||||||
|
// Convert to naive UTC time and return UTC timezone
|
||||||
|
return Ok((dt.naive_utc(), Some("UTC".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ISO format with Z suffix (e.g., 2023-12-25T12:00:00Z)
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%SZ") {
|
||||||
|
// Z suffix means UTC
|
||||||
|
return Ok((dt.naive_utc(), Some("UTC".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for date-only format (all-day events)
|
||||||
|
if *format == "%Y%m%d" {
|
||||||
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(datetime_part, format) {
|
||||||
|
// Convert date to midnight datetime for all-day events
|
||||||
|
let naive_dt = date.and_hms_opt(0, 0, 0).unwrap();
|
||||||
|
let tz = timezone_id.unwrap_or_else(|| "UTC".to_string());
|
||||||
|
return Ok((naive_dt, Some(tz)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try parsing as naive datetime for time-based formats
|
||||||
|
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) {
|
||||||
|
// Per RFC 5545: if no TZID parameter is provided, treat as UTC
|
||||||
|
let tz = timezone_id.unwrap_or_else(|| "UTC".to_string());
|
||||||
|
|
||||||
|
// If it's UTC, the naive time is already correct
|
||||||
|
// If it's a local timezone, we store the naive time and the timezone ID
|
||||||
|
return Ok((naive_dt, Some(tz)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(CalDAVError::ParseError(format!(
|
||||||
|
"Could not parse datetime: {}",
|
||||||
|
datetime_str
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse iCal datetime format with timezone support
|
/// Parse iCal datetime format with timezone support
|
||||||
fn parse_datetime(
|
fn parse_datetime(
|
||||||
&self,
|
&self,
|
||||||
@@ -1207,8 +1306,19 @@ impl CalDAVClient {
|
|||||||
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
|
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
|
||||||
let format_datetime =
|
let format_datetime =
|
||||||
|dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() };
|
|dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() };
|
||||||
|
let format_datetime_naive =
|
||||||
|
|dt: &chrono::NaiveDateTime| -> String { dt.format("%Y%m%dT%H%M%S").to_string() };
|
||||||
|
|
||||||
let format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() };
|
let _format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() };
|
||||||
|
|
||||||
|
// Format NaiveDateTime for iCal (local time without Z suffix)
|
||||||
|
let format_naive_datetime = |dt: &chrono::NaiveDateTime| -> String {
|
||||||
|
dt.format("%Y%m%dT%H%M%S").to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let format_naive_date = |dt: &chrono::NaiveDateTime| -> String {
|
||||||
|
dt.format("%Y%m%d").to_string()
|
||||||
|
};
|
||||||
|
|
||||||
// Start building the iCal event
|
// Start building the iCal event
|
||||||
let mut ical = String::new();
|
let mut ical = String::new();
|
||||||
@@ -1225,15 +1335,77 @@ impl CalDAVClient {
|
|||||||
if event.all_day {
|
if event.all_day {
|
||||||
ical.push_str(&format!(
|
ical.push_str(&format!(
|
||||||
"DTSTART;VALUE=DATE:{}\r\n",
|
"DTSTART;VALUE=DATE:{}\r\n",
|
||||||
format_date(&event.dtstart)
|
format_naive_date(&event.dtstart)
|
||||||
));
|
));
|
||||||
if let Some(end) = &event.dtend {
|
if let Some(end) = &event.dtend {
|
||||||
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end)));
|
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_naive_date(end)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ical.push_str(&format!("DTSTART:{}\r\n", format_datetime(&event.dtstart)));
|
// Include timezone information for non-all-day events per RFC 5545
|
||||||
|
if let Some(ref start_tzid) = event.dtstart_tzid {
|
||||||
|
if start_tzid == "UTC" {
|
||||||
|
// UTC events should use Z suffix format
|
||||||
|
ical.push_str(&format!("DTSTART:{}Z\r\n", format_naive_datetime(&event.dtstart)));
|
||||||
|
} else if start_tzid.starts_with('+') || start_tzid.starts_with('-') {
|
||||||
|
// Timezone offset format (e.g., "+05:00", "-04:00")
|
||||||
|
// Convert local time to UTC using the offset and use Z format
|
||||||
|
if let Ok(offset_hours) = start_tzid[1..3].parse::<i32>() {
|
||||||
|
let offset_minutes = start_tzid[4..6].parse::<i32>().unwrap_or(0);
|
||||||
|
let total_offset_minutes = if start_tzid.starts_with('+') {
|
||||||
|
offset_hours * 60 + offset_minutes
|
||||||
|
} else {
|
||||||
|
-(offset_hours * 60 + offset_minutes)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert local time to UTC by applying the inverse offset
|
||||||
|
// If timezone is +04:00 (local ahead of UTC), subtract to get UTC
|
||||||
|
// If timezone is -04:00 (local behind UTC), add to get UTC
|
||||||
|
let utc_time = event.dtstart - chrono::Duration::minutes(total_offset_minutes as i64);
|
||||||
|
ical.push_str(&format!("DTSTART:{}Z\r\n", format_naive_datetime(&utc_time)));
|
||||||
|
} else {
|
||||||
|
// Fallback to floating time if offset parsing fails
|
||||||
|
ical.push_str(&format!("DTSTART:{}\r\n", format_naive_datetime(&event.dtstart)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Named timezone (e.g., "America/New_York") - use TZID parameter per RFC 5545
|
||||||
|
ical.push_str(&format!("DTSTART;TZID={}:{}\r\n", start_tzid, format_naive_datetime(&event.dtstart)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No timezone info - treat as floating local time per RFC 5545
|
||||||
|
ical.push_str(&format!("DTSTART:{}\r\n", format_naive_datetime(&event.dtstart)));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(end) = &event.dtend {
|
if let Some(end) = &event.dtend {
|
||||||
ical.push_str(&format!("DTEND:{}\r\n", format_datetime(end)));
|
if let Some(ref end_tzid) = event.dtend_tzid {
|
||||||
|
if end_tzid == "UTC" {
|
||||||
|
// UTC events should use Z suffix format
|
||||||
|
ical.push_str(&format!("DTEND:{}Z\r\n", format_naive_datetime(end)));
|
||||||
|
} else if end_tzid.starts_with('+') || end_tzid.starts_with('-') {
|
||||||
|
// Timezone offset format (e.g., "+05:00", "-04:00")
|
||||||
|
// Convert local time to UTC using the offset and use Z format
|
||||||
|
if let Ok(offset_hours) = end_tzid[1..3].parse::<i32>() {
|
||||||
|
let offset_minutes = end_tzid[4..6].parse::<i32>().unwrap_or(0);
|
||||||
|
let total_offset_minutes = if end_tzid.starts_with('+') {
|
||||||
|
offset_hours * 60 + offset_minutes
|
||||||
|
} else {
|
||||||
|
-(offset_hours * 60 + offset_minutes)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert local time to UTC by subtracting the offset
|
||||||
|
let utc_time = *end - chrono::Duration::minutes(total_offset_minutes as i64);
|
||||||
|
ical.push_str(&format!("DTEND:{}Z\r\n", format_naive_datetime(&utc_time)));
|
||||||
|
} else {
|
||||||
|
// Fallback to floating time if offset parsing fails
|
||||||
|
ical.push_str(&format!("DTEND:{}\r\n", format_naive_datetime(end)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Named timezone (e.g., "America/New_York") - use TZID parameter per RFC 5545
|
||||||
|
ical.push_str(&format!("DTEND;TZID={}:{}\r\n", end_tzid, format_naive_datetime(end)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No timezone info - treat as floating local time per RFC 5545
|
||||||
|
ical.push_str(&format!("DTEND:{}\r\n", format_naive_datetime(end)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1289,7 +1461,18 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
// Creation and modification times
|
// Creation and modification times
|
||||||
if let Some(created) = &event.created {
|
if let Some(created) = &event.created {
|
||||||
ical.push_str(&format!("CREATED:{}\r\n", format_datetime(created)));
|
if let Some(ref created_tzid) = event.created_tzid {
|
||||||
|
if created_tzid == "UTC" {
|
||||||
|
ical.push_str(&format!("CREATED:{}Z\r\n", format_datetime_naive(created)));
|
||||||
|
} else {
|
||||||
|
// Per RFC 5545, CREATED typically should be in UTC or floating time
|
||||||
|
// Treat non-UTC as floating time
|
||||||
|
ical.push_str(&format!("CREATED:{}\r\n", format_datetime_naive(created)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No timezone info - output as floating time per RFC 5545
|
||||||
|
ical.push_str(&format!("CREATED:{}\r\n", format_datetime_naive(created)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now)));
|
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now)));
|
||||||
@@ -1346,10 +1529,10 @@ impl CalDAVClient {
|
|||||||
if event.all_day {
|
if event.all_day {
|
||||||
ical.push_str(&format!(
|
ical.push_str(&format!(
|
||||||
"EXDATE;VALUE=DATE:{}\r\n",
|
"EXDATE;VALUE=DATE:{}\r\n",
|
||||||
format_date(exception_date)
|
format_naive_date(exception_date)
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date)));
|
ical.push_str(&format!("EXDATE:{}\r\n", format_naive_datetime(exception_date)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,10 +82,6 @@ pub async fn get_user_info(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
|
||||||
|
|
||||||
println!(
|
|
||||||
"✅ Authentication successful! Found {} calendars",
|
|
||||||
calendar_paths.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
let calendars: Vec<CalendarInfo> = calendar_paths
|
let calendars: Vec<CalendarInfo> = calendar_paths
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::{
|
|||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use calendar_models::{
|
use calendar_models::{
|
||||||
AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent,
|
Attendee, CalendarUser, EventClass, EventStatus, VEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::auth::{extract_bearer_token, extract_password_header};
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
@@ -35,7 +35,6 @@ pub async fn get_calendar_events(
|
|||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
let password = extract_password_header(&headers)?;
|
let password = extract_password_header(&headers)?;
|
||||||
println!("🔑 API call with password length: {}", password.len());
|
|
||||||
|
|
||||||
// Create CalDAV config from token and password
|
// Create CalDAV config from token and password
|
||||||
let config = state
|
let config = state
|
||||||
@@ -85,7 +84,7 @@ pub async fn get_calendar_events(
|
|||||||
} - chrono::Duration::days(1);
|
} - chrono::Duration::days(1);
|
||||||
|
|
||||||
all_events.retain(|event| {
|
all_events.retain(|event| {
|
||||||
let event_date = event.dtstart.date_naive();
|
let event_date = event.dtstart.date();
|
||||||
|
|
||||||
// For non-recurring events, check if the event date is within the month
|
// For non-recurring events, check if the event date is within the month
|
||||||
if event.rrule.is_none() || event.rrule.as_ref().unwrap().is_empty() {
|
if event.rrule.is_none() || event.rrule.as_ref().unwrap().is_empty() {
|
||||||
@@ -127,7 +126,6 @@ pub async fn get_calendar_events(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("📅 Returning {} events", all_events.len());
|
|
||||||
Ok(Json(all_events))
|
Ok(Json(all_events))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,26 +232,26 @@ pub async fn delete_event(
|
|||||||
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
|
||||||
// Recurring event - add EXDATE for this occurrence
|
// Recurring event - add EXDATE for this occurrence
|
||||||
if let Some(occurrence_date) = &request.occurrence_date {
|
if let Some(occurrence_date) = &request.occurrence_date {
|
||||||
let exception_utc = if let Ok(date) =
|
let exception_datetime = if let Ok(date) =
|
||||||
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
chrono::DateTime::parse_from_rfc3339(occurrence_date)
|
||||||
{
|
{
|
||||||
// RFC3339 format (with time and timezone)
|
// RFC3339 format (with time and timezone) - convert to naive
|
||||||
date.with_timezone(&chrono::Utc)
|
date.naive_utc()
|
||||||
} else if let Ok(naive_date) =
|
} else if let Ok(naive_date) =
|
||||||
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
|
||||||
{
|
{
|
||||||
// Simple date format (YYYY-MM-DD)
|
// Simple date format (YYYY-MM-DD)
|
||||||
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc()
|
naive_date.and_hms_opt(0, 0, 0).unwrap()
|
||||||
} else {
|
} else {
|
||||||
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut updated_event = event;
|
let mut updated_event = event;
|
||||||
updated_event.exdate.push(exception_utc);
|
updated_event.exdate.push(exception_datetime);
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"🔄 Adding EXDATE {} to recurring event {}",
|
"🔄 Adding EXDATE {} to recurring event {}",
|
||||||
exception_utc.format("%Y%m%dT%H%M%SZ"),
|
exception_datetime.format("%Y%m%dT%H%M%S"),
|
||||||
updated_event.uid
|
updated_event.uid
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -453,19 +451,16 @@ pub async fn create_event(
|
|||||||
calendar_paths[0].clone()
|
calendar_paths[0].clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse dates and times
|
// Parse dates and times as local times (no UTC conversion)
|
||||||
let start_datetime =
|
let start_datetime =
|
||||||
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||||
|
|
||||||
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
let end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// For all-day events, add one day to end date for RFC-5545 compliance
|
// Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events
|
||||||
// RFC-5545 uses exclusive end dates for all-day events
|
// No additional conversion needed here
|
||||||
if request.all_day {
|
|
||||||
end_datetime = end_datetime + chrono::Duration::days(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that end is after start (allow equal times for all-day events)
|
// Validate that end is after start (allow equal times for all-day events)
|
||||||
if request.all_day {
|
if request.all_day {
|
||||||
@@ -527,19 +522,8 @@ pub async fn create_event(
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse alarms - convert from minutes string to EventReminder structs
|
// Use VAlarms directly from request (no conversion needed)
|
||||||
let alarms: Vec<crate::calendar::EventReminder> = if request.reminder.trim().is_empty() {
|
let alarms = request.alarms;
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
match request.reminder.parse::<i32>() {
|
|
||||||
Ok(minutes) => vec![crate::calendar::EventReminder {
|
|
||||||
minutes_before: minutes,
|
|
||||||
action: crate::calendar::ReminderAction::Display,
|
|
||||||
description: None,
|
|
||||||
}],
|
|
||||||
Err(_) => Vec::new(),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if recurrence is already a full RRULE or just a simple type
|
// Check if recurrence is already a full RRULE or just a simple type
|
||||||
let rrule = if request.recurrence.starts_with("FREQ=") {
|
let rrule = if request.recurrence.starts_with("FREQ=") {
|
||||||
@@ -594,9 +578,13 @@ pub async fn create_event(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the VEvent struct (RFC 5545 compliant)
|
// Create the VEvent struct (RFC 5545 compliant) with local times
|
||||||
let mut event = VEvent::new(uid, start_datetime);
|
let mut event = VEvent::new(uid, start_datetime);
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
|
|
||||||
|
// Set timezone information from client
|
||||||
|
event.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
event.dtend_tzid = Some(request.timezone.clone());
|
||||||
event.summary = if request.title.trim().is_empty() {
|
event.summary = if request.title.trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -646,21 +634,7 @@ pub async fn create_event(
|
|||||||
event.categories = categories;
|
event.categories = categories;
|
||||||
event.rrule = rrule;
|
event.rrule = rrule;
|
||||||
event.all_day = request.all_day;
|
event.all_day = request.all_day;
|
||||||
event.alarms = alarms
|
event.alarms = alarms;
|
||||||
.into_iter()
|
|
||||||
.map(|reminder| VAlarm {
|
|
||||||
action: AlarmAction::Display,
|
|
||||||
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(
|
|
||||||
-reminder.minutes_before as i64,
|
|
||||||
)),
|
|
||||||
duration: None,
|
|
||||||
repeat: None,
|
|
||||||
description: reminder.description,
|
|
||||||
summary: None,
|
|
||||||
attendees: Vec::new(),
|
|
||||||
attach: Vec::new(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
event.calendar_path = Some(calendar_path.clone());
|
event.calendar_path = Some(calendar_path.clone());
|
||||||
|
|
||||||
// Create the event on the CalDAV server
|
// Create the event on the CalDAV server
|
||||||
@@ -757,19 +731,18 @@ pub async fn update_event(
|
|||||||
let (mut event, calendar_path, event_href) = found_event
|
let (mut event, calendar_path, event_href) = found_event
|
||||||
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
|
||||||
|
|
||||||
// Parse dates and times
|
// Parse dates and times as local times (no UTC conversion)
|
||||||
|
println!("🕐 UPDATE: Received start_date: '{}', start_time: '{}', timezone: '{}'",
|
||||||
|
request.start_date, request.start_time, request.timezone);
|
||||||
let start_datetime =
|
let start_datetime =
|
||||||
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
|
parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||||
|
|
||||||
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
|
let end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
|
|
||||||
// For all-day events, add one day to end date for RFC-5545 compliance
|
// Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events
|
||||||
// RFC-5545 uses exclusive end dates for all-day events
|
// No additional conversion needed here
|
||||||
if request.all_day {
|
|
||||||
end_datetime = end_datetime + chrono::Duration::days(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that end is after start (allow equal times for all-day events)
|
// Validate that end is after start (allow equal times for all-day events)
|
||||||
if request.all_day {
|
if request.all_day {
|
||||||
@@ -786,9 +759,11 @@ pub async fn update_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update event properties
|
// Update event properties with local times and timezone info
|
||||||
event.dtstart = start_datetime;
|
event.dtstart = start_datetime;
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
|
event.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
event.dtend_tzid = Some(request.timezone.clone());
|
||||||
event.summary = if request.title.trim().is_empty() {
|
event.summary = if request.title.trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -822,6 +797,99 @@ pub async fn update_event(
|
|||||||
|
|
||||||
event.priority = request.priority;
|
event.priority = request.priority;
|
||||||
|
|
||||||
|
// Process recurrence information to set RRULE
|
||||||
|
println!("🔄 Processing recurrence: '{}'", request.recurrence);
|
||||||
|
println!("🔄 Recurrence days: {:?}", request.recurrence_days);
|
||||||
|
println!("🔄 Recurrence interval: {:?}", request.recurrence_interval);
|
||||||
|
println!("🔄 Recurrence count: {:?}", request.recurrence_count);
|
||||||
|
println!("🔄 Recurrence end date: {:?}", request.recurrence_end_date);
|
||||||
|
|
||||||
|
let rrule = if request.recurrence.starts_with("FREQ=") {
|
||||||
|
// Frontend sent a complete RRULE string, use it directly
|
||||||
|
if request.recurrence.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(request.recurrence.clone())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Parse recurrence type and build RRULE with all parameters
|
||||||
|
let base_rrule = match request.recurrence.to_uppercase().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 = format!("{};BYDAY={}", rrule, selected_days.join(","));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(rrule)
|
||||||
|
}
|
||||||
|
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
|
||||||
|
"YEARLY" => Some("FREQ=YEARLY".to_string()),
|
||||||
|
"NONE" | "" => None, // Clear any existing recurrence
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add INTERVAL, COUNT, and UNTIL parameters if specified
|
||||||
|
if let Some(mut rrule_string) = base_rrule {
|
||||||
|
// Add INTERVAL parameter (every N days/weeks/months/years)
|
||||||
|
if let Some(interval) = request.recurrence_interval {
|
||||||
|
if interval > 1 {
|
||||||
|
rrule_string = format!("{};INTERVAL={}", rrule_string, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add COUNT or UNTIL parameter (but not both - COUNT takes precedence)
|
||||||
|
if let Some(count) = request.recurrence_count {
|
||||||
|
rrule_string = format!("{};COUNT={}", rrule_string, count);
|
||||||
|
} else if let Some(end_date) = &request.recurrence_end_date {
|
||||||
|
// Convert YYYY-MM-DD to YYYYMMDD format for UNTIL
|
||||||
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") {
|
||||||
|
rrule_string = format!("{};UNTIL={}", rrule_string, date.format("%Y%m%d"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(rrule_string)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
event.rrule = rrule.clone();
|
||||||
|
println!("🔄 Set event RRULE to: {:?}", rrule);
|
||||||
|
|
||||||
|
if rrule.is_some() {
|
||||||
|
println!("✨ Converting singleton event to recurring series with RRULE: {}", rrule.as_ref().unwrap());
|
||||||
|
} else {
|
||||||
|
println!("📝 Event remains non-recurring (no RRULE set)");
|
||||||
|
}
|
||||||
|
|
||||||
// Update the event on the CalDAV server
|
// Update the event on the CalDAV server
|
||||||
println!(
|
println!(
|
||||||
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
"📝 Updating event {} at calendar_path: {}, event_href: {}",
|
||||||
@@ -840,33 +908,29 @@ pub async fn update_event(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_event_datetime(
|
fn parse_event_datetime_local(
|
||||||
date_str: &str,
|
date_str: &str,
|
||||||
time_str: &str,
|
time_str: &str,
|
||||||
all_day: bool,
|
all_day: bool,
|
||||||
) -> Result<chrono::DateTime<chrono::Utc>, String> {
|
) -> Result<chrono::NaiveDateTime, String> {
|
||||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
|
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||||
|
|
||||||
// Parse the date
|
// Parse the date
|
||||||
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||||
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
||||||
|
|
||||||
if all_day {
|
if all_day {
|
||||||
// For all-day events, use noon UTC to avoid timezone boundary issues
|
// For all-day events, use start of day
|
||||||
// This ensures the date remains correct when converted to any local timezone
|
|
||||||
let datetime = date
|
let datetime = date
|
||||||
.and_hms_opt(12, 0, 0)
|
.and_hms_opt(0, 0, 0)
|
||||||
.ok_or_else(|| "Failed to create noon datetime".to_string())?;
|
.ok_or_else(|| "Failed to create start-of-day datetime".to_string())?;
|
||||||
Ok(Utc.from_utc_datetime(&datetime))
|
Ok(datetime)
|
||||||
} else {
|
} else {
|
||||||
// Parse the time
|
// Parse the time
|
||||||
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
||||||
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
||||||
|
|
||||||
// Combine date and time
|
// Combine date and time - now keeping as local time
|
||||||
let datetime = NaiveDateTime::new(date, time);
|
Ok(NaiveDateTime::new(date, time))
|
||||||
|
|
||||||
// Frontend now sends UTC times, so treat as UTC directly
|
|
||||||
Ok(Utc.from_utc_datetime(&datetime))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,17 +285,25 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::
|
|||||||
|
|
||||||
let vevent = VEvent {
|
let vevent = VEvent {
|
||||||
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||||
dtstart,
|
dtstart: dtstart.naive_utc(),
|
||||||
dtend,
|
dtstart_tzid: None, // TODO: Parse timezone from ICS
|
||||||
|
dtend: dtend.map(|dt| dt.naive_utc()),
|
||||||
|
dtend_tzid: None, // TODO: Parse timezone from ICS
|
||||||
summary,
|
summary,
|
||||||
description,
|
description,
|
||||||
location,
|
location,
|
||||||
all_day,
|
all_day,
|
||||||
rrule,
|
rrule,
|
||||||
|
rdate: Vec::new(),
|
||||||
|
rdate_tzid: None,
|
||||||
exdate: Vec::new(), // External calendars don't need exception handling
|
exdate: Vec::new(), // External calendars don't need exception handling
|
||||||
|
exdate_tzid: None,
|
||||||
recurrence_id: None,
|
recurrence_id: None,
|
||||||
|
recurrence_id_tzid: None,
|
||||||
created: None,
|
created: None,
|
||||||
|
created_tzid: None,
|
||||||
last_modified: None,
|
last_modified: None,
|
||||||
|
last_modified_tzid: None,
|
||||||
dtstamp: Utc::now(),
|
dtstamp: Utc::now(),
|
||||||
sequence: Some(0),
|
sequence: Some(0),
|
||||||
status: None,
|
status: None,
|
||||||
@@ -313,7 +321,6 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::
|
|||||||
class: None,
|
class: None,
|
||||||
contact: None,
|
contact: None,
|
||||||
comment: None,
|
comment: None,
|
||||||
rdate: Vec::new(),
|
|
||||||
alarms: Vec::new(),
|
alarms: Vec::new(),
|
||||||
etag: None,
|
etag: None,
|
||||||
href: None,
|
href: None,
|
||||||
@@ -478,32 +485,23 @@ fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<Date
|
|||||||
fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
let original_count = events.len();
|
|
||||||
|
|
||||||
// First pass: Group by UID and prefer recurring events over single events with same UID
|
// First pass: Group by UID and prefer recurring events over single events with same UID
|
||||||
let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
||||||
|
|
||||||
for event in events.drain(..) {
|
for event in events.drain(..) {
|
||||||
// Debug logging to understand what's happening
|
|
||||||
println!("🔍 Event: '{}' at {} (RRULE: {}) - UID: {}",
|
|
||||||
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
|
||||||
event.dtstart.format("%Y-%m-%d %H:%M"),
|
|
||||||
if event.rrule.is_some() { "Yes" } else { "No" },
|
|
||||||
event.uid
|
|
||||||
);
|
|
||||||
|
|
||||||
uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event);
|
uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut uid_deduplicated_events = Vec::new();
|
let mut uid_deduplicated_events = Vec::new();
|
||||||
|
|
||||||
for (uid, mut events_with_uid) in uid_groups.drain() {
|
for (_uid, mut events_with_uid) in uid_groups.drain() {
|
||||||
if events_with_uid.len() == 1 {
|
if events_with_uid.len() == 1 {
|
||||||
// Only one event with this UID, keep it
|
// Only one event with this UID, keep it
|
||||||
uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap());
|
uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap());
|
||||||
} else {
|
} else {
|
||||||
// Multiple events with same UID - prefer recurring over non-recurring
|
// Multiple events with same UID - prefer recurring over non-recurring
|
||||||
println!("🔍 Found {} events with UID '{}'", events_with_uid.len(), uid);
|
|
||||||
|
|
||||||
// Sort by preference: recurring events first, then by completeness
|
// Sort by preference: recurring events first, then by completeness
|
||||||
events_with_uid.sort_by(|a, b| {
|
events_with_uid.sort_by(|a, b| {
|
||||||
@@ -522,10 +520,6 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
|||||||
|
|
||||||
// Keep the first (preferred) event
|
// Keep the first (preferred) event
|
||||||
let preferred_event = events_with_uid.into_iter().next().unwrap();
|
let preferred_event = events_with_uid.into_iter().next().unwrap();
|
||||||
println!("🔄 UID dedup: Keeping '{}' (RRULE: {})",
|
|
||||||
preferred_event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
|
||||||
if preferred_event.rrule.is_some() { "Yes" } else { "No" }
|
|
||||||
);
|
|
||||||
uid_deduplicated_events.push(preferred_event);
|
uid_deduplicated_events.push(preferred_event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,13 +546,12 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
|||||||
|
|
||||||
let mut deduplicated_recurring = Vec::new();
|
let mut deduplicated_recurring = Vec::new();
|
||||||
|
|
||||||
for (title, events_with_title) in title_groups.drain() {
|
for (_title, events_with_title) in title_groups.drain() {
|
||||||
if events_with_title.len() == 1 {
|
if events_with_title.len() == 1 {
|
||||||
// Single event with this title, keep as-is
|
// Single event with this title, keep as-is
|
||||||
deduplicated_recurring.push(events_with_title.into_iter().next().unwrap());
|
deduplicated_recurring.push(events_with_title.into_iter().next().unwrap());
|
||||||
} else {
|
} else {
|
||||||
// Multiple events with same title - consolidate or deduplicate
|
// Multiple events with same title - consolidate or deduplicate
|
||||||
println!("🔍 Found {} events with title '{}'", events_with_title.len(), title);
|
|
||||||
|
|
||||||
// Check if these are actually different recurring patterns for the same logical event
|
// Check if these are actually different recurring patterns for the same logical event
|
||||||
let consolidated = consolidate_same_title_events(events_with_title);
|
let consolidated = consolidate_same_title_events(events_with_title);
|
||||||
@@ -585,15 +578,9 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
|||||||
let existing_completeness = event_completeness_score(existing_event);
|
let existing_completeness = event_completeness_score(existing_event);
|
||||||
|
|
||||||
if current_completeness > existing_completeness {
|
if current_completeness > existing_completeness {
|
||||||
println!("🔄 Replacing single event: Keeping '{}' over '{}'",
|
|
||||||
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
|
||||||
existing_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
|
||||||
);
|
|
||||||
deduplicated_single[existing_index] = event;
|
deduplicated_single[existing_index] = event;
|
||||||
} else {
|
} else {
|
||||||
println!("🚫 Discarding duplicate single event: Keeping existing '{}'",
|
// Discarding duplicate single event - keeping existing
|
||||||
existing_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -613,10 +600,6 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if is_rrule_generated {
|
if is_rrule_generated {
|
||||||
println!("🚫 Discarding RRULE-generated instance: '{}' at {} would be generated by recurring event",
|
|
||||||
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
|
||||||
event.dtstart.format("%Y-%m-%d %H:%M")
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// This is a unique single event
|
// This is a unique single event
|
||||||
seen_single.insert(dedup_key, deduplicated_single.len());
|
seen_single.insert(dedup_key, deduplicated_single.len());
|
||||||
@@ -628,11 +611,6 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
|||||||
let mut result = deduplicated_recurring;
|
let mut result = deduplicated_recurring;
|
||||||
result.extend(deduplicated_single);
|
result.extend(deduplicated_single);
|
||||||
|
|
||||||
println!("📊 Deduplication complete: {} -> {} events ({} recurring, {} single)",
|
|
||||||
original_count, result.len(),
|
|
||||||
result.iter().filter(|e| e.rrule.is_some()).count(),
|
|
||||||
result.iter().filter(|e| e.rrule.is_none()).count()
|
|
||||||
);
|
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -658,14 +636,6 @@ fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log the RRULEs we're working with
|
// Log the RRULEs we're working with
|
||||||
for event in &events {
|
|
||||||
if let Some(rrule) = &event.rrule {
|
|
||||||
println!("🔍 RRULE for '{}': {}",
|
|
||||||
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
|
|
||||||
rrule
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if all events have similar time patterns and could be consolidated
|
// Check if all events have similar time patterns and could be consolidated
|
||||||
let first_event = &events[0];
|
let first_event = &events[0];
|
||||||
@@ -688,7 +658,6 @@ fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if !can_consolidate {
|
if !can_consolidate {
|
||||||
println!("🚫 Cannot consolidate events - different times or durations");
|
|
||||||
// Just deduplicate exact duplicates
|
// Just deduplicate exact duplicates
|
||||||
return deduplicate_exact_recurring_events(events);
|
return deduplicate_exact_recurring_events(events);
|
||||||
}
|
}
|
||||||
@@ -701,13 +670,11 @@ fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
|||||||
if weekly_events.len() >= 2 && weekly_events.len() == events.len() {
|
if weekly_events.len() >= 2 && weekly_events.len() == events.len() {
|
||||||
// All events are weekly - try to consolidate into a single multi-day weekly pattern
|
// All events are weekly - try to consolidate into a single multi-day weekly pattern
|
||||||
if let Some(consolidated) = consolidate_weekly_patterns(&events) {
|
if let Some(consolidated) = consolidate_weekly_patterns(&events) {
|
||||||
println!("✅ Successfully consolidated {} weekly patterns into one", events.len());
|
|
||||||
return vec![consolidated];
|
return vec![consolidated];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we can't consolidate, just deduplicate exact matches and keep the most complete one
|
// If we can't consolidate, just deduplicate exact matches and keep the most complete one
|
||||||
println!("🚫 Cannot consolidate - keeping most complete event");
|
|
||||||
let deduplicated = deduplicate_exact_recurring_events(events);
|
let deduplicated = deduplicate_exact_recurring_events(events);
|
||||||
|
|
||||||
// If we still have multiple events, keep only the most complete one
|
// If we still have multiple events, keep only the most complete one
|
||||||
@@ -834,7 +801,7 @@ fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VE
|
|||||||
if rrule.contains("FREQ=DAILY") {
|
if rrule.contains("FREQ=DAILY") {
|
||||||
// Daily recurrence
|
// Daily recurrence
|
||||||
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||||
let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days();
|
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
|
||||||
|
|
||||||
if days_diff >= 0 && days_diff % interval as i64 == 0 {
|
if days_diff >= 0 && days_diff % interval as i64 == 0 {
|
||||||
// Check if times match (allowing for timezone differences within same day)
|
// Check if times match (allowing for timezone differences within same day)
|
||||||
@@ -845,7 +812,7 @@ fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VE
|
|||||||
} else if rrule.contains("FREQ=WEEKLY") {
|
} else if rrule.contains("FREQ=WEEKLY") {
|
||||||
// Weekly recurrence
|
// Weekly recurrence
|
||||||
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||||
let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days();
|
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
|
||||||
|
|
||||||
// First check if it's the same day of week and time
|
// First check if it's the same day of week and time
|
||||||
let recurring_weekday = recurring_event.dtstart.weekday();
|
let recurring_weekday = recurring_event.dtstart.weekday();
|
||||||
|
|||||||
@@ -14,6 +14,33 @@ use calendar_models::{EventClass, EventStatus, VEvent};
|
|||||||
|
|
||||||
use super::auth::{extract_bearer_token, extract_password_header};
|
use super::auth::{extract_bearer_token, extract_password_header};
|
||||||
|
|
||||||
|
fn parse_event_datetime_local(
|
||||||
|
date_str: &str,
|
||||||
|
time_str: &str,
|
||||||
|
all_day: bool,
|
||||||
|
) -> Result<chrono::NaiveDateTime, String> {
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||||
|
|
||||||
|
// Parse the date
|
||||||
|
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
|
||||||
|
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
|
||||||
|
|
||||||
|
if all_day {
|
||||||
|
// For all-day events, use start of day
|
||||||
|
let datetime = date
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
|
.ok_or_else(|| "Failed to create start-of-day datetime".to_string())?;
|
||||||
|
Ok(datetime)
|
||||||
|
} else {
|
||||||
|
// Parse the time
|
||||||
|
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
|
||||||
|
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
|
||||||
|
|
||||||
|
// Combine date and time - now keeping as local time
|
||||||
|
Ok(NaiveDateTime::new(date, time))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new recurring event series
|
/// Create a new recurring event series
|
||||||
pub async fn create_event_series(
|
pub async fn create_event_series(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
@@ -106,84 +133,27 @@ pub async fn create_event_series(
|
|||||||
|
|
||||||
println!("📅 Using calendar path: {}", calendar_path);
|
println!("📅 Using calendar path: {}", calendar_path);
|
||||||
|
|
||||||
// Parse datetime components
|
// Parse dates and times as local times (no UTC conversion)
|
||||||
let start_date =
|
let start_datetime = parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
|
||||||
chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d").map_err(|_| {
|
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
|
||||||
ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let (start_datetime, end_datetime) = if request.all_day {
|
let end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day)
|
||||||
// For all-day events, use the dates as-is
|
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
|
||||||
let start_dt = start_date
|
|
||||||
.and_hms_opt(0, 0, 0)
|
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
|
||||||
|
|
||||||
let end_date = if !request.end_date.is_empty() {
|
// Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events
|
||||||
chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d").map_err(|_| {
|
// No additional conversion needed here
|
||||||
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
|
|
||||||
})?
|
|
||||||
} else {
|
|
||||||
start_date
|
|
||||||
};
|
|
||||||
|
|
||||||
let end_dt = end_date
|
|
||||||
.and_hms_opt(23, 59, 59)
|
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
|
||||||
|
|
||||||
// Frontend now sends UTC times, so treat as UTC directly
|
|
||||||
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
|
|
||||||
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
|
|
||||||
|
|
||||||
(
|
|
||||||
start_local.with_timezone(&chrono::Utc),
|
|
||||||
end_local.with_timezone(&chrono::Utc),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Parse times for timed events
|
|
||||||
let start_time = if !request.start_time.is_empty() {
|
|
||||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
|
||||||
ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string())
|
|
||||||
})?
|
|
||||||
} else {
|
|
||||||
chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9 AM
|
|
||||||
};
|
|
||||||
|
|
||||||
let end_time = if !request.end_time.is_empty() {
|
|
||||||
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| {
|
|
||||||
ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string())
|
|
||||||
})?
|
|
||||||
} else {
|
|
||||||
chrono::NaiveTime::from_hms_opt(10, 0, 0).unwrap() // Default to 1 hour duration
|
|
||||||
};
|
|
||||||
|
|
||||||
let start_dt = start_date.and_time(start_time);
|
|
||||||
let end_dt = if !request.end_date.is_empty() {
|
|
||||||
let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d")
|
|
||||||
.map_err(|_| {
|
|
||||||
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
|
|
||||||
})?;
|
|
||||||
end_date.and_time(end_time)
|
|
||||||
} else {
|
|
||||||
start_date.and_time(end_time)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Frontend now sends UTC times, so treat as UTC directly
|
|
||||||
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
|
|
||||||
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
|
|
||||||
|
|
||||||
(
|
|
||||||
start_local.with_timezone(&chrono::Utc),
|
|
||||||
end_local.with_timezone(&chrono::Utc),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate a unique UID for the series
|
// Generate a unique UID for the series
|
||||||
let uid = format!("series-{}", uuid::Uuid::new_v4().to_string());
|
let uid = format!("series-{}", uuid::Uuid::new_v4().to_string());
|
||||||
|
|
||||||
// Create the VEvent for the series
|
// Create the VEvent for the series with local times
|
||||||
let mut event = VEvent::new(uid.clone(), start_datetime);
|
let mut event = VEvent::new(uid.clone(), start_datetime);
|
||||||
event.dtend = Some(end_datetime);
|
event.dtend = Some(end_datetime);
|
||||||
event.all_day = request.all_day; // Set the all_day flag properly
|
event.all_day = request.all_day; // Set the all_day flag properly
|
||||||
|
|
||||||
|
// Set timezone information from client
|
||||||
|
event.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
event.dtend_tzid = Some(request.timezone.clone());
|
||||||
event.summary = if request.title.trim().is_empty() {
|
event.summary = if request.title.trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -257,6 +227,8 @@ pub async fn update_event_series(
|
|||||||
"🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}",
|
"🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}",
|
||||||
request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date
|
request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date
|
||||||
);
|
);
|
||||||
|
println!("🕐 SERIES: Received start_date: '{}', start_time: '{}', timezone: '{}'",
|
||||||
|
request.start_date, request.start_time, request.timezone);
|
||||||
|
|
||||||
// Extract and verify token
|
// Extract and verify token
|
||||||
let token = extract_bearer_token(&headers)?;
|
let token = extract_bearer_token(&headers)?;
|
||||||
@@ -372,7 +344,7 @@ pub async fn update_event_series(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Parse datetime components for the update
|
// Parse datetime components for the update
|
||||||
let original_start_date = existing_event.dtstart.date_naive();
|
let original_start_date = existing_event.dtstart.date();
|
||||||
|
|
||||||
// For "this_and_future" and "this_only" updates, use the occurrence date for the modified event
|
// For "this_and_future" and "this_only" updates, use the occurrence date for the modified event
|
||||||
// For "all_in_series" updates, preserve the original series start date
|
// For "all_in_series" updates, preserve the original series start date
|
||||||
@@ -399,7 +371,7 @@ pub async fn update_event_series(
|
|||||||
// Calculate the duration from the original event
|
// Calculate the duration from the original event
|
||||||
let original_duration_days = existing_event
|
let original_duration_days = existing_event
|
||||||
.dtend
|
.dtend
|
||||||
.map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days())
|
.map(|end| (end.date() - existing_event.dtstart.date()).num_days())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
start_date + chrono::Duration::days(original_duration_days)
|
start_date + chrono::Duration::days(original_duration_days)
|
||||||
} else {
|
} else {
|
||||||
@@ -410,11 +382,8 @@ pub async fn update_event_series(
|
|||||||
.and_hms_opt(12, 0, 0)
|
.and_hms_opt(12, 0, 0)
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
||||||
|
|
||||||
// For all-day events, use UTC directly (no local conversion needed)
|
// For all-day events, use local times directly
|
||||||
(
|
(start_dt, end_dt)
|
||||||
chrono::Utc.from_utc_datetime(&start_dt),
|
|
||||||
chrono::Utc.from_utc_datetime(&end_dt),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
let start_time = if !request.start_time.is_empty() {
|
let start_time = if !request.start_time.is_empty() {
|
||||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
||||||
@@ -445,17 +414,11 @@ pub async fn update_event_series(
|
|||||||
.dtend
|
.dtend
|
||||||
.map(|end| end - existing_event.dtstart)
|
.map(|end| end - existing_event.dtstart)
|
||||||
.unwrap_or_else(|| chrono::Duration::hours(1));
|
.unwrap_or_else(|| chrono::Duration::hours(1));
|
||||||
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
|
start_dt + original_duration
|
||||||
};
|
};
|
||||||
|
|
||||||
// Frontend now sends UTC times, so treat as UTC directly
|
// Frontend now sends local times, so use them directly
|
||||||
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
|
(start_dt, end_dt)
|
||||||
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
|
|
||||||
|
|
||||||
(
|
|
||||||
start_local.with_timezone(&chrono::Utc),
|
|
||||||
end_local.with_timezone(&chrono::Utc),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle different update scopes
|
// Handle different update scopes
|
||||||
@@ -502,13 +465,10 @@ pub async fn update_event_series(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Update the event on the CalDAV server using the original event's href
|
// Update the event on the CalDAV server using the original event's href
|
||||||
println!("📤 Updating event on CalDAV server...");
|
|
||||||
let event_href = existing_event
|
let event_href = existing_event
|
||||||
.href
|
.href
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?;
|
.ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?;
|
||||||
println!("📤 Using event href: {}", event_href);
|
|
||||||
println!("📤 Calendar path: {}", calendar_path);
|
|
||||||
|
|
||||||
match client
|
match client
|
||||||
.update_event(&calendar_path, &updated_event, event_href)
|
.update_event(&calendar_path, &updated_event, event_href)
|
||||||
@@ -702,8 +662,8 @@ fn build_series_rrule_with_freq(
|
|||||||
fn update_entire_series(
|
fn update_entire_series(
|
||||||
existing_event: &mut VEvent,
|
existing_event: &mut VEvent,
|
||||||
request: &UpdateEventSeriesRequest,
|
request: &UpdateEventSeriesRequest,
|
||||||
start_datetime: chrono::DateTime<chrono::Utc>,
|
start_datetime: chrono::NaiveDateTime,
|
||||||
end_datetime: chrono::DateTime<chrono::Utc>,
|
end_datetime: chrono::NaiveDateTime,
|
||||||
) -> Result<(VEvent, u32), ApiError> {
|
) -> Result<(VEvent, u32), ApiError> {
|
||||||
// Clone the existing event to preserve all metadata
|
// Clone the existing event to preserve all metadata
|
||||||
let mut updated_event = existing_event.clone();
|
let mut updated_event = existing_event.clone();
|
||||||
@@ -711,6 +671,8 @@ fn update_entire_series(
|
|||||||
// Update only the modified properties from the request
|
// Update only the modified properties from the request
|
||||||
updated_event.dtstart = start_datetime;
|
updated_event.dtstart = start_datetime;
|
||||||
updated_event.dtend = Some(end_datetime);
|
updated_event.dtend = Some(end_datetime);
|
||||||
|
updated_event.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
updated_event.dtend_tzid = Some(request.timezone.clone());
|
||||||
updated_event.summary = if request.title.trim().is_empty() {
|
updated_event.summary = if request.title.trim().is_empty() {
|
||||||
existing_event.summary.clone() // Keep original if empty
|
existing_event.summary.clone() // Keep original if empty
|
||||||
} else {
|
} else {
|
||||||
@@ -743,8 +705,9 @@ fn update_entire_series(
|
|||||||
|
|
||||||
// Update timestamps
|
// Update timestamps
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
let now_naive = now.naive_utc();
|
||||||
updated_event.dtstamp = now;
|
updated_event.dtstamp = now;
|
||||||
updated_event.last_modified = Some(now);
|
updated_event.last_modified = Some(now_naive);
|
||||||
// Keep original created timestamp to preserve event history
|
// Keep original created timestamp to preserve event history
|
||||||
|
|
||||||
// Update RRULE if recurrence parameters are provided
|
// Update RRULE if recurrence parameters are provided
|
||||||
@@ -832,8 +795,8 @@ fn update_entire_series(
|
|||||||
async fn update_this_and_future(
|
async fn update_this_and_future(
|
||||||
existing_event: &mut VEvent,
|
existing_event: &mut VEvent,
|
||||||
request: &UpdateEventSeriesRequest,
|
request: &UpdateEventSeriesRequest,
|
||||||
start_datetime: chrono::DateTime<chrono::Utc>,
|
start_datetime: chrono::NaiveDateTime,
|
||||||
end_datetime: chrono::DateTime<chrono::Utc>,
|
end_datetime: chrono::NaiveDateTime,
|
||||||
client: &CalDAVClient,
|
client: &CalDAVClient,
|
||||||
calendar_path: &str,
|
calendar_path: &str,
|
||||||
) -> Result<(VEvent, u32), ApiError> {
|
) -> Result<(VEvent, u32), ApiError> {
|
||||||
@@ -881,6 +844,8 @@ async fn update_this_and_future(
|
|||||||
new_series.uid = new_series_uid.clone();
|
new_series.uid = new_series_uid.clone();
|
||||||
new_series.dtstart = start_datetime;
|
new_series.dtstart = start_datetime;
|
||||||
new_series.dtend = Some(end_datetime);
|
new_series.dtend = Some(end_datetime);
|
||||||
|
new_series.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
new_series.dtend_tzid = Some(request.timezone.clone());
|
||||||
new_series.summary = if request.title.trim().is_empty() {
|
new_series.summary = if request.title.trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -913,9 +878,10 @@ async fn update_this_and_future(
|
|||||||
|
|
||||||
// Update timestamps
|
// Update timestamps
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
let now_naive = now.naive_utc();
|
||||||
new_series.dtstamp = now;
|
new_series.dtstamp = now;
|
||||||
new_series.created = Some(now);
|
new_series.created = Some(now_naive);
|
||||||
new_series.last_modified = Some(now);
|
new_series.last_modified = Some(now_naive);
|
||||||
new_series.href = None; // Will be set when created
|
new_series.href = None; // Will be set when created
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
@@ -943,8 +909,8 @@ async fn update_this_and_future(
|
|||||||
async fn update_single_occurrence(
|
async fn update_single_occurrence(
|
||||||
existing_event: &mut VEvent,
|
existing_event: &mut VEvent,
|
||||||
request: &UpdateEventSeriesRequest,
|
request: &UpdateEventSeriesRequest,
|
||||||
start_datetime: chrono::DateTime<chrono::Utc>,
|
start_datetime: chrono::NaiveDateTime,
|
||||||
end_datetime: chrono::DateTime<chrono::Utc>,
|
end_datetime: chrono::NaiveDateTime,
|
||||||
client: &CalDAVClient,
|
client: &CalDAVClient,
|
||||||
calendar_path: &str,
|
calendar_path: &str,
|
||||||
_original_event_href: &str,
|
_original_event_href: &str,
|
||||||
@@ -969,21 +935,20 @@ async fn update_single_occurrence(
|
|||||||
// Create the EXDATE datetime using the original event's time
|
// Create the EXDATE datetime using the original event's time
|
||||||
let original_time = existing_event.dtstart.time();
|
let original_time = existing_event.dtstart.time();
|
||||||
let exception_datetime = exception_date.and_time(original_time);
|
let exception_datetime = exception_date.and_time(original_time);
|
||||||
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
|
|
||||||
|
|
||||||
// Add the exception date to the original series
|
// Add the exception date to the original series
|
||||||
println!(
|
println!(
|
||||||
"📝 BEFORE adding EXDATE: existing_event.exdate = {:?}",
|
"📝 BEFORE adding EXDATE: existing_event.exdate = {:?}",
|
||||||
existing_event.exdate
|
existing_event.exdate
|
||||||
);
|
);
|
||||||
existing_event.exdate.push(exception_utc);
|
existing_event.exdate.push(exception_datetime);
|
||||||
println!(
|
println!(
|
||||||
"📝 AFTER adding EXDATE: existing_event.exdate = {:?}",
|
"📝 AFTER adding EXDATE: existing_event.exdate = {:?}",
|
||||||
existing_event.exdate
|
existing_event.exdate
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
"🚫 Added EXDATE for single occurrence modification: {}",
|
"🚫 Added EXDATE for single occurrence modification: {}",
|
||||||
exception_utc.format("%Y-%m-%d %H:%M:%S")
|
exception_datetime.format("%Y-%m-%d %H:%M:%S")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create exception event by cloning the existing event to preserve all metadata
|
// Create exception event by cloning the existing event to preserve all metadata
|
||||||
@@ -995,6 +960,8 @@ async fn update_single_occurrence(
|
|||||||
// Update the modified properties from the request
|
// Update the modified properties from the request
|
||||||
exception_event.dtstart = start_datetime;
|
exception_event.dtstart = start_datetime;
|
||||||
exception_event.dtend = Some(end_datetime);
|
exception_event.dtend = Some(end_datetime);
|
||||||
|
exception_event.dtstart_tzid = Some(request.timezone.clone());
|
||||||
|
exception_event.dtend_tzid = Some(request.timezone.clone());
|
||||||
exception_event.summary = if request.title.trim().is_empty() {
|
exception_event.summary = if request.title.trim().is_empty() {
|
||||||
existing_event.summary.clone() // Keep original if empty
|
existing_event.summary.clone() // Keep original if empty
|
||||||
} else {
|
} else {
|
||||||
@@ -1027,8 +994,9 @@ async fn update_single_occurrence(
|
|||||||
|
|
||||||
// Update timestamps for the exception event
|
// Update timestamps for the exception event
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
let now_naive = now.naive_utc();
|
||||||
exception_event.dtstamp = now;
|
exception_event.dtstamp = now;
|
||||||
exception_event.last_modified = Some(now);
|
exception_event.last_modified = Some(now_naive);
|
||||||
// Keep original created timestamp to preserve event history
|
// Keep original created timestamp to preserve event history
|
||||||
|
|
||||||
// Set RECURRENCE-ID to point to the original occurrence
|
// Set RECURRENCE-ID to point to the original occurrence
|
||||||
@@ -1044,7 +1012,7 @@ async fn update_single_occurrence(
|
|||||||
|
|
||||||
println!(
|
println!(
|
||||||
"✨ Created exception event with RECURRENCE-ID: {}",
|
"✨ Created exception event with RECURRENCE-ID: {}",
|
||||||
exception_utc.format("%Y-%m-%d %H:%M:%S")
|
exception_datetime.format("%Y-%m-%d %H:%M:%S")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create the exception event as a new event (original series will be updated by main handler)
|
// Create the exception event as a new event (original series will be updated by main handler)
|
||||||
@@ -1055,7 +1023,7 @@ async fn update_single_occurrence(
|
|||||||
|
|
||||||
println!("✅ Created exception event successfully");
|
println!("✅ Created exception event successfully");
|
||||||
|
|
||||||
// Return the original series (now with EXDATE) - main handler will update it on CalDAV
|
// Return the modified existing event with EXDATE for the main handler to update on CalDAV
|
||||||
Ok((existing_event.clone(), 1)) // 1 occurrence modified (via exception)
|
Ok((existing_event.clone(), 1)) // 1 occurrence modified (via exception)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,15 +1140,14 @@ async fn delete_single_occurrence(
|
|||||||
// Create the EXDATE datetime (use the same time as the original event)
|
// Create the EXDATE datetime (use the same time as the original event)
|
||||||
let original_time = existing_event.dtstart.time();
|
let original_time = existing_event.dtstart.time();
|
||||||
let exception_datetime = exception_date.and_time(original_time);
|
let exception_datetime = exception_date.and_time(original_time);
|
||||||
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
|
|
||||||
|
|
||||||
// Add the exception date to the event's EXDATE list
|
// Add the exception date to the event's EXDATE list
|
||||||
let mut updated_event = existing_event;
|
let mut updated_event = existing_event;
|
||||||
updated_event.exdate.push(exception_utc);
|
updated_event.exdate.push(exception_datetime);
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"🗑️ Added EXDATE for single occurrence deletion: {}",
|
"🗑️ Added EXDATE for single occurrence deletion: {}",
|
||||||
exception_utc.format("%Y%m%dT%H%M%SZ")
|
exception_datetime.format("%Y%m%dT%H%M%S")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the event on the CalDAV server
|
// Update the event on the CalDAV server
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use axum::{
|
|||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
|
use calendar_models::VAlarm;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// API request/response types
|
// API request/response types
|
||||||
@@ -113,10 +114,11 @@ pub struct CreateEventRequest {
|
|||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub alarms: Vec<VAlarm>, // event alarms
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
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
|
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
|
||||||
|
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -143,11 +145,15 @@ pub struct UpdateEventRequest {
|
|||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub alarms: Vec<VAlarm>, // event alarms
|
||||||
pub recurrence: String, // recurrence type
|
pub recurrence: String, // recurrence type
|
||||||
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
|
||||||
|
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
|
||||||
|
pub recurrence_count: Option<u32>, // Number of occurrences
|
||||||
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
pub update_action: Option<String>, // "update_series" for recurring events
|
pub update_action: Option<String>, // "update_series" for recurring events
|
||||||
|
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub until_date: Option<String>, // ISO datetime string for RRULE UNTIL clause
|
pub until_date: Option<String>, // ISO datetime string for RRULE UNTIL clause
|
||||||
}
|
}
|
||||||
@@ -176,7 +182,7 @@ pub struct CreateEventSeriesRequest {
|
|||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub alarms: Vec<VAlarm>, // event alarms
|
||||||
|
|
||||||
// Series-specific fields
|
// Series-specific fields
|
||||||
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
||||||
@@ -185,6 +191,7 @@ pub struct CreateEventSeriesRequest {
|
|||||||
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
|
||||||
pub recurrence_count: Option<u32>, // Number of occurrences
|
pub recurrence_count: Option<u32>, // Number of occurrences
|
||||||
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
|
||||||
|
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -213,7 +220,7 @@ pub struct UpdateEventSeriesRequest {
|
|||||||
pub organizer: String, // organizer email
|
pub organizer: String, // organizer email
|
||||||
pub attendees: String, // comma-separated attendee emails
|
pub attendees: String, // comma-separated attendee emails
|
||||||
pub categories: String, // comma-separated categories
|
pub categories: String, // comma-separated categories
|
||||||
pub reminder: String, // reminder type
|
pub alarms: Vec<VAlarm>, // event alarms
|
||||||
|
|
||||||
// Series-specific fields
|
// Series-specific fields
|
||||||
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
|
||||||
@@ -227,6 +234,7 @@ pub struct UpdateEventSeriesRequest {
|
|||||||
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
|
||||||
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
|
||||||
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
|
||||||
|
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! VEvent - RFC 5545 compliant calendar event structure
|
//! VEvent - RFC 5545 compliant calendar event structure
|
||||||
|
|
||||||
use crate::common::*;
|
use crate::common::*;
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, NaiveDateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ==================== VEVENT COMPONENT ====================
|
// ==================== VEVENT COMPONENT ====================
|
||||||
@@ -9,12 +9,14 @@ use serde::{Deserialize, Serialize};
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct VEvent {
|
pub struct VEvent {
|
||||||
// Required properties
|
// Required properties
|
||||||
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
|
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED (always UTC)
|
||||||
pub uid: String, // Unique identifier (UID) - REQUIRED
|
pub uid: String, // Unique identifier (UID) - REQUIRED
|
||||||
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED
|
pub dtstart: NaiveDateTime, // Start date-time (DTSTART) - REQUIRED (local time)
|
||||||
|
pub dtstart_tzid: Option<String>, // Timezone ID for DTSTART (TZID parameter)
|
||||||
|
|
||||||
// Optional properties (commonly used)
|
// Optional properties (commonly used)
|
||||||
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
|
pub dtend: Option<NaiveDateTime>, // End date-time (DTEND) (local time)
|
||||||
|
pub dtend_tzid: Option<String>, // Timezone ID for DTEND (TZID parameter)
|
||||||
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
|
||||||
pub summary: Option<String>, // Summary/title (SUMMARY)
|
pub summary: Option<String>, // Summary/title (SUMMARY)
|
||||||
pub description: Option<String>, // Description (DESCRIPTION)
|
pub description: Option<String>, // Description (DESCRIPTION)
|
||||||
@@ -43,14 +45,19 @@ pub struct VEvent {
|
|||||||
|
|
||||||
// Versioning and modification
|
// Versioning and modification
|
||||||
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
pub sequence: Option<u32>, // Sequence number (SEQUENCE)
|
||||||
pub created: Option<DateTime<Utc>>, // Creation time (CREATED)
|
pub created: Option<NaiveDateTime>, // Creation time (CREATED) (local time)
|
||||||
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
|
pub created_tzid: Option<String>, // Timezone ID for CREATED
|
||||||
|
pub last_modified: Option<NaiveDateTime>, // Last modified (LAST-MODIFIED) (local time)
|
||||||
|
pub last_modified_tzid: Option<String>, // Timezone ID for LAST-MODIFIED
|
||||||
|
|
||||||
// Recurrence
|
// Recurrence
|
||||||
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
pub rrule: Option<String>, // Recurrence rule (RRULE)
|
||||||
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE)
|
pub rdate: Vec<NaiveDateTime>, // Recurrence dates (RDATE) (local time)
|
||||||
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE)
|
pub rdate_tzid: Option<String>, // Timezone ID for RDATE
|
||||||
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID)
|
pub exdate: Vec<NaiveDateTime>, // Exception dates (EXDATE) (local time)
|
||||||
|
pub exdate_tzid: Option<String>, // Timezone ID for EXDATE
|
||||||
|
pub recurrence_id: Option<NaiveDateTime>, // Recurrence ID (RECURRENCE-ID) (local time)
|
||||||
|
pub recurrence_id_tzid: Option<String>, // Timezone ID for RECURRENCE-ID
|
||||||
|
|
||||||
// Alarms and attachments
|
// Alarms and attachments
|
||||||
pub alarms: Vec<VAlarm>, // VALARM components
|
pub alarms: Vec<VAlarm>, // VALARM components
|
||||||
@@ -64,13 +71,15 @@ pub struct VEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VEvent {
|
impl VEvent {
|
||||||
/// Create a new VEvent with required fields
|
/// Create a new VEvent with required fields (local time)
|
||||||
pub fn new(uid: String, dtstart: DateTime<Utc>) -> Self {
|
pub fn new(uid: String, dtstart: NaiveDateTime) -> Self {
|
||||||
Self {
|
Self {
|
||||||
dtstamp: Utc::now(),
|
dtstamp: Utc::now(),
|
||||||
uid,
|
uid,
|
||||||
dtstart,
|
dtstart,
|
||||||
|
dtstart_tzid: None,
|
||||||
dtend: None,
|
dtend: None,
|
||||||
|
dtend_tzid: None,
|
||||||
duration: None,
|
duration: None,
|
||||||
summary: None,
|
summary: None,
|
||||||
description: None,
|
description: None,
|
||||||
@@ -89,12 +98,17 @@ impl VEvent {
|
|||||||
url: None,
|
url: None,
|
||||||
geo: None,
|
geo: None,
|
||||||
sequence: None,
|
sequence: None,
|
||||||
created: Some(Utc::now()),
|
created: Some(chrono::Local::now().naive_local()),
|
||||||
last_modified: Some(Utc::now()),
|
created_tzid: None,
|
||||||
|
last_modified: Some(chrono::Local::now().naive_local()),
|
||||||
|
last_modified_tzid: None,
|
||||||
rrule: None,
|
rrule: None,
|
||||||
rdate: Vec::new(),
|
rdate: Vec::new(),
|
||||||
|
rdate_tzid: None,
|
||||||
exdate: Vec::new(),
|
exdate: Vec::new(),
|
||||||
|
exdate_tzid: None,
|
||||||
recurrence_id: None,
|
recurrence_id: None,
|
||||||
|
recurrence_id_tzid: None,
|
||||||
alarms: Vec::new(),
|
alarms: Vec::new(),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
etag: None,
|
etag: None,
|
||||||
@@ -105,7 +119,7 @@ impl VEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Helper method to get effective end time (dtend or dtstart + duration)
|
/// Helper method to get effective end time (dtend or dtstart + duration)
|
||||||
pub fn get_end_time(&self) -> DateTime<Utc> {
|
pub fn get_end_time(&self) -> NaiveDateTime {
|
||||||
if let Some(dtend) = self.dtend {
|
if let Some(dtend) = self.dtend {
|
||||||
dtend
|
dtend
|
||||||
} else if let Some(duration) = self.duration {
|
} else if let Some(duration) = self.duration {
|
||||||
@@ -136,7 +150,7 @@ impl VEvent {
|
|||||||
|
|
||||||
/// Helper method to get start date for UI compatibility
|
/// Helper method to get start date for UI compatibility
|
||||||
pub fn get_date(&self) -> chrono::NaiveDate {
|
pub fn get_date(&self) -> chrono::NaiveDate {
|
||||||
self.dtstart.date_naive()
|
self.dtstart.date()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if event is recurring
|
/// Check if event is recurring
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ web-sys = { version = "0.3", features = [
|
|||||||
"CssStyleDeclaration",
|
"CssStyleDeclaration",
|
||||||
"MediaQueryList",
|
"MediaQueryList",
|
||||||
"MediaQueryListEvent",
|
"MediaQueryListEvent",
|
||||||
|
# Notification API for browser notifications
|
||||||
|
"Notification",
|
||||||
|
"NotificationOptions",
|
||||||
|
"NotificationPermission",
|
||||||
|
# Service Worker API for background processing
|
||||||
|
"ServiceWorkerContainer",
|
||||||
|
"ServiceWorkerRegistration",
|
||||||
|
"MessageEvent",
|
||||||
|
# IndexedDB API for persistent alarm storage
|
||||||
|
"IdbDatabase",
|
||||||
|
"IdbObjectStore",
|
||||||
|
"IdbTransaction",
|
||||||
|
"IdbRequest",
|
||||||
|
"IdbKeyRange",
|
||||||
|
"IdbFactory",
|
||||||
|
"IdbOpenDbRequest",
|
||||||
|
"IdbVersionChangeEvent",
|
||||||
] }
|
] }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
@@ -73,3 +90,6 @@ gloo-storage = "0.3"
|
|||||||
gloo-timers = "0.3"
|
gloo-timers = "0.3"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
|
|
||||||
|
# IndexedDB for persistent alarm storage
|
||||||
|
indexed_db_futures = "0.4"
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,29 @@
|
|||||||
<link data-trunk rel="css" href="styles.css">
|
<link data-trunk rel="css" href="styles.css">
|
||||||
<link data-trunk rel="css" href="print-preview.css">
|
<link data-trunk rel="css" href="print-preview.css">
|
||||||
<link data-trunk rel="copy-file" href="styles/google.css">
|
<link data-trunk rel="copy-file" href="styles/google.css">
|
||||||
|
<link data-trunk rel="copy-file" href="styles/apple.css">
|
||||||
|
<link data-trunk rel="copy-file" href="service-worker.js">
|
||||||
<link data-trunk rel="icon" href="favicon.ico">
|
<link data-trunk rel="icon" href="favicon.ico">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
console.log("HTML fully loaded, waiting for WASM...");
|
|
||||||
window.addEventListener('TrunkApplicationStarted', () => {
|
window.addEventListener('TrunkApplicationStarted', () => {
|
||||||
console.log("Trunk application started successfully!");
|
// Application loaded successfully
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register service worker for alarm background processing
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/service-worker.js')
|
||||||
|
.then((registration) => {
|
||||||
|
// Service worker registered successfully
|
||||||
|
})
|
||||||
|
.catch((registrationError) => {
|
||||||
|
console.log('SW registration failed: ', registrationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -212,6 +212,7 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Remove today highlighting in preview */
|
/* Remove today highlighting in preview */
|
||||||
.print-preview-paper .calendar-day.today,
|
.print-preview-paper .calendar-day.today,
|
||||||
.print-preview-paper .week-day-header.today,
|
.print-preview-paper .week-day-header.today,
|
||||||
|
|||||||
150
frontend/service-worker.js
Normal file
150
frontend/service-worker.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// Calendar Alarms Service Worker
|
||||||
|
// Handles background alarm checking when the main app is not active
|
||||||
|
|
||||||
|
const SW_VERSION = 'v1.0.0';
|
||||||
|
const CACHE_NAME = `calendar-alarms-${SW_VERSION}`;
|
||||||
|
const STORAGE_KEY = 'calendar_alarms';
|
||||||
|
|
||||||
|
// Install event
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
self.skipWaiting(); // Activate immediately
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
event.waitUntil(self.clients.claim()); // Take control immediately
|
||||||
|
});
|
||||||
|
|
||||||
|
// Message handler for communication with main app
|
||||||
|
self.addEventListener('message', event => {
|
||||||
|
const { type, data } = event.data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'CHECK_ALARMS':
|
||||||
|
handleCheckAlarms(event, data);
|
||||||
|
break;
|
||||||
|
case 'SCHEDULE_ALARM':
|
||||||
|
handleScheduleAlarm(data, event);
|
||||||
|
break;
|
||||||
|
case 'REMOVE_ALARM':
|
||||||
|
handleRemoveAlarm(data, event);
|
||||||
|
break;
|
||||||
|
case 'PING':
|
||||||
|
event.ports[0].postMessage({ type: 'PONG', version: SW_VERSION });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn('Unknown message type:', type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle alarm checking request
|
||||||
|
function handleCheckAlarms(event, data) {
|
||||||
|
try {
|
||||||
|
// Main app sends alarms data to check
|
||||||
|
const allAlarms = data?.alarms || [];
|
||||||
|
const dueAlarms = checkProvidedAlarms(allAlarms);
|
||||||
|
|
||||||
|
// Send results back to main app
|
||||||
|
event.ports[0].postMessage({
|
||||||
|
type: 'ALARMS_DUE',
|
||||||
|
data: dueAlarms
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking alarms:', error);
|
||||||
|
event.ports[0].postMessage({
|
||||||
|
type: 'ALARM_CHECK_ERROR',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process alarms sent from main app
|
||||||
|
function checkProvidedAlarms(alarms) {
|
||||||
|
const now = new Date();
|
||||||
|
const nowStr = formatDateTimeForComparison(now);
|
||||||
|
|
||||||
|
// Filter alarms that should trigger and are pending
|
||||||
|
const dueAlarms = alarms.filter(alarm => {
|
||||||
|
return alarm.status === 'Pending' && alarm.trigger_time <= nowStr;
|
||||||
|
});
|
||||||
|
|
||||||
|
return dueAlarms;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle schedule alarm request (not needed with localStorage approach)
|
||||||
|
function handleScheduleAlarm(alarmData, event) {
|
||||||
|
// Service worker doesn't handle storage with localStorage approach
|
||||||
|
// Main app handles all storage operations
|
||||||
|
event.ports[0].postMessage({
|
||||||
|
type: 'ALARM_SCHEDULED',
|
||||||
|
data: { success: true, alarmId: alarmData.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle remove alarm request (not needed with localStorage approach)
|
||||||
|
function handleRemoveAlarm(alarmData, event) {
|
||||||
|
// Service worker doesn't handle storage with localStorage approach
|
||||||
|
// Main app handles all storage operations
|
||||||
|
event.ports[0].postMessage({
|
||||||
|
type: 'ALARM_REMOVED',
|
||||||
|
data: { success: true, eventUid: alarmData.eventUid }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Format date time for comparison (YYYY-MM-DDTHH:MM:SS)
|
||||||
|
function formatDateTimeForComparison(date) {
|
||||||
|
return date.getFullYear() + '-' +
|
||||||
|
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||||||
|
String(date.getDate()).padStart(2, '0') + 'T' +
|
||||||
|
String(date.getHours()).padStart(2, '0') + ':' +
|
||||||
|
String(date.getMinutes()).padStart(2, '0') + ':' +
|
||||||
|
String(date.getSeconds()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background alarm checking (runs periodically)
|
||||||
|
// Note: Service worker can't access localStorage, so this just pings the main app
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
// Notify all clients to check their alarms
|
||||||
|
const clients = await self.clients.matchAll();
|
||||||
|
|
||||||
|
clients.forEach(client => {
|
||||||
|
client.postMessage({
|
||||||
|
type: 'BACKGROUND_ALARM_CHECK_REQUEST'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Background alarm check failed:', error);
|
||||||
|
}
|
||||||
|
}, 60000); // Check every minute
|
||||||
|
|
||||||
|
// Handle push notifications (for future enhancement)
|
||||||
|
self.addEventListener('push', event => {
|
||||||
|
console.log('Push notification received:', event);
|
||||||
|
// Future: Handle server-sent alarm notifications
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle notification clicks
|
||||||
|
self.addEventListener('notificationclick', event => {
|
||||||
|
console.log('Notification clicked:', event);
|
||||||
|
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
// Focus or open the calendar app
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll().then(clients => {
|
||||||
|
// Try to focus existing client
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.url.includes('localhost') || client.url.includes(self.location.origin)) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open new window if no client exists
|
||||||
|
return self.clients.openWindow('/');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
use crate::components::{
|
use crate::components::{
|
||||||
CalendarContextMenu, CalendarManagementModal, ContextMenu, CreateEventModal, DeleteAction,
|
CalendarContextMenu, CalendarManagementModal, ColorEditorModal, ContextMenu, CreateEventModal, DeleteAction,
|
||||||
EditAction, EventContextMenu, EventModal, EventCreationData,
|
EditAction, EventContextMenu, EventModal, EventCreationData,
|
||||||
MobileWarningModal, RouteHandler, Sidebar, Theme, ViewMode,
|
MobileWarningModal, RouteHandler, Sidebar, Theme, ViewMode,
|
||||||
};
|
};
|
||||||
use crate::components::mobile_warning_modal::is_mobile_device;
|
use crate::components::mobile_warning_modal::is_mobile_device;
|
||||||
use crate::components::sidebar::{Style};
|
use crate::components::sidebar::{Style};
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService, AlarmScheduler};
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use gloo_timers::callback::Interval;
|
use gloo_timers::callback::Interval;
|
||||||
@@ -15,43 +15,68 @@ use web_sys::MouseEvent;
|
|||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
fn get_theme_event_colors() -> Vec<String> {
|
fn get_default_event_colors() -> Vec<String> {
|
||||||
if let Some(window) = web_sys::window() {
|
vec![
|
||||||
if let Some(document) = window.document() {
|
"#3B82F6".to_string(), // Blue
|
||||||
if let Some(root) = document.document_element() {
|
"#10B981".to_string(), // Emerald
|
||||||
if let Ok(Some(computed_style)) = window.get_computed_style(&root) {
|
"#F59E0B".to_string(), // Amber
|
||||||
if let Ok(colors_string) = computed_style.get_property_value("--event-colors") {
|
"#EF4444".to_string(), // Red
|
||||||
if !colors_string.is_empty() {
|
"#8B5CF6".to_string(), // Violet
|
||||||
return colors_string
|
"#06B6D4".to_string(), // Cyan
|
||||||
.split(',')
|
"#84CC16".to_string(), // Lime
|
||||||
.map(|color| color.trim().to_string())
|
"#F97316".to_string(), // Orange
|
||||||
.filter(|color| !color.is_empty())
|
"#EC4899".to_string(), // Pink
|
||||||
.collect();
|
"#6366F1".to_string(), // Indigo
|
||||||
|
"#14B8A6".to_string(), // Teal
|
||||||
|
"#F3B806".to_string(), // Yellow
|
||||||
|
"#8B5A2B".to_string(), // Brown
|
||||||
|
"#6B7280".to_string(), // Gray
|
||||||
|
"#DC2626".to_string(), // Dark Red
|
||||||
|
"#7C3AED".to_string(), // Purple
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_event_colors_from_preferences() -> Vec<String> {
|
||||||
|
// Try to load custom colors from user preferences
|
||||||
|
if let Some(prefs) = crate::services::preferences::PreferencesService::load_cached() {
|
||||||
|
if let Some(colors_json) = prefs.calendar_colors {
|
||||||
|
// Try to parse the JSON structure
|
||||||
|
if let Ok(colors_data) = serde_json::from_str::<serde_json::Value>(&colors_json) {
|
||||||
|
// Check if it has a custom_palette field
|
||||||
|
if let Some(custom_palette) = colors_data.get("custom_palette") {
|
||||||
|
if let Some(colors_array) = custom_palette.as_array() {
|
||||||
|
let custom_colors: Vec<String> = colors_array
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Only use custom colors if we have a reasonable number
|
||||||
|
if custom_colors.len() >= 8 {
|
||||||
|
return custom_colors;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to default colors
|
||||||
|
get_default_event_colors()
|
||||||
|
}
|
||||||
|
|
||||||
vec![
|
async fn save_custom_colors_to_preferences(colors: Vec<String>) -> Result<(), String> {
|
||||||
"#3B82F6".to_string(),
|
// Create the JSON structure for storing custom colors
|
||||||
"#10B981".to_string(),
|
let colors_json = serde_json::json!({
|
||||||
"#F59E0B".to_string(),
|
"custom_palette": colors
|
||||||
"#EF4444".to_string(),
|
});
|
||||||
"#8B5CF6".to_string(),
|
|
||||||
"#06B6D4".to_string(),
|
// Convert to string for preferences storage
|
||||||
"#84CC16".to_string(),
|
let colors_string = serde_json::to_string(&colors_json)
|
||||||
"#F97316".to_string(),
|
.map_err(|e| format!("Failed to serialize colors: {}", e))?;
|
||||||
"#EC4899".to_string(),
|
|
||||||
"#6366F1".to_string(),
|
// Update preferences via the preferences service
|
||||||
"#14B8A6".to_string(),
|
let preferences_service = crate::services::preferences::PreferencesService::new();
|
||||||
"#F3B806".to_string(),
|
preferences_service.update_preference("calendar_colors", serde_json::Value::String(colors_string)).await
|
||||||
"#8B5A2B".to_string(),
|
|
||||||
"#6B7280".to_string(),
|
|
||||||
"#DC2626".to_string(),
|
|
||||||
"#7C3AED".to_string(),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component]
|
#[function_component]
|
||||||
@@ -71,7 +96,6 @@ pub fn App() -> Html {
|
|||||||
match auth_service.verify_token(&stored_token).await {
|
match auth_service.verify_token(&stored_token).await {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
// Token is valid, set it
|
// Token is valid, set it
|
||||||
web_sys::console::log_1(&"✅ Stored auth token is valid".into());
|
|
||||||
auth_token.set(Some(stored_token));
|
auth_token.set(Some(stored_token));
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -97,6 +121,8 @@ pub fn App() -> Html {
|
|||||||
let color_picker_open = use_state(|| -> Option<String> { None });
|
let color_picker_open = use_state(|| -> Option<String> { None });
|
||||||
let calendar_management_modal_open = use_state(|| false);
|
let calendar_management_modal_open = use_state(|| false);
|
||||||
let context_menu_open = use_state(|| false);
|
let context_menu_open = use_state(|| false);
|
||||||
|
let color_editor_open = use_state(|| false);
|
||||||
|
let color_editor_data = use_state(|| -> Option<(usize, String)> { None }); // (index, current_color)
|
||||||
let context_menu_pos = use_state(|| (0i32, 0i32));
|
let context_menu_pos = use_state(|| (0i32, 0i32));
|
||||||
let context_menu_calendar_path = use_state(|| -> Option<String> { None });
|
let context_menu_calendar_path = use_state(|| -> Option<String> { None });
|
||||||
let event_context_menu_open = use_state(|| false);
|
let event_context_menu_open = use_state(|| false);
|
||||||
@@ -122,6 +148,11 @@ pub fn App() -> Html {
|
|||||||
// Mobile warning state
|
// Mobile warning state
|
||||||
let mobile_warning_open = use_state(|| is_mobile_device());
|
let mobile_warning_open = use_state(|| is_mobile_device());
|
||||||
let refresh_interval = use_state(|| -> Option<Interval> { None });
|
let refresh_interval = use_state(|| -> Option<Interval> { None });
|
||||||
|
|
||||||
|
// Alarm system state
|
||||||
|
let alarm_scheduler = use_state(|| AlarmScheduler::new());
|
||||||
|
let alarm_check_interval = use_state(|| -> Option<Interval> { None });
|
||||||
|
let alarm_system_initialized = use_state(|| false);
|
||||||
|
|
||||||
// Calendar view state - load from localStorage if available
|
// Calendar view state - load from localStorage if available
|
||||||
let current_view = use_state(|| {
|
let current_view = use_state(|| {
|
||||||
@@ -156,7 +187,76 @@ pub fn App() -> Html {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let available_colors = use_state(|| get_theme_event_colors());
|
let available_colors = use_state(|| get_event_colors_from_preferences());
|
||||||
|
|
||||||
|
// Refresh colors when preferences might have changed
|
||||||
|
let refresh_colors = {
|
||||||
|
let available_colors = available_colors.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
available_colors.set(get_event_colors_from_preferences());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize alarm system after user login
|
||||||
|
{
|
||||||
|
let auth_token = auth_token.clone();
|
||||||
|
let alarm_scheduler = alarm_scheduler.clone();
|
||||||
|
let alarm_system_initialized = alarm_system_initialized.clone();
|
||||||
|
let alarm_check_interval = alarm_check_interval.clone();
|
||||||
|
|
||||||
|
use_effect_with((*auth_token).clone(), move |token| {
|
||||||
|
if token.is_some() && !*alarm_system_initialized {
|
||||||
|
|
||||||
|
let alarm_scheduler = alarm_scheduler.clone();
|
||||||
|
let alarm_system_initialized = alarm_system_initialized.clone();
|
||||||
|
let alarm_check_interval = alarm_check_interval.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
// Request notification permission
|
||||||
|
let scheduler = (*alarm_scheduler).clone();
|
||||||
|
match scheduler.request_notification_permission().await {
|
||||||
|
Ok(_permission) => {
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
web_sys::console::warn_1(
|
||||||
|
&format!("⚠️ Failed to request notification permission: {:?}", e).into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alarm_scheduler.set(scheduler);
|
||||||
|
alarm_system_initialized.set(true);
|
||||||
|
|
||||||
|
// Set up alarm checking interval (every 30 seconds)
|
||||||
|
let interval = {
|
||||||
|
let alarm_scheduler_ref = alarm_scheduler.clone();
|
||||||
|
Interval::new(30_000, move || {
|
||||||
|
// Get a fresh copy of the current scheduler state each time
|
||||||
|
let mut scheduler = (*alarm_scheduler_ref).clone();
|
||||||
|
let triggered_count = scheduler.check_and_trigger_alarms();
|
||||||
|
|
||||||
|
if triggered_count > 0 {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("🔔 Triggered {} alarm(s)", triggered_count).into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the scheduler state with any changes (like alarm status updates)
|
||||||
|
alarm_scheduler_ref.set(scheduler);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
alarm_check_interval.set(Some(interval));
|
||||||
|
});
|
||||||
|
} else if token.is_none() {
|
||||||
|
// Clean up alarm system on logout
|
||||||
|
alarm_check_interval.set(None); // This will drop and cancel the interval
|
||||||
|
alarm_system_initialized.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Function to refresh calendar data without full page reload
|
// Function to refresh calendar data without full page reload
|
||||||
let refresh_calendar_data = {
|
let refresh_calendar_data = {
|
||||||
@@ -164,12 +264,14 @@ pub fn App() -> Html {
|
|||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
let external_calendars = external_calendars.clone();
|
let external_calendars = external_calendars.clone();
|
||||||
let external_calendar_events = external_calendar_events.clone();
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
|
let refresh_colors = refresh_colors.clone();
|
||||||
|
|
||||||
Callback::from(move |_| {
|
Callback::from(move |_| {
|
||||||
let user_info = user_info.clone();
|
let user_info = user_info.clone();
|
||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
let external_calendars = external_calendars.clone();
|
let external_calendars = external_calendars.clone();
|
||||||
let external_calendar_events = external_calendar_events.clone();
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
|
let refresh_colors = refresh_colors.clone();
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
// Refresh main calendar data if authenticated
|
// Refresh main calendar data if authenticated
|
||||||
@@ -193,7 +295,21 @@ pub fn App() -> Html {
|
|||||||
if !password.is_empty() {
|
if !password.is_empty() {
|
||||||
match calendar_service.fetch_user_info(&token, &password).await {
|
match calendar_service.fetch_user_info(&token, &password).await {
|
||||||
Ok(mut info) => {
|
Ok(mut info) => {
|
||||||
// Apply saved colors
|
// Preserve existing calendar settings (colors and visibility) from current state
|
||||||
|
if let Some(current_info) = (*user_info).clone() {
|
||||||
|
for current_cal in ¤t_info.calendars {
|
||||||
|
for cal in &mut info.calendars {
|
||||||
|
if cal.path == current_cal.path {
|
||||||
|
// Preserve visibility setting
|
||||||
|
cal.is_visible = current_cal.is_visible;
|
||||||
|
// Preserve color setting
|
||||||
|
cal.color = current_cal.color.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply saved colors as fallback for new calendars
|
||||||
if let Ok(saved_colors_json) =
|
if let Ok(saved_colors_json) =
|
||||||
LocalStorage::get::<String>("calendar_colors")
|
LocalStorage::get::<String>("calendar_colors")
|
||||||
{
|
{
|
||||||
@@ -202,16 +318,20 @@ pub fn App() -> Html {
|
|||||||
{
|
{
|
||||||
for saved_cal in &saved_info.calendars {
|
for saved_cal in &saved_info.calendars {
|
||||||
for cal in &mut info.calendars {
|
for cal in &mut info.calendars {
|
||||||
if cal.path == saved_cal.path {
|
if cal.path == saved_cal.path && cal.color == "#3B82F6" {
|
||||||
|
// Only apply saved color if it's still the default
|
||||||
cal.color = saved_cal.color.clone();
|
cal.color = saved_cal.color.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add timestamp to force re-render
|
// Add timestamp to force re-render
|
||||||
info.last_updated = (js_sys::Date::now() / 1000.0) as u64;
|
info.last_updated = (js_sys::Date::now() / 1000.0) as u64;
|
||||||
user_info.set(Some(info));
|
user_info.set(Some(info));
|
||||||
|
// Refresh colors after loading user preferences
|
||||||
|
refresh_colors.emit(());
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::log_1(
|
web_sys::console::log_1(
|
||||||
@@ -284,6 +404,71 @@ pub fn App() -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_color_editor_open = {
|
||||||
|
let color_editor_open = color_editor_open.clone();
|
||||||
|
let color_editor_data = color_editor_data.clone();
|
||||||
|
Callback::from(move |(index, color): (usize, String)| {
|
||||||
|
color_editor_data.set(Some((index, color)));
|
||||||
|
color_editor_open.set(true);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_color_editor_close = {
|
||||||
|
let color_editor_open = color_editor_open.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
color_editor_open.set(false);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_color_editor_save = {
|
||||||
|
let available_colors = available_colors.clone();
|
||||||
|
let color_editor_open = color_editor_open.clone();
|
||||||
|
let refresh_colors = refresh_colors.clone();
|
||||||
|
Callback::from(move |(index, new_color): (usize, String)| {
|
||||||
|
// Update the colors array
|
||||||
|
let mut colors = (*available_colors).clone();
|
||||||
|
if index < colors.len() {
|
||||||
|
colors[index] = new_color;
|
||||||
|
available_colors.set(colors.clone());
|
||||||
|
|
||||||
|
// Save to preferences asynchronously
|
||||||
|
let colors_for_save = colors.clone();
|
||||||
|
let refresh_colors = refresh_colors.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Err(e) = save_custom_colors_to_preferences(colors_for_save).await {
|
||||||
|
web_sys::console::log_1(&format!("Failed to save custom colors: {}", e).into());
|
||||||
|
} else {
|
||||||
|
// Refresh colors to ensure UI is in sync
|
||||||
|
refresh_colors.emit(());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
color_editor_open.set(false);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_color_editor_reset_all = {
|
||||||
|
let available_colors = available_colors.clone();
|
||||||
|
let refresh_colors = refresh_colors.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
// Reset to default colors
|
||||||
|
let default_colors = get_default_event_colors();
|
||||||
|
available_colors.set(default_colors.clone());
|
||||||
|
|
||||||
|
// Save to preferences asynchronously
|
||||||
|
let colors_for_save = default_colors.clone();
|
||||||
|
let refresh_colors = refresh_colors.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
if let Err(e) = save_custom_colors_to_preferences(colors_for_save).await {
|
||||||
|
web_sys::console::log_1(&format!("Failed to save default colors: {}", e).into());
|
||||||
|
} else {
|
||||||
|
// Refresh colors to ensure UI is in sync
|
||||||
|
refresh_colors.emit(());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let on_view_change = {
|
let on_view_change = {
|
||||||
let current_view = current_view.clone();
|
let current_view = current_view.clone();
|
||||||
Callback::from(move |new_view: ViewMode| {
|
Callback::from(move |new_view: ViewMode| {
|
||||||
@@ -301,7 +486,6 @@ pub fn App() -> Html {
|
|||||||
|
|
||||||
let on_theme_change = {
|
let on_theme_change = {
|
||||||
let current_theme = current_theme.clone();
|
let current_theme = current_theme.clone();
|
||||||
let available_colors = available_colors.clone();
|
|
||||||
Callback::from(move |new_theme: Theme| {
|
Callback::from(move |new_theme: Theme| {
|
||||||
// Save theme to localStorage
|
// Save theme to localStorage
|
||||||
let _ = LocalStorage::set("calendar_theme", new_theme.value());
|
let _ = LocalStorage::set("calendar_theme", new_theme.value());
|
||||||
@@ -316,8 +500,7 @@ pub fn App() -> Html {
|
|||||||
// Update state
|
// Update state
|
||||||
current_theme.set(new_theme);
|
current_theme.set(new_theme);
|
||||||
|
|
||||||
// Update available colors after theme change
|
// Colors are now unified and don't change with themes
|
||||||
available_colors.set(get_theme_event_colors());
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -330,6 +513,11 @@ pub fn App() -> Html {
|
|||||||
// Hot-swap stylesheet
|
// Hot-swap stylesheet
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
if let Some(document) = window.document() {
|
if let Some(document) = window.document() {
|
||||||
|
// Set data-style attribute on document root
|
||||||
|
if let Some(root) = document.document_element() {
|
||||||
|
let _ = root.set_attribute("data-style", new_style.value());
|
||||||
|
}
|
||||||
|
|
||||||
// Remove existing style link if it exists
|
// Remove existing style link if it exists
|
||||||
if let Some(existing_link) = document.get_element_by_id("dynamic-style") {
|
if let Some(existing_link) = document.get_element_by_id("dynamic-style") {
|
||||||
existing_link.remove();
|
existing_link.remove();
|
||||||
@@ -377,6 +565,11 @@ pub fn App() -> Html {
|
|||||||
let style = (*current_style).clone();
|
let style = (*current_style).clone();
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
if let Some(document) = window.document() {
|
if let Some(document) = window.document() {
|
||||||
|
// Set data-style attribute on document root
|
||||||
|
if let Some(root) = document.document_element() {
|
||||||
|
let _ = root.set_attribute("data-style", style.value());
|
||||||
|
}
|
||||||
|
|
||||||
// Create and append stylesheet link for initial style only if it has a path
|
// Create and append stylesheet link for initial style only if it has a path
|
||||||
if let Some(stylesheet_path) = style.stylesheet_path() {
|
if let Some(stylesheet_path) = style.stylesheet_path() {
|
||||||
if let Ok(link) = document.create_element("link") {
|
if let Ok(link) = document.create_element("link") {
|
||||||
@@ -396,6 +589,7 @@ pub fn App() -> Html {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Fetch user info when token is available
|
// Fetch user info when token is available
|
||||||
{
|
{
|
||||||
let user_info = user_info.clone();
|
let user_info = user_info.clone();
|
||||||
@@ -685,7 +879,9 @@ pub fn App() -> Html {
|
|||||||
let create_event_modal_open = create_event_modal_open.clone();
|
let create_event_modal_open = create_event_modal_open.clone();
|
||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
let refresh_calendar_data = refresh_calendar_data.clone();
|
let refresh_calendar_data = refresh_calendar_data.clone();
|
||||||
|
let alarm_scheduler = alarm_scheduler.clone();
|
||||||
Callback::from(move |event_data: EventCreationData| {
|
Callback::from(move |event_data: EventCreationData| {
|
||||||
|
let alarm_scheduler = alarm_scheduler.clone();
|
||||||
// Check if this is an update operation (has original_uid) or a create operation
|
// Check if this is an update operation (has original_uid) or a create operation
|
||||||
if let Some(original_uid) = event_data.original_uid.clone() {
|
if let Some(original_uid) = event_data.original_uid.clone() {
|
||||||
web_sys::console::log_1(&format!("Updating event via modal: {:?}", event_data).into());
|
web_sys::console::log_1(&format!("Updating event via modal: {:?}", event_data).into());
|
||||||
@@ -724,8 +920,9 @@ pub fn App() -> Html {
|
|||||||
crate::components::event_form::RecurrenceType::Yearly);
|
crate::components::event_form::RecurrenceType::Yearly);
|
||||||
|
|
||||||
let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() {
|
let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() {
|
||||||
// Use series update endpoint for recurring events
|
// Only use series endpoint for existing recurring events being edited
|
||||||
let edit_action = event_data_for_update.edit_scope.unwrap();
|
// Singleton→series conversion should use regular update_event endpoint
|
||||||
|
let edit_action = event_data_for_update.edit_scope.as_ref().unwrap();
|
||||||
let scope = match edit_action {
|
let scope = match edit_action {
|
||||||
crate::components::EditAction::EditAll => "all_in_series".to_string(),
|
crate::components::EditAction::EditAll => "all_in_series".to_string(),
|
||||||
crate::components::EditAction::EditFuture => "this_and_future".to_string(),
|
crate::components::EditAction::EditFuture => "this_and_future".to_string(),
|
||||||
@@ -754,11 +951,13 @@ pub fn App() -> Html {
|
|||||||
params.14, // reminder
|
params.14, // reminder
|
||||||
params.15, // recurrence
|
params.15, // recurrence
|
||||||
params.16, // recurrence_days
|
params.16, // recurrence_days
|
||||||
|
params.17, // recurrence_interval
|
||||||
params.18, // recurrence_count
|
params.18, // recurrence_count
|
||||||
params.19, // recurrence_until
|
params.19, // recurrence_until
|
||||||
params.17, // calendar_path
|
params.20, // calendar_path
|
||||||
scope,
|
scope,
|
||||||
event_data_for_update.occurrence_date.map(|d| d.format("%Y-%m-%d").to_string()), // occurrence_date
|
event_data_for_update.occurrence_date.map(|d| d.format("%Y-%m-%d").to_string()), // occurrence_date
|
||||||
|
params.21, // timezone
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
@@ -785,10 +984,11 @@ pub fn App() -> Html {
|
|||||||
params.14, // reminder
|
params.14, // reminder
|
||||||
params.15, // recurrence
|
params.15, // recurrence
|
||||||
params.16, // recurrence_days
|
params.16, // recurrence_days
|
||||||
params.17, // calendar_path
|
params.17, // recurrence_interval
|
||||||
vec![], // exception_dates - empty for simple updates
|
params.18, // recurrence_count
|
||||||
None, // update_action - None for regular updates
|
params.19, // recurrence_until
|
||||||
None, // until_date - None for regular updates
|
params.20, // calendar_path
|
||||||
|
params.21, // timezone
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
@@ -796,6 +996,45 @@ pub fn App() -> Html {
|
|||||||
match update_result {
|
match update_result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
web_sys::console::log_1(&"Event updated successfully via modal".into());
|
web_sys::console::log_1(&"Event updated successfully via modal".into());
|
||||||
|
|
||||||
|
// Re-schedule alarms for the updated event
|
||||||
|
let alarm_scheduler_for_update = alarm_scheduler.clone();
|
||||||
|
let event_data_for_alarms = event_data_for_update.clone();
|
||||||
|
let original_uid_for_alarms = original_uid.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let mut scheduler = (*alarm_scheduler_for_update).clone();
|
||||||
|
|
||||||
|
// Remove old alarms for this event
|
||||||
|
scheduler.remove_event_alarms(&original_uid_for_alarms);
|
||||||
|
|
||||||
|
// Schedule new alarms if any exist
|
||||||
|
if !event_data_for_alarms.alarms.is_empty() {
|
||||||
|
let params = event_data_for_alarms.to_create_event_params();
|
||||||
|
|
||||||
|
// Parse start date/time for alarm scheduling
|
||||||
|
if let (Ok(start_date), Ok(start_time)) = (
|
||||||
|
chrono::NaiveDate::parse_from_str(¶ms.2, "%Y-%m-%d"),
|
||||||
|
chrono::NaiveTime::parse_from_str(¶ms.3, "%H:%M")
|
||||||
|
) {
|
||||||
|
// Create a temporary VEvent for alarm scheduling
|
||||||
|
let start_datetime = start_date.and_time(start_time);
|
||||||
|
let mut temp_event = VEvent::new(original_uid_for_alarms.clone(), start_datetime);
|
||||||
|
temp_event.summary = Some(params.0.clone()); // title
|
||||||
|
temp_event.location = if params.6.is_empty() { None } else { Some(params.6.clone()) };
|
||||||
|
temp_event.alarms = event_data_for_alarms.alarms.clone();
|
||||||
|
|
||||||
|
scheduler.schedule_event_alarms(&temp_event);
|
||||||
|
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("🔔 Re-scheduled {} alarm(s) for updated event", temp_event.alarms.len()).into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alarm_scheduler_for_update.set(scheduler);
|
||||||
|
});
|
||||||
|
|
||||||
// Refresh calendar data without page reload
|
// Refresh calendar data without page reload
|
||||||
refresh_callback.emit(());
|
refresh_callback.emit(());
|
||||||
}
|
}
|
||||||
@@ -853,6 +1092,12 @@ pub fn App() -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let params = event_data.to_create_event_params();
|
let params = event_data.to_create_event_params();
|
||||||
|
// Clone values we'll need for alarm scheduling
|
||||||
|
let title_for_alarms = params.0.clone();
|
||||||
|
let start_date_for_alarms = params.2.clone();
|
||||||
|
let start_time_for_alarms = params.3.clone();
|
||||||
|
let location_for_alarms = params.6.clone();
|
||||||
|
|
||||||
let create_result = _calendar_service
|
let create_result = _calendar_service
|
||||||
.create_event(
|
.create_event(
|
||||||
&_token, &_password, params.0, // title
|
&_token, &_password, params.0, // title
|
||||||
@@ -872,14 +1117,46 @@ pub fn App() -> Html {
|
|||||||
params.14, // reminder
|
params.14, // reminder
|
||||||
params.15, // recurrence
|
params.15, // recurrence
|
||||||
params.16, // recurrence_days
|
params.16, // recurrence_days
|
||||||
|
params.17, // recurrence_interval
|
||||||
params.18, // recurrence_count
|
params.18, // recurrence_count
|
||||||
params.19, // recurrence_until
|
params.19, // recurrence_until
|
||||||
params.17, // calendar_path
|
params.20, // calendar_path
|
||||||
|
params.21, // timezone
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
match create_result {
|
match create_result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
web_sys::console::log_1(&"Event created successfully".into());
|
web_sys::console::log_1(&"Event created successfully".into());
|
||||||
|
|
||||||
|
// Schedule alarms for the created event if any exist
|
||||||
|
if !event_data.alarms.is_empty() {
|
||||||
|
// Since create_event doesn't return the UID, we need to generate one
|
||||||
|
// The backend should be using the same UUID generation logic
|
||||||
|
let event_uid = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// Parse start date/time for alarm scheduling
|
||||||
|
if let (Ok(start_date), Ok(start_time)) = (
|
||||||
|
chrono::NaiveDate::parse_from_str(&start_date_for_alarms, "%Y-%m-%d"),
|
||||||
|
chrono::NaiveTime::parse_from_str(&start_time_for_alarms, "%H:%M")
|
||||||
|
) {
|
||||||
|
// Create a temporary VEvent for alarm scheduling
|
||||||
|
let start_datetime = start_date.and_time(start_time);
|
||||||
|
let mut temp_event = VEvent::new(event_uid.clone(), start_datetime);
|
||||||
|
temp_event.summary = Some(title_for_alarms.clone());
|
||||||
|
temp_event.location = if location_for_alarms.is_empty() { None } else { Some(location_for_alarms.clone()) };
|
||||||
|
temp_event.alarms = event_data.alarms.clone();
|
||||||
|
|
||||||
|
// Schedule alarms for the new event (synchronously)
|
||||||
|
let mut scheduler = (*alarm_scheduler).clone();
|
||||||
|
scheduler.schedule_event_alarms(&temp_event);
|
||||||
|
alarm_scheduler.set(scheduler);
|
||||||
|
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("🔔 Scheduled {} alarm(s) for new event", temp_event.alarms.len()).into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh calendar data without page reload
|
// Refresh calendar data without page reload
|
||||||
refresh_callback.emit(());
|
refresh_callback.emit(());
|
||||||
}
|
}
|
||||||
@@ -906,8 +1183,8 @@ pub fn App() -> Html {
|
|||||||
original_event,
|
original_event,
|
||||||
new_start,
|
new_start,
|
||||||
new_end,
|
new_end,
|
||||||
preserve_rrule,
|
_preserve_rrule,
|
||||||
until_date,
|
_until_date,
|
||||||
update_scope,
|
update_scope,
|
||||||
occurrence_date,
|
occurrence_date,
|
||||||
): (
|
): (
|
||||||
@@ -915,7 +1192,7 @@ pub fn App() -> Html {
|
|||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
bool,
|
bool,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::NaiveDateTime>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
)| {
|
)| {
|
||||||
@@ -954,30 +1231,13 @@ pub fn App() -> Html {
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert local naive datetime to UTC before sending to backend
|
|
||||||
use chrono::TimeZone;
|
|
||||||
let local_tz = chrono::Local;
|
|
||||||
|
|
||||||
let start_utc = local_tz.from_local_datetime(&new_start)
|
|
||||||
.single()
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
// Fallback for ambiguous times (DST transitions)
|
|
||||||
local_tz.from_local_datetime(&new_start).earliest().unwrap()
|
|
||||||
})
|
|
||||||
.with_timezone(&chrono::Utc);
|
|
||||||
|
|
||||||
let end_utc = local_tz.from_local_datetime(&new_end)
|
|
||||||
.single()
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
// Fallback for ambiguous times (DST transitions)
|
|
||||||
local_tz.from_local_datetime(&new_end).earliest().unwrap()
|
|
||||||
})
|
|
||||||
.with_timezone(&chrono::Utc);
|
|
||||||
|
|
||||||
let start_date = start_utc.format("%Y-%m-%d").to_string();
|
// Send local times to backend, which will handle timezone conversion
|
||||||
let start_time = start_utc.format("%H:%M").to_string();
|
let start_date = new_start.format("%Y-%m-%d").to_string();
|
||||||
let end_date = end_utc.format("%Y-%m-%d").to_string();
|
let start_time = new_start.format("%H:%M").to_string();
|
||||||
let end_time = end_utc.format("%H:%M").to_string();
|
let end_date = new_end.format("%Y-%m-%d").to_string();
|
||||||
|
let end_time = new_end.format("%H:%M").to_string();
|
||||||
|
|
||||||
// Convert existing event data to string formats for the API
|
// Convert existing event data to string formats for the API
|
||||||
let status_str = match original_event.status {
|
let status_str = match original_event.status {
|
||||||
@@ -1003,7 +1263,7 @@ pub fn App() -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Convert reminders to string format
|
// Convert reminders to string format
|
||||||
let reminder_str = if !original_event.alarms.is_empty() {
|
let _reminder_str = if !original_event.alarms.is_empty() {
|
||||||
// Convert from VAlarm to minutes before
|
// Convert from VAlarm to minutes before
|
||||||
"15".to_string() // TODO: Convert VAlarm trigger to minutes
|
"15".to_string() // TODO: Convert VAlarm trigger to minutes
|
||||||
} else {
|
} else {
|
||||||
@@ -1054,14 +1314,23 @@ pub fn App() -> Html {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(","),
|
.join(","),
|
||||||
original_event.categories.join(","),
|
original_event.categories.join(","),
|
||||||
reminder_str.clone(),
|
original_event.alarms.clone(),
|
||||||
recurrence_str.clone(),
|
recurrence_str.clone(),
|
||||||
vec![false; 7],
|
vec![false; 7], // recurrence_days
|
||||||
None,
|
1, // recurrence_interval - default for drag-and-drop
|
||||||
None,
|
None, // recurrence_count
|
||||||
original_event.calendar_path.clone(),
|
None, // recurrence_until
|
||||||
scope.clone(),
|
original_event.calendar_path.clone(), // calendar_path
|
||||||
occurrence_date,
|
scope.clone(), // update_scope
|
||||||
|
occurrence_date, // occurrence_date
|
||||||
|
{
|
||||||
|
// Get timezone offset
|
||||||
|
let date = js_sys::Date::new_0();
|
||||||
|
let timezone_offset = date.get_timezone_offset(); // Minutes from UTC
|
||||||
|
let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign
|
||||||
|
let minutes = (timezone_offset as i32).abs() % 60;
|
||||||
|
format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00
|
||||||
|
}, // timezone
|
||||||
)
|
)
|
||||||
.await,
|
.await,
|
||||||
)
|
)
|
||||||
@@ -1102,17 +1371,21 @@ pub fn App() -> Html {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(","),
|
.join(","),
|
||||||
original_event.categories.join(","),
|
original_event.categories.join(","),
|
||||||
reminder_str,
|
original_event.alarms.clone(),
|
||||||
recurrence_str,
|
recurrence_str,
|
||||||
recurrence_days,
|
recurrence_days,
|
||||||
|
1, // recurrence_interval - default to 1 for drag-and-drop
|
||||||
|
None, // recurrence_count - preserve existing
|
||||||
|
None, // recurrence_until - preserve existing
|
||||||
original_event.calendar_path,
|
original_event.calendar_path,
|
||||||
original_event.exdate.clone(),
|
{
|
||||||
if preserve_rrule {
|
// Get timezone offset
|
||||||
Some("update_series".to_string())
|
let date = js_sys::Date::new_0();
|
||||||
} else {
|
let timezone_offset = date.get_timezone_offset(); // Minutes from UTC
|
||||||
Some("this_and_future".to_string())
|
let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign
|
||||||
|
let minutes = (timezone_offset as i32).abs() % 60;
|
||||||
|
format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00
|
||||||
},
|
},
|
||||||
until_date,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
@@ -1193,10 +1466,6 @@ pub fn App() -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!("App rendering: auth_token = {:?}", auth_token.is_some()).into(),
|
|
||||||
);
|
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -1386,6 +1655,7 @@ pub fn App() -> Html {
|
|||||||
on_theme_change={on_theme_change}
|
on_theme_change={on_theme_change}
|
||||||
current_style={(*current_style).clone()}
|
current_style={(*current_style).clone()}
|
||||||
on_style_change={on_style_change}
|
on_style_change={on_style_change}
|
||||||
|
on_color_editor_open={on_color_editor_open}
|
||||||
/>
|
/>
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<RouteHandler
|
<RouteHandler
|
||||||
@@ -1474,7 +1744,7 @@ pub fn App() -> Html {
|
|||||||
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
let calendar_management_modal_open = calendar_management_modal_open.clone();
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
let calendar_service = CalendarService::new();
|
let _calendar_service = CalendarService::new();
|
||||||
match CalendarService::get_external_calendars().await {
|
match CalendarService::get_external_calendars().await {
|
||||||
Ok(calendars) => {
|
Ok(calendars) => {
|
||||||
external_calendars.set(calendars);
|
external_calendars.set(calendars);
|
||||||
@@ -1597,7 +1867,7 @@ pub fn App() -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get the occurrence date from the clicked event
|
// Get the occurrence date from the clicked event
|
||||||
let occurrence_date = Some(event.dtstart.date_naive().format("%Y-%m-%d").to_string());
|
let occurrence_date = Some(event.dtstart.date().format("%Y-%m-%d").to_string());
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🔄 Delete action: {}", action_str).into());
|
web_sys::console::log_1(&format!("🔄 Delete action: {}", action_str).into());
|
||||||
web_sys::console::log_1(&format!("🔄 Event UID: {}", event.uid).into());
|
web_sys::console::log_1(&format!("🔄 Event UID: {}", event.uid).into());
|
||||||
@@ -1637,6 +1907,19 @@ pub fn App() -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
on_edit_singleton={Callback::from({
|
||||||
|
let event_context_menu_event = event_context_menu_event.clone();
|
||||||
|
let event_context_menu_open = event_context_menu_open.clone();
|
||||||
|
let create_event_modal_open = create_event_modal_open.clone();
|
||||||
|
let event_edit_scope = event_edit_scope.clone();
|
||||||
|
move |event: VEvent| {
|
||||||
|
// For singleton events, open edit modal WITHOUT setting edit_scope
|
||||||
|
event_context_menu_event.set(Some(event));
|
||||||
|
event_edit_scope.set(None); // Explicitly set to None for singleton edits
|
||||||
|
event_context_menu_open.set(false);
|
||||||
|
create_event_modal_open.set(true);
|
||||||
|
}
|
||||||
|
})}
|
||||||
on_view_details={Callback::from({
|
on_view_details={Callback::from({
|
||||||
let event_context_menu_open = event_context_menu_open.clone();
|
let event_context_menu_open = event_context_menu_open.clone();
|
||||||
let view_event_modal_open = view_event_modal_open.clone();
|
let view_event_modal_open = view_event_modal_open.clone();
|
||||||
@@ -1701,6 +1984,21 @@ pub fn App() -> Html {
|
|||||||
is_open={*mobile_warning_open}
|
is_open={*mobile_warning_open}
|
||||||
on_close={on_mobile_warning_close}
|
on_close={on_mobile_warning_close}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
// Color editor modal
|
||||||
|
<ColorEditorModal
|
||||||
|
is_open={*color_editor_open}
|
||||||
|
current_color={color_editor_data.as_ref().map(|(_, color)| color.clone()).unwrap_or_default()}
|
||||||
|
color_index={color_editor_data.as_ref().map(|(index, _)| *index).unwrap_or(0)}
|
||||||
|
default_color={
|
||||||
|
let default_colors = get_default_event_colors();
|
||||||
|
let index = color_editor_data.as_ref().map(|(index, _)| *index).unwrap_or(0);
|
||||||
|
default_colors.get(index).cloned().unwrap_or_else(|| "#3B82F6".to_string())
|
||||||
|
}
|
||||||
|
on_close={on_color_editor_close}
|
||||||
|
on_save={on_color_editor_save}
|
||||||
|
on_reset_all={on_color_editor_reset_all}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Hidden print copy that gets shown only during printing
|
// Hidden print copy that gets shown only during printing
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use crate::components::{
|
|||||||
};
|
};
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
|
||||||
use chrono::{Datelike, Duration, Local, NaiveDate};
|
use chrono::{Datelike, Duration, Local, NaiveDate, Weekday};
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
@@ -32,7 +32,7 @@ pub struct CalendarProps {
|
|||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
bool,
|
bool,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::NaiveDateTime>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
)>,
|
)>,
|
||||||
@@ -111,6 +111,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| {
|
use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| {
|
||||||
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
|
||||||
let date = *date; // Clone the date to avoid lifetime issues
|
let date = *date; // Clone the date to avoid lifetime issues
|
||||||
|
let view_mode = _view.clone(); // Clone the view mode to avoid lifetime issues
|
||||||
let external_events = external_events.clone(); // Clone external events to avoid lifetime issues
|
let external_events = external_events.clone(); // Clone external events to avoid lifetime issues
|
||||||
let user_info = user_info.clone(); // Clone user_info to avoid lifetime issues
|
let user_info = user_info.clone(); // Clone user_info to avoid lifetime issues
|
||||||
|
|
||||||
@@ -136,17 +137,67 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
let current_year = date.year();
|
// Determine which months to fetch based on view mode
|
||||||
let current_month = date.month();
|
let months_to_fetch = match view_mode {
|
||||||
|
ViewMode::Month => {
|
||||||
|
// For month view, just fetch the current month
|
||||||
|
vec![(date.year(), date.month())]
|
||||||
|
}
|
||||||
|
ViewMode::Week => {
|
||||||
|
// For week view, calculate the week bounds and fetch all months that intersect
|
||||||
|
let start_of_week = get_start_of_week(date);
|
||||||
|
let end_of_week = start_of_week + Duration::days(6);
|
||||||
|
|
||||||
|
let mut months = vec![(start_of_week.year(), start_of_week.month())];
|
||||||
|
|
||||||
|
// If the week spans into a different month, add that month too
|
||||||
|
if end_of_week.month() != start_of_week.month() || end_of_week.year() != start_of_week.year() {
|
||||||
|
months.push((end_of_week.year(), end_of_week.month()));
|
||||||
|
}
|
||||||
|
|
||||||
|
months
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
match calendar_service
|
// Fetch events for all required months
|
||||||
.fetch_events_for_month_vevent(
|
let mut all_events = Vec::new();
|
||||||
&token,
|
for (year, month) in months_to_fetch {
|
||||||
&password,
|
match calendar_service
|
||||||
current_year,
|
.fetch_events_for_month_vevent(
|
||||||
current_month,
|
&token,
|
||||||
)
|
&password,
|
||||||
.await
|
year,
|
||||||
|
month,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(mut month_events) => {
|
||||||
|
all_events.append(&mut month_events);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error.set(Some(format!("Failed to load events for {}-{}: {}", year, month, err)));
|
||||||
|
loading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate events that may appear in multiple month fetches
|
||||||
|
// This happens when a recurring event spans across month boundaries
|
||||||
|
all_events.sort_by(|a, b| {
|
||||||
|
// Sort by UID first, then by start time
|
||||||
|
match a.uid.cmp(&b.uid) {
|
||||||
|
std::cmp::Ordering::Equal => a.dtstart.cmp(&b.dtstart),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
all_events.dedup_by(|a, b| {
|
||||||
|
// Remove duplicates with same UID and start time
|
||||||
|
a.uid == b.uid && a.dtstart == b.dtstart
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process the combined events
|
||||||
|
match Ok(all_events) as Result<Vec<VEvent>, String>
|
||||||
{
|
{
|
||||||
Ok(vevents) => {
|
Ok(vevents) => {
|
||||||
// Filter CalDAV events based on calendar visibility
|
// Filter CalDAV events based on calendar visibility
|
||||||
@@ -437,7 +488,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
bool,
|
bool,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::NaiveDateTime>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
)| {
|
)| {
|
||||||
@@ -602,3 +653,18 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to calculate the start of the week (Sunday) for a given date
|
||||||
|
fn get_start_of_week(date: NaiveDate) -> NaiveDate {
|
||||||
|
let weekday = date.weekday();
|
||||||
|
let days_from_sunday = match weekday {
|
||||||
|
Weekday::Sun => 0,
|
||||||
|
Weekday::Mon => 1,
|
||||||
|
Weekday::Tue => 2,
|
||||||
|
Weekday::Wed => 3,
|
||||||
|
Weekday::Thu => 4,
|
||||||
|
Weekday::Fri => 5,
|
||||||
|
Weekday::Sat => 6,
|
||||||
|
};
|
||||||
|
date - Duration::days(days_from_sunday)
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub struct CalendarListItemProps {
|
|||||||
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
|
||||||
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
pub on_color_picker_toggle: Callback<String>, // calendar_path
|
||||||
pub available_colors: Vec<String>,
|
pub available_colors: Vec<String>,
|
||||||
|
pub on_color_editor_open: Callback<(usize, String)>, // (index, current_color)
|
||||||
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
|
||||||
pub on_visibility_toggle: Callback<String>, // calendar_path
|
pub on_visibility_toggle: Callback<String>, // calendar_path
|
||||||
}
|
}
|
||||||
@@ -66,13 +67,25 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
|||||||
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let on_color_right_click = {
|
||||||
|
let on_color_editor_open = props.on_color_editor_open.clone();
|
||||||
|
let color_index = props.available_colors.iter().position(|c| c == color).unwrap_or(0);
|
||||||
|
let color_str = color.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
e.stop_propagation();
|
||||||
|
on_color_editor_open.emit((color_index, color_str.clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let is_selected = props.calendar.color == *color;
|
let is_selected = props.calendar.color == *color;
|
||||||
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class={class_name}
|
<div class={class_name}
|
||||||
style={format!("background-color: {}", color)}
|
style={format!("background-color: {}", color)}
|
||||||
onclick={on_color_select}>
|
onclick={on_color_select}
|
||||||
|
oncontextmenu={on_color_right_click}>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ pub fn calendar_management_modal(props: &CalendarManagementModalProps) -> Html {
|
|||||||
let on_external_success = on_external_success.clone();
|
let on_external_success = on_external_success.clone();
|
||||||
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
let calendar_service = CalendarService::new();
|
let _calendar_service = CalendarService::new();
|
||||||
|
|
||||||
match CalendarService::create_external_calendar(&name, &url, &color).await {
|
match CalendarService::create_external_calendar(&name, &url, &color).await {
|
||||||
Ok(calendar) => {
|
Ok(calendar) => {
|
||||||
|
|||||||
176
frontend/src/components/color_editor_modal.rs
Normal file
176
frontend/src/components/color_editor_modal.rs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct ColorEditorModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub current_color: String,
|
||||||
|
pub color_index: usize,
|
||||||
|
pub default_color: String, // Default color for this index
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_save: Callback<(usize, String)>, // (index, new_color)
|
||||||
|
pub on_reset_all: Callback<()>, // Reset all colors to defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ColorEditorModal)]
|
||||||
|
pub fn color_editor_modal(props: &ColorEditorModalProps) -> Html {
|
||||||
|
let selected_color = use_state(|| props.current_color.clone());
|
||||||
|
|
||||||
|
// Reset selected color when modal opens with new color
|
||||||
|
{
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
use_effect_with(props.current_color.clone(), move |current_color| {
|
||||||
|
selected_color.set(current_color.clone());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_color_input = {
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
selected_color.set(input.value());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_save_click = {
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let on_save = props.on_save.clone();
|
||||||
|
let color_index = props.color_index;
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_save.emit((color_index, (*selected_color).clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
// Only close if clicking the backdrop, not the modal content
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Some(element) = target.dyn_ref::<web_sys::Element>() {
|
||||||
|
if element.class_list().contains("color-editor-backdrop") {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predefined color suggestions
|
||||||
|
let suggested_colors = vec![
|
||||||
|
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4",
|
||||||
|
"#84CC16", "#F97316", "#EC4899", "#6366F1", "#14B8A6", "#F3B806",
|
||||||
|
"#8B5A2B", "#6B7280", "#DC2626", "#7C3AED", "#F87171", "#34D399",
|
||||||
|
"#FBBF24", "#A78BFA", "#60A5FA", "#2DD4BF", "#FB7185", "#FDBA74",
|
||||||
|
];
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="color-editor-backdrop" onclick={on_backdrop_click}>
|
||||||
|
<div class="color-editor-modal">
|
||||||
|
<div class="color-editor-header">
|
||||||
|
<h3>{"Edit Color"}</h3>
|
||||||
|
<button class="close-button" onclick={Callback::from({
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
move |_| on_close.emit(())
|
||||||
|
})}>
|
||||||
|
{"×"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-editor-content">
|
||||||
|
<div class="current-color-preview">
|
||||||
|
<div
|
||||||
|
class="color-preview-large"
|
||||||
|
style={format!("background-color: {}", *selected_color)}
|
||||||
|
></div>
|
||||||
|
<div class="color-preview-info">
|
||||||
|
<span class="color-value">{&*selected_color}</span>
|
||||||
|
<button class="reset-this-color-button" onclick={{
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let default_color = props.default_color.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
selected_color.set(default_color.clone());
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
{"Reset This Color"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-input-section">
|
||||||
|
<label for="color-picker">{"Custom Color:"}</label>
|
||||||
|
<div class="color-input-group">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="color-picker"
|
||||||
|
value={(*selected_color).clone()}
|
||||||
|
oninput={on_color_input.clone()}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="color-text-input"
|
||||||
|
value={(*selected_color).clone()}
|
||||||
|
oninput={on_color_input}
|
||||||
|
placeholder="#000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggested-colors-section">
|
||||||
|
<label>{"Suggested Colors:"}</label>
|
||||||
|
<div class="suggested-colors-grid">
|
||||||
|
{
|
||||||
|
suggested_colors.iter().map(|color| {
|
||||||
|
let color = color.to_string();
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let onclick = {
|
||||||
|
let color = color.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
selected_color.set(color.clone());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
class="suggested-color"
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={onclick}
|
||||||
|
title={color.clone()}
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-editor-footer">
|
||||||
|
<button class="cancel-button" onclick={Callback::from({
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
move |_| on_close.emit(())
|
||||||
|
})}>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button class="reset-all-button" onclick={Callback::from({
|
||||||
|
let on_reset_all = props.on_reset_all.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
move |_| {
|
||||||
|
on_reset_all.emit(());
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
})}>
|
||||||
|
{"Reset All Colors"}
|
||||||
|
</button>
|
||||||
|
<button class="save-button" onclick={on_save_click}>
|
||||||
|
{"Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,12 +238,11 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
|
|||||||
|
|
||||||
// Convert VEvent to EventCreationData for editing
|
// Convert VEvent to EventCreationData for editing
|
||||||
fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData {
|
fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData {
|
||||||
use chrono::Local;
|
|
||||||
|
|
||||||
// Convert start datetime from UTC to local
|
// VEvent fields are already local time (NaiveDateTime)
|
||||||
let start_local = event.dtstart.with_timezone(&Local).naive_local();
|
let start_local = event.dtstart;
|
||||||
let end_local = if let Some(dtend) = event.dtend {
|
let end_local = if let Some(dtend) = event.dtend {
|
||||||
dtend.with_timezone(&Local).naive_local()
|
dtend
|
||||||
} else {
|
} else {
|
||||||
// Default to 1 hour after start if no end time
|
// Default to 1 hour after start if no end time
|
||||||
start_local + chrono::Duration::hours(1)
|
start_local + chrono::Duration::hours(1)
|
||||||
@@ -258,7 +257,13 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend
|
|||||||
|
|
||||||
// Timing
|
// Timing
|
||||||
start_date: start_local.date(),
|
start_date: start_local.date(),
|
||||||
end_date: end_local.date(),
|
end_date: if event.all_day {
|
||||||
|
// For all-day events, subtract one day to convert from exclusive to inclusive end date
|
||||||
|
// (UI expects inclusive dates, but iCalendar stores exclusive end dates)
|
||||||
|
end_local.date() - chrono::Duration::days(1)
|
||||||
|
} else {
|
||||||
|
end_local.date()
|
||||||
|
},
|
||||||
start_time: start_local.time(),
|
start_time: start_local.time(),
|
||||||
end_time: end_local.time(),
|
end_time: end_local.time(),
|
||||||
|
|
||||||
@@ -287,13 +292,15 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend
|
|||||||
// Categorization
|
// Categorization
|
||||||
categories: event.categories.join(","),
|
categories: event.categories.join(","),
|
||||||
|
|
||||||
// Reminders - TODO: Parse alarm from VEvent if needed
|
// Reminders - Use VAlarms from the event
|
||||||
reminder: ReminderType::None,
|
alarms: event.alarms.clone(),
|
||||||
|
|
||||||
// Recurrence - Parse RRULE if present
|
// Recurrence - Parse RRULE if present
|
||||||
recurrence: if let Some(ref rrule_str) = event.rrule {
|
recurrence: if let Some(ref rrule_str) = event.rrule {
|
||||||
|
web_sys::console::log_1(&format!("🐛 MODAL DEBUG: Event has RRULE: {}", rrule_str).into());
|
||||||
parse_rrule_frequency(rrule_str)
|
parse_rrule_frequency(rrule_str)
|
||||||
} else {
|
} else {
|
||||||
|
web_sys::console::log_1(&"🐛 MODAL DEBUG: Event has no RRULE (singleton)".into());
|
||||||
RecurrenceType::None
|
RecurrenceType::None
|
||||||
},
|
},
|
||||||
recurrence_interval: if let Some(ref rrule_str) = event.rrule {
|
recurrence_interval: if let Some(ref rrule_str) = event.rrule {
|
||||||
@@ -338,7 +345,10 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Edit tracking
|
// Edit tracking
|
||||||
edit_scope: None, // Will be set by the modal after creation
|
edit_scope: {
|
||||||
|
web_sys::console::log_1(&"🐛 MODAL DEBUG: Setting edit_scope to None for vevent_to_creation_data".into());
|
||||||
|
None // Will be set by the modal after creation
|
||||||
|
},
|
||||||
changed_fields: vec![],
|
changed_fields: vec![],
|
||||||
original_uid: Some(event.uid.clone()), // Preserve original UID for editing
|
original_uid: Some(event.uid.clone()), // Preserve original UID for editing
|
||||||
occurrence_date: Some(start_local.date()), // The occurrence date being edited
|
occurrence_date: Some(start_local.date()), // The occurrence date being edited
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub struct EventContextMenuProps {
|
|||||||
pub on_delete: Callback<DeleteAction>,
|
pub on_delete: Callback<DeleteAction>,
|
||||||
pub on_view_details: Callback<VEvent>,
|
pub on_view_details: Callback<VEvent>,
|
||||||
pub on_close: Callback<()>,
|
pub on_close: Callback<()>,
|
||||||
|
pub on_edit_singleton: Callback<VEvent>, // New callback for editing singleton events without scope
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(EventContextMenu)]
|
#[function_component(EventContextMenu)]
|
||||||
@@ -109,6 +110,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let create_singleton_edit_callback = {
|
||||||
|
let on_edit_singleton = props.on_edit_singleton.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
let event = props.event.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
if let Some(event) = &event {
|
||||||
|
on_edit_singleton.emit(event.clone());
|
||||||
|
}
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let create_delete_callback = |action: DeleteAction| {
|
let create_delete_callback = |action: DeleteAction| {
|
||||||
let on_delete = props.on_delete.clone();
|
let on_delete = props.on_delete.clone();
|
||||||
let on_close = props.on_close.clone();
|
let on_close = props.on_close.clone();
|
||||||
@@ -160,9 +173,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular single events - show edit option
|
// Regular single events - show edit option without setting edit scope
|
||||||
html! {
|
html! {
|
||||||
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
<div class="context-menu-item" onclick={create_singleton_edit_callback}>
|
||||||
{"Edit Event"}
|
{"Edit Event"}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
296
frontend/src/components/event_form/add_alarm_modal.rs
Normal file
296
frontend/src/components/event_form/add_alarm_modal.rs
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
use calendar_models::{VAlarm, AlarmAction, AlarmTrigger};
|
||||||
|
use chrono::{Duration, DateTime, Utc, NaiveTime};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::{HtmlSelectElement, HtmlInputElement};
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum TriggerType {
|
||||||
|
Relative, // Duration before/after event
|
||||||
|
Absolute, // Specific date/time
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum RelativeTo {
|
||||||
|
Start,
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum TimeUnit {
|
||||||
|
Minutes,
|
||||||
|
Hours,
|
||||||
|
Days,
|
||||||
|
Weeks,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct AddAlarmModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub editing_index: Option<usize>, // If editing an existing alarm
|
||||||
|
pub initial_alarm: Option<VAlarm>, // For editing mode
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_save: Callback<VAlarm>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(AddAlarmModal)]
|
||||||
|
pub fn add_alarm_modal(props: &AddAlarmModalProps) -> Html {
|
||||||
|
// Form state
|
||||||
|
let trigger_type = use_state(|| TriggerType::Relative);
|
||||||
|
let relative_to = use_state(|| RelativeTo::Start);
|
||||||
|
let time_unit = use_state(|| TimeUnit::Minutes);
|
||||||
|
let time_value = use_state(|| 15i32);
|
||||||
|
let before_after = use_state(|| true); // true = before, false = after
|
||||||
|
let absolute_date = use_state(|| chrono::Local::now().date_naive());
|
||||||
|
let absolute_time = use_state(|| NaiveTime::from_hms_opt(9, 0, 0).unwrap());
|
||||||
|
|
||||||
|
// Initialize form with existing alarm data if editing
|
||||||
|
{
|
||||||
|
let trigger_type = trigger_type.clone();
|
||||||
|
let time_value = time_value.clone();
|
||||||
|
|
||||||
|
use_effect_with(props.initial_alarm.clone(), move |initial_alarm| {
|
||||||
|
if let Some(alarm) = initial_alarm {
|
||||||
|
match &alarm.trigger {
|
||||||
|
AlarmTrigger::Duration(duration) => {
|
||||||
|
trigger_type.set(TriggerType::Relative);
|
||||||
|
let minutes = duration.num_minutes().abs();
|
||||||
|
time_value.set(minutes as i32);
|
||||||
|
}
|
||||||
|
AlarmTrigger::DateTime(_) => {
|
||||||
|
trigger_type.set(TriggerType::Absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_trigger_type_change = {
|
||||||
|
let trigger_type = trigger_type.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() {
|
||||||
|
let new_type = match target.value().as_str() {
|
||||||
|
"absolute" => TriggerType::Absolute,
|
||||||
|
_ => TriggerType::Relative,
|
||||||
|
};
|
||||||
|
trigger_type.set(new_type);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let on_relative_to_change = {
|
||||||
|
let relative_to = relative_to.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() {
|
||||||
|
let new_relative = match target.value().as_str() {
|
||||||
|
"end" => RelativeTo::End,
|
||||||
|
_ => RelativeTo::Start,
|
||||||
|
};
|
||||||
|
relative_to.set(new_relative);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_time_unit_change = {
|
||||||
|
let time_unit = time_unit.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() {
|
||||||
|
let new_unit = match target.value().as_str() {
|
||||||
|
"hours" => TimeUnit::Hours,
|
||||||
|
"days" => TimeUnit::Days,
|
||||||
|
"weeks" => TimeUnit::Weeks,
|
||||||
|
_ => TimeUnit::Minutes,
|
||||||
|
};
|
||||||
|
time_unit.set(new_unit);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_time_value_change = {
|
||||||
|
let time_value = time_value.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
if let Ok(value) = target.value().parse::<i32>() {
|
||||||
|
time_value.set(value.max(1)); // Minimum 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_before_after_change = {
|
||||||
|
let before_after = before_after.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() {
|
||||||
|
let is_before = target.value() == "before";
|
||||||
|
before_after.set(is_before);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let on_save_click = {
|
||||||
|
let trigger_type = trigger_type.clone();
|
||||||
|
let time_unit = time_unit.clone();
|
||||||
|
let time_value = time_value.clone();
|
||||||
|
let before_after = before_after.clone();
|
||||||
|
let absolute_date = absolute_date.clone();
|
||||||
|
let absolute_time = absolute_time.clone();
|
||||||
|
let on_save = props.on_save.clone();
|
||||||
|
|
||||||
|
Callback::from(move |_| {
|
||||||
|
// Create the alarm trigger
|
||||||
|
let trigger = match *trigger_type {
|
||||||
|
TriggerType::Relative => {
|
||||||
|
let minutes = match *time_unit {
|
||||||
|
TimeUnit::Minutes => *time_value,
|
||||||
|
TimeUnit::Hours => *time_value * 60,
|
||||||
|
TimeUnit::Days => *time_value * 60 * 24,
|
||||||
|
TimeUnit::Weeks => *time_value * 60 * 24 * 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
let signed_minutes = if *before_after { -minutes } else { minutes } as i64;
|
||||||
|
AlarmTrigger::Duration(Duration::minutes(signed_minutes))
|
||||||
|
}
|
||||||
|
TriggerType::Absolute => {
|
||||||
|
// Combine date and time to create a DateTime<Utc>
|
||||||
|
let naive_datetime = absolute_date.and_time(*absolute_time);
|
||||||
|
let utc_datetime = DateTime::from_naive_utc_and_offset(naive_datetime, Utc);
|
||||||
|
AlarmTrigger::DateTime(utc_datetime)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the VAlarm - always use Display action, no custom description
|
||||||
|
let alarm = VAlarm {
|
||||||
|
action: AlarmAction::Display,
|
||||||
|
trigger,
|
||||||
|
duration: None,
|
||||||
|
repeat: None,
|
||||||
|
description: None,
|
||||||
|
summary: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
attach: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
on_save.emit(alarm);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
if let Some(element) = target.dyn_ref::<web_sys::Element>() {
|
||||||
|
if element.class_list().contains("add-alarm-backdrop") {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="add-alarm-backdrop" onclick={on_backdrop_click}>
|
||||||
|
<div class="add-alarm-modal">
|
||||||
|
<div class="add-alarm-header">
|
||||||
|
<h3>{
|
||||||
|
if props.editing_index.is_some() {
|
||||||
|
"Edit Reminder"
|
||||||
|
} else {
|
||||||
|
"Add Reminder"
|
||||||
|
}
|
||||||
|
}</h3>
|
||||||
|
<button class="close-button" onclick={Callback::from({
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
move |_| on_close.emit(())
|
||||||
|
})}>
|
||||||
|
{"×"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="add-alarm-content">
|
||||||
|
// Trigger Type Selection
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="trigger-type">{"Trigger Type"}</label>
|
||||||
|
<select id="trigger-type" class="form-input" onchange={on_trigger_type_change}>
|
||||||
|
<option value="relative" selected={matches!(*trigger_type, TriggerType::Relative)}>
|
||||||
|
{"Relative to event time"}
|
||||||
|
</option>
|
||||||
|
<option value="absolute" selected={matches!(*trigger_type, TriggerType::Absolute)}>
|
||||||
|
{"Specific date and time"}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Relative Trigger Configuration
|
||||||
|
if matches!(*trigger_type, TriggerType::Relative) {
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"When"}</label>
|
||||||
|
<div class="relative-time-inputs">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-input time-value-input"
|
||||||
|
value={time_value.to_string()}
|
||||||
|
min="1"
|
||||||
|
onchange={on_time_value_change}
|
||||||
|
/>
|
||||||
|
<select class="form-input time-unit-select" onchange={on_time_unit_change}>
|
||||||
|
<option value="minutes" selected={matches!(*time_unit, TimeUnit::Minutes)}>{"minute(s)"}</option>
|
||||||
|
<option value="hours" selected={matches!(*time_unit, TimeUnit::Hours)}>{"hour(s)"}</option>
|
||||||
|
<option value="days" selected={matches!(*time_unit, TimeUnit::Days)}>{"day(s)"}</option>
|
||||||
|
<option value="weeks" selected={matches!(*time_unit, TimeUnit::Weeks)}>{"week(s)"}</option>
|
||||||
|
</select>
|
||||||
|
<select class="form-input before-after-select" onchange={on_before_after_change}>
|
||||||
|
<option value="before" selected={*before_after}>{"before"}</option>
|
||||||
|
<option value="after" selected={!*before_after}>{"after"}</option>
|
||||||
|
</select>
|
||||||
|
<select class="form-input relative-to-select" onchange={on_relative_to_change}>
|
||||||
|
<option value="start" selected={matches!(*relative_to, RelativeTo::Start)}>{"event start"}</option>
|
||||||
|
<option value="end" selected={matches!(*relative_to, RelativeTo::End)}>{"event end"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute Trigger Configuration
|
||||||
|
if matches!(*trigger_type, TriggerType::Absolute) {
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Date and Time"}</label>
|
||||||
|
<div class="absolute-time-inputs">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-input"
|
||||||
|
value={absolute_date.format("%Y-%m-%d").to_string()}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="form-input"
|
||||||
|
value={absolute_time.format("%H:%M").to_string()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="add-alarm-footer">
|
||||||
|
<button class="cancel-button" onclick={Callback::from({
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
move |_| on_close.emit(())
|
||||||
|
})}>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button class="save-button" onclick={on_save_click}>
|
||||||
|
{if props.editing_index.is_some() { "Update" } else { "Add Reminder" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
133
frontend/src/components/event_form/alarm_list.rs
Normal file
133
frontend/src/components/event_form/alarm_list.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
use calendar_models::{VAlarm, AlarmAction, AlarmTrigger};
|
||||||
|
use chrono::Duration;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct AlarmListProps {
|
||||||
|
pub alarms: Vec<VAlarm>,
|
||||||
|
pub on_alarm_delete: Callback<usize>, // Index of alarm to delete
|
||||||
|
pub on_alarm_edit: Callback<usize>, // Index of alarm to edit
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(AlarmList)]
|
||||||
|
pub fn alarm_list(props: &AlarmListProps) -> Html {
|
||||||
|
if props.alarms.is_empty() {
|
||||||
|
return html! {
|
||||||
|
<div class="alarm-list-empty">
|
||||||
|
<p class="text-muted">{"No reminders set"}</p>
|
||||||
|
<p class="text-small">{"Click 'Add Reminder' to create your first reminder"}</p>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="alarm-list">
|
||||||
|
<h6>{"Configured Reminders"}</h6>
|
||||||
|
<div class="alarm-items">
|
||||||
|
{
|
||||||
|
props.alarms.iter().enumerate().map(|(index, alarm)| {
|
||||||
|
let alarm_description = format_alarm_description(alarm);
|
||||||
|
let action_icon = get_action_icon(&alarm.action);
|
||||||
|
|
||||||
|
let on_delete = {
|
||||||
|
let on_alarm_delete = props.on_alarm_delete.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_alarm_delete.emit(index);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_edit = {
|
||||||
|
let on_alarm_edit = props.on_alarm_edit.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_alarm_edit.emit(index);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div key={index} class="alarm-item">
|
||||||
|
<div class="alarm-content">
|
||||||
|
<span class="alarm-icon">{action_icon}</span>
|
||||||
|
<span class="alarm-description">{alarm_description}</span>
|
||||||
|
</div>
|
||||||
|
<div class="alarm-actions">
|
||||||
|
<button
|
||||||
|
class="alarm-action-btn edit-btn"
|
||||||
|
title="Edit reminder"
|
||||||
|
onclick={on_edit}
|
||||||
|
>
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="alarm-action-btn delete-btn"
|
||||||
|
title="Delete reminder"
|
||||||
|
onclick={on_delete}
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format alarm description for display
|
||||||
|
fn format_alarm_description(alarm: &VAlarm) -> String {
|
||||||
|
match &alarm.trigger {
|
||||||
|
AlarmTrigger::Duration(duration) => {
|
||||||
|
format_duration_description(duration)
|
||||||
|
}
|
||||||
|
AlarmTrigger::DateTime(datetime) => {
|
||||||
|
format!("At {}", datetime.format("%Y-%m-%d %H:%M UTC"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get icon for alarm action - always use bell for consistent notification type
|
||||||
|
fn get_action_icon(_action: &AlarmAction) -> Html {
|
||||||
|
html! { <i class="fas fa-bell"></i> }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format duration for human-readable description
|
||||||
|
fn format_duration_description(duration: &Duration) -> String {
|
||||||
|
let minutes = duration.num_minutes();
|
||||||
|
|
||||||
|
if minutes == 0 {
|
||||||
|
return "At event time".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let abs_minutes = minutes.abs();
|
||||||
|
let before_or_after = if minutes < 0 { "before" } else { "after" };
|
||||||
|
|
||||||
|
// Convert to human-readable format
|
||||||
|
if abs_minutes >= 60 * 24 * 7 {
|
||||||
|
let weeks = abs_minutes / (60 * 24 * 7);
|
||||||
|
let remainder = abs_minutes % (60 * 24 * 7);
|
||||||
|
if remainder == 0 {
|
||||||
|
format!("{} week{} {}", weeks, if weeks == 1 { "" } else { "s" }, before_or_after)
|
||||||
|
} else {
|
||||||
|
format!("{} minutes {}", abs_minutes, before_or_after)
|
||||||
|
}
|
||||||
|
} else if abs_minutes >= 60 * 24 {
|
||||||
|
let days = abs_minutes / (60 * 24);
|
||||||
|
let remainder = abs_minutes % (60 * 24);
|
||||||
|
if remainder == 0 {
|
||||||
|
format!("{} day{} {}", days, if days == 1 { "" } else { "s" }, before_or_after)
|
||||||
|
} else {
|
||||||
|
format!("{} minutes {}", abs_minutes, before_or_after)
|
||||||
|
}
|
||||||
|
} else if abs_minutes >= 60 {
|
||||||
|
let hours = abs_minutes / 60;
|
||||||
|
let remainder = abs_minutes % 60;
|
||||||
|
if remainder == 0 {
|
||||||
|
format!("{} hour{} {}", hours, if hours == 1 { "" } else { "s" }, before_or_after)
|
||||||
|
} else {
|
||||||
|
format!("{} minutes {}", abs_minutes, before_or_after)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("{} minute{} {}", abs_minutes, if abs_minutes == 1 { "" } else { "s" }, before_or_after)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,26 +99,13 @@ pub fn basic_details_tab(props: &TabProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_reminder_change = {
|
// TODO: Replace with new alarm management UI
|
||||||
let data = data.clone();
|
// let on_reminder_change = {
|
||||||
Callback::from(move |e: Event| {
|
// let data = data.clone();
|
||||||
if let Some(target) = e.target() {
|
// Callback::from(move |e: Event| {
|
||||||
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
// // Will be replaced with VAlarm management
|
||||||
let mut event_data = (*data).clone();
|
// })
|
||||||
event_data.reminder = match select.value().as_str() {
|
// };
|
||||||
"15min" => ReminderType::Minutes15,
|
|
||||||
"30min" => ReminderType::Minutes30,
|
|
||||||
"1hour" => ReminderType::Hour1,
|
|
||||||
"1day" => ReminderType::Day1,
|
|
||||||
"2days" => ReminderType::Days2,
|
|
||||||
"1week" => ReminderType::Week1,
|
|
||||||
_ => ReminderType::None,
|
|
||||||
};
|
|
||||||
data.set(event_data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_recurrence_interval_change = {
|
let on_recurrence_interval_change = {
|
||||||
let data = data.clone();
|
let data = data.clone();
|
||||||
@@ -321,42 +308,31 @@ pub fn basic_details_tab(props: &TabProps) -> Html {
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-calendar">{"Calendar"}</label>
|
|
||||||
<select
|
|
||||||
id="event-calendar"
|
|
||||||
class="form-input"
|
|
||||||
onchange={on_calendar_change}
|
|
||||||
>
|
|
||||||
<option value="">{"Select Calendar"}</option>
|
|
||||||
{
|
|
||||||
props.available_calendars.iter().map(|calendar| {
|
|
||||||
html! {
|
|
||||||
<option
|
|
||||||
key={calendar.path.clone()}
|
|
||||||
value={calendar.path.clone()}
|
|
||||||
selected={data.selected_calendar.as_ref() == Some(&calendar.path)}
|
|
||||||
>
|
|
||||||
{&calendar.display_name}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
}).collect::<Html>()
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={data.all_day}
|
|
||||||
onchange={on_all_day_change}
|
|
||||||
/>
|
|
||||||
{" All Day"}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="event-calendar">{"Calendar"}</label>
|
||||||
|
<select
|
||||||
|
id="event-calendar"
|
||||||
|
class="form-input"
|
||||||
|
onchange={on_calendar_change}
|
||||||
|
>
|
||||||
|
<option value="">{"Select Calendar"}</option>
|
||||||
|
{
|
||||||
|
props.available_calendars.iter().map(|calendar| {
|
||||||
|
html! {
|
||||||
|
<option
|
||||||
|
key={calendar.path.clone()}
|
||||||
|
value={calendar.path.clone()}
|
||||||
|
selected={data.selected_calendar.as_ref() == Some(&calendar.path)}
|
||||||
|
>
|
||||||
|
{&calendar.display_name}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="event-recurrence-basic">{"Repeat"}</label>
|
<label for="event-recurrence-basic">{"Repeat"}</label>
|
||||||
<select
|
<select
|
||||||
@@ -371,21 +347,6 @@ pub fn basic_details_tab(props: &TabProps) -> Html {
|
|||||||
<option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option>
|
<option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="event-reminder-basic">{"Reminder"}</label>
|
|
||||||
<select
|
|
||||||
id="event-reminder-basic"
|
|
||||||
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 before"}</option>
|
|
||||||
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
|
|
||||||
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
|
|
||||||
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder!
|
// RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder!
|
||||||
@@ -659,6 +620,18 @@ pub fn basic_details_tab(props: &TabProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All Day checkbox above date/time fields
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={data.all_day}
|
||||||
|
onchange={on_all_day_change}
|
||||||
|
/>
|
||||||
|
{" All Day"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
// Date and time fields go here AFTER recurrence options
|
// Date and time fields go here AFTER recurrence options
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Event form components module
|
// Event form components module
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
pub mod alarm_list;
|
||||||
|
pub mod add_alarm_modal;
|
||||||
pub mod basic_details;
|
pub mod basic_details;
|
||||||
pub mod advanced;
|
pub mod advanced;
|
||||||
pub mod people;
|
pub mod people;
|
||||||
@@ -8,6 +10,8 @@ pub mod location;
|
|||||||
pub mod reminders;
|
pub mod reminders;
|
||||||
|
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
pub use alarm_list::AlarmList;
|
||||||
|
pub use add_alarm_modal::AddAlarmModal;
|
||||||
pub use basic_details::BasicDetailsTab;
|
pub use basic_details::BasicDetailsTab;
|
||||||
pub use advanced::AdvancedTab;
|
pub use advanced::AdvancedTab;
|
||||||
pub use people::PeopleTab;
|
pub use people::PeopleTab;
|
||||||
|
|||||||
@@ -1,100 +1,116 @@
|
|||||||
use super::types::*;
|
use super::{types::*, AlarmList, AddAlarmModal};
|
||||||
// Types are already imported from super::types::*
|
use calendar_models::VAlarm;
|
||||||
use wasm_bindgen::JsCast;
|
|
||||||
use web_sys::HtmlSelectElement;
|
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[function_component(RemindersTab)]
|
#[function_component(RemindersTab)]
|
||||||
pub fn reminders_tab(props: &TabProps) -> Html {
|
pub fn reminders_tab(props: &TabProps) -> Html {
|
||||||
let data = &props.data;
|
let data = &props.data;
|
||||||
|
|
||||||
let on_reminder_change = {
|
// Modal state
|
||||||
|
let is_modal_open = use_state(|| false);
|
||||||
|
let editing_index = use_state(|| None::<usize>);
|
||||||
|
|
||||||
|
// Add alarm callback
|
||||||
|
let on_add_alarm = {
|
||||||
|
let is_modal_open = is_modal_open.clone();
|
||||||
|
let editing_index = editing_index.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
editing_index.set(None);
|
||||||
|
is_modal_open.set(true);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit alarm callback
|
||||||
|
let on_alarm_edit = {
|
||||||
|
let is_modal_open = is_modal_open.clone();
|
||||||
|
let editing_index = editing_index.clone();
|
||||||
|
Callback::from(move |index: usize| {
|
||||||
|
editing_index.set(Some(index));
|
||||||
|
is_modal_open.set(true);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete alarm callback
|
||||||
|
let on_alarm_delete = {
|
||||||
let data = data.clone();
|
let data = data.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |index: usize| {
|
||||||
if let Some(target) = e.target() {
|
let mut current_data = (*data).clone();
|
||||||
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
|
if index < current_data.alarms.len() {
|
||||||
let mut event_data = (*data).clone();
|
current_data.alarms.remove(index);
|
||||||
event_data.reminder = match select.value().as_str() {
|
data.set(current_data);
|
||||||
"15min" => ReminderType::Minutes15,
|
|
||||||
"30min" => ReminderType::Minutes30,
|
|
||||||
"1hour" => ReminderType::Hour1,
|
|
||||||
"1day" => ReminderType::Day1,
|
|
||||||
"2days" => ReminderType::Days2,
|
|
||||||
"1week" => ReminderType::Week1,
|
|
||||||
_ => ReminderType::None,
|
|
||||||
};
|
|
||||||
data.set(event_data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Close modal callback
|
||||||
|
let on_modal_close = {
|
||||||
|
let is_modal_open = is_modal_open.clone();
|
||||||
|
let editing_index = editing_index.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
is_modal_open.set(false);
|
||||||
|
editing_index.set(None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save alarm callback
|
||||||
|
let on_alarm_save = {
|
||||||
|
let data = data.clone();
|
||||||
|
let is_modal_open = is_modal_open.clone();
|
||||||
|
let editing_index = editing_index.clone();
|
||||||
|
Callback::from(move |alarm: VAlarm| {
|
||||||
|
let mut current_data = (*data).clone();
|
||||||
|
|
||||||
|
if let Some(index) = *editing_index {
|
||||||
|
// Edit existing alarm
|
||||||
|
if index < current_data.alarms.len() {
|
||||||
|
current_data.alarms[index] = alarm;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add new alarm
|
||||||
|
current_data.alarms.push(alarm);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.set(current_data);
|
||||||
|
is_modal_open.set(false);
|
||||||
|
editing_index.set(None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get initial alarm for editing
|
||||||
|
let initial_alarm = (*editing_index).and_then(|index| {
|
||||||
|
data.alarms.get(index).cloned()
|
||||||
|
});
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="tab-panel">
|
<div class="tab-panel">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="event-reminder-main">{"Primary Reminder"}</label>
|
<div class="alarm-management-header">
|
||||||
<select
|
<h5>{"Event Reminders"}</h5>
|
||||||
id="event-reminder-main"
|
<button
|
||||||
class="form-input"
|
class="add-alarm-button"
|
||||||
onchange={on_reminder_change}
|
onclick={on_add_alarm}
|
||||||
>
|
type="button"
|
||||||
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"No reminder"}</option>
|
>
|
||||||
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
|
<i class="fas fa-plus"></i>
|
||||||
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
|
{" Add Reminder"}
|
||||||
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
|
</button>
|
||||||
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
|
|
||||||
<option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days before"}</option>
|
|
||||||
<option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week before"}</option>
|
|
||||||
</select>
|
|
||||||
<p class="form-help-text">{"Choose when you'd like to be reminded about this event"}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="reminder-types">
|
|
||||||
<h5>{"Reminder & Alarm Types"}</h5>
|
|
||||||
<div class="alarm-examples">
|
|
||||||
<div class="alarm-type">
|
|
||||||
<strong>{"Display Alarm"}</strong>
|
|
||||||
<p>{"Pop-up notification on your device"}</p>
|
|
||||||
</div>
|
|
||||||
<div class="alarm-type">
|
|
||||||
<strong>{"Email Reminder"}</strong>
|
|
||||||
<p>{"Email notification sent to your address"}</p>
|
|
||||||
</div>
|
|
||||||
<div class="alarm-type">
|
|
||||||
<strong>{"Audio Alert"}</strong>
|
|
||||||
<p>{"Sound notification with custom audio"}</p>
|
|
||||||
</div>
|
|
||||||
<div class="alarm-type">
|
|
||||||
<strong>{"SMS/Text"}</strong>
|
|
||||||
<p>{"Text message reminder (enterprise feature)"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="form-help-text">{"Multiple alarm types follow RFC 5545 VALARM standards"}</p>
|
<p class="form-help-text">{"Configure multiple reminders with custom timing and notification types"}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="reminder-info">
|
<AlarmList
|
||||||
<h5>{"Advanced Reminder Features"}</h5>
|
alarms={data.alarms.clone()}
|
||||||
<ul>
|
on_alarm_delete={on_alarm_delete}
|
||||||
<li>{"Multiple reminders per event with different timing"}</li>
|
on_alarm_edit={on_alarm_edit}
|
||||||
<li>{"Custom reminder messages and descriptions"}</li>
|
/>
|
||||||
<li>{"Recurring reminders for recurring events"}</li>
|
|
||||||
<li>{"Snooze and dismiss functionality"}</li>
|
|
||||||
<li>{"Integration with system notifications"}</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="attachments-section">
|
<AddAlarmModal
|
||||||
<h6>{"File Attachments & Documents"}</h6>
|
is_open={*is_modal_open}
|
||||||
<p>{"Future attachment features will include:"}</p>
|
editing_index={*editing_index}
|
||||||
<ul>
|
initial_alarm={initial_alarm}
|
||||||
<li>{"Drag-and-drop file uploads"}</li>
|
on_close={on_modal_close}
|
||||||
<li>{"Document preview and thumbnails"}</li>
|
on_save={on_alarm_save}
|
||||||
<li>{"Cloud storage integration (Google Drive, OneDrive)"}</li>
|
/>
|
||||||
<li>{"Version control for updated documents"}</li>
|
|
||||||
<li>{"Shared access permissions for attendees"}</li>
|
|
||||||
</ul>
|
|
||||||
<p class="form-help-text">{"Attachment functionality will be implemented in a future release."}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::services::calendar_service::CalendarInfo;
|
use crate::services::calendar_service::CalendarInfo;
|
||||||
use chrono::{Local, NaiveDate, NaiveTime};
|
use chrono::{Local, NaiveDate, NaiveTime};
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
use calendar_models::VAlarm;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
pub enum EventStatus {
|
pub enum EventStatus {
|
||||||
@@ -28,22 +29,6 @@ impl Default for EventClass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
|
||||||
pub enum ReminderType {
|
|
||||||
None,
|
|
||||||
Minutes15,
|
|
||||||
Minutes30,
|
|
||||||
Hour1,
|
|
||||||
Day1,
|
|
||||||
Days2,
|
|
||||||
Week1,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ReminderType {
|
|
||||||
fn default() -> Self {
|
|
||||||
ReminderType::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
pub enum RecurrenceType {
|
pub enum RecurrenceType {
|
||||||
@@ -104,8 +89,8 @@ pub struct EventCreationData {
|
|||||||
// Categorization
|
// Categorization
|
||||||
pub categories: String,
|
pub categories: String,
|
||||||
|
|
||||||
// Reminders
|
// Reminders/Alarms
|
||||||
pub reminder: ReminderType,
|
pub alarms: Vec<VAlarm>,
|
||||||
|
|
||||||
// Recurrence
|
// Recurrence
|
||||||
pub recurrence: RecurrenceType,
|
pub recurrence: RecurrenceType,
|
||||||
@@ -145,60 +130,49 @@ impl EventCreationData {
|
|||||||
String, // organizer
|
String, // organizer
|
||||||
String, // attendees
|
String, // attendees
|
||||||
String, // categories
|
String, // categories
|
||||||
String, // reminder
|
Vec<VAlarm>, // alarms
|
||||||
String, // recurrence
|
String, // recurrence
|
||||||
Vec<bool>, // recurrence_days
|
Vec<bool>, // recurrence_days
|
||||||
Option<String>, // calendar_path
|
u32, // recurrence_interval
|
||||||
Option<u32>, // recurrence_count
|
Option<u32>, // recurrence_count
|
||||||
Option<String>, // recurrence_until
|
Option<String>, // recurrence_until
|
||||||
|
Option<String>, // calendar_path
|
||||||
|
String, // timezone
|
||||||
) {
|
) {
|
||||||
use chrono::{Local, TimeZone};
|
|
||||||
|
|
||||||
// Convert local date/time to UTC for backend
|
// Use local date/times and timezone - no UTC conversion
|
||||||
let (utc_start_date, utc_start_time, utc_end_date, utc_end_time) = if self.all_day {
|
let effective_end_date = if self.all_day {
|
||||||
// For all-day events, just use the dates as-is (no time conversion needed)
|
// For all-day events, add one day to convert from inclusive to exclusive end date
|
||||||
(
|
// (iCalendar spec requires exclusive end dates for all-day events)
|
||||||
self.start_date.format("%Y-%m-%d").to_string(),
|
self.end_date + chrono::Duration::days(1)
|
||||||
self.start_time.format("%H:%M").to_string(),
|
|
||||||
self.end_date.format("%Y-%m-%d").to_string(),
|
|
||||||
self.end_time.format("%H:%M").to_string(),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
// Convert local date/time to UTC, but preserve original local dates
|
self.end_date
|
||||||
let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single();
|
|
||||||
let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single();
|
|
||||||
|
|
||||||
if let (Some(start_dt), Some(end_dt)) = (start_local, end_local) {
|
|
||||||
let start_utc = start_dt.with_timezone(&chrono::Utc);
|
|
||||||
let end_utc = end_dt.with_timezone(&chrono::Utc);
|
|
||||||
|
|
||||||
// IMPORTANT: Use original local dates, not UTC dates!
|
|
||||||
// This ensures events display on the correct day regardless of timezone conversion
|
|
||||||
(
|
|
||||||
self.start_date.format("%Y-%m-%d").to_string(),
|
|
||||||
start_utc.format("%H:%M").to_string(),
|
|
||||||
self.end_date.format("%Y-%m-%d").to_string(),
|
|
||||||
end_utc.format("%H:%M").to_string(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Fallback if timezone conversion fails - use local time as-is
|
|
||||||
web_sys::console::warn_1(&"⚠️ Failed to convert local time to UTC, using local time".into());
|
|
||||||
(
|
|
||||||
self.start_date.format("%Y-%m-%d").to_string(),
|
|
||||||
self.start_time.format("%H:%M").to_string(),
|
|
||||||
self.end_date.format("%Y-%m-%d").to_string(),
|
|
||||||
self.end_time.format("%H:%M").to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get the local timezone
|
||||||
|
let timezone = {
|
||||||
|
use js_sys::Date;
|
||||||
|
let date = Date::new_0();
|
||||||
|
let timezone_offset = date.get_timezone_offset(); // Minutes from UTC
|
||||||
|
let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign
|
||||||
|
let minutes = (timezone_offset as i32).abs() % 60;
|
||||||
|
format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00
|
||||||
|
};
|
||||||
|
|
||||||
|
let (start_date, start_time, end_date, end_time) = (
|
||||||
|
self.start_date.format("%Y-%m-%d").to_string(),
|
||||||
|
self.start_time.format("%H:%M").to_string(),
|
||||||
|
effective_end_date.format("%Y-%m-%d").to_string(),
|
||||||
|
self.end_time.format("%H:%M").to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
(
|
(
|
||||||
self.title.clone(),
|
self.title.clone(),
|
||||||
self.description.clone(),
|
self.description.clone(),
|
||||||
utc_start_date,
|
start_date,
|
||||||
utc_start_time,
|
start_time,
|
||||||
utc_end_date,
|
end_date,
|
||||||
utc_end_time,
|
end_time,
|
||||||
self.location.clone(),
|
self.location.clone(),
|
||||||
self.all_day,
|
self.all_day,
|
||||||
format!("{:?}", self.status).to_uppercase(),
|
format!("{:?}", self.status).to_uppercase(),
|
||||||
@@ -207,12 +181,14 @@ impl EventCreationData {
|
|||||||
self.organizer.clone(),
|
self.organizer.clone(),
|
||||||
self.attendees.clone(),
|
self.attendees.clone(),
|
||||||
self.categories.clone(),
|
self.categories.clone(),
|
||||||
format!("{:?}", self.reminder),
|
self.alarms.clone(),
|
||||||
format!("{:?}", self.recurrence),
|
format!("{:?}", self.recurrence),
|
||||||
self.recurrence_days.clone(),
|
self.recurrence_days.clone(),
|
||||||
self.selected_calendar.clone(),
|
self.recurrence_interval,
|
||||||
self.recurrence_count,
|
self.recurrence_count,
|
||||||
self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()),
|
self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||||
|
self.selected_calendar.clone(),
|
||||||
|
timezone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,7 +215,7 @@ impl Default for EventCreationData {
|
|||||||
organizer: String::new(),
|
organizer: String::new(),
|
||||||
attendees: String::new(),
|
attendees: String::new(),
|
||||||
categories: String::new(),
|
categories: String::new(),
|
||||||
reminder: ReminderType::default(),
|
alarms: Vec::new(),
|
||||||
recurrence: RecurrenceType::default(),
|
recurrence: RecurrenceType::default(),
|
||||||
recurrence_interval: 1,
|
recurrence_interval: 1,
|
||||||
recurrence_until: None,
|
recurrence_until: None,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
@@ -213,7 +212,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
|
fn format_datetime(dt: &chrono::NaiveDateTime, all_day: bool) -> String {
|
||||||
if all_day {
|
if all_day {
|
||||||
dt.format("%B %d, %Y").to_string()
|
dt.format("%B %d, %Y").to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -221,7 +220,7 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_datetime_end(dt: &DateTime<Utc>, all_day: bool) -> String {
|
fn format_datetime_end(dt: &chrono::NaiveDateTime, all_day: bool) -> String {
|
||||||
if all_day {
|
if all_day {
|
||||||
// For all-day events, subtract one day from end date for display
|
// For all-day events, subtract one day from end date for display
|
||||||
// RFC-5545 uses exclusive end dates, but users expect inclusive display
|
// RFC-5545 uses exclusive end dates, but users expect inclusive display
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod calendar_context_menu;
|
|||||||
pub mod calendar_management_modal;
|
pub mod calendar_management_modal;
|
||||||
pub mod calendar_header;
|
pub mod calendar_header;
|
||||||
pub mod calendar_list_item;
|
pub mod calendar_list_item;
|
||||||
|
pub mod color_editor_modal;
|
||||||
pub mod context_menu;
|
pub mod context_menu;
|
||||||
pub mod create_calendar_modal;
|
pub mod create_calendar_modal;
|
||||||
pub mod create_event_modal;
|
pub mod create_event_modal;
|
||||||
@@ -24,6 +25,7 @@ pub use calendar_context_menu::CalendarContextMenu;
|
|||||||
pub use calendar_management_modal::CalendarManagementModal;
|
pub use calendar_management_modal::CalendarManagementModal;
|
||||||
pub use calendar_header::CalendarHeader;
|
pub use calendar_header::CalendarHeader;
|
||||||
pub use calendar_list_item::CalendarListItem;
|
pub use calendar_list_item::CalendarListItem;
|
||||||
|
pub use color_editor_modal::ColorEditorModal;
|
||||||
pub use context_menu::ContextMenu;
|
pub use context_menu::ContextMenu;
|
||||||
pub use create_event_modal::CreateEventModal;
|
pub use create_event_modal::CreateEventModal;
|
||||||
// Re-export event form types for backwards compatibility
|
// Re-export event form types for backwards compatibility
|
||||||
|
|||||||
@@ -113,8 +113,14 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
"#3B82F6".to_string()
|
"#3B82F6".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let weeks_needed = calculate_minimum_weeks_needed(first_weekday, days_in_month);
|
||||||
|
|
||||||
|
// Use calculated weeks with height-based container sizing for proper fit
|
||||||
|
let dynamic_style = format!("grid-template-rows: var(--weekday-header-height, 50px) repeat({}, 1fr);", weeks_needed);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="calendar-grid">
|
<div class="calendar-grid" style={dynamic_style}>
|
||||||
// Weekday headers
|
// Weekday headers
|
||||||
<div class="weekday-header">{"Sun"}</div>
|
<div class="weekday-header">{"Sun"}</div>
|
||||||
<div class="weekday-header">{"Mon"}</div>
|
<div class="weekday-header">{"Mon"}</div>
|
||||||
@@ -212,7 +218,10 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
{onclick}
|
{onclick}
|
||||||
{oncontextmenu}
|
{oncontextmenu}
|
||||||
>
|
>
|
||||||
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
|
<span class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</span>
|
||||||
|
if !event.alarms.is_empty() {
|
||||||
|
<i class="fas fa-bell event-reminder-icon" title="Has reminders"></i>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
@@ -234,13 +243,27 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
|
|
||||||
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
|
{ render_next_month_days(days_from_prev_month.len(), days_in_month, calculate_minimum_weeks_needed(first_weekday, days_in_month)) }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
|
fn calculate_minimum_weeks_needed(first_weekday: Weekday, days_in_month: u32) -> u32 {
|
||||||
let total_slots = 42; // 6 rows x 7 days
|
let days_before = match first_weekday {
|
||||||
|
Weekday::Sun => 0,
|
||||||
|
Weekday::Mon => 1,
|
||||||
|
Weekday::Tue => 2,
|
||||||
|
Weekday::Wed => 3,
|
||||||
|
Weekday::Thu => 4,
|
||||||
|
Weekday::Fri => 5,
|
||||||
|
Weekday::Sat => 6,
|
||||||
|
};
|
||||||
|
let total_days_needed = days_before + days_in_month;
|
||||||
|
(total_days_needed + 6) / 7 // Round up to get number of weeks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_next_month_days(prev_days_count: usize, current_days_count: u32, weeks_needed: u32) -> Html {
|
||||||
|
let total_slots = (weeks_needed * 7) as usize; // Dynamic based on weeks needed
|
||||||
let used_slots = prev_days_count + current_days_count as usize;
|
let used_slots = prev_days_count + current_days_count as usize;
|
||||||
let remaining_slots = if used_slots < total_slots {
|
let remaining_slots = if used_slots < total_slots {
|
||||||
total_slots - used_slots
|
total_slots - used_slots
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use crate::components::{ViewMode, WeekView, MonthView};
|
use crate::components::{ViewMode, WeekView, MonthView, CalendarHeader};
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use wasm_bindgen::{closure::Closure, JsCast};
|
use wasm_bindgen::{closure::Closure, JsCast};
|
||||||
|
use web_sys::MouseEvent;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
@@ -88,10 +89,11 @@ pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
|
|||||||
let calculate_print_dimensions = |start_hour: u32, end_hour: u32, time_increment: u32| -> (f64, f64, f64) {
|
let calculate_print_dimensions = |start_hour: u32, end_hour: u32, time_increment: u32| -> (f64, f64, f64) {
|
||||||
let visible_hours = (end_hour - start_hour) as f64;
|
let visible_hours = (end_hour - start_hour) as f64;
|
||||||
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
|
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
|
||||||
let header_height = 50.0; // Fixed week header height in print preview
|
let calendar_header_height = 80.0; // Calendar header height in print preview
|
||||||
|
let week_header_height = 50.0; // Fixed week header height in print preview
|
||||||
let header_border = 2.0; // Week header bottom border (2px solid)
|
let header_border = 2.0; // Week header bottom border (2px solid)
|
||||||
let container_spacing = 8.0; // Additional container spacing/margins
|
let container_spacing = 8.0; // Additional container spacing/margins
|
||||||
let total_overhead = header_height + header_border + container_spacing;
|
let total_overhead = calendar_header_height + week_header_height + header_border + container_spacing;
|
||||||
let available_height = 720.0 - total_overhead; // Available for time content
|
let available_height = 720.0 - total_overhead; // Available for time content
|
||||||
let base_unit = available_height / (visible_hours * slots_per_hour);
|
let base_unit = available_height / (visible_hours * slots_per_hour);
|
||||||
let pixels_per_hour = base_unit * slots_per_hour;
|
let pixels_per_hour = base_unit * slots_per_hour;
|
||||||
@@ -151,10 +153,11 @@ pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
|
|||||||
// Recalculate base-unit and pixels-per-hour based on actual height
|
// Recalculate base-unit and pixels-per-hour based on actual height
|
||||||
let visible_hours = (end_hour - start_hour) as f64;
|
let visible_hours = (end_hour - start_hour) as f64;
|
||||||
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
|
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
|
||||||
let header_height = 50.0;
|
let calendar_header_height = 80.0; // Calendar header height
|
||||||
|
let week_header_height = 50.0; // Week header height
|
||||||
let header_border = 2.0;
|
let header_border = 2.0;
|
||||||
let container_spacing = 8.0;
|
let container_spacing = 8.0;
|
||||||
let total_overhead = header_height + header_border + container_spacing;
|
let total_overhead = calendar_header_height + week_header_height + header_border + container_spacing;
|
||||||
let available_height = actual_height - total_overhead;
|
let available_height = actual_height - total_overhead;
|
||||||
let actual_base_unit = available_height / (visible_hours * slots_per_hour);
|
let actual_base_unit = available_height / (visible_hours * slots_per_hour);
|
||||||
let actual_pixels_per_hour = actual_base_unit * slots_per_hour;
|
let actual_pixels_per_hour = actual_base_unit * slots_per_hour;
|
||||||
@@ -320,38 +323,50 @@ pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
|
|||||||
*start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level
|
*start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level
|
||||||
)}>
|
)}>
|
||||||
<div class="print-preview-content">
|
<div class="print-preview-content">
|
||||||
{
|
<div class={classes!("calendar", match props.view_mode { ViewMode::Week => Some("week-view"), _ => None })}>
|
||||||
match props.view_mode {
|
<CalendarHeader
|
||||||
ViewMode::Week => html! {
|
current_date={props.current_date}
|
||||||
<WeekView
|
view_mode={props.view_mode.clone()}
|
||||||
key={format!("week-preview-{}-{}", *start_hour, *end_hour)}
|
on_prev={Callback::from(|_: MouseEvent| {})}
|
||||||
current_date={props.current_date}
|
on_next={Callback::from(|_: MouseEvent| {})}
|
||||||
today={props.today}
|
on_today={Callback::from(|_: MouseEvent| {})}
|
||||||
events={props.events.clone()}
|
time_increment={Some(props.time_increment)}
|
||||||
on_event_click={Callback::noop()}
|
on_time_increment_toggle={None::<Callback<MouseEvent>>}
|
||||||
user_info={props.user_info.clone()}
|
on_print={None::<Callback<MouseEvent>>}
|
||||||
external_calendars={props.external_calendars.clone()}
|
/>
|
||||||
time_increment={props.time_increment}
|
{
|
||||||
print_mode={true}
|
match props.view_mode {
|
||||||
print_pixels_per_hour={Some(pixels_per_hour)}
|
ViewMode::Week => html! {
|
||||||
print_start_hour={Some(*start_hour)}
|
<WeekView
|
||||||
/>
|
key={format!("week-preview-{}-{}", *start_hour, *end_hour)}
|
||||||
},
|
current_date={props.current_date}
|
||||||
ViewMode::Month => html! {
|
today={props.today}
|
||||||
<MonthView
|
events={props.events.clone()}
|
||||||
key={format!("month-preview-{}-{}", *start_hour, *end_hour)}
|
on_event_click={Callback::noop()}
|
||||||
current_month={props.current_date}
|
user_info={props.user_info.clone()}
|
||||||
selected_date={Some(props.selected_date)}
|
external_calendars={props.external_calendars.clone()}
|
||||||
today={props.today}
|
time_increment={props.time_increment}
|
||||||
events={props.events.clone()}
|
print_mode={true}
|
||||||
on_day_select={None::<Callback<NaiveDate>>}
|
print_pixels_per_hour={Some(pixels_per_hour)}
|
||||||
on_event_click={Callback::noop()}
|
print_start_hour={Some(*start_hour)}
|
||||||
user_info={props.user_info.clone()}
|
/>
|
||||||
external_calendars={props.external_calendars.clone()}
|
},
|
||||||
/>
|
ViewMode::Month => html! {
|
||||||
},
|
<MonthView
|
||||||
|
key={format!("month-preview-{}-{}", *start_hour, *end_hour)}
|
||||||
|
current_month={props.current_date}
|
||||||
|
selected_date={Some(props.selected_date)}
|
||||||
|
today={props.today}
|
||||||
|
events={props.events.clone()}
|
||||||
|
on_day_select={None::<Callback<NaiveDate>>}
|
||||||
|
on_event_click={Callback::noop()}
|
||||||
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
|
/>
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub struct RouteHandlerProps {
|
|||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
bool,
|
bool,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::NaiveDateTime>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
)>,
|
)>,
|
||||||
@@ -136,7 +136,7 @@ pub struct CalendarViewProps {
|
|||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
chrono::NaiveDateTime,
|
chrono::NaiveDateTime,
|
||||||
bool,
|
bool,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::NaiveDateTime>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
)>,
|
)>,
|
||||||
|
|||||||
@@ -2,17 +2,7 @@ use crate::components::CalendarListItem;
|
|||||||
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
use web_sys::HtmlSelectElement;
|
use web_sys::HtmlSelectElement;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Clone, Routable, PartialEq)]
|
|
||||||
pub enum Route {
|
|
||||||
#[at("/")]
|
|
||||||
Home,
|
|
||||||
#[at("/login")]
|
|
||||||
Login,
|
|
||||||
#[at("/calendar")]
|
|
||||||
Calendar,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub enum ViewMode {
|
pub enum ViewMode {
|
||||||
@@ -30,12 +20,17 @@ pub enum Theme {
|
|||||||
Dark,
|
Dark,
|
||||||
Rose,
|
Rose,
|
||||||
Mint,
|
Mint,
|
||||||
|
Midnight,
|
||||||
|
Charcoal,
|
||||||
|
Nord,
|
||||||
|
Dracula,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub enum Style {
|
pub enum Style {
|
||||||
Default,
|
Default,
|
||||||
Google,
|
Google,
|
||||||
|
Apple,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Theme {
|
impl Theme {
|
||||||
@@ -49,6 +44,10 @@ impl Theme {
|
|||||||
Theme::Dark => "dark",
|
Theme::Dark => "dark",
|
||||||
Theme::Rose => "rose",
|
Theme::Rose => "rose",
|
||||||
Theme::Mint => "mint",
|
Theme::Mint => "mint",
|
||||||
|
Theme::Midnight => "midnight",
|
||||||
|
Theme::Charcoal => "charcoal",
|
||||||
|
Theme::Nord => "nord",
|
||||||
|
Theme::Dracula => "dracula",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +60,10 @@ impl Theme {
|
|||||||
"dark" => Theme::Dark,
|
"dark" => Theme::Dark,
|
||||||
"rose" => Theme::Rose,
|
"rose" => Theme::Rose,
|
||||||
"mint" => Theme::Mint,
|
"mint" => Theme::Mint,
|
||||||
|
"midnight" => Theme::Midnight,
|
||||||
|
"charcoal" => Theme::Charcoal,
|
||||||
|
"nord" => Theme::Nord,
|
||||||
|
"dracula" => Theme::Dracula,
|
||||||
_ => Theme::Default,
|
_ => Theme::Default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,12 +74,14 @@ impl Style {
|
|||||||
match self {
|
match self {
|
||||||
Style::Default => "default",
|
Style::Default => "default",
|
||||||
Style::Google => "google",
|
Style::Google => "google",
|
||||||
|
Style::Apple => "apple",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_value(value: &str) -> Self {
|
pub fn from_value(value: &str) -> Self {
|
||||||
match value {
|
match value {
|
||||||
"google" => Style::Google,
|
"google" => Style::Google,
|
||||||
|
"apple" => Style::Apple,
|
||||||
_ => Style::Default,
|
_ => Style::Default,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,6 +91,7 @@ impl Style {
|
|||||||
match self {
|
match self {
|
||||||
Style::Default => None, // No additional stylesheet needed - uses base styles.css
|
Style::Default => None, // No additional stylesheet needed - uses base styles.css
|
||||||
Style::Google => Some("google.css"), // Trunk copies to root level
|
Style::Google => Some("google.css"), // Trunk copies to root level
|
||||||
|
Style::Apple => Some("apple.css"), // Trunk copies to root level
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +115,7 @@ pub struct SidebarProps {
|
|||||||
pub on_color_change: Callback<(String, String)>,
|
pub on_color_change: Callback<(String, String)>,
|
||||||
pub on_color_picker_toggle: Callback<String>,
|
pub on_color_picker_toggle: Callback<String>,
|
||||||
pub available_colors: Vec<String>,
|
pub available_colors: Vec<String>,
|
||||||
|
pub on_color_editor_open: Callback<(usize, String)>, // (index, current_color)
|
||||||
pub refreshing_calendar_id: Option<i32>,
|
pub refreshing_calendar_id: Option<i32>,
|
||||||
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
||||||
pub on_calendar_visibility_toggle: Callback<String>,
|
pub on_calendar_visibility_toggle: Callback<String>,
|
||||||
@@ -219,6 +226,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
on_color_change={props.on_color_change.clone()}
|
on_color_change={props.on_color_change.clone()}
|
||||||
on_color_picker_toggle={props.on_color_picker_toggle.clone()}
|
on_color_picker_toggle={props.on_color_picker_toggle.clone()}
|
||||||
available_colors={props.available_colors.clone()}
|
available_colors={props.available_colors.clone()}
|
||||||
|
on_color_editor_open={props.on_color_editor_open.clone()}
|
||||||
on_context_menu={props.on_calendar_context_menu.clone()}
|
on_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
on_visibility_toggle={props.on_calendar_visibility_toggle.clone()}
|
on_visibility_toggle={props.on_calendar_visibility_toggle.clone()}
|
||||||
/>
|
/>
|
||||||
@@ -254,7 +262,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<li class="external-calendar-item" style="position: relative;">
|
<li class="external-calendar-item">
|
||||||
<div
|
<div
|
||||||
class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
|
class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
|
||||||
"external-calendar-info color-picker-active"
|
"external-calendar-info color-picker-active"
|
||||||
@@ -299,6 +307,17 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
let on_color_select = Callback::from(move |_: MouseEvent| {
|
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||||
on_color_change.emit((external_id.clone(), color_str.clone()));
|
on_color_change.emit((external_id.clone(), color_str.clone()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let on_color_right_click = {
|
||||||
|
let on_color_editor_open = props.on_color_editor_open.clone();
|
||||||
|
let color_index = props.available_colors.iter().position(|c| c == color).unwrap_or(0);
|
||||||
|
let color_str = color.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
e.stop_propagation();
|
||||||
|
on_color_editor_open.emit((color_index, color_str.clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let is_selected = cal.color == *color;
|
let is_selected = cal.color == *color;
|
||||||
|
|
||||||
@@ -308,6 +327,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
class={if is_selected { "color-option selected" } else { "color-option" }}
|
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||||
style={format!("background-color: {}", color)}
|
style={format!("background-color: {}", color)}
|
||||||
onclick={on_color_select}
|
onclick={on_color_select}
|
||||||
|
oncontextmenu={on_color_right_click}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
@@ -422,6 +442,10 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
<option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"Dark"}</option>
|
<option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"Dark"}</option>
|
||||||
<option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"Rose"}</option>
|
<option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"Rose"}</option>
|
||||||
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option>
|
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option>
|
||||||
|
<option value="midnight" selected={matches!(props.current_theme, Theme::Midnight)}>{"Midnight"}</option>
|
||||||
|
<option value="charcoal" selected={matches!(props.current_theme, Theme::Charcoal)}>{"Charcoal"}</option>
|
||||||
|
<option value="nord" selected={matches!(props.current_theme, Theme::Nord)}>{"Nord"}</option>
|
||||||
|
<option value="dracula" selected={matches!(props.current_theme, Theme::Dracula)}>{"Dracula"}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -429,6 +453,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
<select class="style-selector-dropdown" onchange={on_style_change}>
|
<select class="style-selector-dropdown" onchange={on_style_change}>
|
||||||
<option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option>
|
<option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option>
|
||||||
<option value="google" selected={matches!(props.current_style, Style::Google)}>{"Google Calendar"}</option>
|
<option value="google" selected={matches!(props.current_style, Style::Google)}>{"Google Calendar"}</option>
|
||||||
|
<option value="apple" selected={matches!(props.current_style, Style::Apple)}>{"Apple Calendar"}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ pub struct WeekViewProps {
|
|||||||
NaiveDateTime,
|
NaiveDateTime,
|
||||||
NaiveDateTime,
|
NaiveDateTime,
|
||||||
bool,
|
bool,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::NaiveDateTime>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
)>,
|
)>,
|
||||||
@@ -285,18 +285,14 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
|
|
||||||
// Calculate the day before this occurrence for UNTIL clause
|
// Calculate the day before this occurrence for UNTIL clause
|
||||||
let until_date =
|
let until_date =
|
||||||
edit.event.dtstart.date_naive() - chrono::Duration::days(1);
|
edit.event.dtstart.date() - chrono::Duration::days(1);
|
||||||
let until_datetime = until_date
|
let until_datetime = until_date
|
||||||
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
||||||
let until_utc =
|
let until_naive = until_datetime; // Use local time directly
|
||||||
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
|
||||||
until_datetime,
|
|
||||||
chrono::Utc,
|
|
||||||
);
|
|
||||||
|
|
||||||
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
|
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
|
||||||
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
|
until_naive.format("%Y-%m-%d %H:%M:%S"),
|
||||||
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into());
|
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S")).into());
|
||||||
|
|
||||||
// Critical: Use the dragged times (new_start/new_end) not the original series times
|
// Critical: Use the dragged times (new_start/new_end) not the original series times
|
||||||
// This ensures the new series reflects the user's drag operation
|
// This ensures the new series reflects the user's drag operation
|
||||||
@@ -317,7 +313,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
new_start, // Dragged start time for new series
|
new_start, // Dragged start time for new series
|
||||||
new_end, // Dragged end time for new series
|
new_end, // Dragged end time for new series
|
||||||
true, // preserve_rrule = true
|
true, // preserve_rrule = true
|
||||||
Some(until_utc), // UNTIL date for original series
|
Some(until_naive), // UNTIL date for original series
|
||||||
Some("this_and_future".to_string()), // Update scope
|
Some("this_and_future".to_string()), // Update scope
|
||||||
Some(occurrence_date), // Date of occurrence being modified
|
Some(occurrence_date), // Date of occurrence being modified
|
||||||
));
|
));
|
||||||
@@ -352,6 +348,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="week-view-container">
|
<div class="week-view-container">
|
||||||
// Header with weekday names and dates
|
// Header with weekday names and dates
|
||||||
@@ -617,10 +614,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
|
|
||||||
// Keep the original end time
|
// Keep the original end time
|
||||||
let original_end = if let Some(end) = event.dtend {
|
let original_end = if let Some(end) = event.dtend {
|
||||||
end.with_timezone(&chrono::Local).naive_local()
|
end } else {
|
||||||
} else {
|
|
||||||
// If no end time, use start time + 1 hour as default
|
// If no end time, use start time + 1 hour as default
|
||||||
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
event.dtstart + chrono::Duration::hours(1)
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
||||||
@@ -651,8 +647,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Calculate new end time based on drag position
|
// Calculate new end time based on drag position
|
||||||
let new_end_time = pixels_to_time(current_drag.current_y, time_increment);
|
let new_end_time = pixels_to_time(current_drag.current_y, time_increment);
|
||||||
|
|
||||||
// Keep the original start time
|
// Keep the original start time (already local)
|
||||||
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
let original_start = event.dtstart;
|
||||||
|
|
||||||
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
|
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
|
||||||
|
|
||||||
@@ -827,9 +823,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let time_display = if event.all_day {
|
let time_display = if event.all_day {
|
||||||
"All Day".to_string()
|
"All Day".to_string()
|
||||||
} else {
|
} else {
|
||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart;
|
||||||
if let Some(end) = event.dtend {
|
if let Some(end) = event.dtend {
|
||||||
let local_end = end.with_timezone(&Local);
|
let local_end = end;
|
||||||
|
|
||||||
// Check if both times are in same AM/PM period to avoid redundancy
|
// Check if both times are in same AM/PM period to avoid redundancy
|
||||||
let start_is_am = local_start.hour() < 12;
|
let start_is_am = local_start.hour() < 12;
|
||||||
@@ -972,7 +968,12 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
|
|
||||||
// Event content
|
// Event content
|
||||||
<div class="event-content">
|
<div class="event-content">
|
||||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
<div class="event-title-row">
|
||||||
|
<span class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</span>
|
||||||
|
if !event.alarms.is_empty() {
|
||||||
|
<i class="fas fa-bell event-reminder-icon" title="Has reminders"></i>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
{if !is_all_day && duration_pixels > 30.0 {
|
{if !is_all_day && duration_pixels > 30.0 {
|
||||||
html! { <div class="event-time">{time_display}</div> }
|
html! { <div class="event-time">{time_display}</div> }
|
||||||
} else {
|
} else {
|
||||||
@@ -1056,14 +1057,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Show the event being resized from the start
|
// Show the event being resized from the start
|
||||||
let new_start_time = pixels_to_time(drag.current_y, props.time_increment);
|
let new_start_time = pixels_to_time(drag.current_y, props.time_increment);
|
||||||
let original_end = if let Some(end) = event.dtend {
|
let original_end = if let Some(end) = event.dtend {
|
||||||
end.with_timezone(&chrono::Local).naive_local()
|
end } else {
|
||||||
} else {
|
event.dtstart + chrono::Duration::hours(1)
|
||||||
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate positions for the preview
|
// Calculate positions for the preview
|
||||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
||||||
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
|
let original_duration = original_end.signed_duration_since(event.dtstart);
|
||||||
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
|
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
|
||||||
|
|
||||||
let new_start_pixels = drag.current_y;
|
let new_start_pixels = drag.current_y;
|
||||||
@@ -1089,7 +1089,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
DragType::ResizeEventEnd(event) => {
|
DragType::ResizeEventEnd(event) => {
|
||||||
// Show the event being resized from the end
|
// Show the event being resized from the end
|
||||||
let new_end_time = pixels_to_time(drag.current_y, props.time_increment);
|
let new_end_time = pixels_to_time(drag.current_y, props.time_increment);
|
||||||
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
let original_start = event.dtstart;
|
||||||
|
|
||||||
// Calculate positions for the preview
|
// Calculate positions for the preview
|
||||||
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
||||||
@@ -1227,12 +1227,12 @@ fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) {
|
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) {
|
||||||
// Convert UTC times to local time for display
|
// Events are already in local time
|
||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart;
|
||||||
|
|
||||||
// Events should display based on their stored date (which now preserves the original local date)
|
// Events should display based on their local date, since we now store proper UTC times
|
||||||
// not the calculated local date from UTC conversion, since we fixed the creation logic
|
// Convert the UTC stored time back to local time to determine display date
|
||||||
let event_date = event.dtstart.date_naive(); // Use the stored date, not the converted local date
|
let event_date = local_start.date();
|
||||||
|
|
||||||
if event_date != date {
|
if event_date != date {
|
||||||
return (0.0, 0.0, false); // Event not on this date
|
return (0.0, 0.0, false); // Event not on this date
|
||||||
@@ -1263,8 +1263,8 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32
|
|||||||
|
|
||||||
// Calculate duration and height
|
// Calculate duration and height
|
||||||
let duration_pixels = if let Some(end) = event.dtend {
|
let duration_pixels = if let Some(end) = event.dtend {
|
||||||
let local_end = end.with_timezone(&Local);
|
let local_end = end;
|
||||||
let end_date = local_end.date_naive();
|
let end_date = local_end.date();
|
||||||
|
|
||||||
// Handle events that span multiple days by capping at midnight
|
// Handle events that span multiple days by capping at midnight
|
||||||
if end_date > date {
|
if end_date > date {
|
||||||
@@ -1291,16 +1291,16 @@ fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start1 = event1.dtstart.with_timezone(&Local).naive_local();
|
let start1 = event1.dtstart;
|
||||||
let end1 = if let Some(end) = event1.dtend {
|
let end1 = if let Some(end) = event1.dtend {
|
||||||
end.with_timezone(&Local).naive_local()
|
end
|
||||||
} else {
|
} else {
|
||||||
start1 + chrono::Duration::hours(1) // Default 1 hour duration
|
start1 + chrono::Duration::hours(1) // Default 1 hour duration
|
||||||
};
|
};
|
||||||
|
|
||||||
let start2 = event2.dtstart.with_timezone(&Local).naive_local();
|
let start2 = event2.dtstart;
|
||||||
let end2 = if let Some(end) = event2.dtend {
|
let end2 = if let Some(end) = event2.dtend {
|
||||||
end.with_timezone(&Local).naive_local()
|
end
|
||||||
} else {
|
} else {
|
||||||
start2 + chrono::Duration::hours(1) // Default 1 hour duration
|
start2 + chrono::Duration::hours(1) // Default 1 hour duration
|
||||||
};
|
};
|
||||||
@@ -1322,8 +1322,8 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (_, _, _) = calculate_event_position(event, date, time_increment, None, None);
|
let (_, _, _) = calculate_event_position(event, date, time_increment, None, None);
|
||||||
let local_start = event.dtstart.with_timezone(&Local);
|
let local_start = event.dtstart;
|
||||||
let event_date = local_start.date_naive();
|
let event_date = local_start.date();
|
||||||
if event_date == date ||
|
if event_date == date ||
|
||||||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
|
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
|
||||||
Some((idx, event))
|
Some((idx, event))
|
||||||
@@ -1334,7 +1334,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sort by start time
|
// Sort by start time
|
||||||
day_events.sort_by_key(|(_, event)| event.dtstart.with_timezone(&Local).naive_local());
|
day_events.sort_by_key(|(_, event)| event.dtstart);
|
||||||
|
|
||||||
// For each event, find all events it overlaps with
|
// For each event, find all events it overlaps with
|
||||||
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
|
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
|
||||||
@@ -1359,7 +1359,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
|
|||||||
} else {
|
} else {
|
||||||
// This event overlaps - we need to calculate column layout
|
// This event overlaps - we need to calculate column layout
|
||||||
// Sort the overlapping group by start time
|
// Sort the overlapping group by start time
|
||||||
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart.with_timezone(&Local).naive_local());
|
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart);
|
||||||
|
|
||||||
// Assign columns using a greedy algorithm
|
// Assign columns using a greedy algorithm
|
||||||
let mut columns: Vec<Vec<usize>> = Vec::new();
|
let mut columns: Vec<Vec<usize>> = Vec::new();
|
||||||
@@ -1407,19 +1407,19 @@ fn event_spans_date(event: &VEvent, date: NaiveDate) -> bool {
|
|||||||
let start_date = if event.all_day {
|
let start_date = if event.all_day {
|
||||||
// For all-day events, extract date directly from UTC without timezone conversion
|
// For all-day events, extract date directly from UTC without timezone conversion
|
||||||
// since all-day events are stored at noon UTC to avoid timezone boundary issues
|
// since all-day events are stored at noon UTC to avoid timezone boundary issues
|
||||||
event.dtstart.date_naive()
|
event.dtstart.date()
|
||||||
} else {
|
} else {
|
||||||
event.dtstart.with_timezone(&Local).date_naive()
|
event.dtstart.date()
|
||||||
};
|
};
|
||||||
|
|
||||||
let end_date = if let Some(dtend) = event.dtend {
|
let end_date = if let Some(dtend) = event.dtend {
|
||||||
if event.all_day {
|
if event.all_day {
|
||||||
// For all-day events, dtend is set to the day after the last day (RFC 5545)
|
// For all-day events, dtend is set to the day after the last day (RFC 5545)
|
||||||
// Extract date directly from UTC and subtract a day to get actual last day
|
// Extract date directly from UTC and subtract a day to get actual last day
|
||||||
dtend.date_naive() - chrono::Duration::days(1)
|
dtend.date() - chrono::Duration::days(1)
|
||||||
} else {
|
} else {
|
||||||
// For timed events, use timezone conversion
|
// For timed events, use timezone conversion
|
||||||
dtend.with_timezone(&Local).date_naive()
|
dtend.date()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single day event
|
// Single day event
|
||||||
|
|||||||
299
frontend/src/services/alarm_scheduler.rs
Normal file
299
frontend/src/services/alarm_scheduler.rs
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
use calendar_models::{VAlarm, AlarmAction, AlarmTrigger, VEvent};
|
||||||
|
use chrono::{Duration, Local, NaiveDateTime};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use crate::services::{NotificationManager, AlarmNotification};
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ScheduledAlarm {
|
||||||
|
pub id: String, // Unique alarm ID
|
||||||
|
pub event_uid: String, // Event this alarm belongs to
|
||||||
|
pub event_summary: String, // Event title for notification
|
||||||
|
pub event_location: Option<String>, // Event location for notification
|
||||||
|
pub event_start: NaiveDateTime, // Event start time (local)
|
||||||
|
pub trigger_time: NaiveDateTime, // When alarm should trigger (local)
|
||||||
|
pub alarm_action: AlarmAction, // Type of alarm
|
||||||
|
pub status: AlarmStatus, // Current status
|
||||||
|
pub created_at: NaiveDateTime, // When alarm was scheduled
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum AlarmStatus {
|
||||||
|
Pending, // Waiting to trigger
|
||||||
|
Triggered, // Has been triggered
|
||||||
|
Dismissed, // User dismissed
|
||||||
|
Expired, // Past due (event ended)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AlarmScheduler {
|
||||||
|
scheduled_alarms: HashMap<String, ScheduledAlarm>,
|
||||||
|
notification_manager: NotificationManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALARMS_STORAGE_KEY: &str = "scheduled_alarms";
|
||||||
|
|
||||||
|
impl AlarmScheduler {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut scheduler = Self {
|
||||||
|
scheduled_alarms: HashMap::new(),
|
||||||
|
notification_manager: NotificationManager::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load alarms from localStorage
|
||||||
|
scheduler.load_alarms_from_storage();
|
||||||
|
scheduler
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load alarms from localStorage
|
||||||
|
fn load_alarms_from_storage(&mut self) {
|
||||||
|
if let Ok(alarms) = LocalStorage::get::<HashMap<String, ScheduledAlarm>>(ALARMS_STORAGE_KEY) {
|
||||||
|
self.scheduled_alarms = alarms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save alarms to localStorage
|
||||||
|
fn save_alarms_to_storage(&self) {
|
||||||
|
if let Err(e) = LocalStorage::set(ALARMS_STORAGE_KEY, &self.scheduled_alarms) {
|
||||||
|
web_sys::console::error_1(
|
||||||
|
&format!("Failed to save alarms to localStorage: {:?}", e).into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule alarms for an event
|
||||||
|
pub fn schedule_event_alarms(&mut self, event: &VEvent) {
|
||||||
|
// Check notification permission before scheduling
|
||||||
|
let permission = NotificationManager::get_permission();
|
||||||
|
if permission != web_sys::NotificationPermission::Granted && !event.alarms.is_empty() {
|
||||||
|
// Try to force request permission asynchronously
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = NotificationManager::force_request_permission().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing alarms for this event
|
||||||
|
self.remove_event_alarms(&event.uid);
|
||||||
|
|
||||||
|
// Get event details
|
||||||
|
let event_summary = event.summary.as_ref().unwrap_or(&"Untitled Event".to_string()).clone();
|
||||||
|
let event_location = event.location.clone();
|
||||||
|
let event_start = event.dtstart;
|
||||||
|
|
||||||
|
// Schedule each alarm
|
||||||
|
for alarm in &event.alarms {
|
||||||
|
if let Some(scheduled_alarm) = self.create_scheduled_alarm(
|
||||||
|
event,
|
||||||
|
alarm,
|
||||||
|
&event_summary,
|
||||||
|
&event_location,
|
||||||
|
event_start,
|
||||||
|
) {
|
||||||
|
self.scheduled_alarms.insert(scheduled_alarm.id.clone(), scheduled_alarm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
self.save_alarms_to_storage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a scheduled alarm from a VAlarm
|
||||||
|
fn create_scheduled_alarm(
|
||||||
|
&self,
|
||||||
|
event: &VEvent,
|
||||||
|
valarm: &VAlarm,
|
||||||
|
event_summary: &str,
|
||||||
|
event_location: &Option<String>,
|
||||||
|
event_start: NaiveDateTime,
|
||||||
|
) -> Option<ScheduledAlarm> {
|
||||||
|
// Only handle Display alarms for now
|
||||||
|
if valarm.action != AlarmAction::Display {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate trigger time
|
||||||
|
let trigger_time = match &valarm.trigger {
|
||||||
|
AlarmTrigger::Duration(duration) => {
|
||||||
|
// Duration relative to event start
|
||||||
|
let trigger_time = event_start + *duration;
|
||||||
|
|
||||||
|
// Ensure trigger time is not in the past (with 30 second tolerance)
|
||||||
|
let now = Local::now().naive_local();
|
||||||
|
if trigger_time < now - Duration::seconds(30) {
|
||||||
|
web_sys::console::warn_1(
|
||||||
|
&format!("Skipping past alarm for event: {} (trigger: {})",
|
||||||
|
event_summary,
|
||||||
|
trigger_time.format("%Y-%m-%d %H:%M:%S")
|
||||||
|
).into()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger_time
|
||||||
|
}
|
||||||
|
AlarmTrigger::DateTime(datetime) => {
|
||||||
|
// Absolute datetime - convert to local time
|
||||||
|
let local_trigger = datetime.with_timezone(&Local).naive_local();
|
||||||
|
|
||||||
|
// Ensure trigger time is not in the past
|
||||||
|
let now = Local::now().naive_local();
|
||||||
|
if local_trigger < now - Duration::seconds(30) {
|
||||||
|
web_sys::console::warn_1(
|
||||||
|
&format!("Skipping past absolute alarm for event: {} (trigger: {})",
|
||||||
|
event_summary,
|
||||||
|
local_trigger.format("%Y-%m-%d %H:%M:%S")
|
||||||
|
).into()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
local_trigger
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate unique alarm ID
|
||||||
|
let alarm_id = format!("{}_{}", event.uid, trigger_time.and_utc().timestamp());
|
||||||
|
|
||||||
|
Some(ScheduledAlarm {
|
||||||
|
id: alarm_id,
|
||||||
|
event_uid: event.uid.clone(),
|
||||||
|
event_summary: event_summary.to_string(),
|
||||||
|
event_location: event_location.clone(),
|
||||||
|
event_start,
|
||||||
|
trigger_time,
|
||||||
|
alarm_action: valarm.action.clone(),
|
||||||
|
status: AlarmStatus::Pending,
|
||||||
|
created_at: Local::now().naive_local(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all alarms for an event
|
||||||
|
pub fn remove_event_alarms(&mut self, event_uid: &str) {
|
||||||
|
let alarm_ids: Vec<String> = self.scheduled_alarms
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, alarm)| alarm.event_uid == event_uid)
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for alarm_id in alarm_ids {
|
||||||
|
self.scheduled_alarms.remove(&alarm_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also close any active notifications for this event
|
||||||
|
self.notification_manager.close_notification(event_uid);
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
self.save_alarms_to_storage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for alarms that should trigger now and trigger them
|
||||||
|
pub fn check_and_trigger_alarms(&mut self) -> usize {
|
||||||
|
// Reload alarms from localStorage to ensure we have the latest data
|
||||||
|
self.load_alarms_from_storage();
|
||||||
|
|
||||||
|
let now = Local::now().naive_local();
|
||||||
|
let mut triggered_count = 0;
|
||||||
|
|
||||||
|
// Find alarms that should trigger (within 30 seconds tolerance)
|
||||||
|
let alarms_to_trigger: Vec<ScheduledAlarm> = self.scheduled_alarms
|
||||||
|
.values()
|
||||||
|
.filter(|alarm| {
|
||||||
|
alarm.status == AlarmStatus::Pending &&
|
||||||
|
alarm.trigger_time <= now + Duration::seconds(30) &&
|
||||||
|
alarm.trigger_time >= now - Duration::seconds(30)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for alarm in alarms_to_trigger {
|
||||||
|
if self.trigger_alarm(&alarm) {
|
||||||
|
// Mark alarm as triggered
|
||||||
|
if let Some(scheduled_alarm) = self.scheduled_alarms.get_mut(&alarm.id) {
|
||||||
|
scheduled_alarm.status = AlarmStatus::Triggered;
|
||||||
|
}
|
||||||
|
triggered_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up expired alarms (events that ended more than 1 hour ago)
|
||||||
|
self.cleanup_expired_alarms();
|
||||||
|
|
||||||
|
// Save to localStorage if any changes were made
|
||||||
|
if triggered_count > 0 {
|
||||||
|
self.save_alarms_to_storage();
|
||||||
|
}
|
||||||
|
|
||||||
|
triggered_count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger a specific alarm
|
||||||
|
fn trigger_alarm(&mut self, alarm: &ScheduledAlarm) -> bool {
|
||||||
|
// Don't trigger if already showing notification for this event
|
||||||
|
if self.notification_manager.has_notification(&alarm.event_uid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let alarm_notification = AlarmNotification {
|
||||||
|
event_uid: alarm.event_uid.clone(),
|
||||||
|
event_summary: alarm.event_summary.clone(),
|
||||||
|
event_location: alarm.event_location.clone(),
|
||||||
|
alarm_time: alarm.event_start,
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.notification_manager.show_alarm_notification(alarm_notification) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(err) => {
|
||||||
|
web_sys::console::error_1(
|
||||||
|
&format!("Failed to trigger alarm: {:?}", err).into()
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up expired alarms
|
||||||
|
fn cleanup_expired_alarms(&mut self) {
|
||||||
|
let now = Local::now().naive_local();
|
||||||
|
let cutoff_time = now - Duration::hours(1);
|
||||||
|
|
||||||
|
let expired_alarm_ids: Vec<String> = self.scheduled_alarms
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, alarm)| {
|
||||||
|
// Mark as expired if event ended more than 1 hour ago
|
||||||
|
alarm.event_start < cutoff_time
|
||||||
|
})
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for alarm_id in &expired_alarm_ids {
|
||||||
|
if let Some(alarm) = self.scheduled_alarms.get_mut(alarm_id) {
|
||||||
|
alarm.status = AlarmStatus::Expired;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove expired alarms from memory
|
||||||
|
let had_expired = !expired_alarm_ids.is_empty();
|
||||||
|
for alarm_id in expired_alarm_ids {
|
||||||
|
self.scheduled_alarms.remove(&alarm_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage if any expired alarms were removed
|
||||||
|
if had_expired {
|
||||||
|
self.save_alarms_to_storage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Request notification permission
|
||||||
|
pub async fn request_notification_permission(&self) -> Result<web_sys::NotificationPermission, wasm_bindgen::JsValue> {
|
||||||
|
NotificationManager::request_permission().await
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AlarmScheduler {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday};
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
|
||||||
use gloo_storage::{LocalStorage, Storage};
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -6,8 +6,8 @@ use wasm_bindgen::JsCast;
|
|||||||
use wasm_bindgen_futures::JsFuture;
|
use wasm_bindgen_futures::JsFuture;
|
||||||
use web_sys::{Request, RequestInit, RequestMode, Response};
|
use web_sys::{Request, RequestInit, RequestMode, Response};
|
||||||
|
|
||||||
// Import RFC 5545 compliant VEvent from shared library
|
// Import RFC 5545 compliant VEvent and VAlarm from shared library
|
||||||
use calendar_models::VEvent;
|
use calendar_models::{VEvent, VAlarm};
|
||||||
|
|
||||||
// Create type alias for backward compatibility
|
// Create type alias for backward compatibility
|
||||||
pub type CalendarEvent = VEvent;
|
pub type CalendarEvent = VEvent;
|
||||||
@@ -247,6 +247,8 @@ impl CalendarService {
|
|||||||
if resp.ok() {
|
if resp.ok() {
|
||||||
let events: Vec<CalendarEvent> = serde_json::from_str(&text_string)
|
let events: Vec<CalendarEvent> = serde_json::from_str(&text_string)
|
||||||
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
||||||
|
|
||||||
|
|
||||||
Ok(events)
|
Ok(events)
|
||||||
} else {
|
} else {
|
||||||
Err(format!(
|
Err(format!(
|
||||||
@@ -275,47 +277,69 @@ impl CalendarService {
|
|||||||
grouped
|
grouped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert UTC events to local timezone for display
|
||||||
|
fn convert_utc_to_local(mut event: VEvent) -> VEvent {
|
||||||
|
// All-day events should not have timezone conversions applied
|
||||||
|
if event.all_day {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if event times are in UTC (legacy events from before timezone migration)
|
||||||
|
let is_utc_event = event.dtstart_tzid.as_ref().map_or(true, |tz| tz == "UTC");
|
||||||
|
|
||||||
|
if is_utc_event {
|
||||||
|
|
||||||
|
// Get current timezone offset (convert from UTC to local)
|
||||||
|
let date = js_sys::Date::new_0();
|
||||||
|
let timezone_offset_minutes = date.get_timezone_offset() as i32;
|
||||||
|
|
||||||
|
// Convert start time from UTC to local
|
||||||
|
// getTimezoneOffset() returns minutes UTC is ahead of local time
|
||||||
|
// To convert UTC to local, we subtract the offset (add negative offset)
|
||||||
|
let local_start = event.dtstart + chrono::Duration::minutes(-timezone_offset_minutes as i64);
|
||||||
|
event.dtstart = local_start;
|
||||||
|
event.dtstart_tzid = None; // Clear UTC timezone indicator
|
||||||
|
|
||||||
|
// Convert end time if present
|
||||||
|
if let Some(end_utc) = event.dtend {
|
||||||
|
let local_end = end_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64);
|
||||||
|
event.dtend = Some(local_end);
|
||||||
|
event.dtend_tzid = None; // Clear UTC timezone indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert created/modified times if present
|
||||||
|
if let Some(created_utc) = event.created {
|
||||||
|
event.created = Some(created_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64));
|
||||||
|
event.created_tzid = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(modified_utc) = event.last_modified {
|
||||||
|
event.last_modified = Some(modified_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64));
|
||||||
|
event.last_modified_tzid = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert EXDATE entries from UTC to local time
|
||||||
|
event.exdate = event.exdate.into_iter()
|
||||||
|
.map(|exdate| exdate + chrono::Duration::minutes(-timezone_offset_minutes as i64))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
event
|
||||||
|
}
|
||||||
|
|
||||||
/// Expand recurring events using VEvent (RFC 5545 compliant)
|
/// Expand recurring events using VEvent (RFC 5545 compliant)
|
||||||
pub fn expand_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
pub fn expand_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
let mut expanded_events = Vec::new();
|
let mut expanded_events = Vec::new();
|
||||||
let today = chrono::Utc::now().date_naive();
|
let today = chrono::Local::now().date_naive();
|
||||||
let start_range = today - Duration::days(36500); // Show past 100 years (to catch any historical yearly events)
|
let start_range = today - Duration::days(36500); // Show past 100 years (to catch any historical yearly events)
|
||||||
let end_range = today + Duration::days(36500); // Show next 100 years
|
let end_range = today + Duration::days(36500); // Show next 100 years
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
|
// Convert UTC events to local time for proper display
|
||||||
|
let event = Self::convert_utc_to_local(event);
|
||||||
if let Some(ref rrule) = event.rrule {
|
if let Some(ref rrule) = event.rrule {
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"📅 Processing recurring VEvent '{}' with RRULE: {}",
|
|
||||||
event.summary.as_deref().unwrap_or("Untitled"),
|
|
||||||
rrule
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Log if event has exception dates
|
|
||||||
if !event.exdate.is_empty() {
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"📅 VEvent '{}' has {} exception dates: {:?}",
|
|
||||||
event.summary.as_deref().unwrap_or("Untitled"),
|
|
||||||
event.exdate.len(),
|
|
||||||
event.exdate
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate occurrences for recurring events using VEvent
|
// Generate occurrences for recurring events using VEvent
|
||||||
let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range);
|
let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range);
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"📅 Generated {} occurrences for VEvent '{}'",
|
|
||||||
occurrences.len(),
|
|
||||||
event.summary.as_deref().unwrap_or("Untitled")
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
expanded_events.extend(occurrences);
|
expanded_events.extend(occurrences);
|
||||||
} else {
|
} else {
|
||||||
// Non-recurring event - add as-is
|
// Non-recurring event - add as-is
|
||||||
@@ -337,7 +361,6 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Parse RRULE components
|
// Parse RRULE components
|
||||||
let rrule_upper = rrule.to_uppercase();
|
let rrule_upper = rrule.to_uppercase();
|
||||||
web_sys::console::log_1(&format!("🔄 Parsing RRULE: {}", rrule_upper).into());
|
|
||||||
|
|
||||||
let components: HashMap<String, String> = rrule_upper
|
let components: HashMap<String, String> = rrule_upper
|
||||||
.split(';')
|
.split(';')
|
||||||
@@ -372,17 +395,18 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Get UNTIL date if specified
|
// Get UNTIL date if specified
|
||||||
let until_date = components.get("UNTIL").and_then(|until_str| {
|
let until_date = components.get("UNTIL").and_then(|until_str| {
|
||||||
// Parse UNTIL date in YYYYMMDDTHHMMSSZ format
|
// Parse UNTIL date in YYYYMMDDTHHMMSSZ format (treat as local time)
|
||||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(
|
||||||
until_str.trim_end_matches('Z'),
|
until_str.trim_end_matches('Z'),
|
||||||
"%Y%m%dT%H%M%S",
|
"%Y%m%dT%H%M%S",
|
||||||
) {
|
) {
|
||||||
Some(chrono::Utc.from_utc_datetime(&dt))
|
Some(dt)
|
||||||
} else if let Ok(dt) = chrono::DateTime::parse_from_str(until_str, "%Y%m%dT%H%M%SZ") {
|
} else if let Ok(dt) = chrono::DateTime::parse_from_str(until_str, "%Y%m%dT%H%M%SZ") {
|
||||||
Some(dt.with_timezone(&chrono::Utc))
|
// Convert UTC to local (naive) time for consistency
|
||||||
|
Some(dt.naive_utc())
|
||||||
} else if let Ok(date) = chrono::NaiveDate::parse_from_str(until_str, "%Y%m%d") {
|
} else if let Ok(date) = chrono::NaiveDate::parse_from_str(until_str, "%Y%m%d") {
|
||||||
// Handle date-only UNTIL
|
// Handle date-only UNTIL
|
||||||
Some(chrono::Utc.from_utc_datetime(&date.and_hms_opt(23, 59, 59).unwrap()))
|
Some(date.and_hms_opt(23, 59, 59).unwrap())
|
||||||
} else {
|
} else {
|
||||||
web_sys::console::log_1(
|
web_sys::console::log_1(
|
||||||
&format!("⚠️ Failed to parse UNTIL date: {}", until_str).into(),
|
&format!("⚠️ Failed to parse UNTIL date: {}", until_str).into(),
|
||||||
@@ -391,11 +415,10 @@ impl CalendarService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(until) = until_date {
|
if let Some(_until) = until_date {
|
||||||
web_sys::console::log_1(&format!("📅 RRULE has UNTIL: {}", until).into());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_date = base_event.dtstart.date_naive();
|
let start_date = base_event.dtstart.date();
|
||||||
let mut current_date = start_date;
|
let mut current_date = start_date;
|
||||||
let mut occurrence_count = 0;
|
let mut occurrence_count = 0;
|
||||||
|
|
||||||
@@ -406,10 +429,6 @@ impl CalendarService {
|
|||||||
let current_datetime = base_event.dtstart
|
let current_datetime = base_event.dtstart
|
||||||
+ Duration::days(current_date.signed_duration_since(start_date).num_days());
|
+ Duration::days(current_date.signed_duration_since(start_date).num_days());
|
||||||
if current_datetime > until {
|
if current_datetime > until {
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!("🛑 Stopping at {} due to UNTIL {}", current_datetime, until)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,25 +440,14 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Check if this occurrence is in the exception dates (EXDATE)
|
// Check if this occurrence is in the exception dates (EXDATE)
|
||||||
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
||||||
// Compare dates ignoring sub-second precision
|
// EXDATE from server is in local time, but stored as NaiveDateTime
|
||||||
let exception_naive = exception_date.naive_utc();
|
// We need to compare both as local time (naive datetimes) instead of UTC
|
||||||
let occurrence_naive = occurrence_datetime.naive_utc();
|
let exception_naive = *exception_date;
|
||||||
|
let occurrence_naive = occurrence_datetime;
|
||||||
|
|
||||||
// Check if dates match (within a minute to handle minor time differences)
|
// Check if dates match (within a minute to handle minor time differences)
|
||||||
let diff = occurrence_naive - exception_naive;
|
let diff = occurrence_naive - exception_naive;
|
||||||
let matches = diff.num_seconds().abs() < 60;
|
diff.num_seconds().abs() < 60
|
||||||
|
|
||||||
if matches {
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"🚫 Excluding occurrence {} due to EXDATE {}",
|
|
||||||
occurrence_naive, exception_naive
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
matches
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if !is_exception {
|
if !is_exception {
|
||||||
@@ -555,7 +563,7 @@ impl CalendarService {
|
|||||||
interval: i32,
|
interval: i32,
|
||||||
start_range: NaiveDate,
|
start_range: NaiveDate,
|
||||||
end_range: NaiveDate,
|
end_range: NaiveDate,
|
||||||
until_date: Option<chrono::DateTime<chrono::Utc>>,
|
until_date: Option<chrono::NaiveDateTime>,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Vec<VEvent> {
|
) -> Vec<VEvent> {
|
||||||
let mut occurrences = Vec::new();
|
let mut occurrences = Vec::new();
|
||||||
@@ -565,7 +573,7 @@ impl CalendarService {
|
|||||||
return occurrences;
|
return occurrences;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_date = base_event.dtstart.date_naive();
|
let start_date = base_event.dtstart.date();
|
||||||
|
|
||||||
// Find the Monday of the week containing the start_date (reference week)
|
// Find the Monday of the week containing the start_date (reference week)
|
||||||
let reference_week_start =
|
let reference_week_start =
|
||||||
@@ -606,13 +614,6 @@ impl CalendarService {
|
|||||||
let days_diff = occurrence_date.signed_duration_since(start_date).num_days();
|
let days_diff = occurrence_date.signed_duration_since(start_date).num_days();
|
||||||
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
||||||
if occurrence_datetime > until {
|
if occurrence_datetime > until {
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"🛑 Stopping at {} due to UNTIL {}",
|
|
||||||
occurrence_datetime, until
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
return occurrences;
|
return occurrences;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,22 +624,11 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Check if this occurrence is in the exception dates (EXDATE)
|
// Check if this occurrence is in the exception dates (EXDATE)
|
||||||
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
||||||
let exception_naive = exception_date.naive_utc();
|
// Compare as local time (naive datetimes) instead of UTC
|
||||||
let occurrence_naive = occurrence_datetime.naive_utc();
|
let exception_naive = *exception_date;
|
||||||
|
let occurrence_naive = occurrence_datetime;
|
||||||
let diff = occurrence_naive - exception_naive;
|
let diff = occurrence_naive - exception_naive;
|
||||||
let matches = diff.num_seconds().abs() < 60;
|
diff.num_seconds().abs() < 60
|
||||||
|
|
||||||
if matches {
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"🚫 Excluding occurrence {} due to EXDATE {}",
|
|
||||||
occurrence_naive, exception_naive
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
matches
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if !is_exception {
|
if !is_exception {
|
||||||
@@ -675,7 +665,7 @@ impl CalendarService {
|
|||||||
interval: i32,
|
interval: i32,
|
||||||
start_range: NaiveDate,
|
start_range: NaiveDate,
|
||||||
end_range: NaiveDate,
|
end_range: NaiveDate,
|
||||||
until_date: Option<chrono::DateTime<chrono::Utc>>,
|
until_date: Option<chrono::NaiveDateTime>,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Vec<VEvent> {
|
) -> Vec<VEvent> {
|
||||||
let mut occurrences = Vec::new();
|
let mut occurrences = Vec::new();
|
||||||
@@ -691,7 +681,7 @@ impl CalendarService {
|
|||||||
return occurrences;
|
return occurrences;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_date = base_event.dtstart.date_naive();
|
let start_date = base_event.dtstart.date();
|
||||||
let mut current_month_start =
|
let mut current_month_start =
|
||||||
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
|
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
|
||||||
let mut total_occurrences = 0;
|
let mut total_occurrences = 0;
|
||||||
@@ -732,13 +722,6 @@ impl CalendarService {
|
|||||||
occurrence_date.signed_duration_since(start_date).num_days();
|
occurrence_date.signed_duration_since(start_date).num_days();
|
||||||
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
|
||||||
if occurrence_datetime > until {
|
if occurrence_datetime > until {
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"🛑 Stopping at {} due to UNTIL {}",
|
|
||||||
occurrence_datetime, until
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
return occurrences;
|
return occurrences;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -749,9 +732,7 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Check if this occurrence is in the exception dates (EXDATE)
|
// Check if this occurrence is in the exception dates (EXDATE)
|
||||||
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
||||||
let exception_naive = exception_date.naive_utc();
|
let diff = occurrence_datetime - *exception_date;
|
||||||
let occurrence_naive = occurrence_datetime.naive_utc();
|
|
||||||
let diff = occurrence_naive - exception_naive;
|
|
||||||
diff.num_seconds().abs() < 60
|
diff.num_seconds().abs() < 60
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -792,14 +773,14 @@ impl CalendarService {
|
|||||||
interval: i32,
|
interval: i32,
|
||||||
start_range: NaiveDate,
|
start_range: NaiveDate,
|
||||||
end_range: NaiveDate,
|
end_range: NaiveDate,
|
||||||
until_date: Option<chrono::DateTime<chrono::Utc>>,
|
until_date: Option<chrono::NaiveDateTime>,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Vec<VEvent> {
|
) -> Vec<VEvent> {
|
||||||
let mut occurrences = Vec::new();
|
let mut occurrences = Vec::new();
|
||||||
|
|
||||||
// Parse BYDAY for monthly (e.g., "1MO" = first Monday, "-1FR" = last Friday)
|
// Parse BYDAY for monthly (e.g., "1MO" = first Monday, "-1FR" = last Friday)
|
||||||
if let Some((position, weekday)) = Self::parse_monthly_byday(byday) {
|
if let Some((position, weekday)) = Self::parse_monthly_byday(byday) {
|
||||||
let start_date = base_event.dtstart.date_naive();
|
let start_date = base_event.dtstart.date();
|
||||||
let mut current_month_start =
|
let mut current_month_start =
|
||||||
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
|
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
|
||||||
let mut total_occurrences = 0;
|
let mut total_occurrences = 0;
|
||||||
@@ -830,9 +811,7 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Check EXDATE
|
// Check EXDATE
|
||||||
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
||||||
let exception_naive = exception_date.naive_utc();
|
let diff = occurrence_datetime - *exception_date;
|
||||||
let occurrence_naive = occurrence_datetime.naive_utc();
|
|
||||||
let diff = occurrence_naive - exception_naive;
|
|
||||||
diff.num_seconds().abs() < 60
|
diff.num_seconds().abs() < 60
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -871,7 +850,7 @@ impl CalendarService {
|
|||||||
interval: i32,
|
interval: i32,
|
||||||
start_range: NaiveDate,
|
start_range: NaiveDate,
|
||||||
end_range: NaiveDate,
|
end_range: NaiveDate,
|
||||||
until_date: Option<chrono::DateTime<chrono::Utc>>,
|
until_date: Option<chrono::NaiveDateTime>,
|
||||||
count: usize,
|
count: usize,
|
||||||
) -> Vec<VEvent> {
|
) -> Vec<VEvent> {
|
||||||
let mut occurrences = Vec::new();
|
let mut occurrences = Vec::new();
|
||||||
@@ -887,7 +866,7 @@ impl CalendarService {
|
|||||||
return occurrences;
|
return occurrences;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_date = base_event.dtstart.date_naive();
|
let start_date = base_event.dtstart.date();
|
||||||
let mut current_year = start_date.year();
|
let mut current_year = start_date.year();
|
||||||
let mut total_occurrences = 0;
|
let mut total_occurrences = 0;
|
||||||
|
|
||||||
@@ -930,9 +909,7 @@ impl CalendarService {
|
|||||||
|
|
||||||
// Check EXDATE
|
// Check EXDATE
|
||||||
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
let is_exception = base_event.exdate.iter().any(|exception_date| {
|
||||||
let exception_naive = exception_date.naive_utc();
|
let diff = occurrence_datetime - *exception_date;
|
||||||
let occurrence_naive = occurrence_datetime.naive_utc();
|
|
||||||
let diff = occurrence_naive - exception_naive;
|
|
||||||
diff.num_seconds().abs() < 60
|
diff.num_seconds().abs() < 60
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1254,12 +1231,14 @@ impl CalendarService {
|
|||||||
organizer: String,
|
organizer: String,
|
||||||
attendees: String,
|
attendees: String,
|
||||||
categories: String,
|
categories: String,
|
||||||
reminder: String,
|
alarms: Vec<VAlarm>,
|
||||||
recurrence: String,
|
recurrence: String,
|
||||||
recurrence_days: Vec<bool>,
|
recurrence_days: Vec<bool>,
|
||||||
|
recurrence_interval: u32,
|
||||||
recurrence_count: Option<u32>,
|
recurrence_count: Option<u32>,
|
||||||
recurrence_until: Option<String>,
|
recurrence_until: Option<String>,
|
||||||
calendar_path: Option<String>,
|
calendar_path: Option<String>,
|
||||||
|
timezone: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
@@ -1287,13 +1266,14 @@ impl CalendarService {
|
|||||||
"organizer": organizer,
|
"organizer": organizer,
|
||||||
"attendees": attendees,
|
"attendees": attendees,
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"reminder": reminder,
|
"alarms": alarms,
|
||||||
"recurrence": recurrence,
|
"recurrence": recurrence,
|
||||||
"recurrence_days": recurrence_days,
|
"recurrence_days": recurrence_days,
|
||||||
"recurrence_interval": 1_u32, // Default interval
|
"recurrence_interval": recurrence_interval,
|
||||||
"recurrence_end_date": recurrence_until,
|
"recurrence_end_date": recurrence_until,
|
||||||
"recurrence_count": recurrence_count,
|
"recurrence_count": recurrence_count,
|
||||||
"calendar_path": calendar_path
|
"calendar_path": calendar_path,
|
||||||
|
"timezone": timezone
|
||||||
});
|
});
|
||||||
let url = format!("{}/calendar/events/series/create", self.base_url);
|
let url = format!("{}/calendar/events/series/create", self.base_url);
|
||||||
(body, url)
|
(body, url)
|
||||||
@@ -1314,10 +1294,11 @@ impl CalendarService {
|
|||||||
"organizer": organizer,
|
"organizer": organizer,
|
||||||
"attendees": attendees,
|
"attendees": attendees,
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"reminder": reminder,
|
"alarms": alarms,
|
||||||
"recurrence": recurrence,
|
"recurrence": recurrence,
|
||||||
"recurrence_days": recurrence_days,
|
"recurrence_days": recurrence_days,
|
||||||
"calendar_path": calendar_path
|
"calendar_path": calendar_path,
|
||||||
|
"timezone": timezone
|
||||||
});
|
});
|
||||||
let url = format!("{}/calendar/events/create", self.base_url);
|
let url = format!("{}/calendar/events/create", self.base_url);
|
||||||
(body, url)
|
(body, url)
|
||||||
@@ -1391,13 +1372,14 @@ impl CalendarService {
|
|||||||
organizer: String,
|
organizer: String,
|
||||||
attendees: String,
|
attendees: String,
|
||||||
categories: String,
|
categories: String,
|
||||||
reminder: String,
|
alarms: Vec<VAlarm>,
|
||||||
recurrence: String,
|
recurrence: String,
|
||||||
recurrence_days: Vec<bool>,
|
recurrence_days: Vec<bool>,
|
||||||
|
recurrence_interval: u32,
|
||||||
|
recurrence_count: Option<u32>,
|
||||||
|
recurrence_until: Option<String>,
|
||||||
calendar_path: Option<String>,
|
calendar_path: Option<String>,
|
||||||
exception_dates: Vec<DateTime<Utc>>,
|
timezone: String,
|
||||||
update_action: Option<String>,
|
|
||||||
until_date: Option<DateTime<Utc>>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Forward to update_event_with_scope with default scope
|
// Forward to update_event_with_scope with default scope
|
||||||
self.update_event_with_scope(
|
self.update_event_with_scope(
|
||||||
@@ -1418,13 +1400,14 @@ impl CalendarService {
|
|||||||
organizer,
|
organizer,
|
||||||
attendees,
|
attendees,
|
||||||
categories,
|
categories,
|
||||||
reminder,
|
alarms,
|
||||||
recurrence,
|
recurrence,
|
||||||
recurrence_days,
|
recurrence_days,
|
||||||
|
recurrence_interval,
|
||||||
|
recurrence_count,
|
||||||
|
recurrence_until,
|
||||||
calendar_path,
|
calendar_path,
|
||||||
exception_dates,
|
timezone,
|
||||||
update_action,
|
|
||||||
until_date,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -1448,13 +1431,14 @@ impl CalendarService {
|
|||||||
organizer: String,
|
organizer: String,
|
||||||
attendees: String,
|
attendees: String,
|
||||||
categories: String,
|
categories: String,
|
||||||
reminder: String,
|
alarms: Vec<VAlarm>,
|
||||||
recurrence: String,
|
recurrence: String,
|
||||||
recurrence_days: Vec<bool>,
|
recurrence_days: Vec<bool>,
|
||||||
|
recurrence_interval: u32,
|
||||||
|
recurrence_count: Option<u32>,
|
||||||
|
recurrence_until: Option<String>,
|
||||||
calendar_path: Option<String>,
|
calendar_path: Option<String>,
|
||||||
exception_dates: Vec<DateTime<Utc>>,
|
timezone: String,
|
||||||
update_action: Option<String>,
|
|
||||||
until_date: Option<DateTime<Utc>>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
@@ -1479,14 +1463,14 @@ impl CalendarService {
|
|||||||
"organizer": organizer,
|
"organizer": organizer,
|
||||||
"attendees": attendees,
|
"attendees": attendees,
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"reminder": reminder,
|
"alarms": alarms,
|
||||||
"recurrence": recurrence,
|
"recurrence": recurrence,
|
||||||
"recurrence_days": recurrence_days,
|
"recurrence_days": recurrence_days,
|
||||||
|
"recurrence_interval": recurrence_interval,
|
||||||
|
"recurrence_count": recurrence_count,
|
||||||
|
"recurrence_end_date": recurrence_until,
|
||||||
"calendar_path": calendar_path,
|
"calendar_path": calendar_path,
|
||||||
"update_action": update_action,
|
"timezone": timezone
|
||||||
"occurrence_date": null,
|
|
||||||
"exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::<Vec<String>>(),
|
|
||||||
"until_date": until_date.as_ref().map(|dt| dt.to_rfc3339())
|
|
||||||
});
|
});
|
||||||
let url = format!("{}/calendar/events/update", self.base_url);
|
let url = format!("{}/calendar/events/update", self.base_url);
|
||||||
|
|
||||||
@@ -1684,14 +1668,16 @@ impl CalendarService {
|
|||||||
organizer: String,
|
organizer: String,
|
||||||
attendees: String,
|
attendees: String,
|
||||||
categories: String,
|
categories: String,
|
||||||
reminder: String,
|
alarms: Vec<VAlarm>,
|
||||||
recurrence: String,
|
recurrence: String,
|
||||||
recurrence_days: Vec<bool>,
|
recurrence_days: Vec<bool>,
|
||||||
|
recurrence_interval: u32,
|
||||||
recurrence_count: Option<u32>,
|
recurrence_count: Option<u32>,
|
||||||
recurrence_until: Option<String>,
|
recurrence_until: Option<String>,
|
||||||
calendar_path: Option<String>,
|
calendar_path: Option<String>,
|
||||||
update_scope: String,
|
update_scope: String,
|
||||||
occurrence_date: Option<String>,
|
occurrence_date: Option<String>,
|
||||||
|
timezone: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let window = web_sys::window().ok_or("No global window exists")?;
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
@@ -1715,15 +1701,16 @@ impl CalendarService {
|
|||||||
"organizer": organizer,
|
"organizer": organizer,
|
||||||
"attendees": attendees,
|
"attendees": attendees,
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"reminder": reminder,
|
"alarms": alarms,
|
||||||
"recurrence": recurrence,
|
"recurrence": recurrence,
|
||||||
"recurrence_days": recurrence_days,
|
"recurrence_days": recurrence_days,
|
||||||
"recurrence_interval": 1_u32, // Default interval - could be enhanced to be a parameter
|
"recurrence_interval": recurrence_interval,
|
||||||
"recurrence_end_date": recurrence_until,
|
"recurrence_end_date": recurrence_until,
|
||||||
"recurrence_count": recurrence_count,
|
"recurrence_count": recurrence_count,
|
||||||
"calendar_path": calendar_path,
|
"calendar_path": calendar_path,
|
||||||
"update_scope": update_scope,
|
"update_scope": update_scope,
|
||||||
"occurrence_date": occurrence_date
|
"occurrence_date": occurrence_date,
|
||||||
|
"timezone": timezone
|
||||||
});
|
});
|
||||||
|
|
||||||
let url = format!("{}/calendar/events/series/update", self.base_url);
|
let url = format!("{}/calendar/events/series/update", self.base_url);
|
||||||
@@ -2095,7 +2082,6 @@ impl CalendarService {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ExternalCalendarEventsResponse {
|
struct ExternalCalendarEventsResponse {
|
||||||
events: Vec<VEvent>,
|
events: Vec<VEvent>,
|
||||||
last_fetched: chrono::DateTime<chrono::Utc>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)
|
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
pub mod calendar_service;
|
pub mod calendar_service;
|
||||||
pub mod preferences;
|
pub mod preferences;
|
||||||
|
pub mod notification_manager;
|
||||||
|
pub mod alarm_scheduler;
|
||||||
|
|
||||||
pub use calendar_service::CalendarService;
|
pub use calendar_service::CalendarService;
|
||||||
|
pub use notification_manager::{NotificationManager, AlarmNotification};
|
||||||
|
pub use alarm_scheduler::AlarmScheduler;
|
||||||
|
|||||||
189
frontend/src/services/notification_manager.rs
Normal file
189
frontend/src/services/notification_manager.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
use web_sys::{window, Notification, NotificationOptions, NotificationPermission};
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NotificationManager {
|
||||||
|
// Track displayed notifications to prevent duplicates
|
||||||
|
active_notifications: HashMap<String, Notification>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AlarmNotification {
|
||||||
|
pub event_uid: String,
|
||||||
|
pub event_summary: String,
|
||||||
|
pub event_location: Option<String>,
|
||||||
|
pub alarm_time: chrono::NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
active_notifications: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the browser supports notifications
|
||||||
|
pub fn is_supported() -> bool {
|
||||||
|
// Check if the Notification constructor exists on the window
|
||||||
|
if let Some(window) = window() {
|
||||||
|
let has_notification = js_sys::Reflect::has(&window, &"Notification".into()).unwrap_or(false);
|
||||||
|
|
||||||
|
// Additional check - try to access Notification directly via JsValue
|
||||||
|
let window_js: &wasm_bindgen::JsValue = window.as_ref();
|
||||||
|
let direct_check = js_sys::Reflect::get(window_js, &"Notification".into()).unwrap_or(wasm_bindgen::JsValue::UNDEFINED);
|
||||||
|
let has_direct = !direct_check.is_undefined();
|
||||||
|
|
||||||
|
// Use either check
|
||||||
|
has_notification || has_direct
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current notification permission status
|
||||||
|
pub fn get_permission() -> NotificationPermission {
|
||||||
|
if Self::is_supported() {
|
||||||
|
Notification::permission()
|
||||||
|
} else {
|
||||||
|
NotificationPermission::Denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force request notification permission (even if previously denied)
|
||||||
|
pub async fn force_request_permission() -> Result<NotificationPermission, JsValue> {
|
||||||
|
if !Self::is_supported() {
|
||||||
|
return Ok(NotificationPermission::Denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always request permission, regardless of current status
|
||||||
|
let promise = Notification::request_permission()?;
|
||||||
|
let js_value = JsFuture::from(promise).await?;
|
||||||
|
|
||||||
|
// Convert JS string back to NotificationPermission
|
||||||
|
if let Some(permission_str) = js_value.as_string() {
|
||||||
|
match permission_str.as_str() {
|
||||||
|
"granted" => Ok(NotificationPermission::Granted),
|
||||||
|
"denied" => Ok(NotificationPermission::Denied),
|
||||||
|
_ => Ok(NotificationPermission::Default),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(NotificationPermission::Denied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request notification permission from the user
|
||||||
|
pub async fn request_permission() -> Result<NotificationPermission, JsValue> {
|
||||||
|
if !Self::is_supported() {
|
||||||
|
return Ok(NotificationPermission::Denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check current permission status
|
||||||
|
let current_permission = Notification::permission();
|
||||||
|
if current_permission != NotificationPermission::Default {
|
||||||
|
return Ok(current_permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request permission
|
||||||
|
let promise = Notification::request_permission()?;
|
||||||
|
let js_value = JsFuture::from(promise).await?;
|
||||||
|
|
||||||
|
// Convert JS string back to NotificationPermission
|
||||||
|
if let Some(permission_str) = js_value.as_string() {
|
||||||
|
match permission_str.as_str() {
|
||||||
|
"granted" => Ok(NotificationPermission::Granted),
|
||||||
|
"denied" => Ok(NotificationPermission::Denied),
|
||||||
|
_ => Ok(NotificationPermission::Default),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(NotificationPermission::Denied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display a notification for an alarm
|
||||||
|
pub fn show_alarm_notification(&mut self, alarm: AlarmNotification) -> Result<(), JsValue> {
|
||||||
|
// Check permission
|
||||||
|
if Self::get_permission() != NotificationPermission::Granted {
|
||||||
|
return Ok(()); // Don't error, just skip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if notification already exists for this event
|
||||||
|
if self.active_notifications.contains_key(&alarm.event_uid) {
|
||||||
|
return Ok(()); // Already showing notification for this event
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notification options
|
||||||
|
let options = NotificationOptions::new();
|
||||||
|
|
||||||
|
// Set notification body with time and location
|
||||||
|
let body = if let Some(location) = &alarm.event_location {
|
||||||
|
format!("📅 {}\n📍 {}",
|
||||||
|
alarm.alarm_time.format("%H:%M"),
|
||||||
|
location
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("📅 {}", alarm.alarm_time.format("%H:%M"))
|
||||||
|
};
|
||||||
|
options.set_body(&body);
|
||||||
|
|
||||||
|
// Set icon
|
||||||
|
options.set_icon("/favicon.ico");
|
||||||
|
|
||||||
|
// Set tag to prevent duplicates
|
||||||
|
options.set_tag(&alarm.event_uid);
|
||||||
|
|
||||||
|
// Set require interaction to keep notification visible
|
||||||
|
options.set_require_interaction(true);
|
||||||
|
|
||||||
|
// Create and show notification
|
||||||
|
let notification = Notification::new_with_options(&alarm.event_summary, &options)?;
|
||||||
|
|
||||||
|
// Store reference to track active notifications
|
||||||
|
self.active_notifications.insert(alarm.event_uid.clone(), notification.clone());
|
||||||
|
|
||||||
|
// Set up click handler to focus the calendar app
|
||||||
|
let _event_uid = alarm.event_uid.clone();
|
||||||
|
let onclick_closure = Closure::wrap(Box::new(move |_event: web_sys::Event| {
|
||||||
|
// Focus the window when notification is clicked
|
||||||
|
if let Some(window) = window() {
|
||||||
|
let _ = window.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
notification.set_onclick(Some(onclick_closure.as_ref().unchecked_ref()));
|
||||||
|
onclick_closure.forget(); // Keep closure alive
|
||||||
|
|
||||||
|
// Set up close handler to clean up tracking
|
||||||
|
let event_uid_close = alarm.event_uid.clone();
|
||||||
|
let mut active_notifications_close = self.active_notifications.clone();
|
||||||
|
let onclose_closure = Closure::wrap(Box::new(move |_event: web_sys::Event| {
|
||||||
|
active_notifications_close.remove(&event_uid_close);
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
notification.set_onclose(Some(onclose_closure.as_ref().unchecked_ref()));
|
||||||
|
onclose_closure.forget(); // Keep closure alive
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close notification for a specific event
|
||||||
|
pub fn close_notification(&mut self, event_uid: &str) {
|
||||||
|
if let Some(notification) = self.active_notifications.remove(event_uid) {
|
||||||
|
notification.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Check if notification exists for event
|
||||||
|
pub fn has_notification(&self, event_uid: &str) -> bool {
|
||||||
|
self.active_notifications.contains_key(event_uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NotificationManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
1680
frontend/styles.css
1680
frontend/styles.css
File diff suppressed because it is too large
Load Diff
691
frontend/styles/apple.css
Normal file
691
frontend/styles/apple.css
Normal file
@@ -0,0 +1,691 @@
|
|||||||
|
/* Apple Calendar-inspired styles */
|
||||||
|
|
||||||
|
/* Override CSS Variables for Apple Calendar Style */
|
||||||
|
:root {
|
||||||
|
/* Apple-style spacing */
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 12px;
|
||||||
|
--spacing-lg: 16px;
|
||||||
|
--spacing-xl: 24px;
|
||||||
|
|
||||||
|
/* Apple-style borders and radius */
|
||||||
|
--border-radius-small: 6px;
|
||||||
|
--border-radius-medium: 10px;
|
||||||
|
--border-radius-large: 16px;
|
||||||
|
|
||||||
|
/* Apple-style shadows */
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||||
|
--shadow-md: 0 3px 6px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.12);
|
||||||
|
--shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.15), 0 3px 6px rgba(0, 0, 0, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme-aware Apple style colors - use theme colors but with Apple aesthetic */
|
||||||
|
[data-style="apple"] {
|
||||||
|
/* Use theme background and text colors */
|
||||||
|
--apple-bg-primary: var(--background-secondary);
|
||||||
|
--apple-bg-secondary: var(--background-primary);
|
||||||
|
--apple-text-primary: var(--text-primary);
|
||||||
|
--apple-text-secondary: var(--text-secondary);
|
||||||
|
--apple-text-tertiary: var(--text-secondary);
|
||||||
|
--apple-text-inverse: var(--text-inverse);
|
||||||
|
--apple-border-primary: var(--border-primary);
|
||||||
|
--apple-border-secondary: var(--border-secondary);
|
||||||
|
--apple-accent: var(--primary-color);
|
||||||
|
--apple-hover-bg: var(--background-tertiary);
|
||||||
|
--apple-today-accent: var(--primary-color);
|
||||||
|
|
||||||
|
/* Apple font family */
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme-specific Apple style adjustments */
|
||||||
|
[data-style="apple"][data-theme="default"] {
|
||||||
|
--apple-bg-tertiary: rgba(248, 249, 250, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(246, 246, 246, 0.7);
|
||||||
|
--apple-accent-bg: rgba(102, 126, 234, 0.1);
|
||||||
|
--apple-today-bg: rgba(102, 126, 234, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="ocean"] {
|
||||||
|
--apple-bg-tertiary: rgba(224, 247, 250, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(224, 247, 250, 0.7);
|
||||||
|
--apple-accent-bg: rgba(0, 105, 148, 0.1);
|
||||||
|
--apple-today-bg: rgba(0, 105, 148, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="forest"] {
|
||||||
|
--apple-bg-tertiary: rgba(232, 245, 232, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(232, 245, 232, 0.7);
|
||||||
|
--apple-accent-bg: rgba(6, 95, 70, 0.1);
|
||||||
|
--apple-today-bg: rgba(6, 95, 70, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="sunset"] {
|
||||||
|
--apple-bg-tertiary: rgba(255, 243, 224, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(255, 243, 224, 0.7);
|
||||||
|
--apple-accent-bg: rgba(234, 88, 12, 0.1);
|
||||||
|
--apple-today-bg: rgba(234, 88, 12, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="purple"] {
|
||||||
|
--apple-bg-tertiary: rgba(243, 229, 245, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(243, 229, 245, 0.7);
|
||||||
|
--apple-accent-bg: rgba(124, 58, 237, 0.1);
|
||||||
|
--apple-today-bg: rgba(124, 58, 237, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="dark"] {
|
||||||
|
--apple-bg-tertiary: rgba(31, 41, 55, 0.9);
|
||||||
|
--apple-bg-sidebar: rgba(44, 44, 46, 0.8);
|
||||||
|
--apple-accent-bg: rgba(55, 65, 81, 0.3);
|
||||||
|
--apple-today-bg: rgba(55, 65, 81, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="rose"] {
|
||||||
|
--apple-bg-tertiary: rgba(252, 228, 236, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(252, 228, 236, 0.7);
|
||||||
|
--apple-accent-bg: rgba(225, 29, 72, 0.1);
|
||||||
|
--apple-today-bg: rgba(225, 29, 72, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="mint"] {
|
||||||
|
--apple-bg-tertiary: rgba(224, 242, 241, 0.8);
|
||||||
|
--apple-bg-sidebar: rgba(224, 242, 241, 0.7);
|
||||||
|
--apple-accent-bg: rgba(16, 185, 129, 0.1);
|
||||||
|
--apple-today-bg: rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="midnight"] {
|
||||||
|
--apple-bg-tertiary: rgba(21, 27, 38, 0.9);
|
||||||
|
--apple-bg-sidebar: rgba(21, 27, 38, 0.8);
|
||||||
|
--apple-accent-bg: rgba(76, 154, 255, 0.15);
|
||||||
|
--apple-today-bg: rgba(76, 154, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="charcoal"] {
|
||||||
|
--apple-bg-tertiary: rgba(26, 26, 26, 0.9);
|
||||||
|
--apple-bg-sidebar: rgba(26, 26, 26, 0.8);
|
||||||
|
--apple-accent-bg: rgba(74, 222, 128, 0.15);
|
||||||
|
--apple-today-bg: rgba(74, 222, 128, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="nord"] {
|
||||||
|
--apple-bg-tertiary: rgba(59, 66, 82, 0.9);
|
||||||
|
--apple-bg-sidebar: rgba(59, 66, 82, 0.8);
|
||||||
|
--apple-accent-bg: rgba(136, 192, 208, 0.15);
|
||||||
|
--apple-today-bg: rgba(136, 192, 208, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"][data-theme="dracula"] {
|
||||||
|
--apple-bg-tertiary: rgba(68, 71, 90, 0.9);
|
||||||
|
--apple-bg-sidebar: rgba(68, 71, 90, 0.8);
|
||||||
|
--apple-accent-bg: rgba(189, 147, 249, 0.15);
|
||||||
|
--apple-today-bg: rgba(189, 147, 249, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style body and base styles */
|
||||||
|
[data-style="apple"] body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: var(--apple-bg-secondary);
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.47;
|
||||||
|
letter-spacing: -0.022em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style sidebar with glassmorphism */
|
||||||
|
[data-style="apple"] .app-sidebar {
|
||||||
|
background: var(--apple-bg-sidebar);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-right: 1px solid var(--apple-border-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .sidebar-header {
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid var(--apple-border-primary);
|
||||||
|
padding: 20px 16px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .sidebar-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .user-info {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .user-info .username {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .user-info .server-url {
|
||||||
|
color: var(--apple-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style buttons */
|
||||||
|
[data-style="apple"] .create-calendar-button {
|
||||||
|
background: var(--apple-accent);
|
||||||
|
color: var(--apple-text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .create-calendar-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
background: var(--apple-accent);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .logout-button {
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
color: var(--apple-accent);
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .logout-button:hover {
|
||||||
|
background: var(--apple-hover-bg);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style navigation */
|
||||||
|
[data-style="apple"] .sidebar-nav .nav-link {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .sidebar-nav .nav-link:hover {
|
||||||
|
color: var(--apple-accent);
|
||||||
|
background: var(--apple-hover-bg);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style calendar list */
|
||||||
|
[data-style="apple"] .calendar-list h3 {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.024em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-list .calendar-name {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .no-calendars {
|
||||||
|
color: var(--apple-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style form elements */
|
||||||
|
[data-style="apple"] .sidebar-footer label,
|
||||||
|
[data-style="apple"] .view-selector label,
|
||||||
|
[data-style="apple"] .theme-selector label,
|
||||||
|
[data-style="apple"] .style-selector label {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .view-selector-dropdown,
|
||||||
|
[data-style="apple"] .theme-selector-dropdown,
|
||||||
|
[data-style="apple"] .style-selector-dropdown {
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .view-selector-dropdown:focus,
|
||||||
|
[data-style="apple"] .theme-selector-dropdown:focus,
|
||||||
|
[data-style="apple"] .style-selector-dropdown:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--apple-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--apple-accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style calendar list items */
|
||||||
|
[data-style="apple"] .calendar-list .calendar-item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-list .calendar-item:hover {
|
||||||
|
background-color: var(--apple-hover-bg);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style main content area */
|
||||||
|
[data-style="apple"] .app-main {
|
||||||
|
background: var(--apple-bg-secondary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style calendar header */
|
||||||
|
[data-style="apple"] .calendar-header {
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-header h2,
|
||||||
|
[data-style="apple"] .calendar-header h3,
|
||||||
|
[data-style="apple"] .month-header,
|
||||||
|
[data-style="apple"] .week-header {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style headings */
|
||||||
|
[data-style="apple"] h1,
|
||||||
|
[data-style="apple"] h2,
|
||||||
|
[data-style="apple"] h3,
|
||||||
|
[data-style="apple"] .month-title,
|
||||||
|
[data-style="apple"] .calendar-title,
|
||||||
|
[data-style="apple"] .current-month,
|
||||||
|
[data-style="apple"] .month-year,
|
||||||
|
[data-style="apple"] .header-title {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style navigation buttons */
|
||||||
|
[data-style="apple"] button,
|
||||||
|
[data-style="apple"] .nav-button,
|
||||||
|
[data-style="apple"] .calendar-nav-button,
|
||||||
|
[data-style="apple"] .prev-button,
|
||||||
|
[data-style="apple"] .next-button,
|
||||||
|
[data-style="apple"] .arrow-button {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] button:hover,
|
||||||
|
[data-style="apple"] .nav-button:hover,
|
||||||
|
[data-style="apple"] .calendar-nav-button:hover,
|
||||||
|
[data-style="apple"] .prev-button:hover,
|
||||||
|
[data-style="apple"] .next-button:hover,
|
||||||
|
[data-style="apple"] .arrow-button:hover {
|
||||||
|
background: var(--apple-accent-bg);
|
||||||
|
color: var(--apple-accent);
|
||||||
|
border-color: var(--apple-accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style calendar controls */
|
||||||
|
[data-style="apple"] .calendar-controls,
|
||||||
|
[data-style="apple"] .current-date,
|
||||||
|
[data-style="apple"] .date-display {
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style calendar grid */
|
||||||
|
[data-style="apple"] .calendar-grid,
|
||||||
|
[data-style="apple"] .calendar-container {
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .month-header,
|
||||||
|
[data-style="apple"] .week-header {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style calendar cells */
|
||||||
|
[data-style="apple"] .calendar-day,
|
||||||
|
[data-style="apple"] .day-cell {
|
||||||
|
border: 1px solid var(--apple-border-secondary);
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
padding: 12px;
|
||||||
|
min-height: 120px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-day:hover,
|
||||||
|
[data-style="apple"] .day-cell:hover {
|
||||||
|
background: var(--apple-hover-bg);
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-day.today,
|
||||||
|
[data-style="apple"] .day-cell.today {
|
||||||
|
background: var(--apple-today-bg);
|
||||||
|
border-color: var(--apple-today-accent);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-day.today::before,
|
||||||
|
[data-style="apple"] .day-cell.today::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--apple-today-accent);
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-day.other-month,
|
||||||
|
[data-style="apple"] .day-cell.other-month {
|
||||||
|
background: var(--apple-bg-secondary);
|
||||||
|
color: var(--apple-text-secondary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .day-number,
|
||||||
|
[data-style="apple"] .date-number {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style day headers */
|
||||||
|
[data-style="apple"] .day-header,
|
||||||
|
[data-style="apple"] .weekday-header {
|
||||||
|
background: var(--apple-bg-secondary);
|
||||||
|
color: var(--apple-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--apple-border-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple Calendar-style events */
|
||||||
|
[data-style="apple"] .event {
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 2px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: block;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.3;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .event::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 2px 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .event * {
|
||||||
|
color: white;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .event:hover {
|
||||||
|
transform: translateY(-1px) scale(1.02);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All-day events styling */
|
||||||
|
[data-style="apple"] .event.all-day {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 3px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .event.all-day::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event time display */
|
||||||
|
[data-style="apple"] .event-time {
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-right: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar table structure */
|
||||||
|
[data-style="apple"] .calendar-table,
|
||||||
|
[data-style="apple"] table {
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .calendar-table td,
|
||||||
|
[data-style="apple"] table td {
|
||||||
|
vertical-align: top;
|
||||||
|
border: 1px solid var(--apple-border-secondary);
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style view toggle */
|
||||||
|
[data-style="apple"] .view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .view-toggle button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--apple-text-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .view-toggle button.active {
|
||||||
|
background: var(--apple-accent);
|
||||||
|
color: var(--apple-text-inverse);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style today button */
|
||||||
|
[data-style="apple"] .today-button {
|
||||||
|
background: var(--apple-accent);
|
||||||
|
border: none;
|
||||||
|
color: var(--apple-text-inverse);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .today-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style modals */
|
||||||
|
[data-style="apple"] .modal-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .modal-content {
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] .modal h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style form inputs */
|
||||||
|
[data-style="apple"] input[type="text"],
|
||||||
|
[data-style="apple"] input[type="email"],
|
||||||
|
[data-style="apple"] input[type="password"],
|
||||||
|
[data-style="apple"] input[type="url"],
|
||||||
|
[data-style="apple"] input[type="date"],
|
||||||
|
[data-style="apple"] input[type="time"],
|
||||||
|
[data-style="apple"] textarea,
|
||||||
|
[data-style="apple"] select {
|
||||||
|
border: 1px solid var(--apple-border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
background: var(--apple-bg-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] input:focus,
|
||||||
|
[data-style="apple"] textarea:focus,
|
||||||
|
[data-style="apple"] select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--apple-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--apple-accent-bg);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style labels */
|
||||||
|
[data-style="apple"] label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--apple-text-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: block;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth animations and transitions */
|
||||||
|
[data-style="apple"] * {
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for Apple style */
|
||||||
|
[data-style="apple"] ::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] ::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] ::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--apple-text-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-style="apple"] ::-webkit-scrollbar-thumb:hover {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user