Skip to main content

max / goingson

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