Skip to main content

max / goingson

22.1 KB · 648 lines History Blame Raw
1 //! Calendar event management commands.
2 //!
3 //! Provides CRUD operations for events (calendar entries).
4 //! Events can be standalone or linked to tasks via linked_task_id.
5
6 use chrono::{DateTime, Duration, Local, TimeZone, Utc};
7 use serde::{Deserialize, Serialize};
8 use std::sync::Arc;
9 use tauri::State;
10 use tracing::instrument;
11
12 use goingson_core::{BlockType, ContactId, DbValue, Event, EventId, NewEvent, ParseableEnum, ProjectId, Recurrence, RecurrenceRule, TaskId, UpdateEvent, Validate, expand_recurrence};
13
14 use crate::state::{AppState, DESKTOP_USER_ID};
15 use super::{ApiError, OptionNotFound};
16
17 // ============ Types ============
18
19 /// Frontend input for creating or updating a calendar event.
20 ///
21 /// String-typed fields like `recurrence` and `block_type` are parsed into
22 /// their enum equivalents in the command handler.
23 #[derive(Debug, Deserialize)]
24 #[serde(rename_all = "camelCase")]
25 pub struct EventInput {
26 /// Associated project, if any.
27 pub project_id: Option<ProjectId>,
28 /// Event title (required, validated non-empty by the command handler).
29 pub title: String,
30 /// Event description or notes (defaults to empty string if omitted).
31 pub description: Option<String>,
32 /// When the event starts.
33 pub start_time: DateTime<Utc>,
34 /// When the event ends (validated to be after start_time if provided).
35 pub end_time: Option<DateTime<Utc>>,
36 /// Location (physical address or video link).
37 pub location: Option<String>,
38 /// Recurrence pattern as a string ("Daily", "Weekly", "Monthly"), parsed to `Recurrence`.
39 pub recurrence: Option<String>,
40 /// Associated contact, if any.
41 pub contact_id: Option<ContactId>,
42 /// Block type as a string ("focus", "meeting", etc.), parsed to `BlockType`.
43 pub block_type: Option<String>,
44 /// Rich recurrence configuration (JSON).
45 pub recurrence_rule: Option<RecurrenceRule>,
46 /// Seconds-before-start_time to fire reminders. Empty / omitted = none.
47 #[serde(default)]
48 pub reminder_offsets_seconds: Vec<i64>,
49 }
50
51 #[derive(Debug, Serialize)]
52 #[serde(rename_all = "camelCase")]
53 pub struct EventResponse {
54 pub id: EventId,
55 pub project_id: Option<ProjectId>,
56 pub project_name: Option<String>,
57 pub title: String,
58 pub description: String,
59 pub description_html: String,
60 pub start_time: DateTime<Utc>,
61 pub end_time: Option<DateTime<Utc>>,
62 pub location: Option<String>,
63 pub linked_task_id: Option<TaskId>,
64 pub recurrence: String,
65 pub recurrence_rule: Option<RecurrenceRule>,
66 pub recurrence_display: String,
67 pub is_recurring_instance: bool,
68 pub contact_id: Option<ContactId>,
69 pub contact_name: Option<String>,
70 pub block_type: Option<String>,
71 // Pre-computed proximity fields (P0.5)
72 /// True if event is in the past
73 pub is_past: bool,
74 /// CSS class: "past", "today", "tomorrow", "week", "future"
75 pub proximity_class: String,
76 /// Display label: "Past", "Today", "Tomorrow", "Mon", "Jan 15", etc.
77 pub proximity_label: String,
78 /// Pre-formatted date: "Today", "Tomorrow", "Mon", "Jan 15"
79 pub date_formatted: String,
80 /// Pre-formatted time: "3:00 PM"
81 pub time_formatted: String,
82 /// Epoch milliseconds for start_time (avoids JS date parsing)
83 pub start_time_epoch: i64,
84 /// Epoch milliseconds for end_time, defaults to start + 1hr if end_time is None
85 pub end_time_epoch: i64,
86 // Pre-computed temporal status
87 /// Temporal status: "past", "happening_now", "upcoming_today", "upcoming"
88 pub status: String,
89 /// Human-readable status label: "Past", "Happening now", "Today", "Upcoming"
90 pub status_label: String,
91 /// True if `snoozed_until` is in the future.
92 pub is_snoozed: bool,
93 /// When the event is snoozed until, if any.
94 pub snoozed_until: Option<DateTime<Utc>>,
95 /// Seconds-before-start_time at which reminders fire.
96 pub reminder_offsets_seconds: Vec<i64>,
97 }
98
99 impl From<Event> for EventResponse {
100 fn from(e: Event) -> Self {
101 let now = Local::now();
102 let now_utc = Utc::now();
103 let event_local = e.start_time.with_timezone(&Local);
104
105 let today = now.date_naive();
106 let event_day = event_local.date_naive();
107 let diff_days = (event_day - today).num_days();
108
109 let start_time_epoch = e.start_time.timestamp_millis();
110 let end_time_epoch = e.end_time
111 .map(|et| et.timestamp_millis())
112 .unwrap_or(start_time_epoch + 3_600_000); // default 1 hour
113
114 // Compute temporal status using precise UTC timestamps
115 let effective_end = e.end_time.unwrap_or(e.start_time + chrono::Duration::hours(1));
116 let (status, status_label, is_past) = if effective_end <= now_utc {
117 ("past".to_string(), "Past".to_string(), true)
118 } else if e.start_time <= now_utc && effective_end > now_utc {
119 ("happening_now".to_string(), "Happening now".to_string(), false)
120 } else if diff_days == 0 {
121 ("upcoming_today".to_string(), "Today".to_string(), false)
122 } else {
123 ("upcoming".to_string(), "Upcoming".to_string(), false)
124 };
125
126 // Proximity classification (day-level granularity for display badges)
127 let proximity_class = if is_past {
128 "past"
129 } else if diff_days == 0 {
130 "today"
131 } else if diff_days == 1 {
132 "tomorrow"
133 } else if diff_days <= 7 {
134 "week"
135 } else {
136 "future"
137 }.to_string();
138
139 let proximity_label = if is_past {
140 "Past".to_string()
141 } else if diff_days == 0 {
142 "Today".to_string()
143 } else if diff_days == 1 {
144 "Tomorrow".to_string()
145 } else if diff_days <= 7 {
146 event_local.format("%a").to_string()
147 } else {
148 event_local.format("%b %d").to_string()
149 };
150
151 let date_formatted = proximity_label.clone();
152 let time_formatted = if let Some(end) = e.end_time {
153 let end_local = end.with_timezone(&Local);
154 format!("{}{}", event_local.format("%-I:%M %p"), end_local.format("%-I:%M %p"))
155 } else {
156 event_local.format("%-I:%M %p").to_string()
157 };
158
159 let recurrence_display = e.effective_recurrence_rule()
160 .map(|r| r.display())
161 .unwrap_or_default();
162 let recurrence_str = e.recurrence.as_str().to_string();
163 let is_snoozed = e.is_snoozed();
164 let snoozed_until = e.snoozed_until;
165 let reminder_offsets_seconds = e.reminder_offsets_seconds.clone();
166
167 EventResponse {
168 id: e.id,
169 project_id: e.project_id,
170 project_name: e.project_name,
171 title: e.title,
172 description_html: docengine::render_standard(&e.description),
173 description: e.description,
174 start_time: e.start_time,
175 end_time: e.end_time,
176 location: e.location,
177 linked_task_id: e.linked_task_id,
178 recurrence: recurrence_str,
179 recurrence_display,
180 recurrence_rule: e.recurrence_rule,
181 is_recurring_instance: e.is_recurring_instance,
182 contact_id: e.contact_id,
183 contact_name: e.contact_name,
184 block_type: e.block_type.as_ref().map(|b| b.db_value().to_string()),
185 is_past,
186 proximity_class,
187 proximity_label,
188 date_formatted,
189 time_formatted,
190 start_time_epoch,
191 end_time_epoch,
192 status,
193 status_label,
194 is_snoozed,
195 snoozed_until,
196 reminder_offsets_seconds,
197 }
198 }
199 }
200
201 /// Drop negative offsets, dedupe, and cap to a reasonable count so a misbehaving
202 /// frontend can't push hundreds of reminders into one event.
203 fn sanitize_reminder_offsets(input: &[i64]) -> Vec<i64> {
204 let mut offsets: Vec<i64> = input.iter().copied().filter(|s| *s >= 0).collect();
205 offsets.sort_unstable();
206 offsets.dedup();
207 offsets.truncate(8);
208 offsets
209 }
210
211 // ============ Recurrence Expansion ============
212
213 /// Expand recurring events for a date range and merge with non-recurring events.
214 /// Returns all events sorted by start_time ASC.
215 fn expand_and_merge(events: Vec<Event>, range_start: DateTime<Utc>, range_end: DateTime<Utc>) -> Vec<Event> {
216 let mut result: Vec<Event> = Vec::new();
217
218 for event in events {
219 if event.has_recurrence() && !event.is_recurring_instance {
220 // Add virtual instances within the range
221 let expanded = expand_recurrence(&event, range_start, range_end);
222 result.extend(expanded);
223 // Include the original if it falls within range
224 let effective_end = event.end_time.unwrap_or(event.start_time + Duration::hours(1));
225 if effective_end >= range_start && event.start_time <= range_end {
226 result.push(event);
227 }
228 } else {
229 result.push(event);
230 }
231 }
232
233 result.sort_by_key(|e| e.start_time);
234 result
235 }
236
237 // ============ Commands ============
238
239 /// Lists all events for the current user, with recurring events expanded.
240 ///
241 /// # Errors
242 ///
243 /// Returns `DATABASE_ERROR` if the query fails.
244 #[tauri::command]
245 #[instrument(skip_all)]
246 pub async fn list_events(state: State<'_, Arc<AppState>>) -> Result<Vec<EventResponse>, ApiError> {
247 let now = Utc::now();
248 let range_start = now - Duration::days(30);
249 let range_end = now + Duration::days(90);
250
251 // Fetch all non-recurring events and all recurring parents
252 let (all_events, recurring) = tokio::join!(
253 state.events.list_all(DESKTOP_USER_ID),
254 state.events.list_recurring(DESKTOP_USER_ID),
255 );
256 let mut events = all_events?;
257
258 // Add recurring parents that might not be in the all_events result
259 // (their start_time might be far in the past)
260 let recurring = recurring?;
261 let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect();
262 for r in recurring {
263 if !existing_ids.contains(&r.id) {
264 events.push(r);
265 }
266 }
267
268 let expanded = expand_and_merge(events, range_start, range_end);
269 Ok(expanded.into_iter().map(EventResponse::from).collect())
270 }
271
272 /// Lists events within a date range, with recurring events expanded.
273 #[tauri::command]
274 #[instrument(skip_all)]
275 pub async fn list_events_between(
276 state: State<'_, Arc<AppState>>,
277 start: DateTime<Utc>,
278 end: DateTime<Utc>,
279 ) -> Result<Vec<EventResponse>, ApiError> {
280 let (range_events, recurring) = tokio::join!(
281 state.events.list_between(DESKTOP_USER_ID, start, end),
282 state.events.list_recurring(DESKTOP_USER_ID),
283 );
284 let mut events = range_events?;
285 let recurring = recurring?;
286 let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect();
287 for r in recurring {
288 if !existing_ids.contains(&r.id) {
289 events.push(r);
290 }
291 }
292 let expanded = expand_and_merge(events, start, end);
293 Ok(expanded.into_iter().map(EventResponse::from).collect())
294 }
295
296 /// Retrieves a single event by ID.
297 ///
298 /// # Errors
299 ///
300 /// Returns `DATABASE_ERROR` if the query fails.
301 /// Returns `None` (not an error) if the event doesn't exist.
302 #[tauri::command]
303 #[instrument(skip_all)]
304 pub async fn get_event(state: State<'_, Arc<AppState>>, id: EventId) -> Result<Option<EventResponse>, ApiError> {
305 let event = state.events.get_by_id(id, DESKTOP_USER_ID).await?;
306 Ok(event.map(EventResponse::from))
307 }
308
309 /// Creates a new calendar event.
310 ///
311 /// # Arguments
312 ///
313 /// * `input` - Event data:
314 /// - `title` (required): Event title
315 /// - `start_time` (required): When the event starts
316 /// - `end_time`: When the event ends (optional for all-day events)
317 /// - `description`: Event notes
318 /// - `location`: Physical or virtual location
319 /// - `project_id`: Optional project association
320 /// - `recurrence`: Recurrence pattern (Daily, Weekly, Monthly)
321 ///
322 /// # Errors
323 ///
324 /// Returns `VALIDATION_ERROR` if title is empty or end_time <= start_time.
325 /// Returns `DATABASE_ERROR` if the insert fails.
326 #[tauri::command]
327 #[instrument(skip_all)]
328 pub async fn create_event(state: State<'_, Arc<AppState>>, input: EventInput) -> Result<EventResponse, ApiError> {
329 if input.title.trim().is_empty() {
330 return Err(ApiError::validation("title", "Title is required"));
331 }
332
333 // Validate end_time > start_time if end_time is provided
334 if let Some(end_time) = input.end_time {
335 if end_time <= input.start_time {
336 return Err(ApiError::validation("endTime", "End time must be after start time"));
337 }
338 }
339
340 let recurrence = input.recurrence.as_deref().map(Recurrence::from_str_or_default).unwrap_or(Recurrence::None);
341 let block_type = input.block_type.as_deref().and_then(BlockType::from_str_opt);
342
343 let new_event = NewEvent {
344 user_id: Some(DESKTOP_USER_ID),
345 project_id: input.project_id,
346 title: input.title,
347 description: input.description.unwrap_or_default(),
348 start_time: input.start_time,
349 end_time: input.end_time,
350 location: input.location,
351 linked_task_id: None,
352 recurrence,
353 recurrence_rule: input.recurrence_rule.clone(),
354 contact_id: input.contact_id,
355 block_type,
356 reminder_offsets_seconds: sanitize_reminder_offsets(&input.reminder_offsets_seconds),
357 };
358
359 new_event.validate()?;
360
361 let event = state.events.create(DESKTOP_USER_ID, new_event).await?;
362 Ok(EventResponse::from(event))
363 }
364
365 /// Updates an existing calendar event.
366 ///
367 /// Preserves the linked_task_id from the existing event.
368 ///
369 /// # Errors
370 ///
371 /// Returns `VALIDATION_ERROR` if title is empty or end_time <= start_time.
372 /// Returns `NOT_FOUND` if the event doesn't exist.
373 /// Returns `DATABASE_ERROR` if the update fails.
374 #[tauri::command]
375 #[instrument(skip_all)]
376 pub async fn update_event(state: State<'_, Arc<AppState>>, id: EventId, input: EventInput) -> Result<EventResponse, ApiError> {
377 // Get existing event to preserve linked_task_id
378 let existing = state.events
379 .get_by_id(id, DESKTOP_USER_ID)
380 .await?
381 .or_not_found("event", id)?;
382
383 let recurrence = input.recurrence.as_deref().map(Recurrence::from_str_or_default).unwrap_or(Recurrence::None);
384 let block_type = match &input.block_type {
385 Some(s) if s.is_empty() => None,
386 Some(s) => BlockType::from_str_opt(s),
387 None => existing.block_type,
388 };
389
390 let update_event = UpdateEvent {
391 project_id: input.project_id,
392 title: input.title,
393 description: input.description.unwrap_or_default(),
394 start_time: input.start_time,
395 end_time: input.end_time,
396 location: input.location,
397 linked_task_id: existing.linked_task_id,
398 recurrence,
399 recurrence_rule: input.recurrence_rule.clone(),
400 contact_id: input.contact_id,
401 block_type,
402 reminder_offsets_seconds: sanitize_reminder_offsets(&input.reminder_offsets_seconds),
403 };
404
405 update_event.validate()?;
406
407 let event = state.events
408 .update(id, DESKTOP_USER_ID, update_event)
409 .await?
410 .or_not_found("event", id)?;
411
412 Ok(EventResponse::from(event))
413 }
414
415 /// Deletes a calendar event.
416 ///
417 /// # Errors
418 ///
419 /// Returns `DATABASE_ERROR` if the delete fails.
420 #[tauri::command]
421 #[instrument(skip_all)]
422 pub async fn delete_event(state: State<'_, Arc<AppState>>, id: EventId) -> Result<bool, ApiError> {
423 Ok(state.events.delete(id, DESKTOP_USER_ID).await?)
424 }
425
426 /// Deletes multiple events.
427 #[tauri::command]
428 #[instrument(skip_all)]
429 pub async fn bulk_delete_events(
430 state: State<'_, Arc<AppState>>,
431 ids: Vec<EventId>,
432 ) -> Result<u64, ApiError> {
433 Ok(state.events.delete_many(&ids, DESKTOP_USER_ID).await?)
434 }
435
436 /// Lists upcoming events for the next 7 days.
437 ///
438 /// # Errors
439 ///
440 /// Returns `DATABASE_ERROR` if the query fails.
441 #[tauri::command]
442 #[instrument(skip_all)]
443 pub async fn list_upcoming_events(state: State<'_, Arc<AppState>>) -> Result<Vec<EventResponse>, ApiError> {
444 let now = Utc::now();
445 let range_end = now + Duration::days(7);
446
447 let (upcoming, recurring) = tokio::join!(
448 state.events.get_upcoming(DESKTOP_USER_ID, 7),
449 state.events.list_recurring(DESKTOP_USER_ID),
450 );
451 let mut events = upcoming?;
452 let recurring = recurring?;
453 let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect();
454 for r in recurring {
455 if !existing_ids.contains(&r.id) {
456 events.push(r);
457 }
458 }
459
460 let expanded = expand_and_merge(events, now, range_end);
461 Ok(expanded.into_iter().map(EventResponse::from).collect())
462 }
463
464 // ============ Project Dashboard Commands ============
465
466 /// Lists all events for a specific project.
467 ///
468 /// # Errors
469 ///
470 /// Returns `DATABASE_ERROR` if the query fails.
471 #[tauri::command]
472 #[instrument(skip_all)]
473 pub async fn list_events_for_project(state: State<'_, Arc<AppState>>, project_id: ProjectId) -> Result<Vec<EventResponse>, ApiError> {
474 let events = state.events.list_by_project(DESKTOP_USER_ID, project_id).await?;
475 Ok(events.into_iter().map(EventResponse::from).collect())
476 }
477
478 // ============ Event Status Indicator ============
479
480 /// Aggregate event status for the UI status dot indicator.
481 ///
482 /// Returned by `get_event_status_indicator` so JS can render the dot
483 /// without doing any date math.
484 #[derive(Debug, Serialize)]
485 #[serde(rename_all = "camelCase")]
486 pub struct EventStatusIndicator {
487 /// Dot color class: "red", "yellow", "green", "none"
488 pub status: String,
489 /// Human-readable label for accessibility / tooltips
490 pub label: String,
491 }
492
493 /// Computes the aggregate event status indicator for the nav dot.
494 ///
495 /// Scans today's upcoming events and returns a single status:
496 /// - `red` / "Event happening now" — an event is currently in progress
497 /// - `yellow` / "Event in N minutes" — an event starts within `lead_minutes`
498 /// - `green` / "No imminent events" — there are more events today but none imminent
499 /// - `none` / "No more events today" — no remaining events today
500 ///
501 /// # Arguments
502 ///
503 /// * `lead_minutes` - How many minutes before an event triggers "yellow" status
504 ///
505 /// # Errors
506 ///
507 /// Returns `DATABASE_ERROR` if the event query fails.
508 #[tauri::command]
509 #[instrument(skip_all)]
510 pub async fn get_event_status_indicator(
511 state: State<'_, Arc<AppState>>,
512 lead_minutes: i64,
513 ) -> Result<EventStatusIndicator, ApiError> {
514 let events = state.events.list_all(DESKTOP_USER_ID).await?;
515 let now = Utc::now();
516 let now_millis = now.timestamp_millis();
517
518 // End of today in local time, converted to UTC for comparison
519 let local_now = Local::now();
520 let end_of_day = local_now
521 .date_naive()
522 .and_hms_opt(23, 59, 59)
523 .and_then(|ndt| Local.from_local_datetime(&ndt).earliest())
524 .map(|dt| dt.with_timezone(&Utc));
525
526 let eod_millis = end_of_day
527 .map(|dt| dt.timestamp_millis())
528 .unwrap_or(now_millis);
529
530 let mut has_remaining_today = false;
531
532 for e in &events {
533 let start_millis = e.start_time.timestamp_millis();
534 let end_millis = e.end_time
535 .map(|et| et.timestamp_millis())
536 .unwrap_or(start_millis + 3_600_000);
537
538 // Currently happening
539 if start_millis <= now_millis && end_millis > now_millis {
540 return Ok(EventStatusIndicator {
541 status: "red".to_string(),
542 label: "Event happening now".to_string(),
543 });
544 }
545
546 // Starting soon (within lead_minutes)
547 let minutes_until = (start_millis - now_millis) as f64 / 60_000.0;
548 if minutes_until > 0.0 && minutes_until <= lead_minutes as f64 {
549 let rounded = minutes_until.round() as i64;
550 let plural = if rounded != 1 { "s" } else { "" };
551 return Ok(EventStatusIndicator {
552 status: "yellow".to_string(),
553 label: format!("Event in {rounded} minute{plural}"),
554 });
555 }
556
557 // Still has events later today
558 if start_millis > now_millis && start_millis <= eod_millis {
559 has_remaining_today = true;
560 }
561 }
562
563 if has_remaining_today {
564 Ok(EventStatusIndicator {
565 status: "green".to_string(),
566 label: "No imminent events".to_string(),
567 })
568 } else {
569 Ok(EventStatusIndicator {
570 status: "none".to_string(),
571 label: "No more events today".to_string(),
572 })
573 }
574 }
575
576 // ============ Snooze Commands ============
577
578 use super::SnoozeInput;
579
580 /// Lists all currently snoozed events.
581 #[tauri::command]
582 #[instrument(skip_all)]
583 pub async fn list_snoozed_events(state: State<'_, Arc<AppState>>) -> Result<Vec<EventResponse>, ApiError> {
584 let events = state.events.list_snoozed(DESKTOP_USER_ID).await?;
585 Ok(events.into_iter().map(EventResponse::from).collect())
586 }
587
588 /// Snoozes an event until the specified date/time.
589 ///
590 /// Snoozed events are hidden from the main list view until their snooze expires.
591 /// The change applies to the template; recurring instances inherit the snooze.
592 #[tauri::command]
593 #[instrument(skip_all)]
594 pub async fn snooze_event(
595 state: State<'_, Arc<AppState>>,
596 id: EventId,
597 input: SnoozeInput,
598 ) -> Result<EventResponse, ApiError> {
599 let event = state.events
600 .snooze(id, DESKTOP_USER_ID, input.until)
601 .await?
602 .or_not_found("event", id)?;
603 Ok(EventResponse::from(event))
604 }
605
606 /// Removes the snooze from an event.
607 #[tauri::command]
608 #[instrument(skip_all)]
609 pub async fn unsnooze_event(
610 state: State<'_, Arc<AppState>>,
611 id: EventId,
612 ) -> Result<EventResponse, ApiError> {
613 let event = state.events
614 .unsnooze(id, DESKTOP_USER_ID)
615 .await?
616 .or_not_found("event", id)?;
617 Ok(EventResponse::from(event))
618 }
619
620 #[cfg(test)]
621 mod sanitize_tests {
622 use super::sanitize_reminder_offsets;
623
624 #[test]
625 fn drops_negative() {
626 assert_eq!(sanitize_reminder_offsets(&[-1, 0, 60, -100]), vec![0, 60]);
627 }
628
629 #[test]
630 fn dedupes_and_sorts() {
631 assert_eq!(sanitize_reminder_offsets(&[60, 0, 60, 300]), vec![0, 60, 300]);
632 }
633
634 #[test]
635 fn caps_to_eight() {
636 let many: Vec<i64> = (0..20).map(|i| i * 60).collect();
637 let out = sanitize_reminder_offsets(&many);
638 assert_eq!(out.len(), 8);
639 assert_eq!(out[0], 0);
640 assert_eq!(out[7], 7 * 60);
641 }
642
643 #[test]
644 fn empty_stays_empty() {
645 assert!(sanitize_reminder_offsets(&[]).is_empty());
646 }
647 }
648