//! Calendar event management commands. //! //! Provides CRUD operations for events (calendar entries). //! Events can be standalone or linked to tasks via linked_task_id. use chrono::{DateTime, Duration, Local, TimeZone, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{BlockType, ContactId, DbValue, Event, EventId, NewEvent, ParseableEnum, ProjectId, Recurrence, RecurrenceRule, TaskId, UpdateEvent, Validate, expand_recurrence}; use crate::state::{AppState, DESKTOP_USER_ID}; use super::{ApiError, OptionNotFound}; // ============ Types ============ /// Frontend input for creating or updating a calendar event. /// /// String-typed fields like `recurrence` and `block_type` are parsed into /// their enum equivalents in the command handler. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EventInput { /// Associated project, if any. pub project_id: Option, /// Event title (required, validated non-empty by the command handler). pub title: String, /// Event description or notes (defaults to empty string if omitted). pub description: Option, /// When the event starts. pub start_time: DateTime, /// When the event ends (validated to be after start_time if provided). pub end_time: Option>, /// Location (physical address or video link). pub location: Option, /// Recurrence pattern as a string ("Daily", "Weekly", "Monthly"), parsed to `Recurrence`. pub recurrence: Option, /// Associated contact, if any. pub contact_id: Option, /// Block type as a string ("focus", "meeting", etc.), parsed to `BlockType`. pub block_type: Option, /// Rich recurrence configuration (JSON). pub recurrence_rule: Option, /// Seconds-before-start_time to fire reminders. Empty / omitted = none. #[serde(default)] pub reminder_offsets_seconds: Vec, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EventResponse { pub id: EventId, pub project_id: Option, pub project_name: Option, pub title: String, pub description: String, pub description_html: String, pub start_time: DateTime, pub end_time: Option>, pub location: Option, pub linked_task_id: Option, pub recurrence: String, pub recurrence_rule: Option, pub recurrence_display: String, pub is_recurring_instance: bool, pub contact_id: Option, pub contact_name: Option, pub block_type: Option, // Pre-computed proximity fields (P0.5) /// True if event is in the past pub is_past: bool, /// CSS class: "past", "today", "tomorrow", "week", "future" pub proximity_class: String, /// Display label: "Past", "Today", "Tomorrow", "Mon", "Jan 15", etc. pub proximity_label: String, /// Pre-formatted date: "Today", "Tomorrow", "Mon", "Jan 15" pub date_formatted: String, /// Pre-formatted time: "3:00 PM" pub time_formatted: String, /// Epoch milliseconds for start_time (avoids JS date parsing) pub start_time_epoch: i64, /// Epoch milliseconds for end_time, defaults to start + 1hr if end_time is None pub end_time_epoch: i64, // Pre-computed temporal status /// Temporal status: "past", "happening_now", "upcoming_today", "upcoming" pub status: String, /// Human-readable status label: "Past", "Happening now", "Today", "Upcoming" pub status_label: String, /// True if `snoozed_until` is in the future. pub is_snoozed: bool, /// When the event is snoozed until, if any. pub snoozed_until: Option>, /// Seconds-before-start_time at which reminders fire. pub reminder_offsets_seconds: Vec, } impl From for EventResponse { fn from(e: Event) -> Self { let now = Local::now(); let now_utc = Utc::now(); let event_local = e.start_time.with_timezone(&Local); let today = now.date_naive(); let event_day = event_local.date_naive(); let diff_days = (event_day - today).num_days(); let start_time_epoch = e.start_time.timestamp_millis(); let end_time_epoch = e.end_time .map(|et| et.timestamp_millis()) .unwrap_or(start_time_epoch + 3_600_000); // default 1 hour // Compute temporal status using precise UTC timestamps let effective_end = e.end_time.unwrap_or(e.start_time + chrono::Duration::hours(1)); let (status, status_label, is_past) = if effective_end <= now_utc { ("past".to_string(), "Past".to_string(), true) } else if e.start_time <= now_utc && effective_end > now_utc { ("happening_now".to_string(), "Happening now".to_string(), false) } else if diff_days == 0 { ("upcoming_today".to_string(), "Today".to_string(), false) } else { ("upcoming".to_string(), "Upcoming".to_string(), false) }; // Proximity classification (day-level granularity for display badges) let proximity_class = if is_past { "past" } else if diff_days == 0 { "today" } else if diff_days == 1 { "tomorrow" } else if diff_days <= 7 { "week" } else { "future" }.to_string(); let proximity_label = if is_past { "Past".to_string() } else if diff_days == 0 { "Today".to_string() } else if diff_days == 1 { "Tomorrow".to_string() } else if diff_days <= 7 { event_local.format("%a").to_string() } else { event_local.format("%b %d").to_string() }; let date_formatted = proximity_label.clone(); let time_formatted = if let Some(end) = e.end_time { let end_local = end.with_timezone(&Local); format!("{} – {}", event_local.format("%-I:%M %p"), end_local.format("%-I:%M %p")) } else { event_local.format("%-I:%M %p").to_string() }; let recurrence_display = e.effective_recurrence_rule() .map(|r| r.display()) .unwrap_or_default(); let recurrence_str = e.recurrence.as_str().to_string(); let is_snoozed = e.is_snoozed(); let snoozed_until = e.snoozed_until; let reminder_offsets_seconds = e.reminder_offsets_seconds.clone(); EventResponse { id: e.id, project_id: e.project_id, project_name: e.project_name, title: e.title, description_html: docengine::render_standard(&e.description), description: e.description, start_time: e.start_time, end_time: e.end_time, location: e.location, linked_task_id: e.linked_task_id, recurrence: recurrence_str, recurrence_display, recurrence_rule: e.recurrence_rule, is_recurring_instance: e.is_recurring_instance, contact_id: e.contact_id, contact_name: e.contact_name, block_type: e.block_type.as_ref().map(|b| b.db_value().to_string()), is_past, proximity_class, proximity_label, date_formatted, time_formatted, start_time_epoch, end_time_epoch, status, status_label, is_snoozed, snoozed_until, reminder_offsets_seconds, } } } /// Drop negative offsets, dedupe, and cap to a reasonable count so a misbehaving /// frontend can't push hundreds of reminders into one event. fn sanitize_reminder_offsets(input: &[i64]) -> Vec { let mut offsets: Vec = input.iter().copied().filter(|s| *s >= 0).collect(); offsets.sort_unstable(); offsets.dedup(); offsets.truncate(8); offsets } // ============ Recurrence Expansion ============ /// Expand recurring events for a date range and merge with non-recurring events. /// Returns all events sorted by start_time ASC. fn expand_and_merge(events: Vec, range_start: DateTime, range_end: DateTime) -> Vec { let mut result: Vec = Vec::new(); for event in events { if event.has_recurrence() && !event.is_recurring_instance { // Add virtual instances within the range let expanded = expand_recurrence(&event, range_start, range_end); result.extend(expanded); // Include the original if it falls within range let effective_end = event.end_time.unwrap_or(event.start_time + Duration::hours(1)); if effective_end >= range_start && event.start_time <= range_end { result.push(event); } } else { result.push(event); } } result.sort_by_key(|e| e.start_time); result } // ============ Commands ============ /// Lists all events for the current user, with recurring events expanded. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the query fails. #[tauri::command] #[instrument(skip_all)] pub async fn list_events(state: State<'_, Arc>) -> Result, ApiError> { let now = Utc::now(); let range_start = now - Duration::days(30); let range_end = now + Duration::days(90); // Fetch all non-recurring events and all recurring parents let (all_events, recurring) = tokio::join!( state.events.list_all(DESKTOP_USER_ID), state.events.list_recurring(DESKTOP_USER_ID), ); let mut events = all_events?; // Add recurring parents that might not be in the all_events result // (their start_time might be far in the past) let recurring = recurring?; let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect(); for r in recurring { if !existing_ids.contains(&r.id) { events.push(r); } } let expanded = expand_and_merge(events, range_start, range_end); Ok(expanded.into_iter().map(EventResponse::from).collect()) } /// Lists events within a date range, with recurring events expanded. #[tauri::command] #[instrument(skip_all)] pub async fn list_events_between( state: State<'_, Arc>, start: DateTime, end: DateTime, ) -> Result, ApiError> { let (range_events, recurring) = tokio::join!( state.events.list_between(DESKTOP_USER_ID, start, end), state.events.list_recurring(DESKTOP_USER_ID), ); let mut events = range_events?; let recurring = recurring?; let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect(); for r in recurring { if !existing_ids.contains(&r.id) { events.push(r); } } let expanded = expand_and_merge(events, start, end); Ok(expanded.into_iter().map(EventResponse::from).collect()) } /// Retrieves a single event by ID. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the query fails. /// Returns `None` (not an error) if the event doesn't exist. #[tauri::command] #[instrument(skip_all)] pub async fn get_event(state: State<'_, Arc>, id: EventId) -> Result, ApiError> { let event = state.events.get_by_id(id, DESKTOP_USER_ID).await?; Ok(event.map(EventResponse::from)) } /// Creates a new calendar event. /// /// # Arguments /// /// * `input` - Event data: /// - `title` (required): Event title /// - `start_time` (required): When the event starts /// - `end_time`: When the event ends (optional for all-day events) /// - `description`: Event notes /// - `location`: Physical or virtual location /// - `project_id`: Optional project association /// - `recurrence`: Recurrence pattern (Daily, Weekly, Monthly) /// /// # Errors /// /// Returns `VALIDATION_ERROR` if title is empty or end_time <= start_time. /// Returns `DATABASE_ERROR` if the insert fails. #[tauri::command] #[instrument(skip_all)] pub async fn create_event(state: State<'_, Arc>, input: EventInput) -> Result { if input.title.trim().is_empty() { return Err(ApiError::validation("title", "Title is required")); } // Validate end_time > start_time if end_time is provided if let Some(end_time) = input.end_time { if end_time <= input.start_time { return Err(ApiError::validation("endTime", "End time must be after start time")); } } let recurrence = input.recurrence.as_deref().map(Recurrence::from_str_or_default).unwrap_or(Recurrence::None); let block_type = input.block_type.as_deref().and_then(BlockType::from_str_opt); let new_event = NewEvent { user_id: Some(DESKTOP_USER_ID), project_id: input.project_id, title: input.title, description: input.description.unwrap_or_default(), start_time: input.start_time, end_time: input.end_time, location: input.location, linked_task_id: None, recurrence, recurrence_rule: input.recurrence_rule.clone(), contact_id: input.contact_id, block_type, reminder_offsets_seconds: sanitize_reminder_offsets(&input.reminder_offsets_seconds), }; new_event.validate()?; let event = state.events.create(DESKTOP_USER_ID, new_event).await?; Ok(EventResponse::from(event)) } /// Updates an existing calendar event. /// /// Preserves the linked_task_id from the existing event. /// /// # Errors /// /// Returns `VALIDATION_ERROR` if title is empty or end_time <= start_time. /// Returns `NOT_FOUND` if the event doesn't exist. /// Returns `DATABASE_ERROR` if the update fails. #[tauri::command] #[instrument(skip_all)] pub async fn update_event(state: State<'_, Arc>, id: EventId, input: EventInput) -> Result { // Get existing event to preserve linked_task_id let existing = state.events .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("event", id)?; let recurrence = input.recurrence.as_deref().map(Recurrence::from_str_or_default).unwrap_or(Recurrence::None); let block_type = match &input.block_type { Some(s) if s.is_empty() => None, Some(s) => BlockType::from_str_opt(s), None => existing.block_type, }; let update_event = UpdateEvent { project_id: input.project_id, title: input.title, description: input.description.unwrap_or_default(), start_time: input.start_time, end_time: input.end_time, location: input.location, linked_task_id: existing.linked_task_id, recurrence, recurrence_rule: input.recurrence_rule.clone(), contact_id: input.contact_id, block_type, reminder_offsets_seconds: sanitize_reminder_offsets(&input.reminder_offsets_seconds), }; update_event.validate()?; let event = state.events .update(id, DESKTOP_USER_ID, update_event) .await? .or_not_found("event", id)?; Ok(EventResponse::from(event)) } /// Deletes a calendar event. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the delete fails. #[tauri::command] #[instrument(skip_all)] pub async fn delete_event(state: State<'_, Arc>, id: EventId) -> Result { Ok(state.events.delete(id, DESKTOP_USER_ID).await?) } /// Deletes multiple events. #[tauri::command] #[instrument(skip_all)] pub async fn bulk_delete_events( state: State<'_, Arc>, ids: Vec, ) -> Result { Ok(state.events.delete_many(&ids, DESKTOP_USER_ID).await?) } /// Lists upcoming events for the next 7 days. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the query fails. #[tauri::command] #[instrument(skip_all)] pub async fn list_upcoming_events(state: State<'_, Arc>) -> Result, ApiError> { let now = Utc::now(); let range_end = now + Duration::days(7); let (upcoming, recurring) = tokio::join!( state.events.get_upcoming(DESKTOP_USER_ID, 7), state.events.list_recurring(DESKTOP_USER_ID), ); let mut events = upcoming?; let recurring = recurring?; let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect(); for r in recurring { if !existing_ids.contains(&r.id) { events.push(r); } } let expanded = expand_and_merge(events, now, range_end); Ok(expanded.into_iter().map(EventResponse::from).collect()) } // ============ Project Dashboard Commands ============ /// Lists all events for a specific project. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the query fails. #[tauri::command] #[instrument(skip_all)] pub async fn list_events_for_project(state: State<'_, Arc>, project_id: ProjectId) -> Result, ApiError> { let events = state.events.list_by_project(DESKTOP_USER_ID, project_id).await?; Ok(events.into_iter().map(EventResponse::from).collect()) } // ============ Event Status Indicator ============ /// Aggregate event status for the UI status dot indicator. /// /// Returned by `get_event_status_indicator` so JS can render the dot /// without doing any date math. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EventStatusIndicator { /// Dot color class: "red", "yellow", "green", "none" pub status: String, /// Human-readable label for accessibility / tooltips pub label: String, } /// Computes the aggregate event status indicator for the nav dot. /// /// Scans today's upcoming events and returns a single status: /// - `red` / "Event happening now" — an event is currently in progress /// - `yellow` / "Event in N minutes" — an event starts within `lead_minutes` /// - `green` / "No imminent events" — there are more events today but none imminent /// - `none` / "No more events today" — no remaining events today /// /// # Arguments /// /// * `lead_minutes` - How many minutes before an event triggers "yellow" status /// /// # Errors /// /// Returns `DATABASE_ERROR` if the event query fails. #[tauri::command] #[instrument(skip_all)] pub async fn get_event_status_indicator( state: State<'_, Arc>, lead_minutes: i64, ) -> Result { let events = state.events.list_all(DESKTOP_USER_ID).await?; let now = Utc::now(); let now_millis = now.timestamp_millis(); // End of today in local time, converted to UTC for comparison let local_now = Local::now(); let end_of_day = local_now .date_naive() .and_hms_opt(23, 59, 59) .and_then(|ndt| Local.from_local_datetime(&ndt).earliest()) .map(|dt| dt.with_timezone(&Utc)); let eod_millis = end_of_day .map(|dt| dt.timestamp_millis()) .unwrap_or(now_millis); let mut has_remaining_today = false; for e in &events { let start_millis = e.start_time.timestamp_millis(); let end_millis = e.end_time .map(|et| et.timestamp_millis()) .unwrap_or(start_millis + 3_600_000); // Currently happening if start_millis <= now_millis && end_millis > now_millis { return Ok(EventStatusIndicator { status: "red".to_string(), label: "Event happening now".to_string(), }); } // Starting soon (within lead_minutes) let minutes_until = (start_millis - now_millis) as f64 / 60_000.0; if minutes_until > 0.0 && minutes_until <= lead_minutes as f64 { let rounded = minutes_until.round() as i64; let plural = if rounded != 1 { "s" } else { "" }; return Ok(EventStatusIndicator { status: "yellow".to_string(), label: format!("Event in {rounded} minute{plural}"), }); } // Still has events later today if start_millis > now_millis && start_millis <= eod_millis { has_remaining_today = true; } } if has_remaining_today { Ok(EventStatusIndicator { status: "green".to_string(), label: "No imminent events".to_string(), }) } else { Ok(EventStatusIndicator { status: "none".to_string(), label: "No more events today".to_string(), }) } } // ============ Snooze Commands ============ use super::SnoozeInput; /// Lists all currently snoozed events. #[tauri::command] #[instrument(skip_all)] pub async fn list_snoozed_events(state: State<'_, Arc>) -> Result, ApiError> { let events = state.events.list_snoozed(DESKTOP_USER_ID).await?; Ok(events.into_iter().map(EventResponse::from).collect()) } /// Snoozes an event until the specified date/time. /// /// Snoozed events are hidden from the main list view until their snooze expires. /// The change applies to the template; recurring instances inherit the snooze. #[tauri::command] #[instrument(skip_all)] pub async fn snooze_event( state: State<'_, Arc>, id: EventId, input: SnoozeInput, ) -> Result { let event = state.events .snooze(id, DESKTOP_USER_ID, input.until) .await? .or_not_found("event", id)?; Ok(EventResponse::from(event)) } /// Removes the snooze from an event. #[tauri::command] #[instrument(skip_all)] pub async fn unsnooze_event( state: State<'_, Arc>, id: EventId, ) -> Result { let event = state.events .unsnooze(id, DESKTOP_USER_ID) .await? .or_not_found("event", id)?; Ok(EventResponse::from(event)) } #[cfg(test)] mod sanitize_tests { use super::sanitize_reminder_offsets; #[test] fn drops_negative() { assert_eq!(sanitize_reminder_offsets(&[-1, 0, 60, -100]), vec![0, 60]); } #[test] fn dedupes_and_sorts() { assert_eq!(sanitize_reminder_offsets(&[60, 0, 60, 300]), vec![0, 60, 300]); } #[test] fn caps_to_eight() { let many: Vec = (0..20).map(|i| i * 60).collect(); let out = sanitize_reminder_offsets(&many); assert_eq!(out.len(), 8); assert_eq!(out[0], 0); assert_eq!(out[7], 7 * 60); } #[test] fn empty_stays_empty() { assert!(sanitize_reminder_offsets(&[]).is_empty()); } }