Compare commits
80 Commits
419cb3d790
...
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 | ||
|
|
890940fe31 | ||
|
|
fdea5cd646 | ||
|
|
b307be7eb1 | ||
|
|
9d84c380d1 | ||
|
|
fad03f94f9 | ||
| a4476dcfae | |||
|
|
ca1ca0c3b1 | ||
|
|
64dbf65beb | ||
|
|
96585440d1 | ||
|
|
a297d38276 | ||
|
|
4fdaa9931d | ||
|
|
c6c7b38bef | ||
|
|
78db2cc00f | ||
|
|
73d191c5ca | ||
| d930468748 | |||
|
|
91be4436a9 | ||
|
|
4cbc495c48 | ||
|
|
927cd7d2bb | ||
|
|
38b22287c7 | ||
|
|
0de2eee626 | ||
|
|
aa7a15e6fa | ||
|
|
b0a8ef09a8 | ||
|
|
efbaea5ac1 | ||
|
|
bbad327ea2 | ||
|
|
72273a3f1c | ||
|
|
8329244c69 | ||
|
|
b16603b50b | ||
|
|
c6eea88002 | ||
|
|
5876553515 | ||
|
|
d73bc78af5 | ||
|
|
393bfecff2 | ||
| aab478202b | |||
|
|
45e16313ba | ||
|
|
64c737c023 | ||
|
|
75d9149c76 | ||
|
|
28b3946e86 | ||
|
|
6a01a75cce | ||
|
|
189dd32f8c | ||
|
|
7461e8b123 | ||
|
|
f88c238b0a | ||
|
|
8caa1f45ae | ||
|
|
289284a532 | ||
|
|
089f4ce105 | ||
|
|
235dcf8e1d | ||
|
|
8dd60a8ec1 | ||
|
|
20679b6b53 | ||
|
|
53c4a99697 | ||
|
|
5ea33b7d0a | ||
|
|
13a752a69c | ||
|
|
0609a99839 | ||
|
|
dce82d5f7d | ||
|
|
1e8a8ce5f2 | ||
|
|
c0bdd3d8c2 | ||
|
|
2b98c4d229 | ||
|
|
ceae654a39 | ||
|
|
fb28fa95c9 |
@@ -4,7 +4,7 @@
|
|||||||

|

|
||||||
|
|
||||||
>[!WARNING]
|
>[!WARNING]
|
||||||
>This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty solid.
|
>This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty decent. There are still a lot of places where the AI has implemented some really poor solutions to the problems that I didn't catch, but I've begun using it for my own general use.
|
||||||
|
|
||||||
A modern CalDAV web client built with Rust WebAssembly.
|
A modern CalDAV web client built with Rust WebAssembly.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ hyper = { version = "1.0", features = ["full"] }
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
chrono-tz = "0.8"
|
||||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
|||||||
2
backend/migrations/005_add_last_used_calendar.sql
Normal file
2
backend/migrations/005_add_last_used_calendar.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add last used calendar preference to user preferences
|
||||||
|
ALTER TABLE user_preferences ADD COLUMN last_used_calendar TEXT;
|
||||||
16
backend/migrations/006_create_external_calendars_table.sql
Normal file
16
backend/migrations/006_create_external_calendars_table.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Create external_calendars table
|
||||||
|
CREATE TABLE external_calendars (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#4285f4',
|
||||||
|
is_visible BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_fetched DATETIME,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index for performance
|
||||||
|
CREATE INDEX idx_external_calendars_user_id ON external_calendars(user_id);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Create external calendar cache table for storing ICS data
|
||||||
|
CREATE TABLE external_calendar_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
external_calendar_id INTEGER NOT NULL,
|
||||||
|
ics_data TEXT NOT NULL,
|
||||||
|
cached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
etag TEXT,
|
||||||
|
FOREIGN KEY (external_calendar_id) REFERENCES external_calendars(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(external_calendar_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for faster lookups
|
||||||
|
CREATE INDEX idx_external_calendar_cache_calendar_id ON external_calendar_cache(external_calendar_id);
|
||||||
|
CREATE INDEX idx_external_calendar_cache_cached_at ON external_calendar_cache(cached_at);
|
||||||
@@ -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);
|
||||||
@@ -93,6 +87,7 @@ impl AuthService {
|
|||||||
calendar_theme: preferences.calendar_theme,
|
calendar_theme: preferences.calendar_theme,
|
||||||
calendar_style: preferences.calendar_style,
|
calendar_style: preferences.calendar_style,
|
||||||
calendar_colors: preferences.calendar_colors,
|
calendar_colors: preferences.calendar_colors,
|
||||||
|
last_used_calendar: preferences.last_used_calendar,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -111,6 +106,17 @@ impl AuthService {
|
|||||||
self.decode_token(token)
|
self.decode_token(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get user from token
|
||||||
|
pub async fn get_user_from_token(&self, token: &str) -> Result<crate::db::User, ApiError> {
|
||||||
|
let claims = self.verify_token(token)?;
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(&self.db);
|
||||||
|
user_repo
|
||||||
|
.find_or_create(&claims.username, &claims.server_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to get user: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Create CalDAV config from token
|
/// Create CalDAV config from token
|
||||||
pub fn caldav_config_from_token(
|
pub fn caldav_config_from_token(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -330,13 +328,28 @@ impl CalDAVClient {
|
|||||||
event: ical::parser::ical::component::IcalEvent,
|
event: ical::parser::ical::component::IcalEvent,
|
||||||
) -> Result<CalendarEvent, CalDAVError> {
|
) -> Result<CalendarEvent, CalDAVError> {
|
||||||
let mut properties: HashMap<String, String> = HashMap::new();
|
let mut properties: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut full_properties: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
// Extract all properties from the event
|
// Extract all properties from the event
|
||||||
for property in &event.properties {
|
for property in &event.properties {
|
||||||
properties.insert(
|
let prop_name = property.name.to_uppercase();
|
||||||
property.name.to_uppercase(),
|
let prop_value = property.value.clone().unwrap_or_default();
|
||||||
property.value.clone().unwrap_or_default(),
|
|
||||||
);
|
properties.insert(prop_name.clone(), prop_value.clone());
|
||||||
|
|
||||||
|
// Build full property string with parameters for timezone parsing
|
||||||
|
let mut full_prop = format!("{}", prop_name);
|
||||||
|
if let Some(params) = &property.params {
|
||||||
|
for (param_name, param_values) in params {
|
||||||
|
if !param_values.is_empty() {
|
||||||
|
full_prop.push_str(&format!(";{}={}", param_name, param_values.join(",")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
full_prop.push_str(&format!(":{}", prop_value));
|
||||||
|
|
||||||
|
|
||||||
|
full_properties.insert(prop_name, full_prop);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required UID field
|
// Required UID field
|
||||||
@@ -345,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, 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, 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")
|
||||||
@@ -398,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();
|
||||||
@@ -437,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
|
||||||
@@ -553,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
|
||||||
@@ -567,14 +596,30 @@ impl CalDAVClient {
|
|||||||
|
|
||||||
let mut all_calendars = Vec::new();
|
let mut all_calendars = Vec::new();
|
||||||
|
|
||||||
|
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 {
|
||||||
if let Ok(calendars) = self.discover_calendars_at_path(&path).await {
|
Ok(calendars) => {
|
||||||
println!("Found {} calendar(s) at {}", calendars.len(), path);
|
has_valid_caldav_response = true;
|
||||||
all_calendars.extend(calendars);
|
all_calendars.extend(calendars);
|
||||||
|
}
|
||||||
|
Err(CalDAVError::ServerError(_status)) => {
|
||||||
|
// HTTP error - this might be expected for some paths, continue trying
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Network or other error - this suggests the server isn't reachable or isn't CalDAV
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we never got a valid CalDAV response (e.g., all requests failed),
|
||||||
|
// this is likely not a CalDAV server
|
||||||
|
if !has_valid_caldav_response {
|
||||||
|
return Err(CalDAVError::ServerError(404));
|
||||||
|
}
|
||||||
|
|
||||||
// Remove duplicates
|
// Remove duplicates
|
||||||
all_calendars.sort();
|
all_calendars.sort();
|
||||||
all_calendars.dedup();
|
all_calendars.dedup();
|
||||||
@@ -620,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();
|
||||||
|
|
||||||
@@ -631,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
|
||||||
@@ -655,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -671,16 +710,37 @@ impl CalDAVClient {
|
|||||||
Ok(calendar_paths)
|
Ok(calendar_paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse iCal datetime format
|
/// Parse iCal datetime format and return NaiveDateTime + timezone info
|
||||||
fn parse_datetime(
|
/// According to RFC 5545: if no TZID parameter is provided, treat as UTC
|
||||||
|
fn parse_datetime_with_tz(
|
||||||
&self,
|
&self,
|
||||||
datetime_str: &str,
|
datetime_str: &str,
|
||||||
_original_property: Option<&String>,
|
original_property: Option<&String>,
|
||||||
) -> Result<DateTime<Utc>, CalDAVError> {
|
) -> Result<(chrono::NaiveDateTime, Option<String>), CalDAVError> {
|
||||||
use chrono::TimeZone;
|
// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle different iCal datetime formats
|
// Clean the datetime string - remove any TZID prefix if present
|
||||||
let cleaned = datetime_str.replace("TZID=", "").trim().to_string();
|
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
|
// Try different parsing formats
|
||||||
let formats = [
|
let formats = [
|
||||||
@@ -690,17 +750,240 @@ impl CalDAVClient {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for format in &formats {
|
for format in &formats {
|
||||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, format) {
|
// Try parsing as UTC format (with Z suffix)
|
||||||
return Ok(Utc.from_utc_datetime(&dt));
|
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())));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(&cleaned, format) {
|
|
||||||
|
// 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
|
||||||
|
fn parse_datetime(
|
||||||
|
&self,
|
||||||
|
datetime_str: &str,
|
||||||
|
original_property: Option<&String>,
|
||||||
|
) -> Result<DateTime<Utc>, CalDAVError> {
|
||||||
|
use chrono::TimeZone;
|
||||||
|
use chrono_tz::Tz;
|
||||||
|
|
||||||
|
// Extract timezone information from the original property if available
|
||||||
|
let mut timezone_id: Option<&str> = 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]);
|
||||||
|
} else if let Some(tzid_end) = tzid_part.find(';') {
|
||||||
|
timezone_id = Some(&tzid_part[..tzid_end]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 first (if it has 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") {
|
||||||
|
return Ok(dt.and_utc());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing with timezone offset (e.g., 20231225T120000-0500)
|
||||||
|
if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y%m%dT%H%M%S%z") {
|
||||||
|
return Ok(dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ISO format with timezone offset (e.g., 2023-12-25T12:00:00-05:00)
|
||||||
|
if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%S%z") {
|
||||||
|
return Ok(dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ISO format with Z suffix (e.g., 2023-12-25T12:00:00Z)
|
||||||
|
if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%SZ") {
|
||||||
|
return Ok(dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as naive datetime
|
||||||
|
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) {
|
||||||
|
// If we have timezone information, convert accordingly
|
||||||
|
if let Some(tz_id) = timezone_id {
|
||||||
|
let tz_result = if tz_id.starts_with("/mozilla.org/") {
|
||||||
|
// Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London
|
||||||
|
tz_id.split('/').last().and_then(|tz_name| tz_name.parse::<Tz>().ok())
|
||||||
|
} else if tz_id.contains('/') {
|
||||||
|
// Standard timezone format: America/New_York, Europe/London
|
||||||
|
tz_id.parse::<Tz>().ok()
|
||||||
|
} else {
|
||||||
|
// Try common abbreviations and Windows timezone names
|
||||||
|
match tz_id {
|
||||||
|
// Standard abbreviations
|
||||||
|
"EST" => Some(Tz::America__New_York),
|
||||||
|
"PST" => Some(Tz::America__Los_Angeles),
|
||||||
|
"MST" => Some(Tz::America__Denver),
|
||||||
|
"CST" => Some(Tz::America__Chicago),
|
||||||
|
|
||||||
|
// North America - Windows timezone names to IANA mapping
|
||||||
|
"Mountain Standard Time" => Some(Tz::America__Denver),
|
||||||
|
"Eastern Standard Time" => Some(Tz::America__New_York),
|
||||||
|
"Central Standard Time" => Some(Tz::America__Chicago),
|
||||||
|
"Pacific Standard Time" => Some(Tz::America__Los_Angeles),
|
||||||
|
"Mountain Daylight Time" => Some(Tz::America__Denver),
|
||||||
|
"Eastern Daylight Time" => Some(Tz::America__New_York),
|
||||||
|
"Central Daylight Time" => Some(Tz::America__Chicago),
|
||||||
|
"Pacific Daylight Time" => Some(Tz::America__Los_Angeles),
|
||||||
|
"Hawaiian Standard Time" => Some(Tz::Pacific__Honolulu),
|
||||||
|
"Alaskan Standard Time" => Some(Tz::America__Anchorage),
|
||||||
|
"Alaskan Daylight Time" => Some(Tz::America__Anchorage),
|
||||||
|
"Atlantic Standard Time" => Some(Tz::America__Halifax),
|
||||||
|
"Newfoundland Standard Time" => Some(Tz::America__St_Johns),
|
||||||
|
|
||||||
|
// Europe
|
||||||
|
"GMT Standard Time" => Some(Tz::Europe__London),
|
||||||
|
"Greenwich Standard Time" => Some(Tz::UTC),
|
||||||
|
"W. Europe Standard Time" => Some(Tz::Europe__Berlin),
|
||||||
|
"Central Europe Standard Time" => Some(Tz::Europe__Warsaw),
|
||||||
|
"Romance Standard Time" => Some(Tz::Europe__Paris),
|
||||||
|
"Central European Standard Time" => Some(Tz::Europe__Belgrade),
|
||||||
|
"E. Europe Standard Time" => Some(Tz::Europe__Bucharest),
|
||||||
|
"FLE Standard Time" => Some(Tz::Europe__Helsinki),
|
||||||
|
"GTB Standard Time" => Some(Tz::Europe__Athens),
|
||||||
|
"Russian Standard Time" => Some(Tz::Europe__Moscow),
|
||||||
|
"Turkey Standard Time" => Some(Tz::Europe__Istanbul),
|
||||||
|
|
||||||
|
// Asia
|
||||||
|
"China Standard Time" => Some(Tz::Asia__Shanghai),
|
||||||
|
"Tokyo Standard Time" => Some(Tz::Asia__Tokyo),
|
||||||
|
"Korea Standard Time" => Some(Tz::Asia__Seoul),
|
||||||
|
"Singapore Standard Time" => Some(Tz::Asia__Singapore),
|
||||||
|
"India Standard Time" => Some(Tz::Asia__Kolkata),
|
||||||
|
"Pakistan Standard Time" => Some(Tz::Asia__Karachi),
|
||||||
|
"Bangladesh Standard Time" => Some(Tz::Asia__Dhaka),
|
||||||
|
"Thailand Standard Time" => Some(Tz::Asia__Bangkok),
|
||||||
|
"SE Asia Standard Time" => Some(Tz::Asia__Bangkok),
|
||||||
|
"Myanmar Standard Time" => Some(Tz::Asia__Yangon),
|
||||||
|
"Sri Lanka Standard Time" => Some(Tz::Asia__Colombo),
|
||||||
|
"Nepal Standard Time" => Some(Tz::Asia__Kathmandu),
|
||||||
|
"Central Asia Standard Time" => Some(Tz::Asia__Almaty),
|
||||||
|
"West Asia Standard Time" => Some(Tz::Asia__Tashkent),
|
||||||
|
"N. Central Asia Standard Time" => Some(Tz::Asia__Novosibirsk),
|
||||||
|
"North Asia Standard Time" => Some(Tz::Asia__Krasnoyarsk),
|
||||||
|
"North Asia East Standard Time" => Some(Tz::Asia__Irkutsk),
|
||||||
|
"Yakutsk Standard Time" => Some(Tz::Asia__Yakutsk),
|
||||||
|
"Vladivostok Standard Time" => Some(Tz::Asia__Vladivostok),
|
||||||
|
"Magadan Standard Time" => Some(Tz::Asia__Magadan),
|
||||||
|
|
||||||
|
// Australia & Pacific
|
||||||
|
"AUS Eastern Standard Time" => Some(Tz::Australia__Sydney),
|
||||||
|
"AUS Central Standard Time" => Some(Tz::Australia__Adelaide),
|
||||||
|
"W. Australia Standard Time" => Some(Tz::Australia__Perth),
|
||||||
|
"Tasmania Standard Time" => Some(Tz::Australia__Hobart),
|
||||||
|
"New Zealand Standard Time" => Some(Tz::Pacific__Auckland),
|
||||||
|
"Fiji Standard Time" => Some(Tz::Pacific__Fiji),
|
||||||
|
"Tonga Standard Time" => Some(Tz::Pacific__Tongatapu),
|
||||||
|
|
||||||
|
// Africa & Middle East
|
||||||
|
"South Africa Standard Time" => Some(Tz::Africa__Johannesburg),
|
||||||
|
"Egypt Standard Time" => Some(Tz::Africa__Cairo),
|
||||||
|
"Israel Standard Time" => Some(Tz::Asia__Jerusalem),
|
||||||
|
"Iran Standard Time" => Some(Tz::Asia__Tehran),
|
||||||
|
"Arabic Standard Time" => Some(Tz::Asia__Baghdad),
|
||||||
|
"Arab Standard Time" => Some(Tz::Asia__Riyadh),
|
||||||
|
|
||||||
|
// South America
|
||||||
|
"SA Eastern Standard Time" => Some(Tz::America__Sao_Paulo),
|
||||||
|
"Argentina Standard Time" => Some(Tz::America__Buenos_Aires),
|
||||||
|
"SA Western Standard Time" => Some(Tz::America__La_Paz),
|
||||||
|
"SA Pacific Standard Time" => Some(Tz::America__Bogota),
|
||||||
|
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(tz) = tz_result {
|
||||||
|
// Convert from the specified timezone to UTC
|
||||||
|
if let Some(local_dt) = tz.from_local_datetime(&naive_dt).single() {
|
||||||
|
return Ok(local_dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If timezone parsing fails, fall back to UTC
|
||||||
|
}
|
||||||
|
// No timezone info or parsing failed - treat as UTC
|
||||||
|
return Ok(Utc.from_utc_datetime(&naive_dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as date only
|
||||||
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(datetime_part, format) {
|
||||||
return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap()));
|
return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(CalDAVError::ParseError(format!(
|
Err(CalDAVError::ParseError(format!(
|
||||||
"Unable to parse datetime: {}",
|
"Unable to parse datetime: {} (cleaned: {}, timezone: {:?})",
|
||||||
datetime_str
|
datetime_str, datetime_part, timezone_id
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,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();
|
||||||
@@ -1041,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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1105,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)));
|
||||||
@@ -1162,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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
|
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
|
||||||
use sqlx::{FromRow, Result};
|
use sqlx::{FromRow, Result};
|
||||||
@@ -95,9 +95,42 @@ pub struct UserPreferences {
|
|||||||
pub calendar_theme: Option<String>,
|
pub calendar_theme: Option<String>,
|
||||||
pub calendar_style: Option<String>,
|
pub calendar_style: Option<String>,
|
||||||
pub calendar_colors: Option<String>, // JSON string
|
pub calendar_colors: Option<String>, // JSON string
|
||||||
|
pub last_used_calendar: Option<String>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// External calendar model
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct ExternalCalendar {
|
||||||
|
pub id: i32,
|
||||||
|
pub user_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub color: String,
|
||||||
|
pub is_visible: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub last_fetched: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalCalendar {
|
||||||
|
/// Create a new external calendar
|
||||||
|
pub fn new(user_id: String, name: String, url: String, color: String) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: 0, // Will be set by database
|
||||||
|
user_id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
color,
|
||||||
|
is_visible: true,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_fetched: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl UserPreferences {
|
impl UserPreferences {
|
||||||
/// Create default preferences for a new user
|
/// Create default preferences for a new user
|
||||||
pub fn default_for_user(user_id: String) -> Self {
|
pub fn default_for_user(user_id: String) -> Self {
|
||||||
@@ -109,6 +142,7 @@ impl UserPreferences {
|
|||||||
calendar_theme: Some("light".to_string()),
|
calendar_theme: Some("light".to_string()),
|
||||||
calendar_style: Some("default".to_string()),
|
calendar_style: Some("default".to_string()),
|
||||||
calendar_colors: None,
|
calendar_colors: None,
|
||||||
|
last_used_calendar: None,
|
||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,8 +300,8 @@ impl<'a> PreferencesRepository<'a> {
|
|||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO user_preferences
|
"INSERT INTO user_preferences
|
||||||
(user_id, calendar_selected_date, calendar_time_increment,
|
(user_id, calendar_selected_date, calendar_time_increment,
|
||||||
calendar_view_mode, calendar_theme, calendar_style, calendar_colors, updated_at)
|
calendar_view_mode, calendar_theme, calendar_style, calendar_colors, last_used_calendar, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&prefs.user_id)
|
.bind(&prefs.user_id)
|
||||||
.bind(&prefs.calendar_selected_date)
|
.bind(&prefs.calendar_selected_date)
|
||||||
@@ -276,6 +310,7 @@ impl<'a> PreferencesRepository<'a> {
|
|||||||
.bind(&prefs.calendar_theme)
|
.bind(&prefs.calendar_theme)
|
||||||
.bind(&prefs.calendar_style)
|
.bind(&prefs.calendar_style)
|
||||||
.bind(&prefs.calendar_colors)
|
.bind(&prefs.calendar_colors)
|
||||||
|
.bind(&prefs.last_used_calendar)
|
||||||
.bind(&prefs.updated_at)
|
.bind(&prefs.updated_at)
|
||||||
.execute(self.db.pool())
|
.execute(self.db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -290,7 +325,7 @@ impl<'a> PreferencesRepository<'a> {
|
|||||||
"UPDATE user_preferences
|
"UPDATE user_preferences
|
||||||
SET calendar_selected_date = ?, calendar_time_increment = ?,
|
SET calendar_selected_date = ?, calendar_time_increment = ?,
|
||||||
calendar_view_mode = ?, calendar_theme = ?, calendar_style = ?,
|
calendar_view_mode = ?, calendar_theme = ?, calendar_style = ?,
|
||||||
calendar_colors = ?, updated_at = ?
|
calendar_colors = ?, last_used_calendar = ?, updated_at = ?
|
||||||
WHERE user_id = ?",
|
WHERE user_id = ?",
|
||||||
)
|
)
|
||||||
.bind(&prefs.calendar_selected_date)
|
.bind(&prefs.calendar_selected_date)
|
||||||
@@ -299,11 +334,155 @@ impl<'a> PreferencesRepository<'a> {
|
|||||||
.bind(&prefs.calendar_theme)
|
.bind(&prefs.calendar_theme)
|
||||||
.bind(&prefs.calendar_style)
|
.bind(&prefs.calendar_style)
|
||||||
.bind(&prefs.calendar_colors)
|
.bind(&prefs.calendar_colors)
|
||||||
|
.bind(&prefs.last_used_calendar)
|
||||||
.bind(Utc::now())
|
.bind(Utc::now())
|
||||||
.bind(&prefs.user_id)
|
.bind(&prefs.user_id)
|
||||||
.execute(self.db.pool())
|
.execute(self.db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository for ExternalCalendar operations
|
||||||
|
pub struct ExternalCalendarRepository<'a> {
|
||||||
|
db: &'a Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ExternalCalendarRepository<'a> {
|
||||||
|
pub fn new(db: &'a Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all external calendars for a user
|
||||||
|
pub async fn get_by_user(&self, user_id: &str) -> Result<Vec<ExternalCalendar>> {
|
||||||
|
sqlx::query_as::<_, ExternalCalendar>(
|
||||||
|
"SELECT * FROM external_calendars WHERE user_id = ? ORDER BY created_at ASC",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(self.db.pool())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new external calendar
|
||||||
|
pub async fn create(&self, calendar: &ExternalCalendar) -> Result<i32> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO external_calendars (user_id, name, url, color, is_visible, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&calendar.user_id)
|
||||||
|
.bind(&calendar.name)
|
||||||
|
.bind(&calendar.url)
|
||||||
|
.bind(&calendar.color)
|
||||||
|
.bind(&calendar.is_visible)
|
||||||
|
.bind(&calendar.created_at)
|
||||||
|
.bind(&calendar.updated_at)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.last_insert_rowid() as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an external calendar
|
||||||
|
pub async fn update(&self, id: i32, calendar: &ExternalCalendar) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE external_calendars
|
||||||
|
SET name = ?, url = ?, color = ?, is_visible = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&calendar.name)
|
||||||
|
.bind(&calendar.url)
|
||||||
|
.bind(&calendar.color)
|
||||||
|
.bind(&calendar.is_visible)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.bind(id)
|
||||||
|
.bind(&calendar.user_id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an external calendar
|
||||||
|
pub async fn delete(&self, id: i32, user_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM external_calendars WHERE id = ? AND user_id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update last_fetched timestamp
|
||||||
|
pub async fn update_last_fetched(&self, id: i32, user_id: &str) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE external_calendars SET last_fetched = ? WHERE id = ? AND user_id = ?",
|
||||||
|
)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached ICS data for an external calendar
|
||||||
|
pub async fn get_cached_data(&self, external_calendar_id: i32) -> Result<Option<(String, DateTime<Utc>)>> {
|
||||||
|
let result = sqlx::query_as::<_, (String, DateTime<Utc>)>(
|
||||||
|
"SELECT ics_data, cached_at FROM external_calendar_cache WHERE external_calendar_id = ?",
|
||||||
|
)
|
||||||
|
.bind(external_calendar_id)
|
||||||
|
.fetch_optional(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update cache with new ICS data
|
||||||
|
pub async fn update_cache(&self, external_calendar_id: i32, ics_data: &str, etag: Option<&str>) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO external_calendar_cache (external_calendar_id, ics_data, etag, cached_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(external_calendar_id) DO UPDATE SET
|
||||||
|
ics_data = excluded.ics_data,
|
||||||
|
etag = excluded.etag,
|
||||||
|
cached_at = excluded.cached_at",
|
||||||
|
)
|
||||||
|
.bind(external_calendar_id)
|
||||||
|
.bind(ics_data)
|
||||||
|
.bind(etag)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if cache is stale (older than max_age_minutes)
|
||||||
|
pub async fn is_cache_stale(&self, external_calendar_id: i32, max_age_minutes: i64) -> Result<bool> {
|
||||||
|
let cutoff_time = Utc::now() - Duration::minutes(max_age_minutes);
|
||||||
|
|
||||||
|
let result = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM external_calendar_cache
|
||||||
|
WHERE external_calendar_id = ? AND cached_at > ?",
|
||||||
|
)
|
||||||
|
.bind(external_calendar_id)
|
||||||
|
.bind(cutoff_time)
|
||||||
|
.fetch_one(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear cache for an external calendar
|
||||||
|
pub async fn clear_cache(&self, external_calendar_id: i32) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM external_calendar_cache WHERE external_calendar_id = ?")
|
||||||
|
.bind(external_calendar_id)
|
||||||
|
.execute(self.db.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// Re-export all handlers from the modular structure
|
|
||||||
mod auth;
|
|
||||||
mod calendar;
|
|
||||||
mod events;
|
|
||||||
mod preferences;
|
|
||||||
mod series;
|
|
||||||
|
|
||||||
pub use auth::{get_user_info, login, verify_token};
|
|
||||||
pub use calendar::{create_calendar, delete_calendar};
|
|
||||||
pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event};
|
|
||||||
pub use preferences::{get_preferences, logout, update_preferences};
|
|
||||||
pub use series::{create_event_series, delete_event_series, update_event_series};
|
|
||||||
@@ -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()
|
||||||
@@ -93,6 +89,7 @@ pub async fn get_user_info(
|
|||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
display_name: extract_calendar_name(path),
|
display_name: extract_calendar_name(path),
|
||||||
color: generate_calendar_color(path),
|
color: generate_calendar_color(path),
|
||||||
|
is_visible: true, // Default to visible
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -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,32 +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 midnight UTC
|
// For all-day events, use start of day
|
||||||
let datetime = date
|
let datetime = date
|
||||||
.and_hms_opt(0, 0, 0)
|
.and_hms_opt(0, 0, 0)
|
||||||
.ok_or_else(|| "Failed to create midnight 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))
|
||||||
|
|
||||||
// Assume local time and convert to UTC (in a real app, you'd want timezone support)
|
|
||||||
Ok(Utc.from_utc_datetime(&datetime))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
142
backend/src/handlers/external_calendars.rs
Normal file
142
backend/src/handlers/external_calendars.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
response::Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{ExternalCalendar, ExternalCalendarRepository},
|
||||||
|
models::ApiError,
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::auth::{extract_bearer_token};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateExternalCalendarRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub color: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateExternalCalendarRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub color: String,
|
||||||
|
pub is_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ExternalCalendarResponse {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub color: String,
|
||||||
|
pub is_visible: bool,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub last_fetched: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ExternalCalendar> for ExternalCalendarResponse {
|
||||||
|
fn from(calendar: ExternalCalendar) -> Self {
|
||||||
|
Self {
|
||||||
|
id: calendar.id,
|
||||||
|
name: calendar.name,
|
||||||
|
url: calendar.url,
|
||||||
|
color: calendar.color,
|
||||||
|
is_visible: calendar.is_visible,
|
||||||
|
created_at: calendar.created_at,
|
||||||
|
updated_at: calendar.updated_at,
|
||||||
|
last_fetched: calendar.last_fetched,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_external_calendars(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<Vec<ExternalCalendarResponse>>, ApiError> {
|
||||||
|
// Extract and verify token, get user
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let user = app_state.auth_service.get_user_from_token(&token).await?;
|
||||||
|
|
||||||
|
let repo = ExternalCalendarRepository::new(&app_state.db);
|
||||||
|
let calendars = repo
|
||||||
|
.get_by_user(&user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?;
|
||||||
|
|
||||||
|
let response: Vec<ExternalCalendarResponse> = calendars.into_iter().map(Into::into).collect();
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_external_calendar(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
Json(request): Json<CreateExternalCalendarRequest>,
|
||||||
|
) -> Result<Json<ExternalCalendarResponse>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let user = app_state.auth_service.get_user_from_token(&token).await?;
|
||||||
|
|
||||||
|
let calendar = ExternalCalendar::new(
|
||||||
|
user.id,
|
||||||
|
request.name,
|
||||||
|
request.url,
|
||||||
|
request.color,
|
||||||
|
);
|
||||||
|
|
||||||
|
let repo = ExternalCalendarRepository::new(&app_state.db);
|
||||||
|
let id = repo
|
||||||
|
.create(&calendar)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to create external calendar: {}", e)))?;
|
||||||
|
|
||||||
|
let mut created_calendar = calendar;
|
||||||
|
created_calendar.id = id;
|
||||||
|
|
||||||
|
Ok(Json(created_calendar.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_external_calendar(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Json(request): Json<UpdateExternalCalendarRequest>,
|
||||||
|
) -> Result<Json<()>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let user = app_state.auth_service.get_user_from_token(&token).await?;
|
||||||
|
|
||||||
|
let mut calendar = ExternalCalendar::new(
|
||||||
|
user.id,
|
||||||
|
request.name,
|
||||||
|
request.url,
|
||||||
|
request.color,
|
||||||
|
);
|
||||||
|
calendar.is_visible = request.is_visible;
|
||||||
|
|
||||||
|
let repo = ExternalCalendarRepository::new(&app_state.db);
|
||||||
|
repo.update(id, &calendar)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to update external calendar: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_external_calendar(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Json<()>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let user = app_state.auth_service.get_user_from_token(&token).await?;
|
||||||
|
|
||||||
|
let repo = ExternalCalendarRepository::new(&app_state.db);
|
||||||
|
repo.delete(id, &user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to delete external calendar: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
873
backend/src/handlers/ics_fetcher.rs
Normal file
873
backend/src/handlers/ics_fetcher.rs
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
response::Json,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc, Datelike};
|
||||||
|
use ical::parser::ical::component::IcalEvent;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::ExternalCalendarRepository,
|
||||||
|
models::ApiError,
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Import VEvent from calendar-models shared crate
|
||||||
|
use calendar_models::VEvent;
|
||||||
|
|
||||||
|
use super::auth::{extract_bearer_token};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ExternalCalendarEventsResponse {
|
||||||
|
pub events: Vec<VEvent>,
|
||||||
|
pub last_fetched: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_external_calendar_events(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
State(app_state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Json<ExternalCalendarEventsResponse>, ApiError> {
|
||||||
|
let token = extract_bearer_token(&headers)?;
|
||||||
|
let user = app_state.auth_service.get_user_from_token(&token).await?;
|
||||||
|
|
||||||
|
let repo = ExternalCalendarRepository::new(&app_state.db);
|
||||||
|
|
||||||
|
// Get user's external calendars to verify ownership and get URL
|
||||||
|
let calendars = repo
|
||||||
|
.get_by_user(&user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?;
|
||||||
|
|
||||||
|
let calendar = calendars
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| c.id == id)
|
||||||
|
.ok_or_else(|| ApiError::NotFound("External calendar not found".to_string()))?;
|
||||||
|
|
||||||
|
if !calendar.is_visible {
|
||||||
|
return Ok(Json(ExternalCalendarEventsResponse {
|
||||||
|
events: vec![],
|
||||||
|
last_fetched: Utc::now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
let cache_max_age_minutes = 5;
|
||||||
|
let mut ics_content = String::new();
|
||||||
|
let mut last_fetched = Utc::now();
|
||||||
|
let mut fetched_from_cache = false;
|
||||||
|
|
||||||
|
// Try to get from cache if not stale
|
||||||
|
match repo.is_cache_stale(id, cache_max_age_minutes).await {
|
||||||
|
Ok(is_stale) => {
|
||||||
|
if !is_stale {
|
||||||
|
// Cache is fresh, use it
|
||||||
|
if let Ok(Some((cached_data, cached_at))) = repo.get_cached_data(id).await {
|
||||||
|
ics_content = cached_data;
|
||||||
|
last_fetched = cached_at;
|
||||||
|
fetched_from_cache = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// If cache check fails, proceed to fetch from URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not fetched from cache, get from external URL
|
||||||
|
if !fetched_from_cache {
|
||||||
|
// Log the URL being fetched for debugging
|
||||||
|
println!("🌍 Fetching calendar URL: {}", calendar.url);
|
||||||
|
|
||||||
|
let user_agents = vec![
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (compatible; Runway Calendar/1.0)",
|
||||||
|
"Outlook-iOS/709.2226530.prod.iphone (3.24.1)"
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut response = None;
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
// Try different user agents
|
||||||
|
for (i, ua) in user_agents.iter().enumerate() {
|
||||||
|
println!("🔄 Attempt {} with User-Agent: {}", i + 1, ua);
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.redirect(reqwest::redirect::Policy::limited(10))
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.user_agent(*ua)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to create HTTP client: {}", e)))?;
|
||||||
|
|
||||||
|
let result = client
|
||||||
|
.get(&calendar.url)
|
||||||
|
.header("Accept", "text/calendar,application/calendar+xml,text/plain,*/*")
|
||||||
|
.header("Accept-Charset", "utf-8")
|
||||||
|
.header("Cache-Control", "no-cache")
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(resp) => {
|
||||||
|
let status = resp.status();
|
||||||
|
println!("📡 Response status: {}", status);
|
||||||
|
if status.is_success() {
|
||||||
|
response = Some(resp);
|
||||||
|
break;
|
||||||
|
} else if status == 400 {
|
||||||
|
// Check if this is an Outlook auth error
|
||||||
|
let error_body = resp.text().await.unwrap_or_default();
|
||||||
|
if error_body.contains("OwaPage") || error_body.contains("Outlook") {
|
||||||
|
println!("🚫 Outlook authentication error detected, trying next approach...");
|
||||||
|
last_error = Some(format!("Outlook auth error: {}", error_body.chars().take(100).collect::<String>()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
last_error = Some(format!("Bad Request: {}", error_body.chars().take(100).collect::<String>()));
|
||||||
|
} else {
|
||||||
|
last_error = Some(format!("HTTP {}", status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("❌ Request failed: {}", e);
|
||||||
|
last_error = Some(format!("Request error: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = response.ok_or_else(|| {
|
||||||
|
ApiError::Internal(format!(
|
||||||
|
"Failed to fetch calendar after trying {} different approaches. Last error: {}",
|
||||||
|
user_agents.len(),
|
||||||
|
last_error.unwrap_or("Unknown error".to_string())
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Response is guaranteed to be successful here since we checked in the loop
|
||||||
|
println!("✅ Successfully fetched calendar data");
|
||||||
|
|
||||||
|
ics_content = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?;
|
||||||
|
|
||||||
|
// Store in cache for future requests
|
||||||
|
let etag = None; // TODO: Extract ETag from response headers if available
|
||||||
|
if let Err(_) = repo.update_cache(id, &ics_content, etag).await {
|
||||||
|
// Log error but don't fail the request
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_fetched timestamp
|
||||||
|
if let Err(_) = repo.update_last_fetched(id, &user.id).await {
|
||||||
|
}
|
||||||
|
|
||||||
|
last_fetched = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ICS content
|
||||||
|
let events = parse_ics_content(&ics_content)
|
||||||
|
.map_err(|e| ApiError::BadRequest(format!("Failed to parse calendar: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(ExternalCalendarEventsResponse {
|
||||||
|
events,
|
||||||
|
last_fetched,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ics_content(ics_content: &str) -> Result<Vec<VEvent>, Box<dyn std::error::Error>> {
|
||||||
|
let reader = ical::IcalParser::new(ics_content.as_bytes());
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut _total_components = 0;
|
||||||
|
let mut _failed_conversions = 0;
|
||||||
|
|
||||||
|
for calendar in reader {
|
||||||
|
let calendar = calendar?;
|
||||||
|
for component in calendar.events {
|
||||||
|
_total_components += 1;
|
||||||
|
match convert_ical_to_vevent(component) {
|
||||||
|
Ok(vevent) => {
|
||||||
|
events.push(vevent);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
_failed_conversions += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate events based on UID, start time, and summary
|
||||||
|
// Outlook sometimes includes duplicate events (recurring exceptions may appear multiple times)
|
||||||
|
events = deduplicate_events(events);
|
||||||
|
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::error::Error>> {
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
let mut summary = None;
|
||||||
|
let mut description = None;
|
||||||
|
let mut location = None;
|
||||||
|
let mut dtstart = None;
|
||||||
|
let mut dtend = None;
|
||||||
|
let mut uid = None;
|
||||||
|
let mut all_day = false;
|
||||||
|
let mut rrule = None;
|
||||||
|
|
||||||
|
|
||||||
|
// Extract properties
|
||||||
|
for property in ical_event.properties {
|
||||||
|
match property.name.as_str() {
|
||||||
|
"SUMMARY" => {
|
||||||
|
summary = property.value;
|
||||||
|
}
|
||||||
|
"DESCRIPTION" => {
|
||||||
|
description = property.value;
|
||||||
|
}
|
||||||
|
"LOCATION" => {
|
||||||
|
location = property.value;
|
||||||
|
}
|
||||||
|
"DTSTART" => {
|
||||||
|
if let Some(value) = property.value {
|
||||||
|
// Check if it's a date-only value (all-day event)
|
||||||
|
if value.len() == 8 && !value.contains('T') {
|
||||||
|
all_day = true;
|
||||||
|
// Parse YYYYMMDD format
|
||||||
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") {
|
||||||
|
dtstart = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Extract timezone info from parameters
|
||||||
|
let tzid = property.params.as_ref()
|
||||||
|
.and_then(|params| params.iter().find(|(k, _)| k == "TZID"))
|
||||||
|
.and_then(|(_, v)| v.first().cloned());
|
||||||
|
|
||||||
|
// Parse datetime with timezone information
|
||||||
|
if let Some(dt) = parse_datetime_with_tz(&value, tzid.as_deref()) {
|
||||||
|
dtstart = Some(dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"DTEND" => {
|
||||||
|
if let Some(value) = property.value {
|
||||||
|
if all_day && value.len() == 8 && !value.contains('T') {
|
||||||
|
// For all-day events, DTEND is exclusive so use the date as-is at noon
|
||||||
|
if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") {
|
||||||
|
dtend = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Extract timezone info from parameters
|
||||||
|
let tzid = property.params.as_ref()
|
||||||
|
.and_then(|params| params.iter().find(|(k, _)| k == "TZID"))
|
||||||
|
.and_then(|(_, v)| v.first().cloned());
|
||||||
|
|
||||||
|
// Parse datetime with timezone information
|
||||||
|
if let Some(dt) = parse_datetime_with_tz(&value, tzid.as_deref()) {
|
||||||
|
dtend = Some(dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"UID" => {
|
||||||
|
uid = property.value;
|
||||||
|
}
|
||||||
|
"RRULE" => {
|
||||||
|
rrule = property.value;
|
||||||
|
}
|
||||||
|
_ => {} // Ignore other properties for now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let dtstart = dtstart.ok_or("Missing DTSTART")?;
|
||||||
|
|
||||||
|
let vevent = VEvent {
|
||||||
|
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||||
|
dtstart: dtstart.naive_utc(),
|
||||||
|
dtstart_tzid: None, // TODO: Parse timezone from ICS
|
||||||
|
dtend: dtend.map(|dt| dt.naive_utc()),
|
||||||
|
dtend_tzid: None, // TODO: Parse timezone from ICS
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
location,
|
||||||
|
all_day,
|
||||||
|
rrule,
|
||||||
|
rdate: Vec::new(),
|
||||||
|
rdate_tzid: None,
|
||||||
|
exdate: Vec::new(), // External calendars don't need exception handling
|
||||||
|
exdate_tzid: None,
|
||||||
|
recurrence_id: None,
|
||||||
|
recurrence_id_tzid: None,
|
||||||
|
created: None,
|
||||||
|
created_tzid: None,
|
||||||
|
last_modified: None,
|
||||||
|
last_modified_tzid: None,
|
||||||
|
dtstamp: Utc::now(),
|
||||||
|
sequence: Some(0),
|
||||||
|
status: None,
|
||||||
|
transp: None,
|
||||||
|
organizer: None,
|
||||||
|
attendees: Vec::new(),
|
||||||
|
url: None,
|
||||||
|
attachments: Vec::new(),
|
||||||
|
categories: Vec::new(),
|
||||||
|
priority: None,
|
||||||
|
resources: Vec::new(),
|
||||||
|
related_to: None,
|
||||||
|
geo: None,
|
||||||
|
duration: None,
|
||||||
|
class: None,
|
||||||
|
contact: None,
|
||||||
|
comment: None,
|
||||||
|
alarms: Vec::new(),
|
||||||
|
etag: None,
|
||||||
|
href: None,
|
||||||
|
calendar_path: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(vevent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<DateTime<Utc>> {
|
||||||
|
use chrono::TimeZone;
|
||||||
|
use chrono_tz::Tz;
|
||||||
|
|
||||||
|
|
||||||
|
// Try various datetime formats commonly found in ICS files
|
||||||
|
|
||||||
|
// Format: 20231201T103000Z (UTC) - handle as naive datetime first
|
||||||
|
if datetime_str.ends_with('Z') {
|
||||||
|
let datetime_without_z = &datetime_str[..datetime_str.len()-1];
|
||||||
|
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_without_z, "%Y%m%dT%H%M%S") {
|
||||||
|
return Some(naive_dt.and_utc());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: 20231201T103000-0500 (with timezone offset)
|
||||||
|
if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S%z") {
|
||||||
|
return Some(dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: 2023-12-01T10:30:00Z (ISO format)
|
||||||
|
if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%SZ") {
|
||||||
|
return Some(dt.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle naive datetime with timezone parameter
|
||||||
|
let naive_dt = if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S") {
|
||||||
|
Some(dt)
|
||||||
|
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") {
|
||||||
|
Some(dt)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(naive_dt) = naive_dt {
|
||||||
|
// If TZID is provided, try to parse it
|
||||||
|
if let Some(tzid_str) = tzid {
|
||||||
|
// Handle common timezone formats
|
||||||
|
let tz_result = if tzid_str.starts_with("/mozilla.org/") {
|
||||||
|
// Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London
|
||||||
|
tzid_str.split('/').last().and_then(|tz_name| tz_name.parse::<Tz>().ok())
|
||||||
|
} else if tzid_str.contains('/') {
|
||||||
|
// Standard timezone format: America/New_York, Europe/London
|
||||||
|
tzid_str.parse::<Tz>().ok()
|
||||||
|
} else {
|
||||||
|
// Try common abbreviations and Windows timezone names
|
||||||
|
match tzid_str {
|
||||||
|
// Standard abbreviations
|
||||||
|
"EST" => Some(Tz::America__New_York),
|
||||||
|
"PST" => Some(Tz::America__Los_Angeles),
|
||||||
|
"MST" => Some(Tz::America__Denver),
|
||||||
|
"CST" => Some(Tz::America__Chicago),
|
||||||
|
|
||||||
|
// North America - Windows timezone names to IANA mapping
|
||||||
|
"Mountain Standard Time" => Some(Tz::America__Denver),
|
||||||
|
"Eastern Standard Time" => Some(Tz::America__New_York),
|
||||||
|
"Central Standard Time" => Some(Tz::America__Chicago),
|
||||||
|
"Pacific Standard Time" => Some(Tz::America__Los_Angeles),
|
||||||
|
"Mountain Daylight Time" => Some(Tz::America__Denver),
|
||||||
|
"Eastern Daylight Time" => Some(Tz::America__New_York),
|
||||||
|
"Central Daylight Time" => Some(Tz::America__Chicago),
|
||||||
|
"Pacific Daylight Time" => Some(Tz::America__Los_Angeles),
|
||||||
|
"Hawaiian Standard Time" => Some(Tz::Pacific__Honolulu),
|
||||||
|
"Alaskan Standard Time" => Some(Tz::America__Anchorage),
|
||||||
|
"Alaskan Daylight Time" => Some(Tz::America__Anchorage),
|
||||||
|
"Atlantic Standard Time" => Some(Tz::America__Halifax),
|
||||||
|
"Newfoundland Standard Time" => Some(Tz::America__St_Johns),
|
||||||
|
|
||||||
|
// Europe
|
||||||
|
"GMT Standard Time" => Some(Tz::Europe__London),
|
||||||
|
"Greenwich Standard Time" => Some(Tz::UTC),
|
||||||
|
"W. Europe Standard Time" => Some(Tz::Europe__Berlin),
|
||||||
|
"Central Europe Standard Time" => Some(Tz::Europe__Warsaw),
|
||||||
|
"Romance Standard Time" => Some(Tz::Europe__Paris),
|
||||||
|
"Central European Standard Time" => Some(Tz::Europe__Belgrade),
|
||||||
|
"E. Europe Standard Time" => Some(Tz::Europe__Bucharest),
|
||||||
|
"FLE Standard Time" => Some(Tz::Europe__Helsinki),
|
||||||
|
"GTB Standard Time" => Some(Tz::Europe__Athens),
|
||||||
|
"Russian Standard Time" => Some(Tz::Europe__Moscow),
|
||||||
|
"Turkey Standard Time" => Some(Tz::Europe__Istanbul),
|
||||||
|
|
||||||
|
// Asia
|
||||||
|
"China Standard Time" => Some(Tz::Asia__Shanghai),
|
||||||
|
"Tokyo Standard Time" => Some(Tz::Asia__Tokyo),
|
||||||
|
"Korea Standard Time" => Some(Tz::Asia__Seoul),
|
||||||
|
"Singapore Standard Time" => Some(Tz::Asia__Singapore),
|
||||||
|
"India Standard Time" => Some(Tz::Asia__Kolkata),
|
||||||
|
"Pakistan Standard Time" => Some(Tz::Asia__Karachi),
|
||||||
|
"Bangladesh Standard Time" => Some(Tz::Asia__Dhaka),
|
||||||
|
"Thailand Standard Time" => Some(Tz::Asia__Bangkok),
|
||||||
|
"SE Asia Standard Time" => Some(Tz::Asia__Bangkok),
|
||||||
|
"Myanmar Standard Time" => Some(Tz::Asia__Yangon),
|
||||||
|
"Sri Lanka Standard Time" => Some(Tz::Asia__Colombo),
|
||||||
|
"Nepal Standard Time" => Some(Tz::Asia__Kathmandu),
|
||||||
|
"Central Asia Standard Time" => Some(Tz::Asia__Almaty),
|
||||||
|
"West Asia Standard Time" => Some(Tz::Asia__Tashkent),
|
||||||
|
"Afghanistan Standard Time" => Some(Tz::Asia__Kabul),
|
||||||
|
"Iran Standard Time" => Some(Tz::Asia__Tehran),
|
||||||
|
"Arabian Standard Time" => Some(Tz::Asia__Dubai),
|
||||||
|
"Arab Standard Time" => Some(Tz::Asia__Riyadh),
|
||||||
|
"Israel Standard Time" => Some(Tz::Asia__Jerusalem),
|
||||||
|
"Jordan Standard Time" => Some(Tz::Asia__Amman),
|
||||||
|
"Syria Standard Time" => Some(Tz::Asia__Damascus),
|
||||||
|
"Middle East Standard Time" => Some(Tz::Asia__Beirut),
|
||||||
|
"Egypt Standard Time" => Some(Tz::Africa__Cairo),
|
||||||
|
"South Africa Standard Time" => Some(Tz::Africa__Johannesburg),
|
||||||
|
"E. Africa Standard Time" => Some(Tz::Africa__Nairobi),
|
||||||
|
"W. Central Africa Standard Time" => Some(Tz::Africa__Lagos),
|
||||||
|
|
||||||
|
// Asia Pacific
|
||||||
|
"AUS Eastern Standard Time" => Some(Tz::Australia__Sydney),
|
||||||
|
"AUS Central Standard Time" => Some(Tz::Australia__Darwin),
|
||||||
|
"W. Australia Standard Time" => Some(Tz::Australia__Perth),
|
||||||
|
"Tasmania Standard Time" => Some(Tz::Australia__Hobart),
|
||||||
|
"New Zealand Standard Time" => Some(Tz::Pacific__Auckland),
|
||||||
|
"Fiji Standard Time" => Some(Tz::Pacific__Fiji),
|
||||||
|
"Tonga Standard Time" => Some(Tz::Pacific__Tongatapu),
|
||||||
|
|
||||||
|
// South America
|
||||||
|
"Argentina Standard Time" => Some(Tz::America__Buenos_Aires),
|
||||||
|
"E. South America Standard Time" => Some(Tz::America__Sao_Paulo),
|
||||||
|
"SA Eastern Standard Time" => Some(Tz::America__Cayenne),
|
||||||
|
"SA Pacific Standard Time" => Some(Tz::America__Bogota),
|
||||||
|
"SA Western Standard Time" => Some(Tz::America__La_Paz),
|
||||||
|
"Pacific SA Standard Time" => Some(Tz::America__Santiago),
|
||||||
|
"Venezuela Standard Time" => Some(Tz::America__Caracas),
|
||||||
|
"Montevideo Standard Time" => Some(Tz::America__Montevideo),
|
||||||
|
|
||||||
|
// Try parsing as IANA name
|
||||||
|
_ => tzid_str.parse::<Tz>().ok()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(tz) = tz_result {
|
||||||
|
if let Some(dt_with_tz) = tz.from_local_datetime(&naive_dt).single() {
|
||||||
|
return Some(dt_with_tz.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no timezone info or parsing failed, treat as UTC (safer than local time assumptions)
|
||||||
|
return Some(chrono::TimeZone::from_utc_datetime(&Utc, &naive_dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deduplicate events based on UID, start time, and summary
|
||||||
|
/// Some calendar systems (like Outlook) may include duplicate events in ICS feeds
|
||||||
|
/// This includes both exact duplicates and recurring event instances that would be
|
||||||
|
/// generated by existing RRULE patterns, and events with same title but different
|
||||||
|
/// RRULE patterns that should be consolidated
|
||||||
|
fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
for event in events.drain(..) {
|
||||||
|
|
||||||
|
uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut uid_deduplicated_events = Vec::new();
|
||||||
|
|
||||||
|
for (_uid, mut events_with_uid) in uid_groups.drain() {
|
||||||
|
if events_with_uid.len() == 1 {
|
||||||
|
// Only one event with this UID, keep it
|
||||||
|
uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap());
|
||||||
|
} else {
|
||||||
|
// Multiple events with same UID - prefer recurring over non-recurring
|
||||||
|
|
||||||
|
// Sort by preference: recurring events first, then by completeness
|
||||||
|
events_with_uid.sort_by(|a, b| {
|
||||||
|
let a_has_rrule = a.rrule.is_some();
|
||||||
|
let b_has_rrule = b.rrule.is_some();
|
||||||
|
|
||||||
|
match (a_has_rrule, b_has_rrule) {
|
||||||
|
(true, false) => std::cmp::Ordering::Less, // a (recurring) comes first
|
||||||
|
(false, true) => std::cmp::Ordering::Greater, // b (recurring) comes first
|
||||||
|
_ => {
|
||||||
|
// Both same type (both recurring or both single) - compare by completeness
|
||||||
|
event_completeness_score(b).cmp(&event_completeness_score(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the first (preferred) event
|
||||||
|
let preferred_event = events_with_uid.into_iter().next().unwrap();
|
||||||
|
uid_deduplicated_events.push(preferred_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: separate recurring and single events from UID-deduplicated set
|
||||||
|
let mut recurring_events = Vec::new();
|
||||||
|
let mut single_events = Vec::new();
|
||||||
|
|
||||||
|
for event in uid_deduplicated_events.drain(..) {
|
||||||
|
if event.rrule.is_some() {
|
||||||
|
recurring_events.push(event);
|
||||||
|
} else {
|
||||||
|
single_events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third pass: Group recurring events by normalized title and consolidate different RRULE patterns
|
||||||
|
let mut title_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
|
||||||
|
|
||||||
|
for event in recurring_events.drain(..) {
|
||||||
|
let title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
title_groups.entry(title).or_insert_with(Vec::new).push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut deduplicated_recurring = Vec::new();
|
||||||
|
|
||||||
|
for (_title, events_with_title) in title_groups.drain() {
|
||||||
|
if events_with_title.len() == 1 {
|
||||||
|
// Single event with this title, keep as-is
|
||||||
|
deduplicated_recurring.push(events_with_title.into_iter().next().unwrap());
|
||||||
|
} else {
|
||||||
|
// Multiple events with same title - consolidate or deduplicate
|
||||||
|
|
||||||
|
// Check if these are actually different recurring patterns for the same logical event
|
||||||
|
let consolidated = consolidate_same_title_events(events_with_title);
|
||||||
|
deduplicated_recurring.extend(consolidated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fourth pass: filter single events, removing those that would be generated by recurring events
|
||||||
|
let mut deduplicated_single = Vec::new();
|
||||||
|
let mut seen_single: HashMap<String, usize> = HashMap::new();
|
||||||
|
|
||||||
|
for event in single_events.drain(..) {
|
||||||
|
let normalized_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
let dedup_key = format!(
|
||||||
|
"{}|{}",
|
||||||
|
event.dtstart.format("%Y%m%dT%H%M%S"),
|
||||||
|
normalized_title
|
||||||
|
);
|
||||||
|
|
||||||
|
// First check for exact duplicates among single events
|
||||||
|
if let Some(&existing_index) = seen_single.get(&dedup_key) {
|
||||||
|
let existing_event: &VEvent = &deduplicated_single[existing_index];
|
||||||
|
let current_completeness = event_completeness_score(&event);
|
||||||
|
let existing_completeness = event_completeness_score(existing_event);
|
||||||
|
|
||||||
|
if current_completeness > existing_completeness {
|
||||||
|
deduplicated_single[existing_index] = event;
|
||||||
|
} else {
|
||||||
|
// Discarding duplicate single event - keeping existing
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this single event would be generated by any recurring event
|
||||||
|
let is_rrule_generated = deduplicated_recurring.iter().any(|recurring_event| {
|
||||||
|
// Check if this single event matches the recurring event's pattern (use normalized titles)
|
||||||
|
let single_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
let recurring_title = normalize_title(recurring_event.summary.as_ref().unwrap_or(&String::new()));
|
||||||
|
|
||||||
|
if single_title != recurring_title {
|
||||||
|
return false; // Different events
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this single event would be generated by the recurring event
|
||||||
|
would_event_be_generated_by_rrule(recurring_event, &event)
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_rrule_generated {
|
||||||
|
} else {
|
||||||
|
// This is a unique single event
|
||||||
|
seen_single.insert(dedup_key, deduplicated_single.len());
|
||||||
|
deduplicated_single.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine recurring and single events
|
||||||
|
let mut result = deduplicated_recurring;
|
||||||
|
result.extend(deduplicated_single);
|
||||||
|
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize title for grouping similar events
|
||||||
|
fn normalize_title(title: &str) -> String {
|
||||||
|
title.trim()
|
||||||
|
.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
|
||||||
|
.collect::<String>()
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consolidate events with the same title but potentially different RRULE patterns
|
||||||
|
/// This handles cases where calendar systems provide multiple recurring definitions
|
||||||
|
/// for the same logical meeting (e.g., one RRULE for Tuesdays, another for Thursdays)
|
||||||
|
fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
|
if events.is_empty() {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the RRULEs we're working with
|
||||||
|
|
||||||
|
// Check if all events have similar time patterns and could be consolidated
|
||||||
|
let first_event = &events[0];
|
||||||
|
let base_time = first_event.dtstart.time();
|
||||||
|
let base_duration = if let Some(end) = first_event.dtend {
|
||||||
|
Some(end.signed_duration_since(first_event.dtstart))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if all events have the same time and duration
|
||||||
|
let can_consolidate = events.iter().all(|event| {
|
||||||
|
let same_time = event.dtstart.time() == base_time;
|
||||||
|
let same_duration = match (event.dtend, base_duration) {
|
||||||
|
(Some(end), Some(base_dur)) => end.signed_duration_since(event.dtstart) == base_dur,
|
||||||
|
(None, None) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
same_time && same_duration
|
||||||
|
});
|
||||||
|
|
||||||
|
if !can_consolidate {
|
||||||
|
// Just deduplicate exact duplicates
|
||||||
|
return deduplicate_exact_recurring_events(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to detect if these are complementary weekly patterns
|
||||||
|
let weekly_events: Vec<_> = events.iter()
|
||||||
|
.filter(|e| e.rrule.as_ref().map_or(false, |r| r.contains("FREQ=WEEKLY")))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if weekly_events.len() >= 2 && weekly_events.len() == events.len() {
|
||||||
|
// All events are weekly - try to consolidate into a single multi-day weekly pattern
|
||||||
|
if let Some(consolidated) = consolidate_weekly_patterns(&events) {
|
||||||
|
return vec![consolidated];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't consolidate, just deduplicate exact matches and keep the most complete one
|
||||||
|
let deduplicated = deduplicate_exact_recurring_events(events);
|
||||||
|
|
||||||
|
// If we still have multiple events, keep only the most complete one
|
||||||
|
if deduplicated.len() > 1 {
|
||||||
|
let best_event = deduplicated.into_iter()
|
||||||
|
.max_by_key(|e| event_completeness_score(e))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("🎯 Kept most complete event: '{}'",
|
||||||
|
best_event.summary.as_ref().unwrap_or(&"No Title".to_string())
|
||||||
|
);
|
||||||
|
vec![best_event]
|
||||||
|
} else {
|
||||||
|
deduplicated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deduplicate exact recurring event matches
|
||||||
|
fn deduplicate_exact_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
let mut seen: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut deduplicated = Vec::new();
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
let dedup_key = format!(
|
||||||
|
"{}|{}|{}",
|
||||||
|
event.dtstart.format("%Y%m%dT%H%M%S"),
|
||||||
|
event.summary.as_ref().unwrap_or(&String::new()),
|
||||||
|
event.rrule.as_ref().unwrap_or(&String::new())
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(&existing_index) = seen.get(&dedup_key) {
|
||||||
|
let existing_event: &VEvent = &deduplicated[existing_index];
|
||||||
|
let current_completeness = event_completeness_score(&event);
|
||||||
|
let existing_completeness = event_completeness_score(existing_event);
|
||||||
|
|
||||||
|
if current_completeness > existing_completeness {
|
||||||
|
println!("🔄 Replacing exact duplicate: Keeping more complete event");
|
||||||
|
deduplicated[existing_index] = event;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seen.insert(dedup_key, deduplicated.len());
|
||||||
|
deduplicated.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deduplicated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to consolidate multiple weekly RRULE patterns into a single pattern
|
||||||
|
fn consolidate_weekly_patterns(events: &[VEvent]) -> Option<VEvent> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let mut all_days = HashSet::new();
|
||||||
|
let mut base_event = None;
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
let Some(rrule) = &event.rrule else { continue; };
|
||||||
|
|
||||||
|
if !rrule.contains("FREQ=WEEKLY") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract BYDAY if present
|
||||||
|
if let Some(byday_part) = rrule.split(';').find(|part| part.starts_with("BYDAY=")) {
|
||||||
|
let days_str = byday_part.strip_prefix("BYDAY=").unwrap_or("");
|
||||||
|
for day in days_str.split(',') {
|
||||||
|
all_days.insert(day.trim().to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no BYDAY specified, use the weekday from the start date
|
||||||
|
let weekday = match event.dtstart.weekday() {
|
||||||
|
chrono::Weekday::Mon => "MO",
|
||||||
|
chrono::Weekday::Tue => "TU",
|
||||||
|
chrono::Weekday::Wed => "WE",
|
||||||
|
chrono::Weekday::Thu => "TH",
|
||||||
|
chrono::Weekday::Fri => "FR",
|
||||||
|
chrono::Weekday::Sat => "SA",
|
||||||
|
chrono::Weekday::Sun => "SU",
|
||||||
|
};
|
||||||
|
all_days.insert(weekday.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first event as the base (we already know they have same time/duration)
|
||||||
|
if base_event.is_none() {
|
||||||
|
base_event = Some(event.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_days.is_empty() || base_event.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create consolidated RRULE
|
||||||
|
let mut base = base_event.unwrap();
|
||||||
|
let days_list: Vec<_> = all_days.into_iter().collect();
|
||||||
|
let byday_str = days_list.join(",");
|
||||||
|
|
||||||
|
// Build new RRULE with consolidated BYDAY
|
||||||
|
let new_rrule = if let Some(existing_rrule) = &base.rrule {
|
||||||
|
// Remove existing BYDAY and add our consolidated one
|
||||||
|
let parts: Vec<_> = existing_rrule.split(';')
|
||||||
|
.filter(|part| !part.starts_with("BYDAY="))
|
||||||
|
.collect();
|
||||||
|
format!("{};BYDAY={}", parts.join(";"), byday_str)
|
||||||
|
} else {
|
||||||
|
format!("FREQ=WEEKLY;BYDAY={}", byday_str)
|
||||||
|
};
|
||||||
|
|
||||||
|
base.rrule = Some(new_rrule);
|
||||||
|
|
||||||
|
println!("🔗 Consolidated weekly pattern: BYDAY={}", byday_str);
|
||||||
|
Some(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a single event would be generated by a recurring event's RRULE
|
||||||
|
fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VEvent) -> bool {
|
||||||
|
let Some(rrule) = &recurring_event.rrule else {
|
||||||
|
return false; // No RRULE to check against
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse basic RRULE patterns
|
||||||
|
if rrule.contains("FREQ=DAILY") {
|
||||||
|
// Daily recurrence
|
||||||
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||||
|
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
|
||||||
|
|
||||||
|
if days_diff >= 0 && days_diff % interval as i64 == 0 {
|
||||||
|
// Check if times match (allowing for timezone differences within same day)
|
||||||
|
let recurring_time = recurring_event.dtstart.time();
|
||||||
|
let single_time = single_event.dtstart.time();
|
||||||
|
return recurring_time == single_time;
|
||||||
|
}
|
||||||
|
} else if rrule.contains("FREQ=WEEKLY") {
|
||||||
|
// Weekly recurrence
|
||||||
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||||
|
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
|
||||||
|
let recurring_weekday = recurring_event.dtstart.weekday();
|
||||||
|
let single_weekday = single_event.dtstart.weekday();
|
||||||
|
let recurring_time = recurring_event.dtstart.time();
|
||||||
|
let single_time = single_event.dtstart.time();
|
||||||
|
|
||||||
|
if recurring_weekday == single_weekday && recurring_time == single_time && days_diff >= 0 {
|
||||||
|
// Calculate how many weeks apart they are
|
||||||
|
let weeks_diff = days_diff / 7;
|
||||||
|
// Check if this falls on an interval boundary
|
||||||
|
return weeks_diff % interval as i64 == 0;
|
||||||
|
}
|
||||||
|
} else if rrule.contains("FREQ=MONTHLY") {
|
||||||
|
// Monthly recurrence - simplified check
|
||||||
|
let months_diff = (single_event.dtstart.year() - recurring_event.dtstart.year()) * 12
|
||||||
|
+ (single_event.dtstart.month() as i32 - recurring_event.dtstart.month() as i32);
|
||||||
|
|
||||||
|
if months_diff >= 0 {
|
||||||
|
let interval = extract_interval_from_rrule(rrule).unwrap_or(1) as i32;
|
||||||
|
if months_diff % interval == 0 {
|
||||||
|
// Same day of month and time
|
||||||
|
return recurring_event.dtstart.day() == single_event.dtstart.day()
|
||||||
|
&& recurring_event.dtstart.time() == single_event.dtstart.time();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract INTERVAL value from RRULE string, defaulting to 1 if not found
|
||||||
|
fn extract_interval_from_rrule(rrule: &str) -> Option<u32> {
|
||||||
|
for part in rrule.split(';') {
|
||||||
|
if part.starts_with("INTERVAL=") {
|
||||||
|
return part.strip_prefix("INTERVAL=")
|
||||||
|
.and_then(|s| s.parse().ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(1) // Default interval is 1 if not specified
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate a completeness score for an event based on how many optional fields are filled
|
||||||
|
fn event_completeness_score(event: &VEvent) -> u32 {
|
||||||
|
let mut score = 0;
|
||||||
|
|
||||||
|
if event.summary.is_some() { score += 1; }
|
||||||
|
if event.description.is_some() { score += 1; }
|
||||||
|
if event.location.is_some() { score += 1; }
|
||||||
|
if event.dtend.is_some() { score += 1; }
|
||||||
|
if event.rrule.is_some() { score += 1; }
|
||||||
|
if !event.categories.is_empty() { score += 1; }
|
||||||
|
if !event.alarms.is_empty() { score += 1; }
|
||||||
|
if event.organizer.is_some() { score += 1; }
|
||||||
|
if !event.attendees.is_empty() { score += 1; }
|
||||||
|
|
||||||
|
score
|
||||||
|
}
|
||||||
15
backend/src/handlers/mod.rs
Normal file
15
backend/src/handlers/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod calendar;
|
||||||
|
pub mod events;
|
||||||
|
pub mod external_calendars;
|
||||||
|
pub mod ics_fetcher;
|
||||||
|
pub mod preferences;
|
||||||
|
pub mod series;
|
||||||
|
|
||||||
|
pub use auth::*;
|
||||||
|
pub use calendar::*;
|
||||||
|
pub use events::*;
|
||||||
|
pub use external_calendars::*;
|
||||||
|
pub use ics_fetcher::*;
|
||||||
|
pub use preferences::*;
|
||||||
|
pub use series::*;
|
||||||
@@ -40,6 +40,7 @@ pub async fn get_preferences(
|
|||||||
calendar_theme: preferences.calendar_theme,
|
calendar_theme: preferences.calendar_theme,
|
||||||
calendar_style: preferences.calendar_style,
|
calendar_style: preferences.calendar_style,
|
||||||
calendar_colors: preferences.calendar_colors,
|
calendar_colors: preferences.calendar_colors,
|
||||||
|
last_used_calendar: preferences.last_used_calendar,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +86,9 @@ pub async fn update_preferences(
|
|||||||
if request.calendar_colors.is_some() {
|
if request.calendar_colors.is_some() {
|
||||||
preferences.calendar_colors = request.calendar_colors;
|
preferences.calendar_colors = request.calendar_colors;
|
||||||
}
|
}
|
||||||
|
if request.last_used_calendar.is_some() {
|
||||||
|
preferences.last_used_calendar = request.last_used_calendar;
|
||||||
|
}
|
||||||
|
|
||||||
prefs_repo
|
prefs_repo
|
||||||
.update(&preferences)
|
.update(&preferences)
|
||||||
@@ -100,6 +104,7 @@ pub async fn update_preferences(
|
|||||||
calendar_theme: preferences.calendar_theme,
|
calendar_theme: preferences.calendar_theme,
|
||||||
calendar_style: preferences.calendar_style,
|
calendar_style: preferences.calendar_style,
|
||||||
calendar_colors: preferences.calendar_colors,
|
calendar_colors: preferences.calendar_colors,
|
||||||
|
last_used_calendar: preferences.last_used_calendar,
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,76 +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()))?;
|
|
||||||
|
|
||||||
(
|
|
||||||
chrono::Utc.from_utc_datetime(&start_dt),
|
|
||||||
chrono::Utc.from_utc_datetime(&end_dt),
|
|
||||||
)
|
|
||||||
} 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)
|
|
||||||
};
|
|
||||||
|
|
||||||
(
|
|
||||||
chrono::Utc.from_utc_datetime(&start_dt),
|
|
||||||
chrono::Utc.from_utc_datetime(&end_dt),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -246,9 +224,11 @@ pub async fn update_event_series(
|
|||||||
Json(request): Json<UpdateEventSeriesRequest>,
|
Json(request): Json<UpdateEventSeriesRequest>,
|
||||||
) -> Result<Json<UpdateEventSeriesResponse>, ApiError> {
|
) -> Result<Json<UpdateEventSeriesResponse>, ApiError> {
|
||||||
println!(
|
println!(
|
||||||
"🔄 Update event series request received: series_uid='{}', update_scope='{}'",
|
"🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}",
|
||||||
request.series_uid, request.update_scope
|
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)?;
|
||||||
@@ -364,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
|
||||||
@@ -381,8 +361,9 @@ pub async fn update_event_series(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (start_datetime, end_datetime) = if request.all_day {
|
let (start_datetime, end_datetime) = if request.all_day {
|
||||||
|
// For all-day events, use noon UTC to avoid timezone boundary issues
|
||||||
let start_dt = start_date
|
let start_dt = start_date
|
||||||
.and_hms_opt(0, 0, 0)
|
.and_hms_opt(12, 0, 0)
|
||||||
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
||||||
|
|
||||||
// For all-day events, also preserve the original date pattern
|
// For all-day events, also preserve the original date pattern
|
||||||
@@ -390,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 {
|
||||||
@@ -398,13 +379,11 @@ pub async fn update_event_series(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let end_dt = end_date
|
let end_dt = end_date
|
||||||
.and_hms_opt(23, 59, 59)
|
.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 local times directly
|
||||||
chrono::Utc.from_utc_datetime(&start_dt),
|
(start_dt, end_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(|_| {
|
||||||
@@ -435,13 +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 local times, so use them directly
|
||||||
chrono::Utc.from_utc_datetime(&start_dt),
|
(start_dt, end_dt)
|
||||||
chrono::Utc.from_utc_datetime(&end_dt),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle different update scopes
|
// Handle different update scopes
|
||||||
@@ -488,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)
|
||||||
@@ -688,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();
|
||||||
@@ -697,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 {
|
||||||
@@ -729,13 +705,41 @@ 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
|
||||||
|
|
||||||
// For simple updates (like drag operations), preserve the existing RRULE
|
// Update RRULE if recurrence parameters are provided
|
||||||
// For more complex updates, we might need to regenerate it, but for now keep it simple
|
if let Some(ref existing_rrule) = updated_event.rrule {
|
||||||
// updated_event.rrule remains unchanged from the clone
|
let mut new_rrule = existing_rrule.clone();
|
||||||
|
println!("🔄 Original RRULE: {}", existing_rrule);
|
||||||
|
|
||||||
|
// Update COUNT if provided
|
||||||
|
if let Some(count) = request.recurrence_count {
|
||||||
|
println!("🔄 Updating RRULE with new COUNT: {}", count);
|
||||||
|
// Remove old COUNT or UNTIL parameters
|
||||||
|
new_rrule = new_rrule.split(';')
|
||||||
|
.filter(|part| !part.starts_with("COUNT=") && !part.starts_with("UNTIL="))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";");
|
||||||
|
// Add new COUNT
|
||||||
|
new_rrule = format!("{};COUNT={}", new_rrule, count);
|
||||||
|
} else if let Some(ref end_date) = request.recurrence_end_date {
|
||||||
|
println!("🔄 Updating RRULE with new UNTIL: {}", end_date);
|
||||||
|
// Remove old COUNT or UNTIL parameters
|
||||||
|
new_rrule = new_rrule.split(';')
|
||||||
|
.filter(|part| !part.starts_with("COUNT=") && !part.starts_with("UNTIL="))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";");
|
||||||
|
// Add new UNTIL (convert YYYY-MM-DD to YYYYMMDD format)
|
||||||
|
let until_date = end_date.replace("-", "");
|
||||||
|
new_rrule = format!("{};UNTIL={}", new_rrule, until_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("🔄 Updated RRULE: {}", new_rrule);
|
||||||
|
updated_event.rrule = Some(new_rrule);
|
||||||
|
}
|
||||||
|
|
||||||
// Copy the updated event back to existing_event for the main handler
|
// Copy the updated event back to existing_event for the main handler
|
||||||
*existing_event = updated_event.clone();
|
*existing_event = updated_event.clone();
|
||||||
@@ -791,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> {
|
||||||
@@ -840,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 {
|
||||||
@@ -872,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!(
|
||||||
@@ -902,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,
|
||||||
@@ -928,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
|
||||||
@@ -954,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 {
|
||||||
@@ -986,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
|
||||||
@@ -1003,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)
|
||||||
@@ -1014,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -72,6 +72,12 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.route("/api/preferences", get(handlers::get_preferences))
|
.route("/api/preferences", get(handlers::get_preferences))
|
||||||
.route("/api/preferences", post(handlers::update_preferences))
|
.route("/api/preferences", post(handlers::update_preferences))
|
||||||
.route("/api/auth/logout", post(handlers::logout))
|
.route("/api/auth/logout", post(handlers::logout))
|
||||||
|
// External calendars endpoints
|
||||||
|
.route("/api/external-calendars", get(handlers::get_external_calendars))
|
||||||
|
.route("/api/external-calendars", post(handlers::create_external_calendar))
|
||||||
|
.route("/api/external-calendars/:id", post(handlers::update_external_calendar))
|
||||||
|
.route("/api/external-calendars/:id", delete(handlers::delete_external_calendar))
|
||||||
|
.route("/api/external-calendars/:id/events", get(handlers::fetch_external_calendar_events))
|
||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -30,6 +31,7 @@ pub struct UserPreferencesResponse {
|
|||||||
pub calendar_theme: Option<String>,
|
pub calendar_theme: Option<String>,
|
||||||
pub calendar_style: Option<String>,
|
pub calendar_style: Option<String>,
|
||||||
pub calendar_colors: Option<String>,
|
pub calendar_colors: Option<String>,
|
||||||
|
pub last_used_calendar: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -40,6 +42,7 @@ pub struct UpdatePreferencesRequest {
|
|||||||
pub calendar_theme: Option<String>,
|
pub calendar_theme: Option<String>,
|
||||||
pub calendar_style: Option<String>,
|
pub calendar_style: Option<String>,
|
||||||
pub calendar_colors: Option<String>,
|
pub calendar_colors: Option<String>,
|
||||||
|
pub last_used_calendar: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -54,6 +57,7 @@ pub struct CalendarInfo {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub color: String,
|
pub color: String,
|
||||||
|
pub is_visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -110,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)]
|
||||||
@@ -140,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
|
||||||
}
|
}
|
||||||
@@ -173,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)
|
||||||
@@ -182,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)]
|
||||||
@@ -210,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)
|
||||||
@@ -224,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
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
export BACKEND_API_URL="https://runway.rcjohnstone.com/api"
|
||||||
trunk build --release --config /home/connor/docs/projects/calendar/frontend/Trunk.toml
|
trunk build --release --config /home/connor/docs/projects/calendar/frontend/Trunk.toml
|
||||||
sudo rsync -azX --delete --info=progress2 -e 'ssh -T -q' --rsync-path='sudo rsync' /home/connor/docs/projects/calendar/frontend/dist connor@server.rcjohnstone.com:/home/connor/data/runway/
|
sudo rsync -azX --delete --info=progress2 -e 'ssh -T -q' --rsync-path='sudo rsync' /home/connor/docs/projects/calendar/frontend/dist connor@server.rcjohnstone.com:/home/connor/data/runway/
|
||||||
|
unset BACKEND_API_URL
|
||||||
|
|||||||
@@ -22,14 +22,36 @@ web-sys = { version = "0.3", features = [
|
|||||||
"Document",
|
"Document",
|
||||||
"Window",
|
"Window",
|
||||||
"Location",
|
"Location",
|
||||||
|
"Navigator",
|
||||||
|
"DomTokenList",
|
||||||
"Headers",
|
"Headers",
|
||||||
"Request",
|
"Request",
|
||||||
"RequestInit",
|
"RequestInit",
|
||||||
"RequestMode",
|
"RequestMode",
|
||||||
"Response",
|
"Response",
|
||||||
"CssStyleDeclaration",
|
"CssStyleDeclaration",
|
||||||
|
"MediaQueryList",
|
||||||
|
"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"
|
||||||
|
|
||||||
# HTTP client for CalDAV requests
|
# HTTP client for CalDAV requests
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
@@ -37,6 +59,7 @@ reqwest = { version = "0.11", features = ["json"] }
|
|||||||
ical = "0.7"
|
ical = "0.7"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
serde-wasm-bindgen = "0.6"
|
||||||
|
|
||||||
# Date and time handling
|
# Date and time handling
|
||||||
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
|
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
|
||||||
@@ -67,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"
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ dist = "dist"
|
|||||||
BACKEND_API_URL = "http://localhost:3000/api"
|
BACKEND_API_URL = "http://localhost:3000/api"
|
||||||
|
|
||||||
[watch]
|
[watch]
|
||||||
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "index.html"]
|
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "print-preview.css", "index.html"]
|
||||||
ignore = ["../backend/", "../target/"]
|
ignore = ["../backend/", "../target/"]
|
||||||
|
|
||||||
[serve]
|
[serve]
|
||||||
|
|||||||
@@ -6,15 +6,31 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<base data-trunk-public-url />
|
<base data-trunk-public-url />
|
||||||
<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="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" />
|
||||||
</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>
|
||||||
|
|||||||
1216
frontend/print-preview.css
Normal file
1216
frontend/print-preview.css
Normal file
File diff suppressed because it is too large
Load Diff
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('/');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
1168
frontend/src/app.rs
1168
frontend/src/app.rs
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,50 @@ impl AuthService {
|
|||||||
self.post_json("/auth/login", &request).await
|
self.post_json("/auth/login", &request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn verify_token(&self, token: &str) -> Result<bool, String> {
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("GET");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let url = format!("{}/auth/verify", self.base_url);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Network request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response cast failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if resp.ok() {
|
||||||
|
let text = JsFuture::from(
|
||||||
|
resp.text()
|
||||||
|
.map_err(|e| format!("Text extraction failed: {:?}", e))?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Text promise failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let text_string = text.as_string().ok_or("Response text is not a string")?;
|
||||||
|
|
||||||
|
// Parse the response to get the "valid" field
|
||||||
|
let response: serde_json::Value = serde_json::from_str(&text_string)
|
||||||
|
.map_err(|e| format!("JSON parsing failed: {}", e))?;
|
||||||
|
|
||||||
|
Ok(response.get("valid").and_then(|v| v.as_bool()).unwrap_or(false))
|
||||||
|
} else {
|
||||||
|
Ok(false) // Invalid token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper method for POST requests with JSON body
|
// Helper method for POST requests with JSON body
|
||||||
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use crate::components::{
|
use crate::components::{
|
||||||
CalendarHeader, CreateEventModalV2, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
|
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, PrintPreviewModal, ViewMode, WeekView,
|
||||||
};
|
};
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
use crate::services::{calendar_service::UserInfo, 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;
|
||||||
@@ -14,6 +14,10 @@ pub struct CalendarProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
|
pub external_calendar_events: Vec<VEvent>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
#[prop_or_default]
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||||
@@ -28,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>,
|
||||||
)>,
|
)>,
|
||||||
@@ -101,10 +105,15 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
let loading = loading.clone();
|
let loading = loading.clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
let current_date = current_date.clone();
|
let current_date = current_date.clone();
|
||||||
|
let external_events = props.external_calendar_events.clone(); // Clone before the effect
|
||||||
|
let view = props.view.clone(); // Clone before the effect
|
||||||
|
|
||||||
use_effect_with((*current_date, props.view.clone()), move |(date, _view)| {
|
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 user_info = user_info.clone(); // Clone user_info to avoid lifetime issues
|
||||||
|
|
||||||
if let Some(token) = auth_token {
|
if let Some(token) = auth_token {
|
||||||
let events = events.clone();
|
let events = events.clone();
|
||||||
@@ -128,20 +137,101 @@ 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) => {
|
||||||
let grouped_events = CalendarService::group_events_by_date(vevents);
|
// Filter CalDAV events based on calendar visibility
|
||||||
|
let mut filtered_events = if let Some(user_info) = user_info.as_ref() {
|
||||||
|
vevents.into_iter()
|
||||||
|
.filter(|event| {
|
||||||
|
if let Some(calendar_path) = event.calendar_path.as_ref() {
|
||||||
|
// Find the calendar info for this event
|
||||||
|
user_info.calendars.iter()
|
||||||
|
.find(|cal| &cal.path == calendar_path)
|
||||||
|
.map(|cal| cal.is_visible)
|
||||||
|
.unwrap_or(true) // Default to visible if not found
|
||||||
|
} else {
|
||||||
|
true // Show events without calendar path
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
vevents // Show all events if no user info
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mark external events as external by adding a special category
|
||||||
|
let marked_external_events: Vec<VEvent> = external_events
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut event| {
|
||||||
|
// Add a special category to identify external events
|
||||||
|
event.categories.push("__EXTERNAL_CALENDAR__".to_string());
|
||||||
|
event
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
filtered_events.extend(marked_external_events);
|
||||||
|
|
||||||
|
let grouped_events = CalendarService::group_events_by_date(filtered_events);
|
||||||
events.set(grouped_events);
|
events.set(grouped_events);
|
||||||
loading.set(false);
|
loading.set(false);
|
||||||
}
|
}
|
||||||
@@ -350,6 +440,15 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle print calendar preview
|
||||||
|
let show_print_preview = use_state(|| false);
|
||||||
|
let on_print = {
|
||||||
|
let show_print_preview = show_print_preview.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
show_print_preview.set(true);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
// Handle drag-to-create event
|
// Handle drag-to-create event
|
||||||
let on_create_event = {
|
let on_create_event = {
|
||||||
let show_create_modal = show_create_modal.clone();
|
let show_create_modal = show_create_modal.clone();
|
||||||
@@ -389,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>,
|
||||||
)| {
|
)| {
|
||||||
@@ -418,6 +517,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
on_today={on_today}
|
on_today={on_today}
|
||||||
time_increment={Some(*time_increment)}
|
time_increment={Some(*time_increment)}
|
||||||
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
on_time_increment_toggle={Some(on_time_increment_toggle)}
|
||||||
|
on_print={Some(on_print)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -452,6 +552,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
on_event_click={on_event_click.clone()}
|
on_event_click={on_event_click.clone()}
|
||||||
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
selected_date={Some(*selected_date)}
|
selected_date={Some(*selected_date)}
|
||||||
@@ -467,6 +568,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
on_event_click={on_event_click.clone()}
|
on_event_click={on_event_click.clone()}
|
||||||
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
refreshing_event_uid={(*refreshing_event_uid).clone()}
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
on_create_event={Some(on_create_event)}
|
on_create_event={Some(on_create_event)}
|
||||||
@@ -492,7 +594,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
// Create event modal
|
// Create event modal
|
||||||
<CreateEventModalV2
|
<CreateEventModal
|
||||||
is_open={*show_create_modal}
|
is_open={*show_create_modal}
|
||||||
selected_date={create_event_data.as_ref().map(|(date, _, _)| *date)}
|
selected_date={create_event_data.as_ref().map(|(date, _, _)| *date)}
|
||||||
event_to_edit={None}
|
event_to_edit={None}
|
||||||
@@ -522,6 +624,47 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
// Print preview modal
|
||||||
|
{
|
||||||
|
if *show_print_preview {
|
||||||
|
html! {
|
||||||
|
<PrintPreviewModal
|
||||||
|
on_close={{
|
||||||
|
let show_print_preview = show_print_preview.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
show_print_preview.set(false);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
view_mode={props.view.clone()}
|
||||||
|
current_date={*current_date}
|
||||||
|
selected_date={*selected_date}
|
||||||
|
events={(*events).clone()}
|
||||||
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
|
time_increment={*time_increment}
|
||||||
|
today={today}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ pub struct CalendarHeaderProps {
|
|||||||
pub time_increment: Option<u32>,
|
pub time_increment: Option<u32>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
|
pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_print: Option<Callback<MouseEvent>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(CalendarHeader)]
|
#[function_component(CalendarHeader)]
|
||||||
@@ -39,6 +41,17 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
if let Some(print_callback) = &props.on_print {
|
||||||
|
html! {
|
||||||
|
<button class="print-button" onclick={print_callback.clone()} title="Print Calendar">
|
||||||
|
<i class="fas fa-print"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<h2 class="month-year">{title}</h2>
|
<h2 class="month-year">{title}</h2>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
#[function_component(CalendarListItem)]
|
#[function_component(CalendarListItem)]
|
||||||
@@ -32,44 +34,71 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_visibility_toggle = {
|
||||||
|
let cal_path = props.calendar.path.clone();
|
||||||
|
let on_visibility_toggle = props.on_visibility_toggle.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_visibility_toggle.emit(cal_path.clone());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
|
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
|
||||||
<span class="calendar-color"
|
<div class="calendar-info">
|
||||||
style={format!("background-color: {}", props.calendar.color)}
|
<input
|
||||||
onclick={on_color_click}>
|
type="checkbox"
|
||||||
{
|
checked={props.calendar.is_visible}
|
||||||
if props.color_picker_open {
|
onchange={on_visibility_toggle}
|
||||||
html! {
|
/>
|
||||||
<div class="color-picker">
|
<span class="calendar-color"
|
||||||
{
|
style={format!("background-color: {}", props.calendar.color)}
|
||||||
props.available_colors.iter().map(|color| {
|
onclick={on_color_click}>
|
||||||
let color_str = color.clone();
|
{
|
||||||
let cal_path = props.calendar.path.clone();
|
if props.color_picker_open {
|
||||||
let on_color_change = props.on_color_change.clone();
|
html! {
|
||||||
|
<div class="color-picker-dropdown">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().map(|color| {
|
||||||
|
let color_str = color.clone();
|
||||||
|
let cal_path = props.calendar.path.clone();
|
||||||
|
let on_color_change = props.on_color_change.clone();
|
||||||
|
|
||||||
let on_color_select = Callback::from(move |_: MouseEvent| {
|
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||||
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
on_color_change.emit((cal_path.clone(), color_str.clone()));
|
||||||
});
|
});
|
||||||
|
|
||||||
let is_selected = props.calendar.color == *color;
|
let on_color_right_click = {
|
||||||
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
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()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
let is_selected = props.calendar.color == *color;
|
||||||
<div class={class_name}
|
let class_name = if is_selected { "color-option selected" } else { "color-option" };
|
||||||
style={format!("background-color: {}", color)}
|
|
||||||
onclick={on_color_select}>
|
html! {
|
||||||
</div>
|
<div class={class_name}
|
||||||
}
|
style={format!("background-color: {}", color)}
|
||||||
}).collect::<Html>()
|
onclick={on_color_select}
|
||||||
}
|
oncontextmenu={on_color_right_click}>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
html! {}
|
|
||||||
}
|
}
|
||||||
}
|
</span>
|
||||||
</span>
|
<span class="calendar-name">{&props.calendar.display_name}</span>
|
||||||
<span class="calendar-name">{&props.calendar.display_name}</span>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
449
frontend/src/components/calendar_management_modal.rs
Normal file
449
frontend/src/components/calendar_management_modal.rs
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use crate::services::calendar_service::CalendarService;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum CalendarTab {
|
||||||
|
Create,
|
||||||
|
External,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CalendarManagementModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_create_calendar: Callback<(String, Option<String>, Option<String>)>, // name, description, color
|
||||||
|
pub on_external_success: Callback<i32>, // Pass the newly created external calendar ID
|
||||||
|
pub available_colors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(CalendarManagementModal)]
|
||||||
|
pub fn calendar_management_modal(props: &CalendarManagementModalProps) -> Html {
|
||||||
|
let active_tab = use_state(|| CalendarTab::Create);
|
||||||
|
|
||||||
|
// Create Calendar state
|
||||||
|
let calendar_name = use_state(|| String::new());
|
||||||
|
let description = use_state(|| String::new());
|
||||||
|
let selected_color = use_state(|| None::<String>);
|
||||||
|
let create_error_message = use_state(|| None::<String>);
|
||||||
|
let is_creating = use_state(|| false);
|
||||||
|
|
||||||
|
// External Calendar state
|
||||||
|
let external_name = use_state(|| String::new());
|
||||||
|
let external_url = use_state(|| String::new());
|
||||||
|
let external_selected_color = use_state(|| Some("#4285f4".to_string()));
|
||||||
|
let external_is_loading = use_state(|| false);
|
||||||
|
let external_error_message = use_state(|| None::<String>);
|
||||||
|
|
||||||
|
// Reset state when modal opens
|
||||||
|
use_effect_with(props.is_open, {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
let description = description.clone();
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let create_error_message = create_error_message.clone();
|
||||||
|
let is_creating = is_creating.clone();
|
||||||
|
let external_name = external_name.clone();
|
||||||
|
let external_url = external_url.clone();
|
||||||
|
let external_is_loading = external_is_loading.clone();
|
||||||
|
let external_error_message = external_error_message.clone();
|
||||||
|
let external_selected_color = external_selected_color.clone();
|
||||||
|
let active_tab = active_tab.clone();
|
||||||
|
|
||||||
|
move |is_open| {
|
||||||
|
if *is_open {
|
||||||
|
// Reset all state when modal opens
|
||||||
|
calendar_name.set(String::new());
|
||||||
|
description.set(String::new());
|
||||||
|
selected_color.set(None);
|
||||||
|
create_error_message.set(None);
|
||||||
|
is_creating.set(false);
|
||||||
|
external_name.set(String::new());
|
||||||
|
external_url.set(String::new());
|
||||||
|
external_is_loading.set(false);
|
||||||
|
external_error_message.set(None);
|
||||||
|
external_selected_color.set(Some("#4285f4".to_string()));
|
||||||
|
active_tab.set(CalendarTab::Create);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let on_tab_click = {
|
||||||
|
let active_tab = active_tab.clone();
|
||||||
|
Callback::from(move |tab: CalendarTab| {
|
||||||
|
active_tab.set(tab);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
let element = target.dyn_into::<web_sys::Element>().unwrap();
|
||||||
|
if element.class_list().contains("modal-backdrop") {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Calendar handlers
|
||||||
|
let on_name_change = {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
calendar_name.set(input.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_description_change = {
|
||||||
|
let description = description.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
|
||||||
|
description.set(input.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_color_select = {
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
Callback::from(move |color: String| {
|
||||||
|
selected_color.set(Some(color));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_external_color_select = {
|
||||||
|
let external_selected_color = external_selected_color.clone();
|
||||||
|
Callback::from(move |color: String| {
|
||||||
|
external_selected_color.set(Some(color));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_create_submit = {
|
||||||
|
let calendar_name = calendar_name.clone();
|
||||||
|
let description = description.clone();
|
||||||
|
let selected_color = selected_color.clone();
|
||||||
|
let create_error_message = create_error_message.clone();
|
||||||
|
let is_creating = is_creating.clone();
|
||||||
|
let on_create_calendar = props.on_create_calendar.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let name = (*calendar_name).trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
create_error_message.set(Some("Calendar name is required".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_creating.set(true);
|
||||||
|
create_error_message.set(None);
|
||||||
|
|
||||||
|
let desc = if description.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((*description).clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
on_create_calendar.emit((name.to_string(), desc, (*selected_color).clone()));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// External Calendar handlers
|
||||||
|
let on_external_submit = {
|
||||||
|
let external_name = external_name.clone();
|
||||||
|
let external_url = external_url.clone();
|
||||||
|
let external_selected_color = external_selected_color.clone();
|
||||||
|
let external_is_loading = external_is_loading.clone();
|
||||||
|
let external_error_message = external_error_message.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
let on_external_success = props.on_external_success.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let name = (*external_name).trim().to_string();
|
||||||
|
let url = (*external_url).trim().to_string();
|
||||||
|
let color = (*external_selected_color).clone().unwrap_or_else(|| "#4285f4".to_string());
|
||||||
|
|
||||||
|
// Debug logging to understand the issue
|
||||||
|
web_sys::console::log_1(&format!("External calendar form submission - Name: '{}', URL: '{}'", name, url).into());
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
external_error_message.set(Some("Calendar name is required".to_string()));
|
||||||
|
web_sys::console::log_1(&"Validation failed: empty name".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if url.is_empty() {
|
||||||
|
external_error_message.set(Some("Calendar URL is required".to_string()));
|
||||||
|
web_sys::console::log_1(&"Validation failed: empty URL".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic URL validation
|
||||||
|
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||||
|
external_error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
external_is_loading.set(true);
|
||||||
|
external_error_message.set(None);
|
||||||
|
|
||||||
|
let external_is_loading = external_is_loading.clone();
|
||||||
|
let external_error_message = external_error_message.clone();
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
let on_external_success = on_external_success.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _calendar_service = CalendarService::new();
|
||||||
|
|
||||||
|
match CalendarService::create_external_calendar(&name, &url, &color).await {
|
||||||
|
Ok(calendar) => {
|
||||||
|
external_is_loading.set(false);
|
||||||
|
on_close.emit(());
|
||||||
|
on_external_success.emit(calendar.id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
external_is_loading.set(false);
|
||||||
|
external_error_message.set(Some(format!("Failed to add calendar: {}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// External input change handlers
|
||||||
|
let on_external_name_change = {
|
||||||
|
let external_name = external_name.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
external_name.set(input.value());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_external_url_change = {
|
||||||
|
let external_url = external_url.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
|
||||||
|
external_url.set(input.value());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
||||||
|
<div class="modal-content calendar-management-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{"Add Calendar"}</h2>
|
||||||
|
<button class="modal-close" onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}>{"×"}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-management-tabs">
|
||||||
|
<button
|
||||||
|
class={if *active_tab == CalendarTab::Create { "tab-button active" } else { "tab-button" }}
|
||||||
|
onclick={
|
||||||
|
let on_tab_click = on_tab_click.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::Create))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{"Create Calendar"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={if *active_tab == CalendarTab::External { "tab-button active" } else { "tab-button" }}
|
||||||
|
onclick={
|
||||||
|
let on_tab_click = on_tab_click.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::External))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{"Add External"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
{
|
||||||
|
match *active_tab {
|
||||||
|
CalendarTab::Create => html! {
|
||||||
|
<form onsubmit={on_create_submit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-name">{"Calendar Name"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="calendar-name"
|
||||||
|
value={(*calendar_name).clone()}
|
||||||
|
oninput={on_name_change}
|
||||||
|
placeholder="Enter calendar name"
|
||||||
|
disabled={*is_creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="calendar-description">{"Description (optional)"}</label>
|
||||||
|
<textarea
|
||||||
|
id="calendar-description"
|
||||||
|
value={(*description).clone()}
|
||||||
|
oninput={on_description_change}
|
||||||
|
placeholder="Enter calendar description"
|
||||||
|
disabled={*is_creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Calendar Color"}</label>
|
||||||
|
<div class="color-picker">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().map(|color| {
|
||||||
|
let is_selected = selected_color.as_ref() == Some(color);
|
||||||
|
let color_clone = color.clone();
|
||||||
|
let on_color_select = on_color_select.clone();
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
key={color.clone()}
|
||||||
|
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={Callback::from(move |_: MouseEvent| {
|
||||||
|
on_color_select.emit(color_clone.clone());
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
if let Some(ref error) = *create_error_message {
|
||||||
|
html! {
|
||||||
|
<div class="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cancel-button"
|
||||||
|
onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}
|
||||||
|
disabled={*is_creating}
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="create-button"
|
||||||
|
disabled={*is_creating}
|
||||||
|
>
|
||||||
|
{if *is_creating { "Creating..." } else { "Create Calendar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
},
|
||||||
|
CalendarTab::External => html! {
|
||||||
|
<form onsubmit={on_external_submit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-name">{"Calendar Name"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="external-name"
|
||||||
|
value={(*external_name).clone()}
|
||||||
|
onchange={on_external_name_change}
|
||||||
|
placeholder="Enter calendar name"
|
||||||
|
disabled={*external_is_loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-url">{"Calendar URL"}</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="external-url"
|
||||||
|
value={(*external_url).clone()}
|
||||||
|
onchange={on_external_url_change}
|
||||||
|
placeholder="https://example.com/calendar.ics"
|
||||||
|
disabled={*external_is_loading}
|
||||||
|
/>
|
||||||
|
<small class="help-text">
|
||||||
|
{"Enter the ICS/CalDAV URL for your external calendar"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{"Calendar Color"}</label>
|
||||||
|
<div class="color-picker">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().map(|color| {
|
||||||
|
let is_selected = external_selected_color.as_ref() == Some(color);
|
||||||
|
let color_clone = color.clone();
|
||||||
|
let on_external_color_select = on_external_color_select.clone();
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
key={color.clone()}
|
||||||
|
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={Callback::from(move |_: MouseEvent| {
|
||||||
|
on_external_color_select.emit(color_clone.clone());
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
if let Some(ref error) = *external_error_message {
|
||||||
|
html! {
|
||||||
|
<div class="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cancel-button"
|
||||||
|
onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}
|
||||||
|
disabled={*external_is_loading}
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="create-button"
|
||||||
|
disabled={*external_is_loading}
|
||||||
|
>
|
||||||
|
{if *external_is_loading { "Adding..." } else { "Add Calendar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,210 +0,0 @@
|
|||||||
use crate::components::event_form::*;
|
|
||||||
use crate::components::create_event_modal::{EventCreationData}; // Use the existing types
|
|
||||||
use crate::components::{EditAction};
|
|
||||||
use crate::models::ical::VEvent;
|
|
||||||
use crate::services::calendar_service::CalendarInfo;
|
|
||||||
use yew::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
pub struct CreateEventModalProps {
|
|
||||||
pub is_open: bool,
|
|
||||||
pub on_close: Callback<()>,
|
|
||||||
pub on_create: Callback<EventCreationData>,
|
|
||||||
pub available_calendars: Vec<CalendarInfo>,
|
|
||||||
pub selected_date: Option<chrono::NaiveDate>,
|
|
||||||
pub initial_start_time: Option<chrono::NaiveTime>,
|
|
||||||
pub initial_end_time: Option<chrono::NaiveTime>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub event_to_edit: Option<VEvent>,
|
|
||||||
#[prop_or_default]
|
|
||||||
pub edit_scope: Option<EditAction>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component(CreateEventModalV2)]
|
|
||||||
pub fn create_event_modal_v2(props: &CreateEventModalProps) -> Html {
|
|
||||||
let active_tab = use_state(|| ModalTab::default());
|
|
||||||
let event_data = use_state(|| EventCreationData::default());
|
|
||||||
|
|
||||||
// Initialize data when modal opens
|
|
||||||
{
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
let is_open = props.is_open;
|
|
||||||
let event_to_edit = props.event_to_edit.clone();
|
|
||||||
let selected_date = props.selected_date;
|
|
||||||
let initial_start_time = props.initial_start_time;
|
|
||||||
let initial_end_time = props.initial_end_time;
|
|
||||||
let edit_scope = props.edit_scope.clone();
|
|
||||||
let available_calendars = props.available_calendars.clone();
|
|
||||||
|
|
||||||
use_effect_with(is_open, move |&is_open| {
|
|
||||||
if is_open {
|
|
||||||
let mut data = if let Some(_event) = &event_to_edit {
|
|
||||||
// TODO: Convert VEvent to EventCreationData
|
|
||||||
EventCreationData::default()
|
|
||||||
} else if let Some(date) = selected_date {
|
|
||||||
let mut data = EventCreationData::default();
|
|
||||||
data.start_date = date;
|
|
||||||
data.end_date = date;
|
|
||||||
if let Some(start_time) = initial_start_time {
|
|
||||||
data.start_time = start_time;
|
|
||||||
}
|
|
||||||
if let Some(end_time) = initial_end_time {
|
|
||||||
data.end_time = end_time;
|
|
||||||
}
|
|
||||||
data
|
|
||||||
} else {
|
|
||||||
EventCreationData::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set default calendar
|
|
||||||
if data.selected_calendar.is_none() && !available_calendars.is_empty() {
|
|
||||||
data.selected_calendar = Some(available_calendars[0].path.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set edit scope if provided
|
|
||||||
if let Some(scope) = &edit_scope {
|
|
||||||
data.edit_scope = Some(scope.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
event_data.set(data);
|
|
||||||
}
|
|
||||||
|| ()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if !props.is_open {
|
|
||||||
return html! {};
|
|
||||||
}
|
|
||||||
|
|
||||||
let on_backdrop_click = {
|
|
||||||
let on_close = props.on_close.clone();
|
|
||||||
Callback::from(move |e: MouseEvent| {
|
|
||||||
if e.target() == e.current_target() {
|
|
||||||
on_close.emit(());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let switch_to_tab = {
|
|
||||||
let active_tab = active_tab.clone();
|
|
||||||
Callback::from(move |tab: ModalTab| {
|
|
||||||
active_tab.set(tab);
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_save = {
|
|
||||||
let event_data = event_data.clone();
|
|
||||||
let on_create = props.on_create.clone();
|
|
||||||
Callback::from(move |_: MouseEvent| {
|
|
||||||
on_create.emit((*event_data).clone());
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_close = props.on_close.clone();
|
|
||||||
let on_close_header = on_close.clone();
|
|
||||||
|
|
||||||
let tab_props = TabProps {
|
|
||||||
data: event_data.clone(),
|
|
||||||
available_calendars: props.available_calendars.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div class="modal-backdrop" onclick={on_backdrop_click}>
|
|
||||||
<div class="modal-content create-event-modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>
|
|
||||||
{if props.event_to_edit.is_some() { "Edit Event" } else { "Create Event" }}
|
|
||||||
</h3>
|
|
||||||
<button class="modal-close" onclick={Callback::from(move |_| on_close_header.emit(()))}>
|
|
||||||
{"×"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-tabs">
|
|
||||||
<div class="tab-navigation">
|
|
||||||
<button
|
|
||||||
class={if *active_tab == ModalTab::BasicDetails { "tab-button active" } else { "tab-button" }}
|
|
||||||
onclick={{
|
|
||||||
let switch_to_tab = switch_to_tab.clone();
|
|
||||||
Callback::from(move |_| switch_to_tab.emit(ModalTab::BasicDetails))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{"Basic"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class={if *active_tab == ModalTab::Advanced { "tab-button active" } else { "tab-button" }}
|
|
||||||
onclick={{
|
|
||||||
let switch_to_tab = switch_to_tab.clone();
|
|
||||||
Callback::from(move |_| switch_to_tab.emit(ModalTab::Advanced))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{"Advanced"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class={if *active_tab == ModalTab::People { "tab-button active" } else { "tab-button" }}
|
|
||||||
onclick={{
|
|
||||||
let switch_to_tab = switch_to_tab.clone();
|
|
||||||
Callback::from(move |_| switch_to_tab.emit(ModalTab::People))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{"People"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class={if *active_tab == ModalTab::Categories { "tab-button active" } else { "tab-button" }}
|
|
||||||
onclick={{
|
|
||||||
let switch_to_tab = switch_to_tab.clone();
|
|
||||||
Callback::from(move |_| switch_to_tab.emit(ModalTab::Categories))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{"Categories"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class={if *active_tab == ModalTab::Location { "tab-button active" } else { "tab-button" }}
|
|
||||||
onclick={{
|
|
||||||
let switch_to_tab = switch_to_tab.clone();
|
|
||||||
Callback::from(move |_| switch_to_tab.emit(ModalTab::Location))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{"Location"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class={if *active_tab == ModalTab::Reminders { "tab-button active" } else { "tab-button" }}
|
|
||||||
onclick={{
|
|
||||||
let switch_to_tab = switch_to_tab.clone();
|
|
||||||
Callback::from(move |_| switch_to_tab.emit(ModalTab::Reminders))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{"Reminders"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="tab-content">
|
|
||||||
{
|
|
||||||
match *active_tab {
|
|
||||||
ModalTab::BasicDetails => html! { <BasicDetailsTab ..tab_props /> },
|
|
||||||
ModalTab::Advanced => html! { <AdvancedTab ..tab_props /> },
|
|
||||||
ModalTab::People => html! { <PeopleTab ..tab_props /> },
|
|
||||||
ModalTab::Categories => html! { <CategoriesTab ..tab_props /> },
|
|
||||||
ModalTab::Location => html! { <LocationTab ..tab_props /> },
|
|
||||||
ModalTab::Reminders => html! { <RemindersTab ..tab_props /> },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="btn btn-secondary" onclick={Callback::from(move |_| on_close.emit(()))}>
|
|
||||||
{"Cancel"}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-primary" onclick={on_save}>
|
|
||||||
{if props.event_to_edit.is_some() { "Update Event" } else { "Create Event" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,9 @@ pub struct EventContextMenuProps {
|
|||||||
pub event: Option<VEvent>,
|
pub event: Option<VEvent>,
|
||||||
pub on_edit: Callback<EditAction>,
|
pub on_edit: Callback<EditAction>,
|
||||||
pub on_delete: Callback<DeleteAction>,
|
pub on_delete: Callback<DeleteAction>,
|
||||||
|
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)]
|
||||||
@@ -90,6 +92,14 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|event| event.rrule.is_some())
|
.map(|event| event.rrule.is_some())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Check if the event is from an external calendar (read-only)
|
||||||
|
let is_external = props
|
||||||
|
.event
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|event| event.calendar_path.as_ref())
|
||||||
|
.map(|path| path.starts_with("external_"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
let create_edit_callback = |action: EditAction| {
|
let create_edit_callback = |action: EditAction| {
|
||||||
let on_edit = props.on_edit.clone();
|
let on_edit = props.on_edit.clone();
|
||||||
@@ -100,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();
|
||||||
@@ -109,6 +131,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let create_view_details_callback = {
|
||||||
|
let on_view_details = props.on_view_details.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
let event = props.event.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
if let Some(event) = &event {
|
||||||
|
on_view_details.emit(event.clone());
|
||||||
|
}
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
ref={menu_ref}
|
ref={menu_ref}
|
||||||
@@ -116,7 +150,15 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
if is_recurring {
|
if is_external {
|
||||||
|
// External calendar events are read-only - only show "View Details"
|
||||||
|
html! {
|
||||||
|
<div class="context-menu-item" onclick={create_view_details_callback}>
|
||||||
|
{"View Event Details"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else if is_recurring {
|
||||||
|
// Regular recurring events - show edit options
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
|
||||||
@@ -131,34 +173,41 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
if is_recurring {
|
if !is_external {
|
||||||
html! {
|
// Only show delete options for non-external events
|
||||||
<>
|
if is_recurring {
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||||
|
{"Delete This Event"}
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
|
||||||
|
{"Delete Following Events"}
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
|
||||||
|
{"Delete Entire Series"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
||||||
{"Delete This Event"}
|
{"Delete Event"}
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
|
}
|
||||||
{"Delete Following Events"}
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
|
|
||||||
{"Delete Entire Series"}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {
|
// No delete options for external events
|
||||||
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
|
html! {}
|
||||||
{"Delete Event"}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::types::*;
|
use super::types::*;
|
||||||
use crate::components::create_event_modal::{EventStatus, EventClass};
|
// Types are already imported from super::types::*
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
use web_sys::{HtmlInputElement, HtmlSelectElement};
|
use web_sys::HtmlSelectElement;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
#[function_component(AdvancedTab)]
|
#[function_component(AdvancedTab)]
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::types::*;
|
use super::types::*;
|
||||||
use crate::components::create_event_modal::{EventStatus, EventClass, RecurrenceType, ReminderType};
|
// Types are already imported from super::types::*
|
||||||
use chrono::{Datelike, NaiveDate};
|
use chrono::{Datelike, NaiveDate};
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
|
use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
|
||||||
@@ -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};
|
||||||
use crate::components::create_event_modal::ReminderType;
|
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,8 +1,7 @@
|
|||||||
use crate::models::ical::VEvent;
|
|
||||||
use crate::services::calendar_service::CalendarInfo;
|
use crate::services::calendar_service::CalendarInfo;
|
||||||
use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone, Utc};
|
use chrono::{Local, NaiveDate, NaiveTime};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
use calendar_models::VAlarm;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
pub enum EventStatus {
|
pub enum EventStatus {
|
||||||
@@ -30,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 {
|
||||||
@@ -78,12 +61,7 @@ impl Default for ModalTab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
// EditAction is now imported from event_context_menu - this duplicate removed
|
||||||
pub enum EditAction {
|
|
||||||
ThisOnly,
|
|
||||||
ThisAndFuture,
|
|
||||||
AllInSeries,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Debug)]
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
pub struct EventCreationData {
|
pub struct EventCreationData {
|
||||||
@@ -111,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,
|
||||||
@@ -130,8 +108,89 @@ pub struct EventCreationData {
|
|||||||
pub selected_calendar: Option<String>,
|
pub selected_calendar: Option<String>,
|
||||||
|
|
||||||
// Edit tracking (for recurring events)
|
// Edit tracking (for recurring events)
|
||||||
pub edit_scope: Option<EditAction>,
|
pub edit_scope: Option<crate::components::EditAction>,
|
||||||
pub changed_fields: Vec<String>,
|
pub changed_fields: Vec<String>,
|
||||||
|
pub original_uid: Option<String>, // Set when editing existing events
|
||||||
|
pub occurrence_date: Option<NaiveDate>, // The specific occurrence date being edited
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventCreationData {
|
||||||
|
pub fn to_create_event_params(&self) -> (
|
||||||
|
String, // title
|
||||||
|
String, // description
|
||||||
|
String, // start_date
|
||||||
|
String, // start_time
|
||||||
|
String, // end_date
|
||||||
|
String, // end_time
|
||||||
|
String, // location
|
||||||
|
bool, // all_day
|
||||||
|
String, // status
|
||||||
|
String, // class
|
||||||
|
Option<u8>, // priority
|
||||||
|
String, // organizer
|
||||||
|
String, // attendees
|
||||||
|
String, // categories
|
||||||
|
Vec<VAlarm>, // alarms
|
||||||
|
String, // recurrence
|
||||||
|
Vec<bool>, // recurrence_days
|
||||||
|
u32, // recurrence_interval
|
||||||
|
Option<u32>, // recurrence_count
|
||||||
|
Option<String>, // recurrence_until
|
||||||
|
Option<String>, // calendar_path
|
||||||
|
String, // timezone
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Use local date/times and timezone - no UTC conversion
|
||||||
|
let effective_end_date = if self.all_day {
|
||||||
|
// 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.end_date + chrono::Duration::days(1)
|
||||||
|
} else {
|
||||||
|
self.end_date
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.description.clone(),
|
||||||
|
start_date,
|
||||||
|
start_time,
|
||||||
|
end_date,
|
||||||
|
end_time,
|
||||||
|
self.location.clone(),
|
||||||
|
self.all_day,
|
||||||
|
format!("{:?}", self.status).to_uppercase(),
|
||||||
|
format!("{:?}", self.class).to_uppercase(),
|
||||||
|
self.priority,
|
||||||
|
self.organizer.clone(),
|
||||||
|
self.attendees.clone(),
|
||||||
|
self.categories.clone(),
|
||||||
|
self.alarms.clone(),
|
||||||
|
format!("{:?}", self.recurrence),
|
||||||
|
self.recurrence_days.clone(),
|
||||||
|
self.recurrence_interval,
|
||||||
|
self.recurrence_count,
|
||||||
|
self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||||
|
self.selected_calendar.clone(),
|
||||||
|
timezone,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for EventCreationData {
|
impl Default for EventCreationData {
|
||||||
@@ -156,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,
|
||||||
@@ -168,6 +227,8 @@ impl Default for EventCreationData {
|
|||||||
selected_calendar: None,
|
selected_calendar: None,
|
||||||
edit_scope: None,
|
edit_scope: None,
|
||||||
changed_fields: vec![],
|
changed_fields: vec![],
|
||||||
|
original_uid: None,
|
||||||
|
occurrence_date: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,6 +236,6 @@ impl Default for EventCreationData {
|
|||||||
// Common props for all tab components
|
// Common props for all tab components
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct TabProps {
|
pub struct TabProps {
|
||||||
pub data: UseStateHandle<crate::components::create_event_modal::EventCreationData>,
|
pub data: UseStateHandle<EventCreationData>,
|
||||||
pub available_calendars: Vec<CalendarInfo>,
|
pub available_calendars: Vec<CalendarInfo>,
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
222
frontend/src/components/external_calendar_modal.rs
Normal file
222
frontend/src/components/external_calendar_modal.rs
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
use crate::services::calendar_service::CalendarService;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct ExternalCalendarModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub on_success: Callback<i32>, // Pass the newly created calendar ID
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ExternalCalendarModal)]
|
||||||
|
pub fn external_calendar_modal(props: &ExternalCalendarModalProps) -> Html {
|
||||||
|
let name_ref = use_node_ref();
|
||||||
|
let url_ref = use_node_ref();
|
||||||
|
let color_ref = use_node_ref();
|
||||||
|
let is_loading = use_state(|| false);
|
||||||
|
let error_message = use_state(|| None::<String>);
|
||||||
|
|
||||||
|
let on_submit = {
|
||||||
|
let name_ref = name_ref.clone();
|
||||||
|
let url_ref = url_ref.clone();
|
||||||
|
let color_ref = color_ref.clone();
|
||||||
|
let is_loading = is_loading.clone();
|
||||||
|
let error_message = error_message.clone();
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
let on_success = props.on_success.clone();
|
||||||
|
|
||||||
|
Callback::from(move |e: SubmitEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
|
||||||
|
let name = name_ref
|
||||||
|
.cast::<HtmlInputElement>()
|
||||||
|
.map(|input| input.value())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let url = url_ref
|
||||||
|
.cast::<HtmlInputElement>()
|
||||||
|
.map(|input| input.value())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let color = color_ref
|
||||||
|
.cast::<HtmlInputElement>()
|
||||||
|
.map(|input| input.value())
|
||||||
|
.unwrap_or_else(|| "#4285f4".to_string());
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
error_message.set(Some("Calendar name is required".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if url.is_empty() {
|
||||||
|
error_message.set(Some("Calendar URL is required".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic URL validation
|
||||||
|
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||||
|
error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_message.set(None);
|
||||||
|
is_loading.set(true);
|
||||||
|
|
||||||
|
let is_loading = is_loading.clone();
|
||||||
|
let error_message = error_message.clone();
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
let on_success = on_success.clone();
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match CalendarService::create_external_calendar(&name, &url, &color).await {
|
||||||
|
Ok(new_calendar) => {
|
||||||
|
is_loading.set(false);
|
||||||
|
on_success.emit(new_calendar.id);
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
is_loading.set(false);
|
||||||
|
error_message.set(Some(format!("Failed to add calendar: {}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_cancel = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_cancel_clone = on_cancel.clone();
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-backdrop" onclick={on_cancel_clone}>
|
||||||
|
<div class="external-calendar-modal" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{"Add External Calendar"}</h3>
|
||||||
|
<button class="modal-close" onclick={on_cancel.clone()}>{"×"}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onsubmit={on_submit}>
|
||||||
|
<div class="modal-body">
|
||||||
|
{
|
||||||
|
if let Some(error) = (*error_message).as_ref() {
|
||||||
|
html! {
|
||||||
|
<div class="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="form-help" style="margin-bottom: 1.5rem; padding: 1rem; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #007bff;">
|
||||||
|
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem; color: #495057;">{"Setting up External Calendars"}</h4>
|
||||||
|
<p style="margin: 0 0 0.5rem 0; font-size: 0.8rem; line-height: 1.4;">
|
||||||
|
{"Currently tested with Outlook 365 and Google Calendar. To get your calendar link:"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<strong style="font-size: 0.8rem; color: #495057;">{"Outlook 365:"}</strong>
|
||||||
|
<ol style="margin: 0.3rem 0 0 0; padding-left: 1.2rem; font-size: 0.8rem; line-height: 1.3;">
|
||||||
|
<li>{"Go to Outlook Settings"}</li>
|
||||||
|
<li>{"Navigate to Calendar → Shared Calendars"}</li>
|
||||||
|
<li>{"Click \"Publish a calendar\""}</li>
|
||||||
|
<li>{"Select your calendar and choose \"Can view all details\""}</li>
|
||||||
|
<li>{"Copy the ICS link and paste it below"}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<strong style="font-size: 0.8rem; color: #495057;">{"Google Calendar:"}</strong>
|
||||||
|
<ol style="margin: 0.3rem 0 0 0; padding-left: 1.2rem; font-size: 0.8rem; line-height: 1.3;">
|
||||||
|
<li>{"Hover over your calendar name in the left sidebar"}</li>
|
||||||
|
<li>{"Click the three dots that appear"}</li>
|
||||||
|
<li>{"Select \"Settings and sharing\""}</li>
|
||||||
|
<li>{"Scroll to \"Integrate calendar\""}</li>
|
||||||
|
<li>{"Copy the \"Public address in iCal format\" link"}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-calendar-name">{"Calendar Name"}</label>
|
||||||
|
<input
|
||||||
|
ref={name_ref}
|
||||||
|
id="external-calendar-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="My External Calendar"
|
||||||
|
disabled={*is_loading}
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-calendar-url">{"ICS URL"}</label>
|
||||||
|
<input
|
||||||
|
ref={url_ref}
|
||||||
|
id="external-calendar-url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/calendar.ics"
|
||||||
|
disabled={*is_loading}
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
<small class="form-help">
|
||||||
|
{"Enter the public ICS URL for the calendar you want to add"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="external-calendar-color">{"Color"}</label>
|
||||||
|
<input
|
||||||
|
ref={color_ref}
|
||||||
|
id="external-calendar-color"
|
||||||
|
type="color"
|
||||||
|
value="#4285f4"
|
||||||
|
disabled={*is_loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
onclick={on_cancel}
|
||||||
|
disabled={*is_loading}
|
||||||
|
>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={*is_loading}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
if *is_loading {
|
||||||
|
"Adding..."
|
||||||
|
} else {
|
||||||
|
"Add Calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
// Remember checkboxes state - default to checked
|
// Remember checkboxes state - default to checked
|
||||||
let remember_server = use_state(|| true);
|
let remember_server = use_state(|| true);
|
||||||
let remember_username = use_state(|| true);
|
let remember_username = use_state(|| true);
|
||||||
|
|
||||||
|
// Password visibility toggle
|
||||||
|
let show_password = use_state(|| false);
|
||||||
|
|
||||||
let server_url_ref = use_node_ref();
|
let server_url_ref = use_node_ref();
|
||||||
let username_ref = use_node_ref();
|
let username_ref = use_node_ref();
|
||||||
@@ -30,17 +33,31 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
|
|
||||||
let on_server_url_change = {
|
let on_server_url_change = {
|
||||||
let server_url = server_url.clone();
|
let server_url = server_url.clone();
|
||||||
|
let remember_server = remember_server.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |e: Event| {
|
||||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
server_url.set(target.value());
|
let new_value = target.value();
|
||||||
|
server_url.set(new_value.clone());
|
||||||
|
|
||||||
|
// Save to localStorage immediately if remember is checked
|
||||||
|
if *remember_server {
|
||||||
|
let _ = LocalStorage::set("remembered_server_url", new_value);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
let on_username_change = {
|
let on_username_change = {
|
||||||
let username = username.clone();
|
let username = username.clone();
|
||||||
|
let remember_username = remember_username.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |e: Event| {
|
||||||
let target = e.target_unchecked_into::<HtmlInputElement>();
|
let target = e.target_unchecked_into::<HtmlInputElement>();
|
||||||
username.set(target.value());
|
let new_value = target.value();
|
||||||
|
username.set(new_value.clone());
|
||||||
|
|
||||||
|
// Save to localStorage immediately if remember is checked
|
||||||
|
if *remember_username {
|
||||||
|
let _ = LocalStorage::set("remembered_username", new_value);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,6 +100,13 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_toggle_password_visibility = {
|
||||||
|
let show_password = show_password.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
show_password.set(!*show_password);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let on_submit = {
|
let on_submit = {
|
||||||
let server_url = server_url.clone();
|
let server_url = server_url.clone();
|
||||||
@@ -90,6 +114,8 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
let password = password.clone();
|
let password = password.clone();
|
||||||
let error_message = error_message.clone();
|
let error_message = error_message.clone();
|
||||||
let is_loading = is_loading.clone();
|
let is_loading = is_loading.clone();
|
||||||
|
let remember_server = remember_server.clone();
|
||||||
|
let remember_username = remember_username.clone();
|
||||||
let on_login = props.on_login.clone();
|
let on_login = props.on_login.clone();
|
||||||
|
|
||||||
Callback::from(move |e: SubmitEvent| {
|
Callback::from(move |e: SubmitEvent| {
|
||||||
@@ -100,6 +126,8 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
let password = (*password).clone();
|
let password = (*password).clone();
|
||||||
let error_message = error_message.clone();
|
let error_message = error_message.clone();
|
||||||
let is_loading = is_loading.clone();
|
let is_loading = is_loading.clone();
|
||||||
|
let remember_server_value = *remember_server;
|
||||||
|
let remember_username_value = *remember_username;
|
||||||
let on_login = on_login.clone();
|
let on_login = on_login.clone();
|
||||||
|
|
||||||
// Basic client-side validation
|
// Basic client-side validation
|
||||||
@@ -140,11 +168,23 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
let _ = LocalStorage::set("user_preferences", &prefs_json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save server URL and username to LocalStorage if remember checkboxes are checked
|
||||||
|
if remember_server_value {
|
||||||
|
let _ = LocalStorage::set("remembered_server_url", server_url.clone());
|
||||||
|
}
|
||||||
|
if remember_username_value {
|
||||||
|
let _ = LocalStorage::set("remembered_username", username.clone());
|
||||||
|
}
|
||||||
|
|
||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
on_login.emit(token);
|
on_login.emit(token);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
web_sys::console::log_1(&format!("❌ Login failed: {}", err).into());
|
web_sys::console::log_1(&format!("❌ Login failed: {}", err).into());
|
||||||
|
// Clear any existing invalid tokens
|
||||||
|
let _ = LocalStorage::delete("auth_token");
|
||||||
|
let _ = LocalStorage::delete("session_token");
|
||||||
|
let _ = LocalStorage::delete("caldav_credentials");
|
||||||
error_message.set(Some(err));
|
error_message.set(Some(err));
|
||||||
is_loading.set(false);
|
is_loading.set(false);
|
||||||
}
|
}
|
||||||
@@ -160,59 +200,79 @@ pub fn Login(props: &LoginProps) -> Html {
|
|||||||
<form onsubmit={on_submit}>
|
<form onsubmit={on_submit}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="server_url">{"CalDAV Server URL"}</label>
|
<label for="server_url">{"CalDAV Server URL"}</label>
|
||||||
<input
|
<div class="input-with-checkbox">
|
||||||
ref={server_url_ref}
|
|
||||||
type="text"
|
|
||||||
id="server_url"
|
|
||||||
placeholder="https://your-caldav-server.com/dav/"
|
|
||||||
value={(*server_url).clone()}
|
|
||||||
onchange={on_server_url_change}
|
|
||||||
disabled={*is_loading}
|
|
||||||
/>
|
|
||||||
<div class="remember-checkbox">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
ref={server_url_ref}
|
||||||
id="remember_server"
|
type="text"
|
||||||
checked={*remember_server}
|
id="server_url"
|
||||||
onchange={on_remember_server_change}
|
placeholder="https://your-caldav-server.com/dav/"
|
||||||
|
value={(*server_url).clone()}
|
||||||
|
onchange={on_server_url_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
tabindex="1"
|
||||||
/>
|
/>
|
||||||
<label for="remember_server">{"Remember server"}</label>
|
<div class="remember-checkbox">
|
||||||
|
<label for="remember_server">{"Remember"}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember_server"
|
||||||
|
checked={*remember_server}
|
||||||
|
onchange={on_remember_server_change}
|
||||||
|
tabindex="4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">{"Username"}</label>
|
<label for="username">{"Username"}</label>
|
||||||
<input
|
<div class="input-with-checkbox">
|
||||||
ref={username_ref}
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
placeholder="Enter your username"
|
|
||||||
value={(*username).clone()}
|
|
||||||
onchange={on_username_change}
|
|
||||||
disabled={*is_loading}
|
|
||||||
/>
|
|
||||||
<div class="remember-checkbox">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
ref={username_ref}
|
||||||
id="remember_username"
|
type="text"
|
||||||
checked={*remember_username}
|
id="username"
|
||||||
onchange={on_remember_username_change}
|
placeholder="Enter your username"
|
||||||
|
value={(*username).clone()}
|
||||||
|
onchange={on_username_change}
|
||||||
|
disabled={*is_loading}
|
||||||
|
tabindex="2"
|
||||||
/>
|
/>
|
||||||
<label for="remember_username">{"Remember username"}</label>
|
<div class="remember-checkbox">
|
||||||
|
<label for="remember_username">{"Remember"}</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember_username"
|
||||||
|
checked={*remember_username}
|
||||||
|
onchange={on_remember_username_change}
|
||||||
|
tabindex="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">{"Password"}</label>
|
<label for="password">{"Password"}</label>
|
||||||
<input
|
<div class="password-input-container">
|
||||||
ref={password_ref}
|
<input
|
||||||
type="password"
|
ref={password_ref}
|
||||||
id="password"
|
type={if *show_password { "text" } else { "password" }}
|
||||||
placeholder="Enter your password"
|
id="password"
|
||||||
value={(*password).clone()}
|
placeholder="Enter your password"
|
||||||
onchange={on_password_change}
|
value={(*password).clone()}
|
||||||
disabled={*is_loading}
|
onchange={on_password_change}
|
||||||
/>
|
disabled={*is_loading}
|
||||||
|
tabindex="3"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="password-toggle-btn"
|
||||||
|
onclick={on_toggle_password_visibility}
|
||||||
|
tabindex="6"
|
||||||
|
title={if *show_password { "Hide password" } else { "Show password" }}
|
||||||
|
>
|
||||||
|
<i class={if *show_password { "fas fa-eye-slash" } else { "fas fa-eye" }}></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
96
frontend/src/components/mobile_warning_modal.rs
Normal file
96
frontend/src/components/mobile_warning_modal.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::window;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct MobileWarningModalProps {
|
||||||
|
pub is_open: bool,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(MobileWarningModal)]
|
||||||
|
pub fn mobile_warning_modal(props: &MobileWarningModalProps) -> Html {
|
||||||
|
let on_backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
if let Some(target) = e.target() {
|
||||||
|
let element = target.dyn_into::<web_sys::Element>().unwrap();
|
||||||
|
if element.class_list().contains("modal-overlay") {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
if !props.is_open {
|
||||||
|
return html! {};
|
||||||
|
}
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-overlay mobile-warning-overlay" onclick={on_backdrop_click}>
|
||||||
|
<div class="modal-content mobile-warning-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{"Desktop Application"}</h2>
|
||||||
|
<button class="modal-close" onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}>{"×"}</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mobile-warning-icon">
|
||||||
|
{"💻"}
|
||||||
|
</div>
|
||||||
|
<p class="mobile-warning-title">
|
||||||
|
{"This calendar application is designed for desktop usage"}
|
||||||
|
</p>
|
||||||
|
<p class="mobile-warning-description">
|
||||||
|
{"For the best mobile calendar experience, we recommend using dedicated CalDAV apps available on your device's app store:"}
|
||||||
|
</p>
|
||||||
|
<ul class="mobile-warning-apps">
|
||||||
|
<li>
|
||||||
|
<strong>{"iOS:"}</strong>
|
||||||
|
{" Calendar (built-in), Calendars 5, Fantastical"}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>{"Android:"}</strong>
|
||||||
|
{" Google Calendar, DAVx5, CalDAV Sync"}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mobile-warning-note">
|
||||||
|
{"These apps can sync with the same CalDAV server you're using here."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="continue-anyway-button" onclick={
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
}>
|
||||||
|
{"Continue Anyway"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to detect mobile devices
|
||||||
|
pub fn is_mobile_device() -> bool {
|
||||||
|
if let Some(window) = window() {
|
||||||
|
let navigator = window.navigator();
|
||||||
|
let user_agent = navigator.user_agent().unwrap_or_default();
|
||||||
|
let user_agent = user_agent.to_lowercase();
|
||||||
|
|
||||||
|
// Check for mobile device indicators
|
||||||
|
user_agent.contains("mobile")
|
||||||
|
|| user_agent.contains("android")
|
||||||
|
|| user_agent.contains("iphone")
|
||||||
|
|| user_agent.contains("ipad")
|
||||||
|
|| user_agent.contains("ipod")
|
||||||
|
|| user_agent.contains("blackberry")
|
||||||
|
|| user_agent.contains("webos")
|
||||||
|
|| user_agent.contains("opera mini")
|
||||||
|
|| user_agent.contains("windows phone")
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
pub mod calendar_context_menu;
|
pub mod calendar_context_menu;
|
||||||
|
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;
|
||||||
pub mod create_event_modal_v2;
|
|
||||||
pub mod event_context_menu;
|
pub mod event_context_menu;
|
||||||
pub mod event_form;
|
pub mod event_form;
|
||||||
pub mod event_modal;
|
pub mod event_modal;
|
||||||
|
pub mod external_calendar_modal;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
pub mod mobile_warning_modal;
|
||||||
pub mod month_view;
|
pub mod month_view;
|
||||||
|
pub mod print_preview_modal;
|
||||||
pub mod recurring_edit_modal;
|
pub mod recurring_edit_modal;
|
||||||
pub mod route_handler;
|
pub mod route_handler;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
@@ -18,22 +22,20 @@ pub mod week_view;
|
|||||||
|
|
||||||
pub use calendar::Calendar;
|
pub use calendar::Calendar;
|
||||||
pub use calendar_context_menu::CalendarContextMenu;
|
pub use calendar_context_menu::CalendarContextMenu;
|
||||||
|
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_calendar_modal::CreateCalendarModal;
|
pub use create_event_modal::CreateEventModal;
|
||||||
pub use create_event_modal::{
|
// Re-export event form types for backwards compatibility
|
||||||
CreateEventModal, EventClass, EventCreationData, EventStatus, RecurrenceType, ReminderType,
|
pub use event_form::EventCreationData;
|
||||||
};
|
|
||||||
pub use create_event_modal_v2::CreateEventModalV2;
|
|
||||||
pub use event_form::{
|
|
||||||
EventClass as EventFormClass, EventCreationData as EventFormData, EventStatus as EventFormStatus,
|
|
||||||
RecurrenceType as EventFormRecurrenceType, ReminderType as EventFormReminderType,
|
|
||||||
};
|
|
||||||
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
|
||||||
pub use event_modal::EventModal;
|
pub use event_modal::EventModal;
|
||||||
pub use login::Login;
|
pub use login::Login;
|
||||||
|
pub use mobile_warning_modal::MobileWarningModal;
|
||||||
pub use month_view::MonthView;
|
pub use month_view::MonthView;
|
||||||
|
pub use print_preview_modal::PrintPreviewModal;
|
||||||
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
|
||||||
pub use route_handler::RouteHandler;
|
pub use route_handler::RouteHandler;
|
||||||
pub use sidebar::{Sidebar, Theme, ViewMode};
|
pub use sidebar::{Sidebar, Theme, ViewMode};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
use crate::services::calendar_service::UserInfo;
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
use chrono::{Datelike, NaiveDate, Weekday};
|
use chrono::{Datelike, NaiveDate, Weekday};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use wasm_bindgen::{prelude::*, JsCast};
|
use wasm_bindgen::{prelude::*, JsCast};
|
||||||
@@ -17,6 +17,8 @@ pub struct MonthViewProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
#[prop_or_default]
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||||
@@ -85,8 +87,20 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
|
|
||||||
// Helper function to get calendar color for an event
|
// Helper function to get calendar color for an event
|
||||||
let get_event_color = |event: &VEvent| -> String {
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
if let Some(user_info) = &props.user_info {
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
if let Some(calendar_path) = &event.calendar_path {
|
// Check external calendars first (path format: "external_{id}")
|
||||||
|
if calendar_path.starts_with("external_") {
|
||||||
|
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
|
||||||
|
if let Some(external_calendar) = props.external_calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| cal.id == id_str)
|
||||||
|
{
|
||||||
|
return external_calendar.color.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check regular calendars
|
||||||
|
else if let Some(user_info) = &props.user_info {
|
||||||
if let Some(calendar) = user_info
|
if let Some(calendar) = user_info
|
||||||
.calendars
|
.calendars
|
||||||
.iter()
|
.iter()
|
||||||
@@ -99,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>
|
||||||
@@ -194,10 +214,14 @@ pub fn month_view(props: &MonthViewProps) -> Html {
|
|||||||
<div
|
<div
|
||||||
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
|
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
|
||||||
style={format!("background-color: {}", event_color)}
|
style={format!("background-color: {}", event_color)}
|
||||||
|
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||||
{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>()
|
||||||
@@ -219,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
|
||||||
|
|||||||
377
frontend/src/components/print_preview_modal.rs
Normal file
377
frontend/src/components/print_preview_modal.rs
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
use crate::components::{ViewMode, WeekView, MonthView, CalendarHeader};
|
||||||
|
use crate::models::ical::VEvent;
|
||||||
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use wasm_bindgen::{closure::Closure, JsCast};
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct PrintPreviewModalProps {
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
pub view_mode: ViewMode,
|
||||||
|
pub current_date: NaiveDate,
|
||||||
|
pub selected_date: NaiveDate,
|
||||||
|
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
||||||
|
pub user_info: Option<UserInfo>,
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
pub time_increment: u32,
|
||||||
|
pub today: NaiveDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
|
||||||
|
let start_hour = use_state(|| 6u32);
|
||||||
|
let end_hour = use_state(|| 22u32);
|
||||||
|
let zoom_level = use_state(|| 0.4f64); // Default 40% zoom
|
||||||
|
|
||||||
|
let close_modal = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_close.emit(());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let backdrop_click = {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
if e.target() == e.current_target() {
|
||||||
|
on_close.emit(());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_start_hour_change = {
|
||||||
|
let start_hour = start_hour.clone();
|
||||||
|
let end_hour = end_hour.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
|
||||||
|
if let Some(select) = target {
|
||||||
|
if let Ok(hour) = select.value().parse::<u32>() {
|
||||||
|
if hour < *end_hour {
|
||||||
|
start_hour.set(hour);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_end_hour_change = {
|
||||||
|
let start_hour = start_hour.clone();
|
||||||
|
let end_hour = end_hour.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
|
||||||
|
if let Some(select) = target {
|
||||||
|
if let Ok(hour) = select.value().parse::<u32>() {
|
||||||
|
if hour > *start_hour && hour <= 24 {
|
||||||
|
end_hour.set(hour);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let format_hour = |hour: u32| -> String {
|
||||||
|
if hour == 0 {
|
||||||
|
"12 AM".to_string()
|
||||||
|
} else if hour < 12 {
|
||||||
|
format!("{} AM", hour)
|
||||||
|
} else if hour == 12 {
|
||||||
|
"12 PM".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} PM", hour - 12)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate dynamic base unit for print preview
|
||||||
|
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 slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
|
||||||
|
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 container_spacing = 8.0; // Additional container spacing/margins
|
||||||
|
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 base_unit = available_height / (visible_hours * slots_per_hour);
|
||||||
|
let pixels_per_hour = base_unit * slots_per_hour;
|
||||||
|
|
||||||
|
(base_unit, pixels_per_hour, available_height)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate print dimensions for the current hour range
|
||||||
|
let (base_unit, pixels_per_hour, _available_height) = calculate_print_dimensions(*start_hour, *end_hour, props.time_increment);
|
||||||
|
|
||||||
|
// Effect to update print copy whenever modal renders or content changes
|
||||||
|
{
|
||||||
|
let start_hour = *start_hour;
|
||||||
|
let end_hour = *end_hour;
|
||||||
|
let time_increment = props.time_increment;
|
||||||
|
let original_base_unit = base_unit;
|
||||||
|
use_effect(move || {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
// Set CSS variables on document root
|
||||||
|
if let Some(document_element) = document.document_element() {
|
||||||
|
if let Some(html_element) = document_element.dyn_ref::<web_sys::HtmlElement>() {
|
||||||
|
let style = html_element.style();
|
||||||
|
let _ = style.set_property("--print-start-hour", &start_hour.to_string());
|
||||||
|
let _ = style.set_property("--print-end-hour", &end_hour.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy content from print-preview-content to the hidden print-preview-copy div
|
||||||
|
let copy_content = move || {
|
||||||
|
if let Some(preview_content) = document.query_selector(".print-preview-content").ok().flatten() {
|
||||||
|
if let Some(print_copy) = document.get_element_by_id("print-preview-copy") {
|
||||||
|
// Clone the preview content
|
||||||
|
if let Some(content_clone) = preview_content.clone_node_with_deep(true).ok() {
|
||||||
|
// Clear the print copy div and add the cloned content
|
||||||
|
print_copy.set_inner_html("");
|
||||||
|
let _ = print_copy.append_child(&content_clone);
|
||||||
|
|
||||||
|
// Get the actual rendered height of the print copy div and recalculate base-unit
|
||||||
|
if let Some(print_copy_html) = print_copy.dyn_ref::<web_sys::HtmlElement>() {
|
||||||
|
// Temporarily make visible to measure height, then hide again
|
||||||
|
let original_display = print_copy_html.style().get_property_value("display").unwrap_or_default();
|
||||||
|
let _ = print_copy_html.style().set_property("display", "block");
|
||||||
|
let _ = print_copy_html.style().set_property("visibility", "hidden");
|
||||||
|
let _ = print_copy_html.style().set_property("position", "absolute");
|
||||||
|
let _ = print_copy_html.style().set_property("top", "-9999px");
|
||||||
|
|
||||||
|
// Now measure the height
|
||||||
|
let actual_height = print_copy_html.client_height() as f64;
|
||||||
|
|
||||||
|
// Restore original display
|
||||||
|
let _ = print_copy_html.style().set_property("display", &original_display);
|
||||||
|
let _ = print_copy_html.style().remove_property("visibility");
|
||||||
|
let _ = print_copy_html.style().remove_property("position");
|
||||||
|
let _ = print_copy_html.style().remove_property("top");
|
||||||
|
|
||||||
|
// Recalculate base-unit and pixels-per-hour based on actual height
|
||||||
|
let visible_hours = (end_hour - start_hour) as f64;
|
||||||
|
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.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 container_spacing = 8.0;
|
||||||
|
let total_overhead = calendar_header_height + week_header_height + header_border + container_spacing;
|
||||||
|
let available_height = actual_height - total_overhead;
|
||||||
|
let actual_base_unit = available_height / (visible_hours * slots_per_hour);
|
||||||
|
let actual_pixels_per_hour = actual_base_unit * slots_per_hour;
|
||||||
|
|
||||||
|
|
||||||
|
// Set CSS variables with recalculated values
|
||||||
|
let style = print_copy_html.style();
|
||||||
|
let _ = style.set_property("--print-base-unit", &format!("{:.2}", actual_base_unit));
|
||||||
|
let _ = style.set_property("--print-pixels-per-hour", &format!("{:.2}", actual_pixels_per_hour));
|
||||||
|
let _ = style.set_property("--print-start-hour", &start_hour.to_string());
|
||||||
|
let _ = style.set_property("--print-end-hour", &end_hour.to_string());
|
||||||
|
|
||||||
|
// Copy data attributes
|
||||||
|
let _ = print_copy.set_attribute("data-start-hour", &start_hour.to_string());
|
||||||
|
let _ = print_copy.set_attribute("data-end-hour", &end_hour.to_string());
|
||||||
|
|
||||||
|
// Recalculate event positions using the new base-unit
|
||||||
|
let events = print_copy.query_selector_all(".week-event").unwrap();
|
||||||
|
let scale_factor = actual_base_unit / original_base_unit;
|
||||||
|
|
||||||
|
for i in 0..events.length() {
|
||||||
|
if let Some(event_element) = events.get(i) {
|
||||||
|
if let Some(event_html) = event_element.dyn_ref::<web_sys::HtmlElement>() {
|
||||||
|
let event_style = event_html.style();
|
||||||
|
|
||||||
|
// Get current positioning values and recalculate
|
||||||
|
if let Ok(current_top) = event_style.get_property_value("top") {
|
||||||
|
if current_top.ends_with("px") {
|
||||||
|
if let Ok(top_px) = current_top[..current_top.len()-2].parse::<f64>() {
|
||||||
|
let new_top = top_px * scale_factor;
|
||||||
|
let _ = event_style.set_property("top", &format!("{:.2}px", new_top));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(current_height) = event_style.get_property_value("height") {
|
||||||
|
if current_height.ends_with("px") {
|
||||||
|
if let Ok(height_px) = current_height[..current_height.len()-2].parse::<f64>() {
|
||||||
|
let new_height = height_px * scale_factor;
|
||||||
|
let _ = event_style.set_property("height", &format!("{:.2}px", new_height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
web_sys::console::log_1(&format!("Height: {:.2}, Original base-unit: {:.2}, New base-unit: {:.2}, Scale factor: {:.2}",
|
||||||
|
actual_height, original_base_unit, actual_base_unit, scale_factor).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy content immediately
|
||||||
|
copy_content();
|
||||||
|
|
||||||
|
// Also set up a small delay to catch any async rendering
|
||||||
|
let copy_callback = Closure::wrap(Box::new(copy_content) as Box<dyn FnMut()>);
|
||||||
|
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||||
|
copy_callback.as_ref().unchecked_ref(),
|
||||||
|
100
|
||||||
|
);
|
||||||
|
copy_callback.forget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_print = {
|
||||||
|
Callback::from(move |_: MouseEvent| {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
// Print copy is already updated by the use_effect, just trigger print
|
||||||
|
let _ = window.print();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-backdrop print-preview-modal-backdrop" onclick={backdrop_click}>
|
||||||
|
<div class="modal-content print-preview-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{"Print Preview"}</h3>
|
||||||
|
<button class="modal-close" onclick={close_modal.clone()}>{"×"}</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body print-preview-body">
|
||||||
|
<div class="print-preview-controls">
|
||||||
|
{
|
||||||
|
if props.view_mode == ViewMode::Week {
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="start-hour">{"Start Hour:"}</label>
|
||||||
|
<select id="start-hour" onchange={on_start_hour_change}>
|
||||||
|
{
|
||||||
|
(0..24).map(|hour| {
|
||||||
|
html! {
|
||||||
|
<option value={hour.to_string()} selected={hour == *start_hour}>
|
||||||
|
{format_hour(hour)}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="end-hour">{"End Hour:"}</label>
|
||||||
|
<select id="end-hour" onchange={on_end_hour_change}>
|
||||||
|
{
|
||||||
|
(1..=24).map(|hour| {
|
||||||
|
html! {
|
||||||
|
<option value={hour.to_string()} selected={hour == *end_hour}>
|
||||||
|
{if hour == 24 { "12 AM".to_string() } else { format_hour(hour) }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="hour-range-info">
|
||||||
|
{format!("Will print from {} to {}",
|
||||||
|
format_hour(*start_hour),
|
||||||
|
if *end_hour == 24 { "12 AM".to_string() } else { format_hour(*end_hour) }
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<div class="month-info">
|
||||||
|
{"Will print entire month view"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div class="zoom-display-info">
|
||||||
|
<label>{"Zoom: "}</label>
|
||||||
|
<span>{format!("{}%", (*zoom_level * 100.0) as i32)}</span>
|
||||||
|
<span class="zoom-hint">{"(scroll to zoom)"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-actions">
|
||||||
|
<button class="btn-primary" onclick={on_print}>{"Print"}</button>
|
||||||
|
<button class="btn-secondary" onclick={close_modal}>{"Cancel"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="print-preview-display" onwheel={{
|
||||||
|
let zoom_level = zoom_level.clone();
|
||||||
|
Callback::from(move |e: WheelEvent| {
|
||||||
|
e.prevent_default(); // Prevent page scroll
|
||||||
|
let delta_y = e.delta_y();
|
||||||
|
let zoom_change = if delta_y < 0.0 { 1.1 } else { 1.0 / 1.1 };
|
||||||
|
let new_zoom = (*zoom_level * zoom_change).clamp(0.2, 1.5);
|
||||||
|
zoom_level.set(new_zoom);
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<div class="print-preview-paper"
|
||||||
|
data-start-hour={start_hour.to_string()}
|
||||||
|
data-end-hour={end_hour.to_string()}
|
||||||
|
style={format!(
|
||||||
|
"--print-start-hour: {}; --print-end-hour: {}; --print-base-unit: {:.2}; --print-pixels-per-hour: {:.2}; transform: scale({}); transform-origin: top center;",
|
||||||
|
*start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level
|
||||||
|
)}>
|
||||||
|
<div class="print-preview-content">
|
||||||
|
<div class={classes!("calendar", match props.view_mode { ViewMode::Week => Some("week-view"), _ => None })}>
|
||||||
|
<CalendarHeader
|
||||||
|
current_date={props.current_date}
|
||||||
|
view_mode={props.view_mode.clone()}
|
||||||
|
on_prev={Callback::from(|_: MouseEvent| {})}
|
||||||
|
on_next={Callback::from(|_: MouseEvent| {})}
|
||||||
|
on_today={Callback::from(|_: MouseEvent| {})}
|
||||||
|
time_increment={Some(props.time_increment)}
|
||||||
|
on_time_increment_toggle={None::<Callback<MouseEvent>>}
|
||||||
|
on_print={None::<Callback<MouseEvent>>}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
match props.view_mode {
|
||||||
|
ViewMode::Week => html! {
|
||||||
|
<WeekView
|
||||||
|
key={format!("week-preview-{}-{}", *start_hour, *end_hour)}
|
||||||
|
current_date={props.current_date}
|
||||||
|
today={props.today}
|
||||||
|
events={props.events.clone()}
|
||||||
|
on_event_click={Callback::noop()}
|
||||||
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
|
time_increment={props.time_increment}
|
||||||
|
print_mode={true}
|
||||||
|
print_pixels_per_hour={Some(pixels_per_hour)}
|
||||||
|
print_start_hour={Some(*start_hour)}
|
||||||
|
/>
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::components::{Login, ViewMode};
|
use crate::components::{Login, ViewMode};
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
use crate::services::calendar_service::UserInfo;
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
@@ -20,6 +20,10 @@ pub struct RouteHandlerProps {
|
|||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
pub on_login: Callback<String>,
|
pub on_login: Callback<String>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
|
pub external_calendar_events: Vec<VEvent>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
#[prop_or_default]
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||||
@@ -34,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>,
|
||||||
)>,
|
)>,
|
||||||
@@ -48,6 +52,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
let auth_token = props.auth_token.clone();
|
let auth_token = props.auth_token.clone();
|
||||||
let user_info = props.user_info.clone();
|
let user_info = props.user_info.clone();
|
||||||
let on_login = props.on_login.clone();
|
let on_login = props.on_login.clone();
|
||||||
|
let external_calendar_events = props.external_calendar_events.clone();
|
||||||
|
let external_calendars = props.external_calendars.clone();
|
||||||
let on_event_context_menu = props.on_event_context_menu.clone();
|
let on_event_context_menu = props.on_event_context_menu.clone();
|
||||||
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
|
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
|
||||||
let view = props.view.clone();
|
let view = props.view.clone();
|
||||||
@@ -60,6 +66,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
let auth_token = auth_token.clone();
|
let auth_token = auth_token.clone();
|
||||||
let user_info = user_info.clone();
|
let user_info = user_info.clone();
|
||||||
let on_login = on_login.clone();
|
let on_login = on_login.clone();
|
||||||
|
let external_calendar_events = external_calendar_events.clone();
|
||||||
|
let external_calendars = external_calendars.clone();
|
||||||
let on_event_context_menu = on_event_context_menu.clone();
|
let on_event_context_menu = on_event_context_menu.clone();
|
||||||
let on_calendar_context_menu = on_calendar_context_menu.clone();
|
let on_calendar_context_menu = on_calendar_context_menu.clone();
|
||||||
let view = view.clone();
|
let view = view.clone();
|
||||||
@@ -87,6 +95,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
html! {
|
html! {
|
||||||
<CalendarView
|
<CalendarView
|
||||||
user_info={user_info}
|
user_info={user_info}
|
||||||
|
external_calendar_events={external_calendar_events}
|
||||||
|
external_calendars={external_calendars}
|
||||||
on_event_context_menu={on_event_context_menu}
|
on_event_context_menu={on_event_context_menu}
|
||||||
on_calendar_context_menu={on_calendar_context_menu}
|
on_calendar_context_menu={on_calendar_context_menu}
|
||||||
view={view}
|
view={view}
|
||||||
@@ -108,6 +118,10 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
|
|||||||
pub struct CalendarViewProps {
|
pub struct CalendarViewProps {
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
|
pub external_calendar_events: Vec<VEvent>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
#[prop_or_default]
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
|
||||||
@@ -122,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>,
|
||||||
)>,
|
)>,
|
||||||
@@ -139,6 +153,8 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html {
|
|||||||
<div class="calendar-view">
|
<div class="calendar-view">
|
||||||
<Calendar
|
<Calendar
|
||||||
user_info={props.user_info.clone()}
|
user_info={props.user_info.clone()}
|
||||||
|
external_calendar_events={props.external_calendar_events.clone()}
|
||||||
|
external_calendars={props.external_calendars.clone()}
|
||||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||||
view={props.view.clone()}
|
view={props.view.clone()}
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
use crate::components::CalendarListItem;
|
use crate::components::CalendarListItem;
|
||||||
use crate::services::calendar_service::UserInfo;
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,12 +106,19 @@ impl Default for ViewMode {
|
|||||||
pub struct SidebarProps {
|
pub struct SidebarProps {
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
pub on_logout: Callback<()>,
|
pub on_logout: Callback<()>,
|
||||||
pub on_create_calendar: Callback<()>,
|
pub on_add_calendar: Callback<()>,
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
pub on_external_calendar_toggle: Callback<i32>,
|
||||||
|
pub on_external_calendar_delete: Callback<i32>,
|
||||||
|
pub on_external_calendar_refresh: Callback<i32>,
|
||||||
pub color_picker_open: Option<String>,
|
pub color_picker_open: Option<String>,
|
||||||
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 on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
|
||||||
|
pub on_calendar_visibility_toggle: Callback<String>,
|
||||||
pub current_view: ViewMode,
|
pub current_view: ViewMode,
|
||||||
pub on_view_change: Callback<ViewMode>,
|
pub on_view_change: Callback<ViewMode>,
|
||||||
pub current_theme: Theme,
|
pub current_theme: Theme,
|
||||||
@@ -116,6 +129,7 @@ pub struct SidebarProps {
|
|||||||
|
|
||||||
#[function_component(Sidebar)]
|
#[function_component(Sidebar)]
|
||||||
pub fn sidebar(props: &SidebarProps) -> Html {
|
pub fn sidebar(props: &SidebarProps) -> Html {
|
||||||
|
let external_context_menu_open = use_state(|| None::<i32>);
|
||||||
let on_view_change = {
|
let on_view_change = {
|
||||||
let on_view_change = props.on_view_change.clone();
|
let on_view_change = props.on_view_change.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |e: Event| {
|
||||||
@@ -155,6 +169,30 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_external_calendar_context_menu = {
|
||||||
|
let external_context_menu_open = external_context_menu_open.clone();
|
||||||
|
Callback::from(move |(e, cal_id): (MouseEvent, i32)| {
|
||||||
|
e.prevent_default();
|
||||||
|
external_context_menu_open.set(Some(cal_id));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_external_calendar_delete = {
|
||||||
|
let on_external_calendar_delete = props.on_external_calendar_delete.clone();
|
||||||
|
let external_context_menu_open = external_context_menu_open.clone();
|
||||||
|
Callback::from(move |cal_id: i32| {
|
||||||
|
on_external_calendar_delete.emit(cal_id);
|
||||||
|
external_context_menu_open.set(None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let close_external_context_menu = {
|
||||||
|
let external_context_menu_open = external_context_menu_open.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
external_context_menu_open.set(None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
@@ -172,9 +210,6 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
|
||||||
<Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>>
|
|
||||||
</nav>
|
|
||||||
{
|
{
|
||||||
if let Some(ref info) = props.user_info {
|
if let Some(ref info) = props.user_info {
|
||||||
if !info.calendars.is_empty() {
|
if !info.calendars.is_empty() {
|
||||||
@@ -191,7 +226,9 @@ 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()}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
@@ -206,9 +243,186 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// External calendars section
|
||||||
|
<div class="external-calendar-list">
|
||||||
|
<h3>{"External Calendars"}</h3>
|
||||||
|
{
|
||||||
|
if !props.external_calendars.is_empty() {
|
||||||
|
html! {
|
||||||
|
<ul class="external-calendar-items">
|
||||||
|
{
|
||||||
|
props.external_calendars.iter().map(|cal| {
|
||||||
|
let on_toggle = {
|
||||||
|
let on_external_calendar_toggle = props.on_external_calendar_toggle.clone();
|
||||||
|
let cal_id = cal.id;
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_external_calendar_toggle.emit(cal_id);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<li class="external-calendar-item">
|
||||||
|
<div
|
||||||
|
class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
|
||||||
|
"external-calendar-info color-picker-active"
|
||||||
|
} else {
|
||||||
|
"external-calendar-info"
|
||||||
|
}}
|
||||||
|
oncontextmenu={{
|
||||||
|
let on_context_menu = on_external_calendar_context_menu.clone();
|
||||||
|
let cal_id = cal.id;
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
on_context_menu.emit((e, cal_id));
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cal.is_visible}
|
||||||
|
onchange={on_toggle}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="external-calendar-color"
|
||||||
|
style={format!("background-color: {}", cal.color)}
|
||||||
|
onclick={{
|
||||||
|
let on_color_picker_toggle = props.on_color_picker_toggle.clone();
|
||||||
|
let external_id = format!("external_{}", cal.id);
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
on_color_picker_toggle.emit(external_id.clone());
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
|
||||||
|
html! {
|
||||||
|
<div class="color-picker-dropdown">
|
||||||
|
{
|
||||||
|
props.available_colors.iter().map(|color| {
|
||||||
|
let color_str = color.clone();
|
||||||
|
let external_id = format!("external_{}", cal.id);
|
||||||
|
let on_color_change = props.on_color_change.clone();
|
||||||
|
|
||||||
|
let on_color_select = Callback::from(move |_: MouseEvent| {
|
||||||
|
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;
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
key={color.clone()}
|
||||||
|
class={if is_selected { "color-option selected" } else { "color-option" }}
|
||||||
|
style={format!("background-color: {}", color)}
|
||||||
|
onclick={on_color_select}
|
||||||
|
oncontextmenu={on_color_right_click}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="external-calendar-name">{&cal.name}</span>
|
||||||
|
<div class="external-calendar-actions">
|
||||||
|
{
|
||||||
|
if let Some(last_fetched) = cal.last_fetched {
|
||||||
|
let local_time = last_fetched.with_timezone(&chrono::Local);
|
||||||
|
html! {
|
||||||
|
<span class="last-updated" title={format!("Last updated: {}", local_time.format("%Y-%m-%d %H:%M"))}>
|
||||||
|
{format!("{}", local_time.format("%H:%M"))}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<span class="last-updated">{"Never"}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
class="external-calendar-refresh-btn"
|
||||||
|
title="Refresh calendar"
|
||||||
|
onclick={{
|
||||||
|
let on_refresh = props.on_external_calendar_refresh.clone();
|
||||||
|
let cal_id = cal.id;
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
on_refresh.emit(cal_id);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={props.refreshing_calendar_id == Some(cal.id)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
if props.refreshing_calendar_id == Some(cal.id) {
|
||||||
|
html! { <i class="fas fa-spinner fa-spin"></i> }
|
||||||
|
} else {
|
||||||
|
html! { <i class="fas fa-sync-alt"></i> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
if *external_context_menu_open == Some(cal.id) {
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
class="context-menu-overlay"
|
||||||
|
onclick={close_external_context_menu.clone()}
|
||||||
|
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 999;"
|
||||||
|
/>
|
||||||
|
<div class="context-menu" style="position: absolute; top: 0; right: 0; background: white; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 1000; min-width: 120px;">
|
||||||
|
<div
|
||||||
|
class="context-menu-item"
|
||||||
|
style="padding: 8px 12px; cursor: pointer; color: #d73a49;"
|
||||||
|
onclick={{
|
||||||
|
let on_delete = on_external_calendar_delete.clone();
|
||||||
|
let cal_id = cal.id;
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_delete.emit(cal_id);
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{"Delete Calendar"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
|
<button onclick={props.on_add_calendar.reform(|_| ())} class="add-calendar-button">
|
||||||
{"+ Create Calendar"}
|
{"+ Add Calendar"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="view-selector">
|
<div class="view-selector">
|
||||||
@@ -228,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>
|
||||||
|
|
||||||
@@ -235,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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal};
|
use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal};
|
||||||
use crate::models::ical::VEvent;
|
use crate::models::ical::VEvent;
|
||||||
use crate::services::calendar_service::UserInfo;
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
||||||
use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday};
|
use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
@@ -17,6 +17,8 @@ pub struct WeekViewProps {
|
|||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub user_info: Option<UserInfo>,
|
pub user_info: Option<UserInfo>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
|
pub external_calendars: Vec<ExternalCalendar>,
|
||||||
|
#[prop_or_default]
|
||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
||||||
@@ -31,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>,
|
||||||
)>,
|
)>,
|
||||||
@@ -40,6 +42,12 @@ pub struct WeekViewProps {
|
|||||||
pub context_menus_open: bool,
|
pub context_menus_open: bool,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub time_increment: u32,
|
pub time_increment: u32,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub print_mode: bool,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub print_pixels_per_hour: Option<f64>,
|
||||||
|
#[prop_or_default]
|
||||||
|
pub print_start_hour: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
@@ -79,10 +87,47 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
|
|
||||||
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
|
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
|
||||||
|
|
||||||
|
// Current time state for time indicator
|
||||||
|
let current_time = use_state(|| Local::now());
|
||||||
|
|
||||||
|
// Update current time every 5 seconds
|
||||||
|
{
|
||||||
|
let current_time = current_time.clone();
|
||||||
|
use_effect_with((), move |_| {
|
||||||
|
let interval = gloo_timers::callback::Interval::new(5_000, move || {
|
||||||
|
current_time.set(Local::now());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the interval to keep it alive
|
||||||
|
move || drop(interval)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to calculate current time indicator position
|
||||||
|
let calculate_current_time_position = |time_increment: u32| -> f64 {
|
||||||
|
let now = current_time.time();
|
||||||
|
let hour = now.hour() as f64;
|
||||||
|
let minute = now.minute() as f64;
|
||||||
|
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
|
||||||
|
(hour + minute / 60.0) * pixels_per_hour
|
||||||
|
};
|
||||||
|
|
||||||
// Helper function to get calendar color for an event
|
// Helper function to get calendar color for an event
|
||||||
let get_event_color = |event: &VEvent| -> String {
|
let get_event_color = |event: &VEvent| -> String {
|
||||||
if let Some(user_info) = &props.user_info {
|
if let Some(calendar_path) = &event.calendar_path {
|
||||||
if let Some(calendar_path) = &event.calendar_path {
|
// Check external calendars first (path format: "external_{id}")
|
||||||
|
if calendar_path.starts_with("external_") {
|
||||||
|
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
|
||||||
|
if let Some(external_calendar) = props.external_calendars
|
||||||
|
.iter()
|
||||||
|
.find(|cal| cal.id == id_str)
|
||||||
|
{
|
||||||
|
return external_calendar.color.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check regular calendars
|
||||||
|
else if let Some(user_info) = &props.user_info {
|
||||||
if let Some(calendar) = user_info
|
if let Some(calendar) = user_info
|
||||||
.calendars
|
.calendars
|
||||||
.iter()
|
.iter()
|
||||||
@@ -95,8 +140,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
"#3B82F6".to_string()
|
"#3B82F6".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate time labels - 24 hours plus the final midnight boundary
|
// Generate time labels - 24 hours
|
||||||
let mut time_labels: Vec<String> = (0..24)
|
let time_labels: Vec<String> = (0..24)
|
||||||
.map(|hour| {
|
.map(|hour| {
|
||||||
if hour == 0 {
|
if hour == 0 {
|
||||||
"12 AM".to_string()
|
"12 AM".to_string()
|
||||||
@@ -110,9 +155,6 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Add the final midnight boundary to show where the day ends
|
|
||||||
time_labels.push("12 AM".to_string());
|
|
||||||
|
|
||||||
// Handlers for recurring event modification modal
|
// Handlers for recurring event modification modal
|
||||||
let on_recurring_choice = {
|
let on_recurring_choice = {
|
||||||
let pending_recurring_edit = pending_recurring_edit.clone();
|
let pending_recurring_edit = pending_recurring_edit.clone();
|
||||||
@@ -243,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
|
||||||
@@ -275,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
|
||||||
));
|
));
|
||||||
@@ -310,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
|
||||||
@@ -319,10 +358,19 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
week_days.iter().map(|date| {
|
week_days.iter().map(|date| {
|
||||||
let is_today = *date == props.today;
|
let is_today = *date == props.today;
|
||||||
let weekday_name = get_weekday_name(date.weekday());
|
let weekday_name = get_weekday_name(date.weekday());
|
||||||
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
|
||||||
|
|
||||||
// Filter for all-day events only
|
// Collect all-day events that span this date (from any day in the week)
|
||||||
let all_day_events: Vec<_> = day_events.iter().filter(|event| event.all_day).collect();
|
let mut all_day_events: Vec<&VEvent> = Vec::new();
|
||||||
|
for events_list in props.events.values() {
|
||||||
|
for event in events_list {
|
||||||
|
if event.all_day && event_spans_date(event, *date) {
|
||||||
|
all_day_events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove duplicates (same event might appear in multiple day buckets)
|
||||||
|
all_day_events.sort_by_key(|e| &e.uid);
|
||||||
|
all_day_events.dedup_by_key(|e| &e.uid);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
||||||
@@ -365,6 +413,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
<div
|
<div
|
||||||
class="all-day-event"
|
class="all-day-event"
|
||||||
style={format!("background-color: {}", event_color)}
|
style={format!("background-color: {}", event_color)}
|
||||||
|
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||||
{onclick}
|
{onclick}
|
||||||
{oncontextmenu}
|
{oncontextmenu}
|
||||||
>
|
>
|
||||||
@@ -388,14 +437,17 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
|
|
||||||
// Scrollable content area with time grid
|
// Scrollable content area with time grid
|
||||||
<div class="week-content">
|
<div class="week-content">
|
||||||
<div class="time-grid">
|
<div class={classes!("time-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
||||||
// Time labels
|
// Time labels
|
||||||
<div class="time-labels">
|
<div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
||||||
{
|
{
|
||||||
time_labels.iter().enumerate().map(|(index, time)| {
|
time_labels.iter().enumerate().map(|(hour, time)| {
|
||||||
let is_final = index == time_labels.len() - 1;
|
let is_quarter_mode = props.time_increment == 15;
|
||||||
html! {
|
html! {
|
||||||
<div class={classes!("time-label", if is_final { Some("final-boundary") } else { None })}>
|
<div class={classes!(
|
||||||
|
"time-label",
|
||||||
|
if is_quarter_mode { Some("quarter-mode") } else { None }
|
||||||
|
)} data-hour={hour.to_string()}>
|
||||||
{time}
|
{time}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -404,12 +456,12 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Day columns
|
// Day columns
|
||||||
<div class="week-days-grid">
|
<div class={classes!("week-days-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
||||||
{
|
{
|
||||||
week_days.iter().enumerate().map(|(_column_index, date)| {
|
week_days.iter().enumerate().map(|(_column_index, date)| {
|
||||||
let is_today = *date == props.today;
|
let is_today = *date == props.today;
|
||||||
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
||||||
let event_layouts = calculate_event_layout(&day_events, *date);
|
let event_layouts = calculate_event_layout(&day_events, *date, props.time_increment);
|
||||||
|
|
||||||
// Drag event handlers
|
// Drag event handlers
|
||||||
let drag_state_clone = drag_state.clone();
|
let drag_state_clone = drag_state.clone();
|
||||||
@@ -500,8 +552,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
match ¤t_drag.drag_type {
|
match ¤t_drag.drag_type {
|
||||||
DragType::CreateEvent => {
|
DragType::CreateEvent => {
|
||||||
// Calculate start and end times
|
// Calculate start and end times
|
||||||
let start_time = pixels_to_time(current_drag.start_y);
|
let start_time = pixels_to_time(current_drag.start_y, time_increment);
|
||||||
let end_time = pixels_to_time(current_drag.current_y);
|
let end_time = pixels_to_time(current_drag.current_y, time_increment);
|
||||||
|
|
||||||
// Ensure start is before end
|
// Ensure start is before end
|
||||||
let (actual_start, actual_end) = if start_time <= end_time {
|
let (actual_start, actual_end) = if start_time <= end_time {
|
||||||
@@ -529,7 +581,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let unsnapped_position = current_drag.current_y - current_drag.offset_y;
|
let unsnapped_position = current_drag.current_y - current_drag.offset_y;
|
||||||
// Snap the final position to maintain time increment alignment
|
// Snap the final position to maintain time increment alignment
|
||||||
let event_top_position = snap_to_increment(unsnapped_position, time_increment);
|
let event_top_position = snap_to_increment(unsnapped_position, time_increment);
|
||||||
let new_start_time = pixels_to_time(event_top_position);
|
let new_start_time = pixels_to_time(event_top_position, time_increment);
|
||||||
|
|
||||||
// Calculate duration from original event
|
// Calculate duration from original event
|
||||||
let original_duration = if let Some(end) = event.dtend {
|
let original_duration = if let Some(end) = event.dtend {
|
||||||
@@ -558,14 +610,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
},
|
},
|
||||||
DragType::ResizeEventStart(event) => {
|
DragType::ResizeEventStart(event) => {
|
||||||
// Calculate new start time based on drag position
|
// Calculate new start time based on drag position
|
||||||
let new_start_time = pixels_to_time(current_drag.current_y);
|
let new_start_time = pixels_to_time(current_drag.current_y, time_increment);
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -594,10 +645,10 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
},
|
},
|
||||||
DragType::ResizeEventEnd(event) => {
|
DragType::ResizeEventEnd(event) => {
|
||||||
// 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);
|
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);
|
||||||
|
|
||||||
@@ -643,7 +694,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
class={classes!(
|
class={classes!(
|
||||||
"week-day-column",
|
"week-day-column",
|
||||||
if is_today { Some("today") } else { None },
|
if is_today { Some("today") } else { None },
|
||||||
if is_creating_event { Some("creating-event") } else { None }
|
if is_creating_event { Some("creating-event") } else { None },
|
||||||
|
if props.time_increment == 15 { Some("quarter-mode") } else { None }
|
||||||
)}
|
)}
|
||||||
{onmousedown}
|
{onmousedown}
|
||||||
{onmousemove}
|
{onmousemove}
|
||||||
@@ -651,11 +703,22 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
>
|
>
|
||||||
// Time slot backgrounds - 24 hour slots to represent full day
|
// Time slot backgrounds - 24 hour slots to represent full day
|
||||||
{
|
{
|
||||||
(0..24).map(|_hour| {
|
(0..24).map(|hour| {
|
||||||
|
let slots_per_hour = 60 / props.time_increment;
|
||||||
html! {
|
html! {
|
||||||
<div class="time-slot">
|
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })} data-hour={hour.to_string()}>
|
||||||
<div class="time-slot-half"></div>
|
{
|
||||||
<div class="time-slot-half"></div>
|
(0..slots_per_hour).map(|_slot| {
|
||||||
|
let slot_class = if props.time_increment == 15 {
|
||||||
|
"time-slot-quarter"
|
||||||
|
} else {
|
||||||
|
"time-slot-half"
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<div class={slot_class}></div>
|
||||||
|
}
|
||||||
|
}).collect::<Html>()
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
@@ -665,7 +728,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
<div class="events-container">
|
<div class="events-container">
|
||||||
{
|
{
|
||||||
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
|
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
|
||||||
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
||||||
|
|
||||||
// Skip all-day events (they're rendered in the header)
|
// Skip all-day events (they're rendered in the header)
|
||||||
if is_all_day {
|
if is_all_day {
|
||||||
@@ -693,7 +756,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let drag_state = drag_state.clone();
|
let drag_state = drag_state.clone();
|
||||||
let event_for_drag = event.clone();
|
let event_for_drag = event.clone();
|
||||||
let date_for_drag = *date;
|
let date_for_drag = *date;
|
||||||
let _time_increment = props.time_increment;
|
let time_increment = props.time_increment;
|
||||||
|
let print_pixels_per_hour = props.print_pixels_per_hour;
|
||||||
|
let print_start_hour = props.print_start_hour;
|
||||||
Callback::from(move |e: MouseEvent| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
||||||
|
|
||||||
@@ -707,7 +772,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
|
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
|
||||||
|
|
||||||
// Get event's current position in day column coordinates
|
// Get event's current position in day column coordinates
|
||||||
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag);
|
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment, print_pixels_per_hour, print_start_hour);
|
||||||
let event_start_pixels = event_start_pixels as f64;
|
let event_start_pixels = event_start_pixels as f64;
|
||||||
|
|
||||||
// Convert click position to day column coordinates
|
// Convert click position to day column coordinates
|
||||||
@@ -758,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;
|
||||||
@@ -884,6 +949,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
column_width
|
column_width
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||||
{onclick}
|
{onclick}
|
||||||
{oncontextmenu}
|
{oncontextmenu}
|
||||||
onmousedown={onmousedown_event}
|
onmousedown={onmousedown_event}
|
||||||
@@ -902,8 +968,13 @@ 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">
|
||||||
{if !is_all_day {
|
<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 {
|
||||||
html! { <div class="event-time">{time_display}</div> }
|
html! { <div class="event-time">{time_display}</div> }
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
@@ -939,8 +1010,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let height = (drag.current_y - drag.start_y).abs().max(20.0);
|
let height = (drag.current_y - drag.start_y).abs().max(20.0);
|
||||||
|
|
||||||
// Convert pixels to times for display
|
// Convert pixels to times for display
|
||||||
let start_time = pixels_to_time(start_y);
|
let start_time = pixels_to_time(start_y, props.time_increment);
|
||||||
let end_time = pixels_to_time(end_y);
|
let end_time = pixels_to_time(end_y, props.time_increment);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div
|
<div
|
||||||
@@ -956,7 +1027,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
let unsnapped_position = drag.current_y - drag.offset_y;
|
let unsnapped_position = drag.current_y - drag.offset_y;
|
||||||
// Snap the final position to maintain time increment alignment
|
// Snap the final position to maintain time increment alignment
|
||||||
let preview_position = snap_to_increment(unsnapped_position, props.time_increment);
|
let preview_position = snap_to_increment(unsnapped_position, props.time_increment);
|
||||||
let new_start_time = pixels_to_time(preview_position);
|
let new_start_time = pixels_to_time(preview_position, props.time_increment);
|
||||||
let original_duration = if let Some(end) = event.dtend {
|
let original_duration = if let Some(end) = event.dtend {
|
||||||
end.signed_duration_since(event.dtstart)
|
end.signed_duration_since(event.dtstart)
|
||||||
} else {
|
} else {
|
||||||
@@ -971,24 +1042,28 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
<div
|
<div
|
||||||
class="temp-event-box moving-event"
|
class="temp-event-box moving-event"
|
||||||
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", preview_position, duration_pixels, event_color)}
|
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", preview_position, duration_pixels, event_color)}
|
||||||
|
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||||
>
|
>
|
||||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||||
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
|
{if duration_pixels > 30.0 {
|
||||||
|
html! { <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> }
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
DragType::ResizeEventStart(event) => {
|
DragType::ResizeEventStart(event) => {
|
||||||
// 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);
|
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);
|
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;
|
||||||
@@ -1000,19 +1075,24 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
<div
|
<div
|
||||||
class="temp-event-box resizing-event"
|
class="temp-event-box resizing-event"
|
||||||
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", new_start_pixels, new_height, event_color)}
|
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", new_start_pixels, new_height, event_color)}
|
||||||
|
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||||
>
|
>
|
||||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||||
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div>
|
{if new_height > 30.0 {
|
||||||
|
html! { <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div> }
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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);
|
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);
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
|
||||||
|
|
||||||
let new_end_pixels = drag.current_y;
|
let new_end_pixels = drag.current_y;
|
||||||
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
|
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
|
||||||
@@ -1023,9 +1103,14 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
<div
|
<div
|
||||||
class="temp-event-box resizing-event"
|
class="temp-event-box resizing-event"
|
||||||
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", original_start_pixels, new_height, event_color)}
|
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", original_start_pixels, new_height, event_color)}
|
||||||
|
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
||||||
>
|
>
|
||||||
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
||||||
<div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
|
{if new_height > 30.0 {
|
||||||
|
html! { <div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> }
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1037,6 +1122,29 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Current time indicator - only show on today
|
||||||
|
{
|
||||||
|
if *date == props.today {
|
||||||
|
let current_time_position = calculate_current_time_position(props.time_increment);
|
||||||
|
let current_time_str = current_time.time().format("%I:%M %p").to_string();
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="current-time-indicator-container">
|
||||||
|
<div
|
||||||
|
class="current-time-indicator"
|
||||||
|
style={format!("top: {}px;", current_time_position)}
|
||||||
|
>
|
||||||
|
<div class="current-time-dot"></div>
|
||||||
|
<div class="current-time-line"></div>
|
||||||
|
<div class="current-time-label">{current_time_str}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
@@ -1089,22 +1197,25 @@ fn get_weekday_name(weekday: Weekday) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the pixel position of an event based on its time
|
// Calculate the pixel position of an event based on its time
|
||||||
// Each hour is 60px, so we convert time to pixels
|
// Snap pixel position based on time increment and grid scaling
|
||||||
// Snap pixel position to 15-minute increments (15px = 15 minutes since 60px = 60 minutes)
|
// In 30-minute mode: 60px per hour (1px = 1 minute)
|
||||||
|
// In 15-minute mode: 120px per hour (2px = 1 minute)
|
||||||
fn snap_to_increment(pixels: f64, increment: u32) -> f64 {
|
fn snap_to_increment(pixels: f64, increment: u32) -> f64 {
|
||||||
let increment_px = increment as f64; // Convert to pixels (1px = 1 minute)
|
let pixels_per_minute = if increment == 15 { 2.0 } else { 1.0 };
|
||||||
|
let increment_px = increment as f64 * pixels_per_minute;
|
||||||
(pixels / increment_px).round() * increment_px
|
(pixels / increment_px).round() * increment_px
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert pixel position to time (inverse of time to pixels)
|
// Convert pixel position to time (inverse of time to pixels)
|
||||||
fn pixels_to_time(pixels: f64) -> NaiveTime {
|
fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
|
||||||
// Since 60px = 1 hour, pixels directly represent minutes
|
let pixels_per_minute = if time_increment == 15 { 2.0 } else { 1.0 };
|
||||||
let total_minutes = pixels; // 1px = 1 minute
|
let total_minutes = pixels / pixels_per_minute;
|
||||||
let hours = (total_minutes / 60.0) as u32;
|
let hours = (total_minutes / 60.0) as u32;
|
||||||
let minutes = (total_minutes % 60.0) as u32;
|
let minutes = (total_minutes % 60.0) as u32;
|
||||||
|
|
||||||
// Handle midnight boundary - if we're at exactly 1440 pixels (24:00), return midnight
|
// Handle midnight boundary - check against scaled boundary
|
||||||
if total_minutes >= 1440.0 {
|
let max_pixels = 1440.0 * pixels_per_minute; // 24 hours in pixels
|
||||||
|
if pixels >= max_pixels {
|
||||||
return NaiveTime::from_hms_opt(0, 0, 0).unwrap();
|
return NaiveTime::from_hms_opt(0, 0, 0).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1115,18 +1226,15 @@ fn pixels_to_time(pixels: f64) -> NaiveTime {
|
|||||||
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (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;
|
||||||
let event_date = local_start.date_naive();
|
|
||||||
|
|
||||||
// Position events based on when they appear in local time, not their original date
|
|
||||||
// For timezone issues: an event created at 10 PM Sunday might be stored as Monday UTC
|
|
||||||
// but should still display on Sunday's column since that's when the user sees it
|
|
||||||
let should_display_here = event_date == date ||
|
|
||||||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20);
|
|
||||||
|
|
||||||
if !should_display_here {
|
// Events should display based on their local date, since we now store proper UTC times
|
||||||
|
// Convert the UTC stored time back to local time to determine display date
|
||||||
|
let event_date = local_start.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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1135,45 +1243,64 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
|
|||||||
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
|
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate start position in pixels from midnight
|
// Calculate start position in pixels
|
||||||
let start_hour = local_start.hour() as f32;
|
let start_hour = local_start.hour() as f32;
|
||||||
let start_minute = local_start.minute() as f32;
|
let start_minute = local_start.minute() as f32;
|
||||||
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
|
let pixels_per_hour = if let Some(print_pph) = print_pixels_per_hour {
|
||||||
|
print_pph as f32 // Use the dynamic print mode calculation
|
||||||
|
} else {
|
||||||
|
if time_increment == 15 { 120.0 } else { 60.0 } // Default values
|
||||||
|
};
|
||||||
|
|
||||||
|
// In print mode, offset by the start hour to show relative position within visible range
|
||||||
|
let hour_offset = if let Some(print_start) = print_start_hour {
|
||||||
|
print_start as f32
|
||||||
|
} else {
|
||||||
|
0.0 // No offset for normal view (starts at midnight)
|
||||||
|
};
|
||||||
|
|
||||||
|
let start_pixels = ((start_hour + start_minute / 60.0) - hour_offset) * pixels_per_hour;
|
||||||
|
|
||||||
// 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 {
|
||||||
// Event continues past midnight, cap at 24:00 (1440px)
|
// Event continues past midnight, cap at end of visible range
|
||||||
1440.0 - start_pixels
|
let max_hour = if let Some(_print_start) = print_start_hour { 24.0 } else { 24.0 };
|
||||||
|
let max_pixels = (max_hour - hour_offset) * pixels_per_hour;
|
||||||
|
(max_pixels - start_pixels).max(20.0)
|
||||||
} else {
|
} else {
|
||||||
let end_hour = local_end.hour() as f32;
|
let end_hour = local_end.hour() as f32;
|
||||||
let end_minute = local_end.minute() as f32;
|
let end_minute = local_end.minute() as f32;
|
||||||
let end_pixels = (end_hour + end_minute / 60.0) * 60.0;
|
let end_pixels = ((end_hour + end_minute / 60.0) - hour_offset) * pixels_per_hour;
|
||||||
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
|
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
60.0 // Default 1 hour if no end time
|
pixels_per_hour // Default 1 hour if no end time
|
||||||
};
|
};
|
||||||
|
|
||||||
(start_pixels, duration_pixels, false) // is_all_day = false
|
(start_pixels, duration_pixels, false) // is_all_day = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if two events overlap in time
|
// Check if two events overlap in time
|
||||||
fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
|
fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
|
||||||
let start1 = event1.dtstart.with_timezone(&Local).naive_local();
|
// All-day events don't overlap with timed events for width calculation purposes
|
||||||
|
if event1.all_day || event2.all_day {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
};
|
};
|
||||||
@@ -1183,15 +1310,20 @@ fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate layout columns for overlapping events
|
// Calculate layout columns for overlapping events
|
||||||
fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usize)> {
|
fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u32) -> Vec<(usize, usize)> {
|
||||||
|
|
||||||
// Filter and sort events that should appear on this date
|
// Filter and sort events that should appear on this date (excluding all-day events)
|
||||||
let mut day_events: Vec<_> = events.iter()
|
let mut day_events: Vec<_> = events.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(idx, event)| {
|
.filter_map(|(idx, event)| {
|
||||||
let (_, _, _) = calculate_event_position(event, date);
|
// Skip all-day events as they don't participate in timed event overlap calculations
|
||||||
let local_start = event.dtstart.with_timezone(&Local);
|
if event.all_day {
|
||||||
let event_date = local_start.date_naive();
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, _, _) = calculate_event_position(event, date, time_increment, None, None);
|
||||||
|
let local_start = event.dtstart;
|
||||||
|
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))
|
||||||
@@ -1202,7 +1334,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usi
|
|||||||
.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)
|
||||||
@@ -1227,7 +1359,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usi
|
|||||||
} 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();
|
||||||
@@ -1269,3 +1401,31 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usi
|
|||||||
|
|
||||||
event_columns
|
event_columns
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if an all-day event spans the given date
|
||||||
|
fn event_spans_date(event: &VEvent, date: NaiveDate) -> bool {
|
||||||
|
let start_date = if event.all_day {
|
||||||
|
// 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
|
||||||
|
event.dtstart.date()
|
||||||
|
} else {
|
||||||
|
event.dtstart.date()
|
||||||
|
};
|
||||||
|
|
||||||
|
let end_date = if let Some(dtend) = event.dtend {
|
||||||
|
if event.all_day {
|
||||||
|
// 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
|
||||||
|
dtend.date() - chrono::Duration::days(1)
|
||||||
|
} else {
|
||||||
|
// For timed events, use timezone conversion
|
||||||
|
dtend.date()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single day event
|
||||||
|
start_date
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if the given date falls within the event's date range
|
||||||
|
date >= start_date && date <= end_date
|
||||||
|
}
|
||||||
|
|||||||
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,12 +1,13 @@
|
|||||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday};
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use wasm_bindgen::JsCast;
|
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;
|
||||||
@@ -36,6 +37,12 @@ pub struct UserInfo {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
pub calendars: Vec<CalendarInfo>,
|
pub calendars: Vec<CalendarInfo>,
|
||||||
|
#[serde(default = "default_timestamp")]
|
||||||
|
pub last_updated: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_timestamp() -> u64 {
|
||||||
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -43,6 +50,7 @@ pub struct CalendarInfo {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub color: String,
|
pub color: String,
|
||||||
|
pub is_visible: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalendarEvent, EventStatus, and EventClass are now imported from shared library
|
// CalendarEvent, EventStatus, and EventClass are now imported from shared library
|
||||||
@@ -239,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!(
|
||||||
@@ -267,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(30); // Show past 30 days
|
let start_range = today - Duration::days(36500); // Show past 100 years (to catch any historical yearly events)
|
||||||
let end_range = today + Duration::days(365); // Show next 365 days
|
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
|
||||||
@@ -329,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(';')
|
||||||
@@ -364,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(),
|
||||||
@@ -383,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;
|
||||||
|
|
||||||
@@ -398,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,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 {
|
||||||
@@ -547,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();
|
||||||
@@ -557,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 =
|
||||||
@@ -598,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -615,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 {
|
||||||
@@ -667,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();
|
||||||
@@ -683,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;
|
||||||
@@ -724,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -741,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
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -784,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;
|
||||||
@@ -822,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
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -863,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();
|
||||||
@@ -879,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;
|
||||||
|
|
||||||
@@ -922,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
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1246,10 +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_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")?;
|
||||||
|
|
||||||
@@ -1277,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": None as Option<String>, // No end date by default
|
"recurrence_end_date": recurrence_until,
|
||||||
"recurrence_count": None as Option<u32>, // No count limit by default
|
"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)
|
||||||
@@ -1304,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)
|
||||||
@@ -1381,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(
|
||||||
@@ -1408,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
|
||||||
}
|
}
|
||||||
@@ -1438,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")?;
|
||||||
|
|
||||||
@@ -1469,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);
|
||||||
|
|
||||||
@@ -1674,11 +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_interval: u32,
|
||||||
|
recurrence_count: Option<u32>,
|
||||||
|
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")?;
|
||||||
|
|
||||||
@@ -1702,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": vec![false; 7], // Default - could be enhanced
|
"recurrence_days": recurrence_days,
|
||||||
"recurrence_interval": 1_u32, // Default interval
|
"recurrence_interval": recurrence_interval,
|
||||||
"recurrence_end_date": None as Option<String>, // No end date by default
|
"recurrence_end_date": recurrence_until,
|
||||||
"recurrence_count": None as Option<u32>, // No count limit by default
|
"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);
|
||||||
@@ -1849,4 +1849,256 @@ impl CalendarService {
|
|||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== EXTERNAL CALENDAR METHODS ====================
|
||||||
|
|
||||||
|
pub async fn get_external_calendars() -> Result<Vec<ExternalCalendar>, String> {
|
||||||
|
let token = LocalStorage::get::<String>("auth_token")
|
||||||
|
.map_err(|_| "No authentication token found".to_string())?;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("GET");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let service = Self::new();
|
||||||
|
let url = format!("{}/external-calendars", service.base_url);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP error: {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = JsFuture::from(resp.json().unwrap())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let external_calendars: Vec<ExternalCalendar> = serde_wasm_bindgen::from_value(json)
|
||||||
|
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(external_calendars)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_external_calendar(name: &str, url: &str, color: &str) -> Result<ExternalCalendar, String> {
|
||||||
|
let token = LocalStorage::get::<String>("auth_token")
|
||||||
|
.map_err(|_| "No authentication token found".to_string())?;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("POST");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"url": url,
|
||||||
|
"color": color
|
||||||
|
});
|
||||||
|
|
||||||
|
let service = Self::new();
|
||||||
|
let body_string = serde_json::to_string(&body)
|
||||||
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
opts.set_body(&body_string.into());
|
||||||
|
|
||||||
|
let url = format!("{}/external-calendars", service.base_url);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP error: {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = JsFuture::from(resp.json().unwrap())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let external_calendar: ExternalCalendar = serde_wasm_bindgen::from_value(json)
|
||||||
|
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(external_calendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_external_calendar(
|
||||||
|
id: i32,
|
||||||
|
name: &str,
|
||||||
|
url: &str,
|
||||||
|
color: &str,
|
||||||
|
is_visible: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let token = LocalStorage::get::<String>("auth_token")
|
||||||
|
.map_err(|_| "No authentication token found".to_string())?;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("POST");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"url": url,
|
||||||
|
"color": color,
|
||||||
|
"is_visible": is_visible
|
||||||
|
});
|
||||||
|
|
||||||
|
let service = Self::new();
|
||||||
|
let body_string = serde_json::to_string(&body)
|
||||||
|
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||||
|
opts.set_body(&body_string.into());
|
||||||
|
|
||||||
|
let url = format!("{}/external-calendars/{}", service.base_url, id);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP error: {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_external_calendar(id: i32) -> Result<(), String> {
|
||||||
|
let token = LocalStorage::get::<String>("auth_token")
|
||||||
|
.map_err(|_| "No authentication token found".to_string())?;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("DELETE");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let service = Self::new();
|
||||||
|
let url = format!("{}/external-calendars/{}", service.base_url, id);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP error: {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_external_calendar_events(id: i32) -> Result<Vec<VEvent>, String> {
|
||||||
|
let token = LocalStorage::get::<String>("auth_token")
|
||||||
|
.map_err(|_| "No authentication token found".to_string())?;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("No global window exists")?;
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("GET");
|
||||||
|
opts.set_mode(RequestMode::Cors);
|
||||||
|
|
||||||
|
let service = Self::new();
|
||||||
|
let url = format!("{}/external-calendars/{}/events", service.base_url, id);
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("Request creation failed: {:?}", e))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.set("Authorization", &format!("Bearer {}", token))
|
||||||
|
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response casting failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP error: {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = JsFuture::from(resp.json().unwrap())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ExternalCalendarEventsResponse {
|
||||||
|
events: Vec<VEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)
|
||||||
|
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
|
||||||
|
|
||||||
|
Ok(response.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ExternalCalendar {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub color: String,
|
||||||
|
pub is_visible: bool,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub last_fetched: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ pub struct UserPreferences {
|
|||||||
pub calendar_view_mode: Option<String>,
|
pub calendar_view_mode: Option<String>,
|
||||||
pub calendar_theme: Option<String>,
|
pub calendar_theme: Option<String>,
|
||||||
pub calendar_colors: Option<String>,
|
pub calendar_colors: Option<String>,
|
||||||
|
pub last_used_calendar: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -22,6 +23,7 @@ pub struct UpdatePreferencesRequest {
|
|||||||
pub calendar_view_mode: Option<String>,
|
pub calendar_view_mode: Option<String>,
|
||||||
pub calendar_theme: Option<String>,
|
pub calendar_theme: Option<String>,
|
||||||
pub calendar_colors: Option<String>,
|
pub calendar_colors: Option<String>,
|
||||||
|
pub last_used_calendar: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -61,6 +63,7 @@ impl PreferencesService {
|
|||||||
calendar_view_mode: None,
|
calendar_view_mode: None,
|
||||||
calendar_theme: None,
|
calendar_theme: None,
|
||||||
calendar_colors: None,
|
calendar_colors: None,
|
||||||
|
last_used_calendar: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the specific field
|
// Update the specific field
|
||||||
@@ -95,6 +98,7 @@ impl PreferencesService {
|
|||||||
calendar_view_mode: preferences.calendar_view_mode.clone(),
|
calendar_view_mode: preferences.calendar_view_mode.clone(),
|
||||||
calendar_theme: preferences.calendar_theme.clone(),
|
calendar_theme: preferences.calendar_theme.clone(),
|
||||||
calendar_colors: preferences.calendar_colors.clone(),
|
calendar_colors: preferences.calendar_colors.clone(),
|
||||||
|
last_used_calendar: preferences.last_used_calendar.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.sync_preferences(&session_token, &request).await
|
self.sync_preferences(&session_token, &request).await
|
||||||
@@ -156,6 +160,7 @@ impl PreferencesService {
|
|||||||
calendar_view_mode: LocalStorage::get::<String>("calendar_view_mode").ok(),
|
calendar_view_mode: LocalStorage::get::<String>("calendar_view_mode").ok(),
|
||||||
calendar_theme: LocalStorage::get::<String>("calendar_theme").ok(),
|
calendar_theme: LocalStorage::get::<String>("calendar_theme").ok(),
|
||||||
calendar_colors: LocalStorage::get::<String>("calendar_colors").ok(),
|
calendar_colors: LocalStorage::get::<String>("calendar_colors").ok(),
|
||||||
|
last_used_calendar: LocalStorage::get::<String>("last_used_calendar").ok(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only migrate if we have some preferences to migrate
|
// Only migrate if we have some preferences to migrate
|
||||||
@@ -164,6 +169,7 @@ impl PreferencesService {
|
|||||||
|| request.calendar_view_mode.is_some()
|
|| request.calendar_view_mode.is_some()
|
||||||
|| request.calendar_theme.is_some()
|
|| request.calendar_theme.is_some()
|
||||||
|| request.calendar_colors.is_some()
|
|| request.calendar_colors.is_some()
|
||||||
|
|| request.last_used_calendar.is_some()
|
||||||
{
|
{
|
||||||
self.sync_preferences(&session_token, &request).await?;
|
self.sync_preferences(&session_token, &request).await?;
|
||||||
|
|
||||||
@@ -177,4 +183,24 @@ impl PreferencesService {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the last used calendar and sync with backend
|
||||||
|
pub async fn update_last_used_calendar(&self, calendar_path: &str) -> Result<(), String> {
|
||||||
|
// Get session token
|
||||||
|
let session_token = LocalStorage::get::<String>("session_token")
|
||||||
|
.map_err(|_| "No session token found".to_string())?;
|
||||||
|
|
||||||
|
// Create minimal update request with only the last used calendar
|
||||||
|
let request = UpdatePreferencesRequest {
|
||||||
|
calendar_selected_date: None,
|
||||||
|
calendar_time_increment: None,
|
||||||
|
calendar_view_mode: None,
|
||||||
|
calendar_theme: None,
|
||||||
|
calendar_colors: None,
|
||||||
|
last_used_calendar: Some(calendar_path.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync to backend
|
||||||
|
self.sync_preferences(&session_token, &request).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
3198
frontend/styles.css
3198
frontend/styles.css
File diff suppressed because it is too large
Load Diff
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;
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/* Base Styles - Always Loaded */
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
color: #333;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-layout {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base Layout */
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 280px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Basic Form Elements */
|
|
||||||
input, select, textarea, button {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utility Classes */
|
|
||||||
.loading {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success {
|
|
||||||
color: #28a745;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user