Compare commits
	
		
			54 Commits
		
	
	
		
			d089f1545b
			...
			bugfix/ext
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 91be4436a9 | ||
|   | 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 | ||
|   | 419cb3d790 | ||
|   | 53a62fb05e | ||
|   | 322c88612a | ||
|   | 4aa53d79e7 | ||
|   | 3464754489 | ||
|   | e56253b9c2 | ||
|   | cb8cc7258c | ||
|   | b576cd8c4a | ||
|   | a773159016 | ||
|   | a9521ad536 | ||
|   | 5456d7140c | ||
|   | 62cc910e1a | ||
|   | 6ec7bb5422 | ||
|   | ce74750d85 | 
| @@ -18,17 +18,18 @@ jobs: | |||||||
|       - name: Login to Docker Registry |       - name: Login to Docker Registry | ||||||
|         uses: docker/login-action@v3 |         uses: docker/login-action@v3 | ||||||
|         with: |         with: | ||||||
|           registry: ${{ secrets.DOCKER_REGISTRY }} |           registry: ${{ vars.REGISTRY }} | ||||||
|           username: ${{ secrets.DOCKER_USERNAME }} |           username: ${{ vars.USERNAME }} | ||||||
|           password: ${{ secrets.DOCKER_PASSWORD }} |           password: ${{ secrets.DOCKER_PASSWORD }} | ||||||
|  |  | ||||||
|       - name: Build and push |       - name: Build and push | ||||||
|         uses: docker/build-push-action@v5 |         uses: docker/build-push-action@v5 | ||||||
|         with: |         with: | ||||||
|           context: . |           context: . | ||||||
|  |           file: ./backend/Dockerfile | ||||||
|           push: true |           push: true | ||||||
|           tags: | |           tags: | | ||||||
|             ${{ secrets.DOCKER_REGISTRY }}/connor/calendar:latest |             ${{ vars.REGISTRY }}/connor/calendar:latest | ||||||
|             ${{ secrets.DOCKER_REGISTRY }}/connor/calendar:${{ github.sha }} |             ${{ vars.REGISTRY }}/connor/calendar:${{ github.sha }} | ||||||
|           cache-from: type=gha |           cache-from: type=gha | ||||||
|           cache-to: type=gha,mode=max |           cache-to: type=gha,mode=max | ||||||
|   | |||||||
| @@ -5,6 +5,11 @@ | |||||||
| } | } | ||||||
|  |  | ||||||
| :80, :443 { | :80, :443 { | ||||||
|  |     @backend { | ||||||
|  |         path /api /api/* | ||||||
|  |     } | ||||||
|  |     reverse_proxy @backend calendar-backend:3000 | ||||||
|  |     try_files {path} /index.html | ||||||
|     root * /srv/www |     root * /srv/www | ||||||
|     file_server |     file_server | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										109
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										109
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -1,109 +0,0 @@ | |||||||
| # Build stage |  | ||||||
| # ----------------------------------------------------------- |  | ||||||
| FROM rust:alpine AS builder |  | ||||||
|  |  | ||||||
| RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static nodejs npm |  | ||||||
|  |  | ||||||
| # Install trunk ahead of the compilation. This may break and then you'll have to update the version. |  | ||||||
| RUN cargo install trunk@0.21.14 wasm-pack@0.13.1 wasm-bindgen-cli@0.2.100 |  | ||||||
|  |  | ||||||
| RUN rustup target add wasm32-unknown-unknown |  | ||||||
| WORKDIR /app |  | ||||||
|  |  | ||||||
| # Copy workspace files to maintain workspace structure |  | ||||||
| COPY Cargo.toml Cargo.lock ./ |  | ||||||
| COPY calendar-models ./calendar-models |  | ||||||
| COPY frontend/Cargo.toml ./frontend/ |  | ||||||
| COPY frontend/Trunk.toml ./frontend/ |  | ||||||
| COPY frontend/index.html ./frontend/ |  | ||||||
| COPY frontend/styles.css ./frontend/ |  | ||||||
|  |  | ||||||
| # Create empty backend directory to satisfy workspace |  | ||||||
| RUN mkdir -p backend/src && \ |  | ||||||
|     printf '[package]\nname = "calendar-backend"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > backend/Cargo.toml && \ |  | ||||||
|     echo 'fn main() {}' > backend/src/main.rs |  | ||||||
|  |  | ||||||
| # Create dummy source files to build dependencies first |  | ||||||
| RUN mkdir -p frontend/src && \ |  | ||||||
|     echo "use web_sys::*; fn main() {}" > frontend/src/main.rs && \ |  | ||||||
|     echo "pub fn add(a: usize, b: usize) -> usize { a + b }" > calendar-models/src/lib.rs |  | ||||||
|  |  | ||||||
| # Build dependencies (this layer will be cached unless dependencies change) |  | ||||||
| RUN cargo build --release --target wasm32-unknown-unknown --bin runway |  | ||||||
|  |  | ||||||
| # Copy actual source code and build the frontend application |  | ||||||
| RUN rm -rf frontend |  | ||||||
| COPY frontend ./frontend |  | ||||||
| RUN trunk build --release --config ./frontend/Trunk.toml |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # Backend build stage |  | ||||||
| # ----------------------------------------------------------- |  | ||||||
| FROM rust:alpine AS backend-builder |  | ||||||
|  |  | ||||||
| # Install build dependencies for backend |  | ||||||
| WORKDIR /app |  | ||||||
| RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static |  | ||||||
|  |  | ||||||
| # Install sqlx-cli for migrations |  | ||||||
| RUN cargo install sqlx-cli --no-default-features --features sqlite |  | ||||||
|  |  | ||||||
| # Copy shared models |  | ||||||
| COPY calendar-models ./calendar-models |  | ||||||
|  |  | ||||||
| # Create empty frontend directory to satisfy workspace |  | ||||||
| RUN mkdir -p frontend/src && \ |  | ||||||
|     printf '[package]\nname = "runway"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > frontend/Cargo.toml && \ |  | ||||||
|     echo 'fn main() {}' > frontend/src/main.rs |  | ||||||
|  |  | ||||||
| # Create dummy backend source to build dependencies first |  | ||||||
| RUN mkdir -p backend/src && \ |  | ||||||
|     echo "fn main() {}" > backend/src/main.rs |  | ||||||
|  |  | ||||||
| # Build dependencies (this layer will be cached unless dependencies change) |  | ||||||
| COPY Cargo.toml Cargo.lock ./ |  | ||||||
| COPY backend/Cargo.toml ./backend/ |  | ||||||
| RUN cargo build --release |  | ||||||
|  |  | ||||||
| # Build the backend |  | ||||||
| COPY backend ./backend |  | ||||||
| RUN cargo build --release --bin backend |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # Runtime stage |  | ||||||
| # ----------------------------------------------------------- |  | ||||||
| FROM alpine:latest |  | ||||||
|  |  | ||||||
| # Install runtime dependencies |  | ||||||
| RUN apk add --no-cache ca-certificates tzdata sqlite |  | ||||||
|  |  | ||||||
| # Copy frontend files to temporary location |  | ||||||
| COPY --from=builder /app/frontend/dist /app/frontend-dist |  | ||||||
|  |  | ||||||
| # Copy backend binary and sqlx-cli |  | ||||||
| COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend |  | ||||||
| COPY --from=backend-builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx |  | ||||||
|  |  | ||||||
| # Copy migrations for database setup |  | ||||||
| COPY --from=backend-builder /app/backend/migrations /migrations |  | ||||||
|  |  | ||||||
| # Create startup script to copy frontend files, run migrations, and start backend |  | ||||||
| RUN mkdir -p /srv/www /db |  | ||||||
| RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \ |  | ||||||
|     echo 'echo "Copying frontend files..."' >> /usr/local/bin/start.sh && \ |  | ||||||
|     echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \ |  | ||||||
|     echo 'echo "Ensuring database directory exists..."' >> /usr/local/bin/start.sh && \ |  | ||||||
|     echo 'mkdir -p /db && chmod 755 /db' >> /usr/local/bin/start.sh && \ |  | ||||||
|     echo 'echo "Running database migrations..."' >> /usr/local/bin/start.sh && \ |  | ||||||
|     echo 'sqlx migrate run --database-url "sqlite:///db/calendar.db" --source /migrations || echo "Migration failed but continuing..."' >> /usr/local/bin/start.sh && \ |  | ||||||
|     echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \ |  | ||||||
|     echo 'export DATABASE_URL="sqlite:///db/calendar.db"' >> /usr/local/bin/start.sh && \ |  | ||||||
|     echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \ |  | ||||||
|     chmod +x /usr/local/bin/start.sh |  | ||||||
|  |  | ||||||
| # Start with script that copies frontend files then starts backend |  | ||||||
| CMD ["/usr/local/bin/start.sh"] |  | ||||||
| @@ -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" | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										64
									
								
								backend/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								backend/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | |||||||
|  | # Build stage | ||||||
|  | # ----------------------------------------------------------- | ||||||
|  | FROM rust:alpine AS builder | ||||||
|  |  | ||||||
|  | # Install build dependencies for backend | ||||||
|  | WORKDIR /app | ||||||
|  | RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static | ||||||
|  |  | ||||||
|  | # Install sqlx-cli for migrations | ||||||
|  | RUN cargo install sqlx-cli --no-default-features --features sqlite | ||||||
|  |  | ||||||
|  | # Copy workspace files to maintain workspace structure | ||||||
|  | COPY ./Cargo.toml ./ | ||||||
|  | COPY ./calendar-models ./calendar-models | ||||||
|  |  | ||||||
|  | # Create empty frontend directory to satisfy workspace | ||||||
|  | RUN mkdir -p frontend/src && \ | ||||||
|  |     printf '[package]\nname = "runway"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > frontend/Cargo.toml && \ | ||||||
|  |     echo 'fn main() {}' > frontend/src/main.rs | ||||||
|  |  | ||||||
|  | # Copy backend files | ||||||
|  | COPY backend/Cargo.toml ./backend/ | ||||||
|  |  | ||||||
|  | # Create dummy backend source to build dependencies first | ||||||
|  | RUN mkdir -p backend/src && \ | ||||||
|  |     echo "fn main() {}" > backend/src/main.rs | ||||||
|  |  | ||||||
|  | # Build dependencies (this layer will be cached unless dependencies change) | ||||||
|  | RUN cargo build --release | ||||||
|  |  | ||||||
|  | # Copy actual backend source and build | ||||||
|  | COPY backend/src ./backend/src | ||||||
|  | COPY backend/migrations ./backend/migrations | ||||||
|  | RUN cargo build --release --bin backend | ||||||
|  |  | ||||||
|  | # Runtime stage | ||||||
|  | # ----------------------------------------------------------- | ||||||
|  | FROM alpine:latest | ||||||
|  |  | ||||||
|  | # Install runtime dependencies | ||||||
|  | RUN apk add --no-cache ca-certificates tzdata sqlite | ||||||
|  |  | ||||||
|  | # Copy backend binary and sqlx-cli | ||||||
|  | COPY --from=builder /app/target/release/backend /usr/local/bin/backend | ||||||
|  | COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx | ||||||
|  |  | ||||||
|  | # Copy migrations for database setup | ||||||
|  | COPY backend/migrations /migrations | ||||||
|  |  | ||||||
|  | # Create startup script to run migrations and start backend | ||||||
|  | RUN mkdir -p /db | ||||||
|  | RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'echo "Ensuring database directory exists..."' >> /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'mkdir -p /db && chmod 755 /db' >> /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'touch /db/calendar.db' >> /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'echo "Running database migrations..."' >> /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'sqlx migrate run --database-url "sqlite:///db/calendar.db" --source /migrations || echo "Migration failed but continuing..."' >> /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \ | ||||||
|  |     echo 'export DATABASE_URL="sqlite:///db/calendar.db"' >> /usr/local/bin/start.sh && \ | ||||||
|  |     echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \ | ||||||
|  |     chmod +x /usr/local/bin/start.sh | ||||||
|  |  | ||||||
|  | # Start with script that runs migrations then starts backend | ||||||
|  | CMD ["/usr/local/bin/start.sh"] | ||||||
							
								
								
									
										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); | ||||||
| @@ -93,6 +93,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 +112,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, | ||||||
|   | |||||||
| @@ -330,13 +330,26 @@ 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 | ||||||
| @@ -349,11 +362,11 @@ impl CalDAVClient { | |||||||
|         let start = properties |         let start = 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 = self.parse_datetime(start, 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 = if let Some(dtend) = properties.get("DTEND") { | ||||||
|             Some(self.parse_datetime(dtend, properties.get("DTEND"))?) |             Some(self.parse_datetime(dtend, full_properties.get("DTEND"))?) | ||||||
|         } 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) | ||||||
| @@ -361,11 +374,10 @@ impl CalDAVClient { | |||||||
|             None |             None | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         // Determine if it's an all-day event |         // Determine if it's an all-day event by checking for VALUE=DATE parameter | ||||||
|         let all_day = properties |         let empty_string = String::new(); | ||||||
|             .get("DTSTART") |         let dtstart_raw = properties.get("DTSTART").unwrap_or(&empty_string); | ||||||
|             .map(|s| !s.contains("T")) |         let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_raw.contains("T") && dtstart_raw.len() == 8); | ||||||
|             .unwrap_or(false); |  | ||||||
|  |  | ||||||
|         // Parse status |         // Parse status | ||||||
|         let status = properties |         let status = properties | ||||||
| @@ -568,12 +580,32 @@ 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); |             println!("Trying discovery path: {}", path); | ||||||
|             if let Ok(calendars) = self.discover_calendars_at_path(&path).await { |             match self.discover_calendars_at_path(&path).await { | ||||||
|  |                 Ok(calendars) => { | ||||||
|                     println!("Found {} calendar(s) at {}", calendars.len(), path); |                     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 | ||||||
|  |                     println!("Discovery path {} returned HTTP {}, trying next path", path, status); | ||||||
|  |                 } | ||||||
|  |                 Err(e) => { | ||||||
|  |                     // Network or other error - this suggests the server isn't reachable or isn't CalDAV | ||||||
|  |                     println!("Discovery failed for path {}: {:?}", path, e); | ||||||
|  |                     return Err(e); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 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 | ||||||
| @@ -672,17 +704,40 @@ impl CalDAVClient { | |||||||
|         Ok(calendar_paths) |         Ok(calendar_paths) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Parse iCal datetime format |     /// Parse iCal datetime format with timezone support | ||||||
|     fn parse_datetime( |     fn parse_datetime( | ||||||
|         &self, |         &self, | ||||||
|         datetime_str: &str, |         datetime_str: &str, | ||||||
|         _original_property: Option<&String>, |         original_property: Option<&String>, | ||||||
|     ) -> Result<DateTime<Utc>, CalDAVError> { |     ) -> Result<DateTime<Utc>, CalDAVError> { | ||||||
|         use chrono::TimeZone; |         use chrono::TimeZone; | ||||||
|  |         use chrono_tz::Tz; | ||||||
|  |  | ||||||
|         // Handle different iCal datetime formats |         // 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(); |         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 = [ | ||||||
|             "%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z |             "%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z | ||||||
| @@ -691,17 +746,145 @@ 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 first (if it has 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") { | ||||||
|  |                     return Ok(dt.and_utc()); | ||||||
|                 } |                 } | ||||||
|             if let Ok(date) = chrono::NaiveDate::parse_from_str(&cleaned, format) { |             } | ||||||
|  |              | ||||||
|  |             // 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 | ||||||
|         ))) |         ))) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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,6 +334,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(Utc::now()) |         .bind(Utc::now()) | ||||||
|         .bind(&prefs.user_id) |         .bind(&prefs.user_id) | ||||||
|         .execute(self.db.pool()) |         .execute(self.db.pool()) | ||||||
| @@ -307,3 +343,146 @@ impl<'a> PreferencesRepository<'a> { | |||||||
|         Ok(()) |         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(()) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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}; |  | ||||||
| @@ -93,6 +93,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(); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -76,10 +76,54 @@ pub async fn get_calendar_events( | |||||||
|  |  | ||||||
|     // If year and month are specified, filter events |     // If year and month are specified, filter events | ||||||
|     if let (Some(year), Some(month)) = (params.year, params.month) { |     if let (Some(year), Some(month)) = (params.year, params.month) { | ||||||
|  |         let target_date = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap(); | ||||||
|  |         let month_start = target_date; | ||||||
|  |         let month_end = if month == 12 { | ||||||
|  |             chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap() | ||||||
|  |         } else { | ||||||
|  |             chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap() | ||||||
|  |         } - chrono::Duration::days(1); | ||||||
|  |  | ||||||
|         all_events.retain(|event| { |         all_events.retain(|event| { | ||||||
|  |             let event_date = event.dtstart.date_naive(); | ||||||
|  |              | ||||||
|  |             // 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() { | ||||||
|                 let event_year = event.dtstart.year(); |                 let event_year = event.dtstart.year(); | ||||||
|                 let event_month = event.dtstart.month(); |                 let event_month = event.dtstart.month(); | ||||||
|             event_year == year && event_month == month |                 return event_year == year && event_month == month; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // For recurring events, check if they could have instances in this month | ||||||
|  |             // Include if: | ||||||
|  |             // 1. The event starts before or during the requested month | ||||||
|  |             // 2. The event doesn't have an UNTIL date, OR the UNTIL date is after the month start | ||||||
|  |             if event_date > month_end { | ||||||
|  |                 // Event starts after the requested month | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // Check UNTIL date in RRULE if present | ||||||
|  |             if let Some(ref rrule) = event.rrule { | ||||||
|  |                 if let Some(until_pos) = rrule.find("UNTIL=") { | ||||||
|  |                     let until_part = &rrule[until_pos + 6..]; | ||||||
|  |                     let until_end = until_part.find(';').unwrap_or(until_part.len()); | ||||||
|  |                     let until_str = &until_part[..until_end]; | ||||||
|  |                      | ||||||
|  |                     // Try to parse UNTIL date (format: YYYYMMDDTHHMMSSZ or YYYYMMDD) | ||||||
|  |                     if until_str.len() >= 8 { | ||||||
|  |                         if let Ok(until_date) = chrono::NaiveDate::parse_from_str(&until_str[..8], "%Y%m%d") { | ||||||
|  |                             if until_date < month_start { | ||||||
|  |                                 // Recurring event ended before the requested month | ||||||
|  |                                 return false; | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // Include the recurring event - the frontend will do proper expansion | ||||||
|  |             true | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -414,9 +458,15 @@ pub async fn create_event( | |||||||
|         parse_event_datetime(&request.start_date, &request.start_time, request.all_day) |         parse_event_datetime(&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 end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) |     let mut end_datetime = parse_event_datetime(&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 | ||||||
|  |     // RFC-5545 uses exclusive end dates for all-day events | ||||||
|  |     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 { | ||||||
|         if end_datetime < start_datetime { |         if end_datetime < start_datetime { | ||||||
| @@ -712,9 +762,15 @@ pub async fn update_event( | |||||||
|         parse_event_datetime(&request.start_date, &request.start_time, request.all_day) |         parse_event_datetime(&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 end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) |     let mut end_datetime = parse_event_datetime(&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 | ||||||
|  |     // RFC-5545 uses exclusive end dates for all-day events | ||||||
|  |     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 { | ||||||
|         if end_datetime < start_datetime { |         if end_datetime < start_datetime { | ||||||
| @@ -796,10 +852,11 @@ fn parse_event_datetime( | |||||||
|         .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 noon UTC to avoid timezone boundary issues | ||||||
|  |         // This ensures the date remains correct when converted to any local timezone | ||||||
|         let datetime = date |         let datetime = date | ||||||
|             .and_hms_opt(0, 0, 0) |             .and_hms_opt(12, 0, 0) | ||||||
|             .ok_or_else(|| "Failed to create midnight datetime".to_string())?; |             .ok_or_else(|| "Failed to create noon datetime".to_string())?; | ||||||
|         Ok(Utc.from_utc_datetime(&datetime)) |         Ok(Utc.from_utc_datetime(&datetime)) | ||||||
|     } else { |     } else { | ||||||
|         // Parse the time |         // Parse the time | ||||||
| @@ -809,7 +866,7 @@ fn parse_event_datetime( | |||||||
|         // Combine date and time |         // Combine date and time | ||||||
|         let datetime = NaiveDateTime::new(date, time); |         let datetime = NaiveDateTime::new(date, time); | ||||||
|  |  | ||||||
|         // Assume local time and convert to UTC (in a real app, you'd want timezone support) |         // Frontend now sends UTC times, so treat as UTC directly | ||||||
|         Ok(Utc.from_utc_datetime(&datetime)) |         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(())) | ||||||
|  | } | ||||||
							
								
								
									
										906
									
								
								backend/src/handlers/ics_fetcher.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										906
									
								
								backend/src/handlers/ics_fetcher.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,906 @@ | |||||||
|  | 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, | ||||||
|  |         dtend, | ||||||
|  |         summary, | ||||||
|  |         description, | ||||||
|  |         location, | ||||||
|  |         all_day, | ||||||
|  |         rrule, | ||||||
|  |         exdate: Vec::new(), // External calendars don't need exception handling | ||||||
|  |         recurrence_id: None, | ||||||
|  |         created: None, | ||||||
|  |         last_modified: 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, | ||||||
|  |         rdate: Vec::new(), | ||||||
|  |         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; | ||||||
|  |      | ||||||
|  |     let original_count = events.len(); | ||||||
|  |      | ||||||
|  |     // 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(..) { | ||||||
|  |         // Debug logging to understand what's happening | ||||||
|  |         println!("🔍 Event: '{}' at {} (RRULE: {}) - UID: {}",  | ||||||
|  |             event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||||
|  |             event.dtstart.format("%Y-%m-%d %H:%M"), | ||||||
|  |             if event.rrule.is_some() { "Yes" } else { "No" }, | ||||||
|  |             event.uid | ||||||
|  |         ); | ||||||
|  |          | ||||||
|  |         uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     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 | ||||||
|  |             println!("🔍 Found {} events with UID '{}'", events_with_uid.len(), uid); | ||||||
|  |              | ||||||
|  |             // 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(); | ||||||
|  |             println!("🔄 UID dedup: Keeping '{}' (RRULE: {})", | ||||||
|  |                 preferred_event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||||
|  |                 if preferred_event.rrule.is_some() { "Yes" } else { "No" } | ||||||
|  |             ); | ||||||
|  |             uid_deduplicated_events.push(preferred_event); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 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 | ||||||
|  |             println!("🔍 Found {} events with title '{}'", events_with_title.len(), title); | ||||||
|  |              | ||||||
|  |             // 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 { | ||||||
|  |                 println!("🔄 Replacing single event: Keeping '{}' over '{}'", | ||||||
|  |                     event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||||
|  |                     existing_event.summary.as_ref().unwrap_or(&"No Title".to_string()) | ||||||
|  |                 ); | ||||||
|  |                 deduplicated_single[existing_index] = event; | ||||||
|  |             } else { | ||||||
|  |                 println!("🚫 Discarding duplicate single event: Keeping existing '{}'", | ||||||
|  |                     existing_event.summary.as_ref().unwrap_or(&"No Title".to_string()) | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|  |             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 { | ||||||
|  |             println!("🚫 Discarding RRULE-generated instance: '{}' at {} would be generated by recurring event", | ||||||
|  |                 event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||||
|  |                 event.dtstart.format("%Y-%m-%d %H:%M") | ||||||
|  |             ); | ||||||
|  |         } else { | ||||||
|  |             // 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); | ||||||
|  |      | ||||||
|  |     println!("📊 Deduplication complete: {} -> {} events ({} recurring, {} single)",  | ||||||
|  |         original_count, result.len(),  | ||||||
|  |         result.iter().filter(|e| e.rrule.is_some()).count(), | ||||||
|  |         result.iter().filter(|e| e.rrule.is_none()).count() | ||||||
|  |     ); | ||||||
|  |      | ||||||
|  |     result | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// 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 | ||||||
|  |     for event in &events { | ||||||
|  |         if let Some(rrule) = &event.rrule { | ||||||
|  |             println!("🔍 RRULE for '{}': {}",  | ||||||
|  |                 event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||||
|  |                 rrule | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Check if all events have similar time patterns and could be consolidated | ||||||
|  |     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 { | ||||||
|  |         println!("🚫 Cannot consolidate events - different times or durations"); | ||||||
|  |         // 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) { | ||||||
|  |             println!("✅ Successfully consolidated {} weekly patterns into one", events.len()); | ||||||
|  |             return vec![consolidated]; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // If we can't consolidate, just deduplicate exact matches and keep the most complete one | ||||||
|  |     println!("🚫 Cannot consolidate - keeping most complete event"); | ||||||
|  |     let deduplicated = deduplicate_exact_recurring_events(events); | ||||||
|  |      | ||||||
|  |     // 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_naive() - recurring_event.dtstart.date_naive()).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_naive() - recurring_event.dtstart.date_naive()).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, | ||||||
|         }), |         }), | ||||||
|     )) |     )) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -130,9 +130,13 @@ pub async fn create_event_series( | |||||||
|             .and_hms_opt(23, 59, 59) |             .and_hms_opt(23, 59, 59) | ||||||
|             .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; |             .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; | ||||||
|  |  | ||||||
|  |         // Frontend now sends UTC times, so treat as UTC directly | ||||||
|  |         let start_local = chrono::Utc.from_utc_datetime(&start_dt); | ||||||
|  |         let end_local = chrono::Utc.from_utc_datetime(&end_dt); | ||||||
|  |          | ||||||
|         ( |         ( | ||||||
|             chrono::Utc.from_utc_datetime(&start_dt), |             start_local.with_timezone(&chrono::Utc), | ||||||
|             chrono::Utc.from_utc_datetime(&end_dt), |             end_local.with_timezone(&chrono::Utc), | ||||||
|         ) |         ) | ||||||
|     } else { |     } else { | ||||||
|         // Parse times for timed events |         // Parse times for timed events | ||||||
| @@ -163,9 +167,13 @@ pub async fn create_event_series( | |||||||
|             start_date.and_time(end_time) |             start_date.and_time(end_time) | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|  |         // Frontend now sends UTC times, so treat as UTC directly | ||||||
|  |         let start_local = chrono::Utc.from_utc_datetime(&start_dt); | ||||||
|  |         let end_local = chrono::Utc.from_utc_datetime(&end_dt); | ||||||
|  |          | ||||||
|         ( |         ( | ||||||
|             chrono::Utc.from_utc_datetime(&start_dt), |             start_local.with_timezone(&chrono::Utc), | ||||||
|             chrono::Utc.from_utc_datetime(&end_dt), |             end_local.with_timezone(&chrono::Utc), | ||||||
|         ) |         ) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| @@ -175,6 +183,7 @@ pub async fn create_event_series( | |||||||
|     // Create the VEvent for the series |     // Create the VEvent for the series | ||||||
|     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.summary = if request.title.trim().is_empty() { |     event.summary = if request.title.trim().is_empty() { | ||||||
|         None |         None | ||||||
|     } else { |     } else { | ||||||
| @@ -245,8 +254,8 @@ 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 | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     // Extract and verify token |     // Extract and verify token | ||||||
| @@ -380,8 +389,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 | ||||||
| @@ -397,9 +407,10 @@ 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 UTC directly (no local conversion needed) | ||||||
|         ( |         ( | ||||||
|             chrono::Utc.from_utc_datetime(&start_dt), |             chrono::Utc.from_utc_datetime(&start_dt), | ||||||
|             chrono::Utc.from_utc_datetime(&end_dt), |             chrono::Utc.from_utc_datetime(&end_dt), | ||||||
| @@ -437,9 +448,13 @@ pub async fn update_event_series( | |||||||
|             (chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc() |             (chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc() | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|  |         // Frontend now sends UTC times, so treat as UTC directly | ||||||
|  |         let start_local = chrono::Utc.from_utc_datetime(&start_dt); | ||||||
|  |         let end_local = chrono::Utc.from_utc_datetime(&end_dt); | ||||||
|  |          | ||||||
|         ( |         ( | ||||||
|             chrono::Utc.from_utc_datetime(&start_dt), |             start_local.with_timezone(&chrono::Utc), | ||||||
|             chrono::Utc.from_utc_datetime(&end_dt), |             end_local.with_timezone(&chrono::Utc), | ||||||
|         ) |         ) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
| @@ -732,9 +747,36 @@ fn update_entire_series( | |||||||
|     updated_event.last_modified = Some(now); |     updated_event.last_modified = Some(now); | ||||||
|     // 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(); | ||||||
|   | |||||||
| @@ -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) | ||||||
|   | |||||||
| @@ -30,6 +30,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 +41,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 +56,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)] | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								calendar.db
									
									
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								calendar.db
									
									
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -1,10 +1,11 @@ | |||||||
| services: | services: | ||||||
|   calendar-backend: |   calendar-backend: | ||||||
|     build: . |     build: | ||||||
|  |       context: . | ||||||
|  |       dockerfile: ./backend/Dockerfile | ||||||
|     ports: |     ports: | ||||||
|       - "3000:3000" |       - "3000:3000" | ||||||
|     volumes: |     volumes: | ||||||
|       - ./data/site_dist:/srv/www |  | ||||||
|       - ./data/db:/db |       - ./data/db:/db | ||||||
|  |  | ||||||
|   calendar-frontend: |   calendar-frontend: | ||||||
| @@ -15,7 +16,7 @@ services: | |||||||
|       - "80:80" |       - "80:80" | ||||||
|       - "443:443" |       - "443:443" | ||||||
|     volumes: |     volumes: | ||||||
|       - ./data/site_dist:/srv/www:ro |       - ./frontend/dist:/srv/www:ro | ||||||
|       - ./Caddyfile:/etc/caddy/Caddyfile:ro |       - ./Caddyfile:/etc/caddy/Caddyfile:ro | ||||||
|       - ./data/caddy/data:/data |       - ./data/caddy/data:/data | ||||||
|       - ./data/caddy/config:/config |       - ./data/caddy/config:/config | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								deploy_frontend.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										6
									
								
								deploy_frontend.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | #!/bin/sh | ||||||
|  |  | ||||||
|  | export BACKEND_API_URL="https://runway.rcjohnstone.com/api" | ||||||
|  | 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/ | ||||||
|  | unset BACKEND_API_URL | ||||||
							
								
								
									
										
											BIN
										
									
								
								favicon_big.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								favicon_big.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 952 KiB | 
| @@ -22,6 +22,8 @@ web-sys = { version = "0.3", features = [ | |||||||
|     "Document", |     "Document", | ||||||
|     "Window", |     "Window", | ||||||
|     "Location", |     "Location", | ||||||
|  |     "Navigator", | ||||||
|  |     "DomTokenList", | ||||||
|     "Headers", |     "Headers", | ||||||
|     "Request", |     "Request", | ||||||
|     "RequestInit", |     "RequestInit", | ||||||
| @@ -30,6 +32,7 @@ web-sys = { version = "0.3", features = [ | |||||||
|     "CssStyleDeclaration", |     "CssStyleDeclaration", | ||||||
| ] } | ] } | ||||||
| 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 +40,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"] } | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								frontend/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 58 KiB | 
| @@ -7,10 +7,11 @@ | |||||||
|     <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="copy-file" href="styles/google.css"> |     <link data-trunk rel="copy-file" href="styles/google.css"> | ||||||
|  |     <link data-trunk rel="icon" href="favicon.ico"> | ||||||
| </head> | </head> | ||||||
| <body> | <body> | ||||||
|     <script> |     <script> | ||||||
|         console.log("HTML loaded, waiting for WASM..."); |         console.log("HTML fully loaded, waiting for WASM..."); | ||||||
|         window.addEventListener('TrunkApplicationStarted', () => { |         window.addEventListener('TrunkApplicationStarted', () => { | ||||||
|             console.log("Trunk application started successfully!"); |             console.log("Trunk application started successfully!"); | ||||||
|         }); |         }); | ||||||
|   | |||||||
										
											
												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, | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ use crate::components::{ | |||||||
|     CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView, |     CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, 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}; | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| @@ -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)>>, | ||||||
| @@ -101,10 +105,14 @@ 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 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(); | ||||||
| @@ -141,7 +149,38 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                         .await |                         .await | ||||||
|                     { |                     { | ||||||
|                         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); | ||||||
|                         } |                         } | ||||||
| @@ -452,6 +491,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 +507,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)} | ||||||
| @@ -521,15 +562,6 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|                         } |                         } | ||||||
|                     }) |                     }) | ||||||
|                 }} |                 }} | ||||||
|                 on_update={{ |  | ||||||
|                     let show_create_modal = show_create_modal.clone(); |  | ||||||
|                     let create_event_data = create_event_data.clone(); |  | ||||||
|                     Callback::from(move |(_original_event, _updated_data): (VEvent, EventCreationData)| { |  | ||||||
|                         show_create_modal.set(false); |  | ||||||
|                         create_event_data.set(None); |  | ||||||
|                         // TODO: Handle actual event update |  | ||||||
|                     }) |  | ||||||
|                 }} |  | ||||||
|             /> |             /> | ||||||
|         </div> |         </div> | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -18,9 +18,45 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html { | |||||||
|         return html! {}; |         return html! {}; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Smart positioning to keep menu within viewport | ||||||
|  |     let (x, y) = { | ||||||
|  |         let mut x = props.x; | ||||||
|  |         let mut y = props.y; | ||||||
|  |          | ||||||
|  |         // Try to get actual viewport dimensions | ||||||
|  |         if let Some(window) = web_sys::window() { | ||||||
|  |             if let (Ok(width), Ok(height)) = (window.inner_width(), window.inner_height()) { | ||||||
|  |                 if let (Some(w), Some(h)) = (width.as_f64(), height.as_f64()) { | ||||||
|  |                     let viewport_width = w as i32; | ||||||
|  |                     let viewport_height = h as i32; | ||||||
|  |                      | ||||||
|  |                     // Calendar context menu: "Create Event" with icon | ||||||
|  |                     let menu_width = 180; // "Create Event" text + icon + padding | ||||||
|  |                     let menu_height = 60; // Single item + padding + margins | ||||||
|  |                      | ||||||
|  |                     // Adjust horizontally if too close to right edge | ||||||
|  |                     if x + menu_width > viewport_width - 10 { | ||||||
|  |                         x = x.saturating_sub(menu_width); | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     // Adjust vertically if too close to bottom edge | ||||||
|  |                     if y + menu_height > viewport_height - 10 { | ||||||
|  |                         y = y.saturating_sub(menu_height); | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     // Ensure minimum margins from edges | ||||||
|  |                     x = x.max(5); | ||||||
|  |                     y = y.max(5); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         (x, y) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     let style = format!( |     let style = format!( | ||||||
|         "position: fixed; left: {}px; top: {}px; z-index: 1001;", |         "position: fixed; left: {}px; top: {}px; z-index: 1001;", | ||||||
|         props.x, props.y |         x, y | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     let on_create_event_click = { |     let on_create_event_click = { | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ pub struct CalendarListItemProps { | |||||||
|     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_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,15 +33,29 @@ 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}> | ||||||
|  |             <div class="calendar-info"> | ||||||
|  |                 <input | ||||||
|  |                     type="checkbox" | ||||||
|  |                     checked={props.calendar.is_visible} | ||||||
|  |                     onchange={on_visibility_toggle} | ||||||
|  |                 /> | ||||||
|                 <span class="calendar-color" |                 <span class="calendar-color" | ||||||
|                       style={format!("background-color: {}", props.calendar.color)} |                       style={format!("background-color: {}", props.calendar.color)} | ||||||
|                       onclick={on_color_click}> |                       onclick={on_color_click}> | ||||||
|                     { |                     { | ||||||
|                         if props.color_picker_open { |                         if props.color_picker_open { | ||||||
|                             html! { |                             html! { | ||||||
|                             <div class="color-picker"> |                                 <div class="color-picker-dropdown"> | ||||||
|                                     { |                                     { | ||||||
|                                         props.available_colors.iter().map(|color| { |                                         props.available_colors.iter().map(|color| { | ||||||
|                                             let color_str = color.clone(); |                                             let color_str = color.clone(); | ||||||
| @@ -70,6 +85,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> 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> | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -20,9 +20,45 @@ pub fn context_menu(props: &ContextMenuProps) -> Html { | |||||||
|         return html! {}; |         return html! {}; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Smart positioning to keep menu within viewport | ||||||
|  |     let (x, y) = { | ||||||
|  |         let mut x = props.x; | ||||||
|  |         let mut y = props.y; | ||||||
|  |          | ||||||
|  |         // Try to get actual viewport dimensions | ||||||
|  |         if let Some(window) = web_sys::window() { | ||||||
|  |             if let (Ok(width), Ok(height)) = (window.inner_width(), window.inner_height()) { | ||||||
|  |                 if let (Some(w), Some(h)) = (width.as_f64(), height.as_f64()) { | ||||||
|  |                     let viewport_width = w as i32; | ||||||
|  |                     let viewport_height = h as i32; | ||||||
|  |                      | ||||||
|  |                     // Generic context menu: "Delete Calendar"  | ||||||
|  |                     let menu_width = 180; // "Delete Calendar" text + padding | ||||||
|  |                     let menu_height = 60; // Single item + padding + margins | ||||||
|  |                      | ||||||
|  |                     // Adjust horizontally if too close to right edge | ||||||
|  |                     if x + menu_width > viewport_width - 10 { | ||||||
|  |                         x = x.saturating_sub(menu_width); | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     // Adjust vertically if too close to bottom edge | ||||||
|  |                     if y + menu_height > viewport_height - 10 { | ||||||
|  |                         y = y.saturating_sub(menu_height); | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     // Ensure minimum margins from edges | ||||||
|  |                     x = x.max(5); | ||||||
|  |                     y = y.max(5); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         (x, y) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     let style = format!( |     let style = format!( | ||||||
|         "position: fixed; left: {}px; top: {}px; z-index: 1001;", |         "position: fixed; left: {}px; top: {}px; z-index: 1001;", | ||||||
|         props.x, props.y |         x, y | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     let on_delete_click = { |     let on_delete_click = { | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -24,6 +24,7 @@ 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<()>, | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -35,9 +36,53 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | |||||||
|         return html! {}; |         return html! {}; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Smart positioning to keep menu within viewport | ||||||
|  |     let (x, y) = { | ||||||
|  |         let mut x = props.x; | ||||||
|  |         let mut y = props.y; | ||||||
|  |          | ||||||
|  |         // Try to get actual viewport dimensions | ||||||
|  |         if let Some(window) = web_sys::window() { | ||||||
|  |             if let (Ok(width), Ok(height)) = (window.inner_width(), window.inner_height()) { | ||||||
|  |                 if let (Some(w), Some(h)) = (width.as_f64(), height.as_f64()) { | ||||||
|  |                     let viewport_width = w as i32; | ||||||
|  |                     let viewport_height = h as i32; | ||||||
|  |                      | ||||||
|  |                     // More accurate menu dimensions based on actual CSS and content | ||||||
|  |                     let menu_width = if props.event.as_ref().map_or(false, |e| e.rrule.is_some()) { | ||||||
|  |                         280 // Recurring: "Edit This and Future Events" is long text + padding | ||||||
|  |                     } else { | ||||||
|  |                         180 // Non-recurring: "Edit Event" + "Delete Event" + padding | ||||||
|  |                     }; | ||||||
|  |                     let menu_height = if props.event.as_ref().map_or(false, |e| e.rrule.is_some()) { | ||||||
|  |                         200 // 6 items × ~32px per item (12px padding top/bottom + text height + borders) | ||||||
|  |                     } else { | ||||||
|  |                         100 // 2 items × ~32px per item + some extra margin | ||||||
|  |                     }; | ||||||
|  |                      | ||||||
|  |                     // Adjust horizontally if too close to right edge | ||||||
|  |                     if x + menu_width > viewport_width - 10 { | ||||||
|  |                         x = x.saturating_sub(menu_width); | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     // Adjust vertically if too close to bottom edge | ||||||
|  |                     if y + menu_height > viewport_height - 10 { | ||||||
|  |                         y = y.saturating_sub(menu_height); | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     // Ensure minimum margins from edges | ||||||
|  |                     x = x.max(5); | ||||||
|  |                     y = y.max(5); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         (x, y) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|     let style = format!( |     let style = format!( | ||||||
|         "position: fixed; left: {}px; top: {}px; z-index: 1001;", |         "position: fixed; left: {}px; top: {}px; z-index: 1001;", | ||||||
|         props.x, props.y |         x, y | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     // Check if the event is recurring |     // Check if the event is recurring | ||||||
| @@ -47,6 +92,14 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | |||||||
|         .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(); | ||||||
|         let on_close = props.on_close.clone(); |         let on_close = props.on_close.clone(); | ||||||
| @@ -65,6 +118,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} | ||||||
| @@ -72,7 +137,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)}> | ||||||
| @@ -87,6 +160,7 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | |||||||
|                         </> |                         </> | ||||||
|                     } |                     } | ||||||
|                 } else { |                 } else { | ||||||
|  |                     // Regular single events - show edit option | ||||||
|                     html! { |                     html! { | ||||||
|                         <div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}> |                         <div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}> | ||||||
|                             {"Edit Event"} |                             {"Edit Event"} | ||||||
| @@ -95,6 +169,8 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|             { |             { | ||||||
|  |                 if !is_external { | ||||||
|  |                     // Only show delete options for non-external events | ||||||
|                     if is_recurring { |                     if is_recurring { | ||||||
|                         html! { |                         html! { | ||||||
|                             <> |                             <> | ||||||
| @@ -116,6 +192,10 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | |||||||
|                             </div> |                             </div> | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|  |                 } else { | ||||||
|  |                     // No delete options for external events | ||||||
|  |                     html! {} | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|         </div> |         </div> | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										109
									
								
								frontend/src/components/event_form/advanced.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								frontend/src/components/event_form/advanced.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | |||||||
|  | use super::types::*; | ||||||
|  | // Types are already imported from super::types::* | ||||||
|  | use wasm_bindgen::JsCast; | ||||||
|  | use web_sys::HtmlSelectElement; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
|  | #[function_component(AdvancedTab)] | ||||||
|  | pub fn advanced_tab(props: &TabProps) -> Html { | ||||||
|  |     let data = &props.data; | ||||||
|  |      | ||||||
|  |     let on_status_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.status = match select.value().as_str() { | ||||||
|  |                         "tentative" => EventStatus::Tentative, | ||||||
|  |                         "cancelled" => EventStatus::Cancelled, | ||||||
|  |                         _ => EventStatus::Confirmed, | ||||||
|  |                     }; | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_class_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.class = match select.value().as_str() { | ||||||
|  |                         "private" => EventClass::Private, | ||||||
|  |                         "confidential" => EventClass::Confidential, | ||||||
|  |                         _ => EventClass::Public, | ||||||
|  |                     }; | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_priority_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     let value = select.value(); | ||||||
|  |                     event_data.priority = if value.is_empty() { | ||||||
|  |                         None | ||||||
|  |                     } else { | ||||||
|  |                         value.parse::<u8>().ok().filter(|&p| p <= 9) | ||||||
|  |                     }; | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     html! { | ||||||
|  |         <div class="tab-panel"> | ||||||
|  |             <div class="form-row"> | ||||||
|  |                 <div class="form-group"> | ||||||
|  |                     <label for="event-status">{"Status"}</label> | ||||||
|  |                     <select | ||||||
|  |                         id="event-status" | ||||||
|  |                         class="form-input" | ||||||
|  |                         onchange={on_status_change} | ||||||
|  |                     > | ||||||
|  |                         <option value="confirmed" selected={matches!(data.status, EventStatus::Confirmed)}>{"Confirmed"}</option> | ||||||
|  |                         <option value="tentative" selected={matches!(data.status, EventStatus::Tentative)}>{"Tentative"}</option> | ||||||
|  |                         <option value="cancelled" selected={matches!(data.status, EventStatus::Cancelled)}>{"Cancelled"}</option> | ||||||
|  |                     </select> | ||||||
|  |                 </div> | ||||||
|  |  | ||||||
|  |                 <div class="form-group"> | ||||||
|  |                     <label for="event-class">{"Privacy"}</label> | ||||||
|  |                     <select | ||||||
|  |                         id="event-class" | ||||||
|  |                         class="form-input" | ||||||
|  |                         onchange={on_class_change} | ||||||
|  |                     > | ||||||
|  |                         <option value="public" selected={matches!(data.class, EventClass::Public)}>{"Public"}</option> | ||||||
|  |                         <option value="private" selected={matches!(data.class, EventClass::Private)}>{"Private"}</option> | ||||||
|  |                         <option value="confidential" selected={matches!(data.class, EventClass::Confidential)}>{"Confidential"}</option> | ||||||
|  |                     </select> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="event-priority">{"Priority"}</label> | ||||||
|  |                 <select  | ||||||
|  |                     id="event-priority"  | ||||||
|  |                     class="form-input" | ||||||
|  |                     onchange={on_priority_change} | ||||||
|  |                 > | ||||||
|  |                     <option value="" selected={data.priority.is_none()}>{"Not set"}</option> | ||||||
|  |                     <option value="1" selected={data.priority == Some(1)}>{"High"}</option> | ||||||
|  |                     <option value="5" selected={data.priority == Some(5)}>{"Medium"}</option> | ||||||
|  |                     <option value="9" selected={data.priority == Some(9)}>{"Low"}</option> | ||||||
|  |                 </select> | ||||||
|  |                 <p class="form-help-text">{"Set the importance level for this event."}</p> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										730
									
								
								frontend/src/components/event_form/basic_details.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										730
									
								
								frontend/src/components/event_form/basic_details.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,730 @@ | |||||||
|  | use super::types::*; | ||||||
|  | // Types are already imported from super::types::* | ||||||
|  | use chrono::{Datelike, NaiveDate}; | ||||||
|  | use wasm_bindgen::JsCast; | ||||||
|  | use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement}; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
|  | #[function_component(BasicDetailsTab)] | ||||||
|  | pub fn basic_details_tab(props: &TabProps) -> Html { | ||||||
|  |     let data = &props.data; | ||||||
|  |      | ||||||
|  |     // Event handlers | ||||||
|  |     let on_title_input = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: InputEvent| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(input) = target.dyn_into::<HtmlInputElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.title = input.value(); | ||||||
|  |                     if !event_data.changed_fields.contains(&"title".to_string()) { | ||||||
|  |                         event_data.changed_fields.push("title".to_string()); | ||||||
|  |                     } | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_description_input = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: InputEvent| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(textarea) = target.dyn_into::<HtmlTextAreaElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.description = textarea.value(); | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_calendar_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     let value = select.value(); | ||||||
|  |                     let new_calendar = if value.is_empty() { None } else { Some(value) }; | ||||||
|  |                     if event_data.selected_calendar != new_calendar { | ||||||
|  |                         event_data.selected_calendar = new_calendar; | ||||||
|  |                         if !event_data.changed_fields.contains(&"selected_calendar".to_string()) { | ||||||
|  |                             event_data.changed_fields.push("selected_calendar".to_string()); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_all_day_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(input) = target.dyn_into::<HtmlInputElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.all_day = input.checked(); | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_recurrence_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.recurrence = match select.value().as_str() { | ||||||
|  |                         "daily" => RecurrenceType::Daily, | ||||||
|  |                         "weekly" => RecurrenceType::Weekly, | ||||||
|  |                         "monthly" => RecurrenceType::Monthly, | ||||||
|  |                         "yearly" => RecurrenceType::Yearly, | ||||||
|  |                         _ => RecurrenceType::None, | ||||||
|  |                     }; | ||||||
|  |                     // Reset recurrence-related fields when changing type | ||||||
|  |                     event_data.recurrence_days = vec![false; 7]; | ||||||
|  |                     event_data.recurrence_interval = 1; | ||||||
|  |                     event_data.recurrence_until = None; | ||||||
|  |                     event_data.recurrence_count = None; | ||||||
|  |                     event_data.monthly_by_day = None; | ||||||
|  |                     event_data.monthly_by_monthday = None; | ||||||
|  |                     event_data.yearly_by_month = vec![false; 12]; | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_reminder_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { | ||||||
|  |                     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 data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||||
|  |                 if let Ok(interval) = input.value().parse::<u32>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.recurrence_interval = interval.max(1); | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_recurrence_until_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||||
|  |                 let mut event_data = (*data).clone(); | ||||||
|  |                 if input.value().is_empty() { | ||||||
|  |                     event_data.recurrence_until = None; | ||||||
|  |                 } else if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") { | ||||||
|  |                     event_data.recurrence_until = Some(date); | ||||||
|  |                 } | ||||||
|  |                 data.set(event_data); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_recurrence_count_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||||
|  |                 let mut event_data = (*data).clone(); | ||||||
|  |                 if input.value().is_empty() { | ||||||
|  |                     event_data.recurrence_count = None; | ||||||
|  |                 } else if let Ok(count) = input.value().parse::<u32>() { | ||||||
|  |                     event_data.recurrence_count = Some(count.max(1)); | ||||||
|  |                 } | ||||||
|  |                 data.set(event_data); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_monthly_by_monthday_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||||
|  |                 let mut event_data = (*data).clone(); | ||||||
|  |                 if input.value().is_empty() { | ||||||
|  |                     event_data.monthly_by_monthday = None; | ||||||
|  |                 } else if let Ok(day) = input.value().parse::<u8>() { | ||||||
|  |                     if day >= 1 && day <= 31 { | ||||||
|  |                         event_data.monthly_by_monthday = Some(day); | ||||||
|  |                         event_data.monthly_by_day = None; // Clear the other option | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 data.set(event_data); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_monthly_by_day_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() { | ||||||
|  |                 let mut event_data = (*data).clone(); | ||||||
|  |                 if select.value().is_empty() || select.value() == "none" { | ||||||
|  |                     event_data.monthly_by_day = None; | ||||||
|  |                 } else { | ||||||
|  |                     event_data.monthly_by_day = Some(select.value()); | ||||||
|  |                     event_data.monthly_by_monthday = None; // Clear the other option | ||||||
|  |                 } | ||||||
|  |                 data.set(event_data); | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_weekday_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         move |day_index: usize| { | ||||||
|  |             let data = data.clone(); | ||||||
|  |             Callback::from(move |e: Event| { | ||||||
|  |                 if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     if day_index < event_data.recurrence_days.len() { | ||||||
|  |                         event_data.recurrence_days[day_index] = input.checked(); | ||||||
|  |                         data.set(event_data); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_yearly_month_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         move |month_index: usize| { | ||||||
|  |             let data = data.clone(); | ||||||
|  |             Callback::from(move |e: Event| { | ||||||
|  |                 if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     if month_index < event_data.yearly_by_month.len() { | ||||||
|  |                         event_data.yearly_by_month[month_index] = input.checked(); | ||||||
|  |                         data.set(event_data); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_start_date_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||||
|  |                 if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.start_date = date; | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_start_time_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||||
|  |                 if let Ok(time) = chrono::NaiveTime::parse_from_str(&input.value(), "%H:%M") { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.start_time = time; | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_end_date_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||||
|  |                 if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.end_date = date; | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_end_time_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||||
|  |                 if let Ok(time) = chrono::NaiveTime::parse_from_str(&input.value(), "%H:%M") { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.end_time = time; | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_location_input = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: InputEvent| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(input) = target.dyn_into::<HtmlInputElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.location = input.value(); | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |      | ||||||
|  |     html! { | ||||||
|  |         <div class="tab-panel"> | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="event-title">{"Event Title *"}</label> | ||||||
|  |                 <input | ||||||
|  |                     type="text" | ||||||
|  |                     id="event-title" | ||||||
|  |                     class="form-input" | ||||||
|  |                     value={data.title.clone()} | ||||||
|  |                     oninput={on_title_input} | ||||||
|  |                     placeholder="Add a title" | ||||||
|  |                     required=true | ||||||
|  |                 /> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="event-description">{"Description"}</label> | ||||||
|  |                 <textarea | ||||||
|  |                     id="event-description" | ||||||
|  |                     class="form-input" | ||||||
|  |                     value={data.description.clone()} | ||||||
|  |                     oninput={on_description_input} | ||||||
|  |                     placeholder="Add a description" | ||||||
|  |                     rows="3" | ||||||
|  |                 ></textarea> | ||||||
|  |             </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-group"> | ||||||
|  |                     <label for="event-recurrence-basic">{"Repeat"}</label> | ||||||
|  |                     <select | ||||||
|  |                         id="event-recurrence-basic" | ||||||
|  |                         class="form-input" | ||||||
|  |                         onchange={on_recurrence_change} | ||||||
|  |                     > | ||||||
|  |                         <option value="none" selected={matches!(data.recurrence, RecurrenceType::None)}>{"Does not repeat"}</option> | ||||||
|  |                         <option value="daily" selected={matches!(data.recurrence, RecurrenceType::Daily)}>{"Daily"}</option> | ||||||
|  |                         <option value="weekly" selected={matches!(data.recurrence, RecurrenceType::Weekly)}>{"Weekly"}</option> | ||||||
|  |                         <option value="monthly" selected={matches!(data.recurrence, RecurrenceType::Monthly)}>{"Monthly"}</option> | ||||||
|  |                         <option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option> | ||||||
|  |                     </select> | ||||||
|  |                 </div> | ||||||
|  |  | ||||||
|  |                 <div 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> | ||||||
|  |  | ||||||
|  |             // RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder! | ||||||
|  |             if matches!(data.recurrence, RecurrenceType::Weekly) { | ||||||
|  |                 <div class="form-group"> | ||||||
|  |                     <label>{"Repeat on"}</label> | ||||||
|  |                     <div class="weekday-selection"> | ||||||
|  |                         { | ||||||
|  |                             ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] | ||||||
|  |                                 .iter() | ||||||
|  |                                 .enumerate() | ||||||
|  |                                 .map(|(i, day)| { | ||||||
|  |                                     let day_checked = data.recurrence_days.get(i).cloned().unwrap_or(false); | ||||||
|  |                                     let on_change = on_weekday_change(i); | ||||||
|  |                                     html! { | ||||||
|  |                                         <label key={i} class="weekday-checkbox"> | ||||||
|  |                                             <input | ||||||
|  |                                                 type="checkbox" | ||||||
|  |                                                 checked={day_checked} | ||||||
|  |                                                 onchange={on_change} | ||||||
|  |                                             /> | ||||||
|  |                                             <span class="weekday-label">{day}</span> | ||||||
|  |                                         </label> | ||||||
|  |                                     } | ||||||
|  |                                 }) | ||||||
|  |                                 .collect::<Html>() | ||||||
|  |                         } | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if !matches!(data.recurrence, RecurrenceType::None) { | ||||||
|  |                 <div class="recurrence-options"> | ||||||
|  |                     <div class="form-row"> | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label for="recurrence-interval">{"Every"}</label> | ||||||
|  |                             <div class="interval-input"> | ||||||
|  |                                 <input | ||||||
|  |                                     id="recurrence-interval" | ||||||
|  |                                     type="number" | ||||||
|  |                                     class="form-input" | ||||||
|  |                                     value={data.recurrence_interval.to_string()} | ||||||
|  |                                     min="1" | ||||||
|  |                                     max="999" | ||||||
|  |                                     onchange={on_recurrence_interval_change} | ||||||
|  |                                 /> | ||||||
|  |                                 <span class="interval-unit"> | ||||||
|  |                                     {match data.recurrence { | ||||||
|  |                                         RecurrenceType::Daily => if data.recurrence_interval == 1 { "day" } else { "days" }, | ||||||
|  |                                         RecurrenceType::Weekly => if data.recurrence_interval == 1 { "week" } else { "weeks" }, | ||||||
|  |                                         RecurrenceType::Monthly => if data.recurrence_interval == 1 { "month" } else { "months" }, | ||||||
|  |                                         RecurrenceType::Yearly => if data.recurrence_interval == 1 { "year" } else { "years" }, | ||||||
|  |                                         RecurrenceType::None => "", | ||||||
|  |                                     }} | ||||||
|  |                                 </span> | ||||||
|  |                             </div> | ||||||
|  |                         </div> | ||||||
|  |  | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label>{"Ends"}</label> | ||||||
|  |                             <div class="end-options"> | ||||||
|  |                                 <div class="end-option"> | ||||||
|  |                                     <label class="radio-label"> | ||||||
|  |                                         <input | ||||||
|  |                                             type="radio" | ||||||
|  |                                             name="recurrence-end" | ||||||
|  |                                             value="never" | ||||||
|  |                                             checked={data.recurrence_until.is_none() && data.recurrence_count.is_none()} | ||||||
|  |                                             onchange={{ | ||||||
|  |                                                 let data = data.clone(); | ||||||
|  |                                                 Callback::from(move |_| { | ||||||
|  |                                                     let mut new_data = (*data).clone(); | ||||||
|  |                                                     new_data.recurrence_until = None; | ||||||
|  |                                                     new_data.recurrence_count = None; | ||||||
|  |                                                     data.set(new_data); | ||||||
|  |                                                 }) | ||||||
|  |                                             }} | ||||||
|  |                                         /> | ||||||
|  |                                         {"Never"} | ||||||
|  |                                     </label> | ||||||
|  |                                 </div> | ||||||
|  |  | ||||||
|  |                                 <div class="end-option"> | ||||||
|  |                                     <label class="radio-label"> | ||||||
|  |                                         <input | ||||||
|  |                                             type="radio" | ||||||
|  |                                             name="recurrence-end" | ||||||
|  |                                             value="until" | ||||||
|  |                                             checked={data.recurrence_until.is_some()} | ||||||
|  |                                             onchange={{ | ||||||
|  |                                                 let data = data.clone(); | ||||||
|  |                                                 Callback::from(move |_| { | ||||||
|  |                                                     let mut new_data = (*data).clone(); | ||||||
|  |                                                     new_data.recurrence_count = None; | ||||||
|  |                                                     new_data.recurrence_until = Some(new_data.start_date); | ||||||
|  |                                                     data.set(new_data); | ||||||
|  |                                                 }) | ||||||
|  |                                             }} | ||||||
|  |                                         /> | ||||||
|  |                                         {"Until"} | ||||||
|  |                                     </label> | ||||||
|  |                                     <input | ||||||
|  |                                         type="date" | ||||||
|  |                                         class="form-input" | ||||||
|  |                                         value={data.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()} | ||||||
|  |                                         onchange={on_recurrence_until_change} | ||||||
|  |                                     /> | ||||||
|  |                                 </div> | ||||||
|  |  | ||||||
|  |                                 <div class="end-option"> | ||||||
|  |                                     <label class="radio-label"> | ||||||
|  |                                         <input | ||||||
|  |                                             type="radio" | ||||||
|  |                                             name="recurrence-end" | ||||||
|  |                                             value="count" | ||||||
|  |                                             checked={data.recurrence_count.is_some()} | ||||||
|  |                                             onchange={{ | ||||||
|  |                                                 let data = data.clone(); | ||||||
|  |                                                 Callback::from(move |_| { | ||||||
|  |                                                     let mut new_data = (*data).clone(); | ||||||
|  |                                                     new_data.recurrence_until = None; | ||||||
|  |                                                     new_data.recurrence_count = Some(10); // Default count | ||||||
|  |                                                     data.set(new_data); | ||||||
|  |                                                 }) | ||||||
|  |                                             }} | ||||||
|  |                                         /> | ||||||
|  |                                         {"After"} | ||||||
|  |                                     </label> | ||||||
|  |                                     <input | ||||||
|  |                                         type="number" | ||||||
|  |                                         class="form-input count-input" | ||||||
|  |                                         value={data.recurrence_count.map(|c| c.to_string()).unwrap_or_default()} | ||||||
|  |                                         min="1" | ||||||
|  |                                         max="999" | ||||||
|  |                                         placeholder="1" | ||||||
|  |                                         onchange={on_recurrence_count_change} | ||||||
|  |                                     /> | ||||||
|  |                                     <span class="count-unit">{"occurrences"}</span> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                      | ||||||
|  |                     // Monthly specific options | ||||||
|  |                     if matches!(data.recurrence, RecurrenceType::Monthly) { | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label>{"Repeat by"}</label> | ||||||
|  |                             <div class="monthly-options"> | ||||||
|  |                                 <div class="monthly-option"> | ||||||
|  |                                     <label class="radio-label"> | ||||||
|  |                                         <input | ||||||
|  |                                             type="radio" | ||||||
|  |                                             name="monthly-type" | ||||||
|  |                                             checked={data.monthly_by_monthday.is_some()} | ||||||
|  |                                             onchange={{ | ||||||
|  |                                                 let data = data.clone(); | ||||||
|  |                                                 Callback::from(move |_| { | ||||||
|  |                                                     let mut new_data = (*data).clone(); | ||||||
|  |                                                     new_data.monthly_by_day = None; | ||||||
|  |                                                     new_data.monthly_by_monthday = Some(new_data.start_date.day() as u8); | ||||||
|  |                                                     data.set(new_data); | ||||||
|  |                                                 }) | ||||||
|  |                                             }} | ||||||
|  |                                         /> | ||||||
|  |                                         {"Day of month:"} | ||||||
|  |                                     </label> | ||||||
|  |                                     <input | ||||||
|  |                                         type="number" | ||||||
|  |                                         class="form-input day-input" | ||||||
|  |                                         value={data.monthly_by_monthday.map(|d| d.to_string()).unwrap_or_else(|| data.start_date.day().to_string())} | ||||||
|  |                                         min="1" | ||||||
|  |                                         max="31" | ||||||
|  |                                         onchange={on_monthly_by_monthday_change} | ||||||
|  |                                     /> | ||||||
|  |                                 </div> | ||||||
|  |  | ||||||
|  |                                 <div class="monthly-option"> | ||||||
|  |                                     <label class="radio-label"> | ||||||
|  |                                         <input | ||||||
|  |                                             type="radio" | ||||||
|  |                                             name="monthly-type" | ||||||
|  |                                             checked={data.monthly_by_day.is_some()} | ||||||
|  |                                             onchange={{ | ||||||
|  |                                                 let data = data.clone(); | ||||||
|  |                                                 Callback::from(move |_| { | ||||||
|  |                                                     let mut new_data = (*data).clone(); | ||||||
|  |                                                     new_data.monthly_by_monthday = None; | ||||||
|  |                                                     new_data.monthly_by_day = Some("1MO".to_string()); // Default to first Monday | ||||||
|  |                                                     data.set(new_data); | ||||||
|  |                                                 }) | ||||||
|  |                                             }} | ||||||
|  |                                         /> | ||||||
|  |                                         {"Day of week:"} | ||||||
|  |                                     </label> | ||||||
|  |                                     <select | ||||||
|  |                                         class="form-input" | ||||||
|  |                                         value={data.monthly_by_day.clone().unwrap_or_default()} | ||||||
|  |                                         onchange={on_monthly_by_day_change} | ||||||
|  |                                     > | ||||||
|  |                                         <option value="none">{"Select..."}</option> | ||||||
|  |                                         <option value="1MO">{"First Monday"}</option> | ||||||
|  |                                         <option value="1TU">{"First Tuesday"}</option> | ||||||
|  |                                         <option value="1WE">{"First Wednesday"}</option> | ||||||
|  |                                         <option value="1TH">{"First Thursday"}</option> | ||||||
|  |                                         <option value="1FR">{"First Friday"}</option> | ||||||
|  |                                         <option value="1SA">{"First Saturday"}</option> | ||||||
|  |                                         <option value="1SU">{"First Sunday"}</option> | ||||||
|  |                                         <option value="2MO">{"Second Monday"}</option> | ||||||
|  |                                         <option value="2TU">{"Second Tuesday"}</option> | ||||||
|  |                                         <option value="2WE">{"Second Wednesday"}</option> | ||||||
|  |                                         <option value="2TH">{"Second Thursday"}</option> | ||||||
|  |                                         <option value="2FR">{"Second Friday"}</option> | ||||||
|  |                                         <option value="2SA">{"Second Saturday"}</option> | ||||||
|  |                                         <option value="2SU">{"Second Sunday"}</option> | ||||||
|  |                                         <option value="3MO">{"Third Monday"}</option> | ||||||
|  |                                         <option value="3TU">{"Third Tuesday"}</option> | ||||||
|  |                                         <option value="3WE">{"Third Wednesday"}</option> | ||||||
|  |                                         <option value="3TH">{"Third Thursday"}</option> | ||||||
|  |                                         <option value="3FR">{"Third Friday"}</option> | ||||||
|  |                                         <option value="3SA">{"Third Saturday"}</option> | ||||||
|  |                                         <option value="3SU">{"Third Sunday"}</option> | ||||||
|  |                                         <option value="4MO">{"Fourth Monday"}</option> | ||||||
|  |                                         <option value="4TU">{"Fourth Tuesday"}</option> | ||||||
|  |                                         <option value="4WE">{"Fourth Wednesday"}</option> | ||||||
|  |                                         <option value="4TH">{"Fourth Thursday"}</option> | ||||||
|  |                                         <option value="4FR">{"Fourth Friday"}</option> | ||||||
|  |                                         <option value="4SA">{"Fourth Saturday"}</option> | ||||||
|  |                                         <option value="4SU">{"Fourth Sunday"}</option> | ||||||
|  |                                         <option value="-1MO">{"Last Monday"}</option> | ||||||
|  |                                         <option value="-1TU">{"Last Tuesday"}</option> | ||||||
|  |                                         <option value="-1WE">{"Last Wednesday"}</option> | ||||||
|  |                                         <option value="-1TH">{"Last Thursday"}</option> | ||||||
|  |                                         <option value="-1FR">{"Last Friday"}</option> | ||||||
|  |                                         <option value="-1SA">{"Last Saturday"}</option> | ||||||
|  |                                         <option value="-1SU">{"Last Sunday"}</option> | ||||||
|  |                                     </select> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                         </div> | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     // Yearly specific options   | ||||||
|  |                     if matches!(data.recurrence, RecurrenceType::Yearly) { | ||||||
|  |                         <div class="form-group"> | ||||||
|  |                             <label>{"Repeat in months"}</label> | ||||||
|  |                             <div class="yearly-months"> | ||||||
|  |                                 { | ||||||
|  |                                     ["Jan", "Feb", "Mar", "Apr", "May", "Jun",  | ||||||
|  |                                      "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] | ||||||
|  |                                         .iter() | ||||||
|  |                                         .enumerate() | ||||||
|  |                                         .map(|(i, month)| { | ||||||
|  |                                             let month_checked = data.yearly_by_month.get(i).cloned().unwrap_or(false); | ||||||
|  |                                             let on_change = on_yearly_month_change(i); | ||||||
|  |                                             html! { | ||||||
|  |                                                 <label key={i} class="month-checkbox"> | ||||||
|  |                                                     <input | ||||||
|  |                                                         type="checkbox" | ||||||
|  |                                                         checked={month_checked} | ||||||
|  |                                                         onchange={on_change} | ||||||
|  |                                                     /> | ||||||
|  |                                                     <span class="month-label">{month}</span> | ||||||
|  |                                                 </label> | ||||||
|  |                                             } | ||||||
|  |                                         }) | ||||||
|  |                                         .collect::<Html>() | ||||||
|  |                                 } | ||||||
|  |                             </div> | ||||||
|  |                         </div> | ||||||
|  |                     } | ||||||
|  |                 </div> | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Date and time fields go here AFTER recurrence options | ||||||
|  |             <div class="form-row"> | ||||||
|  |                 <div class="form-group"> | ||||||
|  |                     <label for="start-date">{"Start Date *"}</label> | ||||||
|  |                     <input | ||||||
|  |                         type="date" | ||||||
|  |                         id="start-date" | ||||||
|  |                         class="form-input" | ||||||
|  |                         value={data.start_date.format("%Y-%m-%d").to_string()} | ||||||
|  |                         onchange={on_start_date_change} | ||||||
|  |                         required=true | ||||||
|  |                     /> | ||||||
|  |                 </div> | ||||||
|  |                  | ||||||
|  |                 if !data.all_day { | ||||||
|  |                     <div class="form-group"> | ||||||
|  |                         <label for="start-time">{"Start Time"}</label> | ||||||
|  |                         <input | ||||||
|  |                             type="time" | ||||||
|  |                             id="start-time" | ||||||
|  |                             class="form-input" | ||||||
|  |                             value={data.start_time.format("%H:%M").to_string()} | ||||||
|  |                             onchange={on_start_time_change} | ||||||
|  |                         /> | ||||||
|  |                     </div> | ||||||
|  |                 } | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="form-row"> | ||||||
|  |                 <div class="form-group"> | ||||||
|  |                     <label for="end-date">{"End Date *"}</label> | ||||||
|  |                     <input | ||||||
|  |                         type="date" | ||||||
|  |                         id="end-date" | ||||||
|  |                         class="form-input" | ||||||
|  |                         value={data.end_date.format("%Y-%m-%d").to_string()} | ||||||
|  |                         onchange={on_end_date_change} | ||||||
|  |                         required=true | ||||||
|  |                     /> | ||||||
|  |                 </div> | ||||||
|  |                  | ||||||
|  |                 if !data.all_day { | ||||||
|  |                     <div class="form-group"> | ||||||
|  |                         <label for="end-time">{"End Time"}</label> | ||||||
|  |                         <input | ||||||
|  |                             type="time" | ||||||
|  |                             id="end-time" | ||||||
|  |                             class="form-input" | ||||||
|  |                             value={data.end_time.format("%H:%M").to_string()} | ||||||
|  |                             onchange={on_end_time_change} | ||||||
|  |                         /> | ||||||
|  |                     </div> | ||||||
|  |                 } | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="event-location">{"Location"}</label> | ||||||
|  |                 <input | ||||||
|  |                     type="text" | ||||||
|  |                     id="event-location" | ||||||
|  |                     class="form-input" | ||||||
|  |                     value={data.location.clone()} | ||||||
|  |                     oninput={on_location_input} | ||||||
|  |                     placeholder="Enter event location" | ||||||
|  |                 /> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										98
									
								
								frontend/src/components/event_form/categories.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								frontend/src/components/event_form/categories.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | |||||||
|  | use super::types::*; | ||||||
|  | use wasm_bindgen::JsCast; | ||||||
|  | use web_sys::HtmlInputElement; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
|  | #[function_component(CategoriesTab)] | ||||||
|  | pub fn categories_tab(props: &TabProps) -> Html { | ||||||
|  |     let data = &props.data; | ||||||
|  |      | ||||||
|  |     let on_categories_input = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: InputEvent| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(input) = target.dyn_into::<HtmlInputElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.categories = input.value(); | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let add_category = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         move |category: &str| { | ||||||
|  |             let data = data.clone(); | ||||||
|  |             let category = category.to_string(); | ||||||
|  |             Callback::from(move |_| { | ||||||
|  |                 let mut event_data = (*data).clone(); | ||||||
|  |                 if event_data.categories.is_empty() { | ||||||
|  |                     event_data.categories = category.clone(); | ||||||
|  |                 } else { | ||||||
|  |                     event_data.categories = format!("{}, {}", event_data.categories, category); | ||||||
|  |                 } | ||||||
|  |                 data.set(event_data); | ||||||
|  |             }) | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     html! { | ||||||
|  |         <div class="tab-panel"> | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="event-categories">{"Categories"}</label> | ||||||
|  |                 <input | ||||||
|  |                     type="text" | ||||||
|  |                     id="event-categories" | ||||||
|  |                     class="form-input" | ||||||
|  |                     value={data.categories.clone()} | ||||||
|  |                     oninput={on_categories_input} | ||||||
|  |                     placeholder="work, meeting, personal, project, urgent" | ||||||
|  |                 /> | ||||||
|  |                 <p class="form-help-text">{"Enter categories separated by commas to help organize and filter your events"}</p> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="categories-suggestions"> | ||||||
|  |                 <h5>{"Common Categories"}</h5> | ||||||
|  |                 <div class="category-tags"> | ||||||
|  |                     <button type="button" class="category-tag" onclick={add_category("work")}>{"work"}</button> | ||||||
|  |                     <button type="button" class="category-tag" onclick={add_category("meeting")}>{"meeting"}</button> | ||||||
|  |                     <button type="button" class="category-tag" onclick={add_category("personal")}>{"personal"}</button> | ||||||
|  |                     <button type="button" class="category-tag" onclick={add_category("project")}>{"project"}</button> | ||||||
|  |                     <button type="button" class="category-tag" onclick={add_category("urgent")}>{"urgent"}</button> | ||||||
|  |                     <button type="button" class="category-tag" onclick={add_category("social")}>{"social"}</button> | ||||||
|  |                     <button type="button" class="category-tag" onclick={add_category("travel")}>{"travel"}</button> | ||||||
|  |                     <button type="button" class="category-tag" onclick={add_category("health")}>{"health"}</button> | ||||||
|  |                 </div> | ||||||
|  |                 <p class="form-help-text">{"Click to add these common categories to your event"}</p> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="categories-info"> | ||||||
|  |                 <h5>{"Event Organization & Filtering"}</h5> | ||||||
|  |                 <ul> | ||||||
|  |                     <li>{"Categories help organize events in calendar views"}</li> | ||||||
|  |                     <li>{"Filter events by category to focus on specific types"}</li> | ||||||
|  |                     <li>{"Categories are searchable and can be used for reporting"}</li> | ||||||
|  |                     <li>{"Multiple categories per event are fully supported"}</li> | ||||||
|  |                 </ul> | ||||||
|  |  | ||||||
|  |                 <div class="categories-examples"> | ||||||
|  |                     <h6>{"Category Usage Examples"}</h6> | ||||||
|  |                     <div class="category-example"> | ||||||
|  |                         <strong>{"Work Events:"}</strong> | ||||||
|  |                         <span>{"work, meeting, project, urgent, deadline"}</span> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="category-example"> | ||||||
|  |                         <strong>{"Personal Events:"}</strong> | ||||||
|  |                         <span>{"personal, family, health, social, travel"}</span> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="category-example"> | ||||||
|  |                         <strong>{"Mixed Events:"}</strong> | ||||||
|  |                         <span>{"work, travel, client, important"}</span> | ||||||
|  |                     </div> | ||||||
|  |                     <p class="form-help-text">{"Categories follow RFC 5545 CATEGORIES property standards"}</p> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										118
									
								
								frontend/src/components/event_form/location.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								frontend/src/components/event_form/location.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,118 @@ | |||||||
|  | use super::types::*; | ||||||
|  | use wasm_bindgen::JsCast; | ||||||
|  | use web_sys::HtmlInputElement; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
|  | #[function_component(LocationTab)] | ||||||
|  | pub fn location_tab(props: &TabProps) -> Html { | ||||||
|  |     let data = &props.data; | ||||||
|  |      | ||||||
|  |     let on_location_input = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: InputEvent| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(input) = target.dyn_into::<HtmlInputElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.location = input.value(); | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let set_location = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         move |location: &str| { | ||||||
|  |             let data = data.clone(); | ||||||
|  |             let location = location.to_string(); | ||||||
|  |             Callback::from(move |_| { | ||||||
|  |                 let mut event_data = (*data).clone(); | ||||||
|  |                 event_data.location = location.clone(); | ||||||
|  |                 data.set(event_data); | ||||||
|  |             }) | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     html! { | ||||||
|  |         <div class="tab-panel"> | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="event-location-detailed">{"Event Location"}</label> | ||||||
|  |                 <input | ||||||
|  |                     type="text" | ||||||
|  |                     id="event-location-detailed" | ||||||
|  |                     class="form-input" | ||||||
|  |                     value={data.location.clone()} | ||||||
|  |                     oninput={on_location_input} | ||||||
|  |                     placeholder="Conference Room A, 123 Main St, City, State 12345" | ||||||
|  |                 /> | ||||||
|  |                 <p class="form-help-text">{"Enter the full address or location description for the event"}</p> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="location-suggestions"> | ||||||
|  |                 <h5>{"Common Locations"}</h5> | ||||||
|  |                 <div class="location-tags"> | ||||||
|  |                     <button type="button" class="location-tag" onclick={set_location("Conference Room")}>{"Conference Room"}</button> | ||||||
|  |                     <button type="button" class="location-tag" onclick={set_location("Online Meeting")}>{"Online Meeting"}</button> | ||||||
|  |                     <button type="button" class="location-tag" onclick={set_location("Main Office")}>{"Main Office"}</button> | ||||||
|  |                     <button type="button" class="location-tag" onclick={set_location("Client Site")}>{"Client Site"}</button> | ||||||
|  |                     <button type="button" class="location-tag" onclick={set_location("Home Office")}>{"Home Office"}</button> | ||||||
|  |                     <button type="button" class="location-tag" onclick={set_location("Remote")}>{"Remote"}</button> | ||||||
|  |                 </div> | ||||||
|  |                 <p class="form-help-text">{"Click to quickly set common location types"}</p> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="location-info"> | ||||||
|  |                 <h5>{"Location Features & Integration"}</h5> | ||||||
|  |                 <ul> | ||||||
|  |                     <li>{"Location information is included in calendar invitations"}</li> | ||||||
|  |                     <li>{"Supports both physical addresses and virtual meeting links"}</li> | ||||||
|  |                     <li>{"Compatible with mapping and navigation applications"}</li> | ||||||
|  |                     <li>{"Room booking integration available for enterprise setups"}</li> | ||||||
|  |                 </ul> | ||||||
|  |  | ||||||
|  |                 <div class="geo-section"> | ||||||
|  |                     <h6>{"Geographic Coordinates (Advanced)"}</h6> | ||||||
|  |                     <p>{"Future versions will support:"}</p> | ||||||
|  |                     <div class="geo-features"> | ||||||
|  |                         <div class="geo-item"> | ||||||
|  |                             <strong>{"GPS Coordinates:"}</strong> | ||||||
|  |                             <span>{"Precise latitude/longitude positioning"}</span> | ||||||
|  |                         </div> | ||||||
|  |                         <div class="geo-item"> | ||||||
|  |                             <strong>{"Map Integration:"}</strong> | ||||||
|  |                             <span>{"Embedded maps in event details"}</span> | ||||||
|  |                         </div> | ||||||
|  |                         <div class="geo-item"> | ||||||
|  |                             <strong>{"Travel Time:"}</strong> | ||||||
|  |                             <span>{"Automatic travel time calculation"}</span> | ||||||
|  |                         </div> | ||||||
|  |                         <div class="geo-item"> | ||||||
|  |                             <strong>{"Proximity Alerts:"}</strong> | ||||||
|  |                             <span>{"Location-based notifications"}</span> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                     <p class="form-help-text">{"Advanced geographic features will be implemented in future releases"}</p> | ||||||
|  |                 </div> | ||||||
|  |  | ||||||
|  |                 <div class="virtual-meeting-section"> | ||||||
|  |                     <h6>{"Virtual Meeting Integration"}</h6> | ||||||
|  |                     <div class="meeting-platforms"> | ||||||
|  |                         <div class="platform-item"> | ||||||
|  |                             <strong>{"Video Conferencing:"}</strong> | ||||||
|  |                             <span>{"Zoom, Teams, Google Meet links"}</span> | ||||||
|  |                         </div> | ||||||
|  |                         <div class="platform-item"> | ||||||
|  |                             <strong>{"Phone Conference:"}</strong> | ||||||
|  |                             <span>{"Dial-in numbers and access codes"}</span> | ||||||
|  |                         </div> | ||||||
|  |                         <div class="platform-item"> | ||||||
|  |                             <strong>{"Webinar Links:"}</strong> | ||||||
|  |                             <span>{"Live streaming and presentation URLs"}</span> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                     <p class="form-help-text">{"Paste meeting links directly in the location field for virtual events"}</p> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								frontend/src/components/event_form/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								frontend/src/components/event_form/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | // Event form components module | ||||||
|  | pub mod types; | ||||||
|  | pub mod basic_details; | ||||||
|  | pub mod advanced; | ||||||
|  | pub mod people; | ||||||
|  | pub mod categories; | ||||||
|  | pub mod location; | ||||||
|  | pub mod reminders; | ||||||
|  |  | ||||||
|  | pub use types::*; | ||||||
|  | pub use basic_details::BasicDetailsTab; | ||||||
|  | pub use advanced::AdvancedTab; | ||||||
|  | pub use people::PeopleTab; | ||||||
|  | pub use categories::CategoriesTab; | ||||||
|  | pub use location::LocationTab; | ||||||
|  | pub use reminders::RemindersTab; | ||||||
							
								
								
									
										103
									
								
								frontend/src/components/event_form/people.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								frontend/src/components/event_form/people.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | |||||||
|  | use super::types::*; | ||||||
|  | use wasm_bindgen::JsCast; | ||||||
|  | use web_sys::{HtmlInputElement, HtmlTextAreaElement}; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
|  | #[function_component(PeopleTab)] | ||||||
|  | pub fn people_tab(props: &TabProps) -> Html { | ||||||
|  |     let data = &props.data; | ||||||
|  |      | ||||||
|  |     let on_organizer_input = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: InputEvent| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(input) = target.dyn_into::<HtmlInputElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.organizer = input.value(); | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     let on_attendees_input = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: InputEvent| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(textarea) = target.dyn_into::<HtmlTextAreaElement>() { | ||||||
|  |                     let mut event_data = (*data).clone(); | ||||||
|  |                     event_data.attendees = textarea.value(); | ||||||
|  |                     data.set(event_data); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     html! { | ||||||
|  |         <div class="tab-panel"> | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="event-organizer">{"Organizer"}</label> | ||||||
|  |                 <input | ||||||
|  |                     type="email" | ||||||
|  |                     id="event-organizer" | ||||||
|  |                     class="form-input" | ||||||
|  |                     value={data.organizer.clone()} | ||||||
|  |                     oninput={on_organizer_input} | ||||||
|  |                     placeholder="organizer@example.com" | ||||||
|  |                 /> | ||||||
|  |                 <p class="form-help-text">{"Email address of the person organizing this event"}</p> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="event-attendees">{"Attendees"}</label> | ||||||
|  |                 <textarea | ||||||
|  |                     id="event-attendees" | ||||||
|  |                     class="form-input" | ||||||
|  |                     value={data.attendees.clone()} | ||||||
|  |                     oninput={on_attendees_input} | ||||||
|  |                     placeholder="attendee1@example.com, attendee2@example.com, attendee3@example.com" | ||||||
|  |                     rows="4" | ||||||
|  |                 ></textarea> | ||||||
|  |                 <p class="form-help-text">{"Enter attendee email addresses separated by commas"}</p> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="people-info"> | ||||||
|  |                 <h5>{"Invitation & Response Management"}</h5> | ||||||
|  |                 <ul> | ||||||
|  |                     <li>{"Invitations are sent automatically when the event is saved"}</li> | ||||||
|  |                     <li>{"Attendees can respond with Accept, Decline, or Tentative"}</li> | ||||||
|  |                     <li>{"Response tracking follows RFC 5545 PARTSTAT standards"}</li> | ||||||
|  |                     <li>{"Delegation and role management available after event creation"}</li> | ||||||
|  |                 </ul> | ||||||
|  |  | ||||||
|  |                 <div class="people-validation"> | ||||||
|  |                     <h6>{"Email Validation"}</h6> | ||||||
|  |                     <p>{"Email addresses will be validated when you save the event. Invalid emails will be highlighted and must be corrected before proceeding."}</p> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="attendee-roles-preview"> | ||||||
|  |                 <h5>{"Advanced Attendee Features"}</h5> | ||||||
|  |                 <div class="role-examples"> | ||||||
|  |                     <div class="role-item"> | ||||||
|  |                         <strong>{"Required Participant:"}</strong> | ||||||
|  |                         <span>{"Must attend for meeting to proceed"}</span> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="role-item"> | ||||||
|  |                         <strong>{"Optional Participant:"}</strong> | ||||||
|  |                         <span>{"Attendance welcome but not required"}</span> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="role-item"> | ||||||
|  |                         <strong>{"Resource:"}</strong> | ||||||
|  |                         <span>{"Meeting room, equipment, or facility"}</span> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="role-item"> | ||||||
|  |                         <strong>{"Non-Participant:"}</strong> | ||||||
|  |                         <span>{"For information only"}</span> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |                 <p class="form-help-text">{"Advanced role assignment and RSVP management will be available in future versions"}</p> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										100
									
								
								frontend/src/components/event_form/reminders.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								frontend/src/components/event_form/reminders.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | |||||||
|  | use super::types::*; | ||||||
|  | // Types are already imported from super::types::* | ||||||
|  | use wasm_bindgen::JsCast; | ||||||
|  | use web_sys::HtmlSelectElement; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
|  | #[function_component(RemindersTab)] | ||||||
|  | pub fn reminders_tab(props: &TabProps) -> Html { | ||||||
|  |     let data = &props.data; | ||||||
|  |      | ||||||
|  |     let on_reminder_change = { | ||||||
|  |         let data = data.clone(); | ||||||
|  |         Callback::from(move |e: Event| { | ||||||
|  |             if let Some(target) = e.target() { | ||||||
|  |                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { | ||||||
|  |                     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); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     html! { | ||||||
|  |         <div class="tab-panel"> | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="event-reminder-main">{"Primary Reminder"}</label> | ||||||
|  |                 <select | ||||||
|  |                     id="event-reminder-main" | ||||||
|  |                     class="form-input" | ||||||
|  |                     onchange={on_reminder_change} | ||||||
|  |                 > | ||||||
|  |                     <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> | ||||||
|  |                     <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> | ||||||
|  |                     <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> | ||||||
|  |                 <p class="form-help-text">{"Multiple alarm types follow RFC 5545 VALARM standards"}</p> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="reminder-info"> | ||||||
|  |                 <h5>{"Advanced Reminder Features"}</h5> | ||||||
|  |                 <ul> | ||||||
|  |                     <li>{"Multiple reminders per event with different timing"}</li> | ||||||
|  |                     <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"> | ||||||
|  |                     <h6>{"File Attachments & Documents"}</h6> | ||||||
|  |                     <p>{"Future attachment features will include:"}</p> | ||||||
|  |                     <ul> | ||||||
|  |                         <li>{"Drag-and-drop file uploads"}</li> | ||||||
|  |                         <li>{"Document preview and thumbnails"}</li> | ||||||
|  |                         <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> | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										262
									
								
								frontend/src/components/event_form/types.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										262
									
								
								frontend/src/components/event_form/types.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,262 @@ | |||||||
|  | use crate::services::calendar_service::CalendarInfo; | ||||||
|  | use chrono::{Local, NaiveDate, NaiveTime}; | ||||||
|  | use yew::prelude::*; | ||||||
|  |  | ||||||
|  | #[derive(Clone, PartialEq, Debug)] | ||||||
|  | pub enum EventStatus { | ||||||
|  |     Confirmed, | ||||||
|  |     Tentative, | ||||||
|  |     Cancelled, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Default for EventStatus { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         EventStatus::Confirmed | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Clone, PartialEq, Debug)] | ||||||
|  | pub enum EventClass { | ||||||
|  |     Public, | ||||||
|  |     Private, | ||||||
|  |     Confidential, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Default for EventClass { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         EventClass::Public | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Clone, PartialEq, Debug)] | ||||||
|  | pub enum ReminderType { | ||||||
|  |     None, | ||||||
|  |     Minutes15, | ||||||
|  |     Minutes30, | ||||||
|  |     Hour1, | ||||||
|  |     Day1, | ||||||
|  |     Days2, | ||||||
|  |     Week1, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Default for ReminderType { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         ReminderType::None | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Clone, PartialEq, Debug)] | ||||||
|  | pub enum RecurrenceType { | ||||||
|  |     None, | ||||||
|  |     Daily, | ||||||
|  |     Weekly, | ||||||
|  |     Monthly, | ||||||
|  |     Yearly, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Default for RecurrenceType { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         RecurrenceType::None | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Clone, PartialEq)] | ||||||
|  | pub enum ModalTab { | ||||||
|  |     BasicDetails, | ||||||
|  |     Advanced, | ||||||
|  |     People, | ||||||
|  |     Categories, | ||||||
|  |     Location, | ||||||
|  |     Reminders, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Default for ModalTab { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         ModalTab::BasicDetails | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // EditAction is now imported from event_context_menu - this duplicate removed | ||||||
|  |  | ||||||
|  | #[derive(Clone, PartialEq, Debug)] | ||||||
|  | pub struct EventCreationData { | ||||||
|  |     // Basic event info | ||||||
|  |     pub title: String, | ||||||
|  |     pub description: String, | ||||||
|  |     pub location: String, | ||||||
|  |     pub all_day: bool, | ||||||
|  |      | ||||||
|  |     // Timing | ||||||
|  |     pub start_date: NaiveDate, | ||||||
|  |     pub end_date: NaiveDate, | ||||||
|  |     pub start_time: NaiveTime, | ||||||
|  |     pub end_time: NaiveTime, | ||||||
|  |      | ||||||
|  |     // Classification | ||||||
|  |     pub status: EventStatus, | ||||||
|  |     pub class: EventClass, | ||||||
|  |     pub priority: Option<u8>, | ||||||
|  |      | ||||||
|  |     // People | ||||||
|  |     pub organizer: String, | ||||||
|  |     pub attendees: String, | ||||||
|  |      | ||||||
|  |     // Categorization | ||||||
|  |     pub categories: String, | ||||||
|  |      | ||||||
|  |     // Reminders | ||||||
|  |     pub reminder: ReminderType, | ||||||
|  |      | ||||||
|  |     // Recurrence | ||||||
|  |     pub recurrence: RecurrenceType, | ||||||
|  |     pub recurrence_interval: u32, | ||||||
|  |     pub recurrence_until: Option<NaiveDate>, | ||||||
|  |     pub recurrence_count: Option<u32>, | ||||||
|  |     pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] | ||||||
|  |      | ||||||
|  |     // Advanced recurrence | ||||||
|  |     pub monthly_by_day: Option<String>, // e.g., "1MO" for first Monday | ||||||
|  |     pub monthly_by_monthday: Option<u8>, // e.g., 15 for 15th day of month | ||||||
|  |     pub yearly_by_month: Vec<bool>, // [Jan, Feb, Mar, ...]  | ||||||
|  |      | ||||||
|  |     // Calendar selection | ||||||
|  |     pub selected_calendar: Option<String>, | ||||||
|  |      | ||||||
|  |     // Edit tracking (for recurring events) | ||||||
|  |     pub edit_scope: Option<crate::components::EditAction>, | ||||||
|  |     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 | ||||||
|  |         String, // reminder | ||||||
|  |         String, // recurrence | ||||||
|  |         Vec<bool>, // recurrence_days | ||||||
|  |         Option<String>, // calendar_path | ||||||
|  |         Option<u32>, // recurrence_count | ||||||
|  |         Option<String>, // recurrence_until | ||||||
|  |     ) { | ||||||
|  |         use chrono::{Local, TimeZone}; | ||||||
|  |          | ||||||
|  |         // Convert local date/time to UTC for backend | ||||||
|  |         let (utc_start_date, utc_start_time, utc_end_date, utc_end_time) = if self.all_day { | ||||||
|  |             // For all-day events, just use the dates as-is (no time conversion needed) | ||||||
|  |             ( | ||||||
|  |                 self.start_date.format("%Y-%m-%d").to_string(), | ||||||
|  |                 self.start_time.format("%H:%M").to_string(), | ||||||
|  |                 self.end_date.format("%Y-%m-%d").to_string(), | ||||||
|  |                 self.end_time.format("%H:%M").to_string(), | ||||||
|  |             ) | ||||||
|  |         } else { | ||||||
|  |             // Convert local date/time to UTC | ||||||
|  |             let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single(); | ||||||
|  |             let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single(); | ||||||
|  |              | ||||||
|  |             if let (Some(start_dt), Some(end_dt)) = (start_local, end_local) { | ||||||
|  |                 let start_utc = start_dt.with_timezone(&chrono::Utc); | ||||||
|  |                 let end_utc = end_dt.with_timezone(&chrono::Utc); | ||||||
|  |                 ( | ||||||
|  |                     start_utc.format("%Y-%m-%d").to_string(), | ||||||
|  |                     start_utc.format("%H:%M").to_string(), | ||||||
|  |                     end_utc.format("%Y-%m-%d").to_string(), | ||||||
|  |                     end_utc.format("%H:%M").to_string(), | ||||||
|  |                 ) | ||||||
|  |             } else { | ||||||
|  |                 // Fallback if timezone conversion fails - use local time as-is | ||||||
|  |                 web_sys::console::warn_1(&"⚠️ Failed to convert local time to UTC, using local time".into()); | ||||||
|  |                 ( | ||||||
|  |                     self.start_date.format("%Y-%m-%d").to_string(), | ||||||
|  |                     self.start_time.format("%H:%M").to_string(), | ||||||
|  |                     self.end_date.format("%Y-%m-%d").to_string(), | ||||||
|  |                     self.end_time.format("%H:%M").to_string(), | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         ( | ||||||
|  |             self.title.clone(), | ||||||
|  |             self.description.clone(), | ||||||
|  |             utc_start_date, | ||||||
|  |             utc_start_time, | ||||||
|  |             utc_end_date, | ||||||
|  |             utc_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(), | ||||||
|  |             format!("{:?}", self.reminder), | ||||||
|  |             format!("{:?}", self.recurrence), | ||||||
|  |             self.recurrence_days.clone(), | ||||||
|  |             self.selected_calendar.clone(), | ||||||
|  |             self.recurrence_count, | ||||||
|  |             self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()), | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Default for EventCreationData { | ||||||
|  |     fn default() -> Self { | ||||||
|  |         let now_local = Local::now(); | ||||||
|  |         let start_date = now_local.date_naive(); | ||||||
|  |         let start_time = NaiveTime::from_hms_opt(9, 0, 0).unwrap_or_default(); | ||||||
|  |         let end_time = NaiveTime::from_hms_opt(10, 0, 0).unwrap_or_default(); | ||||||
|  |  | ||||||
|  |         Self { | ||||||
|  |             title: String::new(), | ||||||
|  |             description: String::new(), | ||||||
|  |             location: String::new(), | ||||||
|  |             all_day: false, | ||||||
|  |             start_date, | ||||||
|  |             end_date: start_date, | ||||||
|  |             start_time, | ||||||
|  |             end_time, | ||||||
|  |             status: EventStatus::default(), | ||||||
|  |             class: EventClass::default(), | ||||||
|  |             priority: None, | ||||||
|  |             organizer: String::new(), | ||||||
|  |             attendees: String::new(), | ||||||
|  |             categories: String::new(), | ||||||
|  |             reminder: ReminderType::default(), | ||||||
|  |             recurrence: RecurrenceType::default(), | ||||||
|  |             recurrence_interval: 1, | ||||||
|  |             recurrence_until: None, | ||||||
|  |             recurrence_count: None, | ||||||
|  |             recurrence_days: vec![false; 7], | ||||||
|  |             monthly_by_day: None, | ||||||
|  |             monthly_by_monthday: None, | ||||||
|  |             yearly_by_month: vec![false; 12], | ||||||
|  |             selected_calendar: None, | ||||||
|  |             edit_scope: None, | ||||||
|  |             changed_fields: vec![], | ||||||
|  |             original_uid: None, | ||||||
|  |             occurrence_date: None, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Common props for all tab components | ||||||
|  | #[derive(Properties, PartialEq)] | ||||||
|  | pub struct TabProps { | ||||||
|  |     pub data: UseStateHandle<EventCreationData>, | ||||||
|  |     pub available_calendars: Vec<CalendarInfo>, | ||||||
|  | } | ||||||
| @@ -63,7 +63,7 @@ pub fn EventModal(props: &EventModalProps) -> Html { | |||||||
|                                 html! { |                                 html! { | ||||||
|                                     <div class="event-detail"> |                                     <div class="event-detail"> | ||||||
|                                         <strong>{"End:"}</strong> |                                         <strong>{"End:"}</strong> | ||||||
|                                         <span>{format_datetime(end, event.all_day)}</span> |                                         <span>{format_datetime_end(end, event.all_day)}</span> | ||||||
|                                     </div> |                                     </div> | ||||||
|                                 } |                                 } | ||||||
|                             } else { |                             } else { | ||||||
| @@ -221,6 +221,17 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | fn format_datetime_end(dt: &DateTime<Utc>, all_day: bool) -> String { | ||||||
|  |     if all_day { | ||||||
|  |         // For all-day events, subtract one day from end date for display | ||||||
|  |         // RFC-5545 uses exclusive end dates, but users expect inclusive display | ||||||
|  |         let display_date = *dt - chrono::Duration::days(1); | ||||||
|  |         display_date.format("%B %d, %Y").to_string() | ||||||
|  |     } else { | ||||||
|  |         dt.format("%B %d, %Y at %I:%M %p").to_string() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| fn format_recurrence_rule(rrule: &str) -> String { | fn format_recurrence_rule(rrule: &str) -> String { | ||||||
|     // Basic parsing of RRULE to display user-friendly text |     // Basic parsing of RRULE to display user-friendly text | ||||||
|     if rrule.contains("FREQ=DAILY") { |     if rrule.contains("FREQ=DAILY") { | ||||||
|   | |||||||
							
								
								
									
										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> | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -145,6 +145,10 @@ pub fn Login(props: &LoginProps) -> Html { | |||||||
|                     } |                     } | ||||||
|                     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); | ||||||
|                     } |                     } | ||||||
|   | |||||||
							
								
								
									
										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,13 +1,17 @@ | |||||||
| 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 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 event_context_menu; | pub mod event_context_menu; | ||||||
|  | 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 recurring_edit_modal; | pub mod recurring_edit_modal; | ||||||
| pub mod route_handler; | pub mod route_handler; | ||||||
| @@ -16,16 +20,17 @@ 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 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 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 recurring_edit_modal::{RecurringEditAction, RecurringEditModal}; | pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal}; | ||||||
| pub use route_handler::RouteHandler; | pub use route_handler::RouteHandler; | ||||||
|   | |||||||
| @@ -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() | ||||||
| @@ -194,6 +208,7 @@ 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} | ||||||
|                                             > |                                             > | ||||||
|   | |||||||
| @@ -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)>>, | ||||||
| @@ -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)>>, | ||||||
| @@ -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,5 +1,5 @@ | |||||||
| 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::*; | use yew_router::prelude::*; | ||||||
| @@ -100,12 +100,18 @@ 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 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 +122,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 +162,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 +203,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() { | ||||||
| @@ -192,6 +220,7 @@ pub fn sidebar(props: &SidebarProps) -> Html { | |||||||
|                                                     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_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 +235,174 @@ 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" style="position: relative;"> | ||||||
|  |                                                 <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 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} | ||||||
|  |                                                                                     /> | ||||||
|  |                                                                                 } | ||||||
|  |                                                                             }).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) { | ||||||
|  |                                                                     "⏳" // Loading spinner | ||||||
|  |                                                                 } else { | ||||||
|  |                                                                     "🔄" // Normal refresh icon | ||||||
|  |                                                                 } | ||||||
|  |                                                             } | ||||||
|  |                                                         </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"> | ||||||
|   | |||||||
| @@ -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)>>, | ||||||
| @@ -79,10 +81,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 +134,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 +149,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(); | ||||||
| @@ -319,10 +355,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 })}> | ||||||
| @@ -347,11 +392,27 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|                                                         }) |                                                         }) | ||||||
|                                                     }; |                                                     }; | ||||||
|                                                      |                                                      | ||||||
|  |                                                     let oncontextmenu = { | ||||||
|  |                                                         if let Some(callback) = &props.on_event_context_menu { | ||||||
|  |                                                             let callback = callback.clone(); | ||||||
|  |                                                             let event = (*event).clone(); | ||||||
|  |                                                             Some(Callback::from(move |e: web_sys::MouseEvent| { | ||||||
|  |                                                                 e.prevent_default(); | ||||||
|  |                                                                 e.stop_propagation(); // Prevent calendar context menu from also triggering | ||||||
|  |                                                                 callback.emit((e, event.clone())); | ||||||
|  |                                                             })) | ||||||
|  |                                                         } else { | ||||||
|  |                                                             None | ||||||
|  |                                                         } | ||||||
|  |                                                     }; | ||||||
|  |                                                      | ||||||
|                                                     html! { |                                                     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} | ||||||
|                                                         > |                                                         > | ||||||
|                                                             <span class="all-day-event-title"> |                                                             <span class="all-day-event-title"> | ||||||
|                                                                 {event.summary.as_ref().unwrap_or(&"Untitled".to_string())} |                                                                 {event.summary.as_ref().unwrap_or(&"Untitled".to_string())} | ||||||
| @@ -373,14 +434,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().map(|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 } | ||||||
|  |                                     )}> | ||||||
|                                         {time} |                                         {time} | ||||||
|                                     </div> |                                     </div> | ||||||
|                                 } |                                 } | ||||||
| @@ -389,12 +453,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(); | ||||||
| @@ -485,8 +549,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 { | ||||||
| @@ -514,7 +578,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 { | ||||||
| @@ -543,7 +607,7 @@ 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 { | ||||||
| @@ -579,7 +643,7 @@ 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 | ||||||
|                                                         let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local(); |                                                         let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local(); | ||||||
| @@ -628,7 +692,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} | ||||||
| @@ -637,10 +702,21 @@ 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 })}> | ||||||
|                                                         <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>() | ||||||
| @@ -650,7 +726,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); | ||||||
|  |  | ||||||
|                                                     // 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 { | ||||||
| @@ -678,7 +754,7 @@ 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; | ||||||
|                                                         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 | ||||||
|  |  | ||||||
| @@ -692,7 +768,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); | ||||||
|                                                             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 | ||||||
| @@ -869,6 +945,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} | ||||||
| @@ -888,7 +965,7 @@ 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">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div> | ||||||
|                                                                     {if !is_all_day { |                                                                     {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! {} | ||||||
| @@ -924,8 +1001,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 | ||||||
| @@ -941,7 +1018,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 { | ||||||
| @@ -956,15 +1033,20 @@ 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.with_timezone(&chrono::Local).naive_local() | ||||||
|                                                             } else { |                                                             } else { | ||||||
| @@ -972,7 +1054,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|                                                             }; |                                                             }; | ||||||
|  |  | ||||||
|                                                             // 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); | ||||||
|                                                             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.with_timezone(&chrono::Local).naive_local()); | ||||||
|                                                             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); | ||||||
|  |  | ||||||
| @@ -985,19 +1067,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.with_timezone(&chrono::Local).naive_local(); | ||||||
|  |  | ||||||
|                                                             // 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); | ||||||
|  |  | ||||||
|                                                             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); | ||||||
| @@ -1008,9 +1095,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> | ||||||
|                                                             } |                                                             } | ||||||
|                                                         } |                                                         } | ||||||
| @@ -1022,6 +1114,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>() | ||||||
| @@ -1074,22 +1189,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(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -1100,7 +1218,7 @@ 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) -> (f32, f32, bool) { | ||||||
|     // Convert UTC times to local time for display |     // Convert UTC times to local time for display | ||||||
|     let local_start = event.dtstart.with_timezone(&Local); |     let local_start = event.dtstart.with_timezone(&Local); | ||||||
|     let event_date = local_start.date_naive(); |     let event_date = local_start.date_naive(); | ||||||
| @@ -1123,7 +1241,8 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) | |||||||
|     // Calculate start position in pixels from midnight |     // Calculate start position in pixels from midnight | ||||||
|     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 time_increment == 15 { 120.0 } else { 60.0 }; | ||||||
|  |     let start_pixels = (start_hour + start_minute / 60.0) * 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 { | ||||||
| @@ -1132,16 +1251,17 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) | |||||||
|  |  | ||||||
|         // 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 24:00  | ||||||
|             1440.0 - start_pixels |             let max_pixels = 24.0 * pixels_per_hour; | ||||||
|  |             max_pixels - start_pixels | ||||||
|         } 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) * 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 | ||||||
| @@ -1149,6 +1269,11 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) | |||||||
|  |  | ||||||
| // 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 { | ||||||
|  |     // 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.with_timezone(&Local).naive_local(); |     let start1 = event1.dtstart.with_timezone(&Local).naive_local(); | ||||||
|     let end1 = if let Some(end) = event1.dtend { |     let end1 = if let Some(end) = event1.dtend { | ||||||
|         end.with_timezone(&Local).naive_local() |         end.with_timezone(&Local).naive_local() | ||||||
| @@ -1168,13 +1293,18 @@ 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 | ||||||
|  |             if event.all_day { | ||||||
|  |                 return None; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             let (_, _, _) = calculate_event_position(event, date, time_increment); | ||||||
|             let local_start = event.dtstart.with_timezone(&Local); |             let local_start = event.dtstart.with_timezone(&Local); | ||||||
|             let event_date = local_start.date_naive(); |             let event_date = local_start.date_naive(); | ||||||
|             if event_date == date ||  |             if event_date == date ||  | ||||||
| @@ -1254,3 +1384,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_naive() | ||||||
|  |     } else { | ||||||
|  |         event.dtstart.with_timezone(&Local).date_naive() | ||||||
|  |     }; | ||||||
|  |      | ||||||
|  |     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_naive() - chrono::Duration::days(1) | ||||||
|  |         } else { | ||||||
|  |             // For timed events, use timezone conversion | ||||||
|  |             dtend.with_timezone(&Local).date_naive() | ||||||
|  |         } | ||||||
|  |     } else { | ||||||
|  |         // Single day event | ||||||
|  |         start_date | ||||||
|  |     }; | ||||||
|  |      | ||||||
|  |     // Check if the given date falls within the event's date range | ||||||
|  |     date >= start_date && date <= end_date | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday}; | use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, 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; | ||||||
| @@ -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 | ||||||
| @@ -271,8 +279,8 @@ impl CalendarService { | |||||||
|     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::Utc::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 { | ||||||
|             if let Some(ref rrule) = event.rrule { |             if let Some(ref rrule) = event.rrule { | ||||||
| @@ -440,8 +448,16 @@ impl CalendarService { | |||||||
|                     occurrence_event.dtstart = occurrence_datetime; |                     occurrence_event.dtstart = occurrence_datetime; | ||||||
|                     occurrence_event.dtstamp = chrono::Utc::now(); // Update DTSTAMP for each occurrence |                     occurrence_event.dtstamp = chrono::Utc::now(); // Update DTSTAMP for each occurrence | ||||||
|                      |                      | ||||||
|                     if let Some(end) = base_event.dtend { |  | ||||||
|                         occurrence_event.dtend = Some(end + Duration::days(days_diff)); |                     if let Some(base_end) = base_event.dtend { | ||||||
|  |                         if base_event.all_day { | ||||||
|  |                             // For all-day events, maintain the RFC-5545 end date pattern | ||||||
|  |                             // End date should always be exactly one day after start date | ||||||
|  |                             occurrence_event.dtend = Some(occurrence_datetime + Duration::days(1)); | ||||||
|  |                         } else { | ||||||
|  |                             // For timed events, preserve the original duration | ||||||
|  |                             occurrence_event.dtend = Some(base_end + Duration::days(days_diff)); | ||||||
|  |                         } | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|                     occurrences.push(occurrence_event); |                     occurrences.push(occurrence_event); | ||||||
| @@ -1241,6 +1257,8 @@ impl CalendarService { | |||||||
|         reminder: String, |         reminder: String, | ||||||
|         recurrence: String, |         recurrence: String, | ||||||
|         recurrence_days: Vec<bool>, |         recurrence_days: Vec<bool>, | ||||||
|  |         recurrence_count: Option<u32>, | ||||||
|  |         recurrence_until: Option<String>, | ||||||
|         calendar_path: Option<String>, |         calendar_path: Option<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")?; | ||||||
| @@ -1273,8 +1291,8 @@ impl CalendarService { | |||||||
|                 "recurrence": recurrence, |                 "recurrence": recurrence, | ||||||
|                 "recurrence_days": recurrence_days, |                 "recurrence_days": recurrence_days, | ||||||
|                 "recurrence_interval": 1_u32, // Default interval |                 "recurrence_interval": 1_u32, // Default 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 | ||||||
|             }); |             }); | ||||||
|             let url = format!("{}/calendar/events/series/create", self.base_url); |             let url = format!("{}/calendar/events/series/create", self.base_url); | ||||||
| @@ -1668,6 +1686,9 @@ impl CalendarService { | |||||||
|         categories: String, |         categories: String, | ||||||
|         reminder: String, |         reminder: String, | ||||||
|         recurrence: String, |         recurrence: String, | ||||||
|  |         recurrence_days: Vec<bool>, | ||||||
|  |         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>, | ||||||
| @@ -1696,10 +1717,10 @@ impl CalendarService { | |||||||
|             "categories": categories, |             "categories": categories, | ||||||
|             "reminder": reminder, |             "reminder": reminder, | ||||||
|             "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": 1_u32, // Default interval - could be enhanced to be a parameter | ||||||
|             "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 | ||||||
| @@ -1841,4 +1862,257 @@ 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>, | ||||||
|  |             last_fetched: chrono::DateTime<chrono::Utc>, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         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>>, | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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 | ||||||
|  |     } | ||||||
| } | } | ||||||
							
								
								
									
										1409
									
								
								frontend/styles.css
									
									
									
									
									
								
							
							
						
						
									
										1409
									
								
								frontend/styles.css
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -648,6 +648,16 @@ body { | |||||||
|     border-bottom: none; |     border-bottom: none; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .time-slot-quarter { | ||||||
|  |     height: 30px; | ||||||
|  |     border-bottom: 1px dotted var(--calendar-border-light, #f8f8f8); | ||||||
|  |     pointer-events: none; /* Don't capture mouse events */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .time-slot-quarter:last-child { | ||||||
|  |     border-bottom: none; | ||||||
|  | } | ||||||
|  |  | ||||||
| .time-slot.boundary-slot { | .time-slot.boundary-slot { | ||||||
|     height: 60px; /* Match the final time label height */ |     height: 60px; /* Match the final time label height */ | ||||||
|     border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */ |     border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */ | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user