//! Event domain types and DTOs. //! //! Events represent calendar entries that can be standalone or linked to a task //! for time-blocking. They support recurrence patterns (Daily, Weekly, Monthly), //! optional project and contact associations, and block-type classification //! (focus, meeting, break, etc.). use chrono::{DateTime, Duration, TimeZone, Timelike, Utc}; use serde::{Deserialize, Serialize}; use crate::constants::DAYS_THRESHOLD_SHORT_FORMAT; use crate::id_types::{EventId, UserId, ProjectId, ContactId, TaskId}; use super::shared::{BlockType, Recurrence, RecurrenceRule}; // ============ Event ============ /// A calendar event with optional time-blocking link to a task. /// /// Events can be standalone or linked to a task for time-blocking purposes. /// When linked, the event represents a scheduled time slot for working on the task. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Event { /// Unique identifier. pub id: EventId, /// Owner user ID (internal). #[serde(skip_serializing)] pub user_id: Option, /// Associated project, if any. pub project_id: Option, /// Denormalized project name for display. pub project_name: Option, /// Associated contact, if any. pub contact_id: Option, /// Denormalized contact name for display. pub contact_name: Option, /// Event title. pub title: String, /// Event description/notes. pub description: String, /// When the event starts. pub start_time: DateTime, /// When the event ends (optional for all-day events). pub end_time: Option>, /// Location (physical address or video link). pub location: Option, /// If this is a time-block, the linked task ID. pub linked_task_id: Option, /// Recurrence pattern (legacy, used when recurrence_rule is absent). pub recurrence: Recurrence, /// Rich recurrence configuration (JSON). Takes precedence over `recurrence`. pub recurrence_rule: Option, /// Original event ID if this is a recurrence instance. pub recurrence_parent_id: Option, /// True if this is a virtual instance produced by recurrence expansion (not persisted). #[serde(default)] pub is_recurring_instance: bool, /// If this is a time block, the block type. pub block_type: Option, /// External sync source (e.g., "vcf", "ics", "google", "apple"). pub external_source: Option, /// External ID for dedup (e.g., UID from .ics, provider-specific ID). pub external_id: Option, /// Whether this event is read-only (synced from external calendar). pub is_read_only: bool, /// If set, the event is snoozed (hidden from main views) until this time. pub snoozed_until: Option>, /// Seconds-before-start_time at which to fire desktop reminder notifications. /// `[0, 300, 900]` = at time, 5 minutes before, 15 minutes before. Empty = no reminders. #[serde(default)] pub reminder_offsets_seconds: Vec, } impl Event { /// Returns a formatted time string (e.g., "Jan 15, 14:00" or "Jan 15, 14:00 - 15:30"). pub fn time_formatted(&self) -> String { let start = self.start_time.format("%b %d, %H:%M").to_string(); match &self.end_time { Some(end) => format!("{} - {}", start, end.format("%H:%M")), None => start, } } /// Returns a relative date string: "Past", "Today", "Tomorrow", day name, or "Mon DD". pub fn date_formatted(&self) -> String { let now = Utc::now(); let days = (self.start_time.date_naive() - now.date_naive()).num_days(); if days < 0 { "Past".to_string() } else if days == 0 { "Today".to_string() } else if days == 1 { "Tomorrow".to_string() } else if days < DAYS_THRESHOLD_SHORT_FORMAT { self.start_time.format("%A").to_string() } else { self.start_time.format("%b %d").to_string() } } /// Returns the zero-padded day of the month (e.g., "07", "15"). pub fn day_number(&self) -> String { self.start_time.format("%d").to_string() } /// Returns the start time as a Unix timestamp (seconds since epoch). pub fn timestamp(&self) -> i64 { self.start_time.timestamp() } /// Returns true if the event has a location set. pub fn has_location(&self) -> bool { self.location.is_some() } /// Returns the location string, or an empty string if unset. pub fn location_or_empty(&self) -> &str { self.location.as_deref().unwrap_or("") } /// Returns true if the event is associated with a project. pub fn has_project(&self) -> bool { self.project_name.is_some() } /// Returns the project name, or an empty string if unset. pub fn project_name_or_empty(&self) -> &str { self.project_name.as_deref().unwrap_or("") } /// Returns true if the event has a non-empty description. pub fn has_description(&self) -> bool { !self.description.is_empty() } /// Returns true if the event has a recurrence pattern set (not `None`). pub fn has_recurrence(&self) -> bool { self.recurrence_rule.is_some() || self.recurrence != Recurrence::None } /// Returns the effective recurrence rule, synthesizing from the legacy /// column if no explicit rule is set. pub fn effective_recurrence_rule(&self) -> Option { RecurrenceRule::effective(self.recurrence_rule.as_ref(), &self.recurrence) } /// Returns true if this event is a time-block linked to a task. pub fn is_linked_to_task(&self) -> bool { self.linked_task_id.is_some() } /// True if `snoozed_until` is in the future. pub fn is_snoozed(&self) -> bool { self.snoozed_until.is_some_and(|t| t > Utc::now()) } /// True if this event looks like an all-day event in the given timezone. /// /// An event is all-day when it has an end time, spans at least 23 hours, /// and starts at local midnight (00:00). The 23-hour floor (rather than a /// strict 24) tolerates DST transitions and near-midnight authoring; a /// multi-day midnight-to-midnight span therefore also qualifies. pub fn is_all_day_in(&self, tz: &Tz) -> bool { let Some(end) = self.end_time else { return false; }; if end - self.start_time < Duration::hours(23) { return false; } let local_start = self.start_time.with_timezone(tz); local_start.hour() == 0 && local_start.minute() == 0 } } /// Snap a start / optional-end instant to the canonical all-day span in `tz`. /// /// The start is pulled back to local midnight of its own day. The end becomes the /// *exclusive* local-midnight boundary after the last covered day: /// - no end given → the day after the start (a single-day span); /// - an end already at local midnight is treated as already-exclusive and kept /// as-is (so re-snapping a stored all-day span is idempotent — it does not grow /// by a day on every edit); /// - a mid-day end covers the whole of its day, so it rounds up to the next midnight. /// /// Both bounds are returned as UTC instants — the shape [`Event::is_all_day_in`] /// detects, so a snapped event round-trips as all-day. /// /// On a DST spring-forward gap the civil midnight doesn't exist; it falls back to /// treating the naive value as UTC rather than panicking (mirrors the app's /// `tz::local_civil_to_utc`). pub fn snap_all_day_span( start: DateTime, end: Option>, tz: &Tz, ) -> (DateTime, DateTime) { let start_day = start.with_timezone(tz).date_naive(); let end_exclusive = match end { None => start_day.succ_opt().unwrap_or(start_day), Some(e) => { let local_end = e.with_timezone(tz); let end_day = local_end.date_naive(); if local_end.time() == chrono::NaiveTime::MIN { end_day // already the exclusive midnight boundary } else { end_day.succ_opt().unwrap_or(end_day) } } }; (local_midnight_utc(start_day, tz), local_midnight_utc(end_exclusive, tz)) } /// Convert a calendar date's local midnight in `tz` to the corresponding UTC instant. fn local_midnight_utc(date: chrono::NaiveDate, tz: &Tz) -> DateTime { let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid"); tz.from_local_datetime(&naive) .earliest() .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|| DateTime::::from_naive_utc_and_offset(naive, Utc)) } // ============ Event DTOs ============ /// Data for creating a new event. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewEvent { /// Owner user ID (set by the command layer for desktop). pub user_id: Option, /// Associated project, if any. pub project_id: Option, /// Associated contact, if any. pub contact_id: Option, /// Event title (required, validated non-empty). pub title: String, /// Event description or notes. pub description: String, /// When the event starts. pub start_time: DateTime, /// When the event ends (optional for all-day or open-ended events). pub end_time: Option>, /// Location (physical address or video link). pub location: Option, /// Linked task ID for time-blocking (set programmatically, not via form). pub linked_task_id: Option, /// Recurrence pattern (None, Daily, Weekly, Monthly). pub recurrence: Recurrence, /// Rich recurrence configuration (JSON). pub recurrence_rule: Option, /// Block type classification (focus, meeting, break, etc.). pub block_type: Option, /// Seconds-before-start_time at which to fire reminders. Empty = none. #[serde(default)] pub reminder_offsets_seconds: Vec, } /// Data for updating an existing event. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateEvent { /// Associated project, if any. pub project_id: Option, /// Associated contact, if any. pub contact_id: Option, /// Event title (required, validated non-empty). pub title: String, /// Event description or notes. pub description: String, /// When the event starts. pub start_time: DateTime, /// When the event ends (optional for all-day or open-ended events). pub end_time: Option>, /// Location (physical address or video link). pub location: Option, /// Linked task ID for time-blocking (preserved from the existing event on update). pub linked_task_id: Option, /// Recurrence pattern (None, Daily, Weekly, Monthly). pub recurrence: Recurrence, /// Rich recurrence configuration (JSON). pub recurrence_rule: Option, /// Block type classification (focus, meeting, break, etc.). pub block_type: Option, /// Seconds-before-start_time at which to fire reminders. Empty = none. #[serde(default)] pub reminder_offsets_seconds: Vec, } impl NewEvent { /// Creates a builder for constructing a new event. /// /// # Example /// /// ```rust /// use goingson_core::NewEvent; /// use chrono::{Duration, Utc}; /// /// let start = Utc::now(); /// let event = NewEvent::builder("Team Meeting", start) /// .end_time(start + Duration::hours(1)) /// .location("Conference Room A") /// .build(); /// ``` pub fn builder(title: impl Into, start_time: DateTime) -> NewEventBuilder { NewEventBuilder::new(title, start_time) } } /// Builder for constructing [`NewEvent`] with sensible defaults. #[derive(Debug, Clone)] pub struct NewEventBuilder { title: String, start_time: DateTime, user_id: Option, project_id: Option, contact_id: Option, description: String, end_time: Option>, location: Option, linked_task_id: Option, recurrence: Recurrence, recurrence_rule: Option, block_type: Option, } impl NewEventBuilder { /// Creates a new builder with the given title and start time. pub fn new(title: impl Into, start_time: DateTime) -> Self { Self { title: title.into(), start_time, user_id: None, project_id: None, contact_id: None, description: String::new(), end_time: None, location: None, linked_task_id: None, recurrence: Recurrence::default(), recurrence_rule: None, block_type: None, } } /// Sets the user ID. pub fn user_id(mut self, user_id: UserId) -> Self { self.user_id = Some(user_id); self } /// Sets the project ID. pub fn project_id(mut self, project_id: ProjectId) -> Self { self.project_id = Some(project_id); self } /// Sets the contact ID. pub fn contact_id(mut self, contact_id: ContactId) -> Self { self.contact_id = Some(contact_id); self } /// Sets the description. pub fn description(mut self, description: impl Into) -> Self { self.description = description.into(); self } /// Sets the end time. pub fn end_time(mut self, end_time: DateTime) -> Self { self.end_time = Some(end_time); self } /// Sets the location. pub fn location(mut self, location: impl Into) -> Self { self.location = Some(location.into()); self } /// Sets the linked task ID (for time-blocking). pub fn linked_task_id(mut self, task_id: TaskId) -> Self { self.linked_task_id = Some(task_id); self } /// Sets the recurrence pattern. pub fn recurrence(mut self, recurrence: Recurrence) -> Self { self.recurrence = recurrence; self } /// Sets the rich recurrence rule. pub fn recurrence_rule(mut self, rule: RecurrenceRule) -> Self { self.recurrence_rule = Some(rule); self } /// Sets the block type. pub fn block_type(mut self, block_type: BlockType) -> Self { self.block_type = Some(block_type); self } /// Builds the [`NewEvent`]. pub fn build(self) -> NewEvent { NewEvent { user_id: self.user_id, project_id: self.project_id, contact_id: self.contact_id, title: self.title, description: self.description, start_time: self.start_time, end_time: self.end_time, location: self.location, linked_task_id: self.linked_task_id, recurrence: self.recurrence, recurrence_rule: self.recurrence_rule, block_type: self.block_type, reminder_offsets_seconds: Vec::new(), } } } #[cfg(test)] mod is_all_day_tests { use super::*; use chrono::TimeZone; /// Builds a minimal event with the given start and optional end, in UTC. fn event_at(start: DateTime, end: Option>) -> Event { Event { id: EventId::new(), user_id: None, project_id: None, project_name: None, contact_id: None, contact_name: None, title: "Test".to_string(), description: String::new(), start_time: start, end_time: end, location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, recurrence_parent_id: None, is_recurring_instance: false, block_type: None, external_source: None, external_id: None, is_read_only: false, snoozed_until: None, reminder_offsets_seconds: Vec::new(), } } #[test] fn midnight_full_day_is_all_day() { let start = Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap(); let event = event_at(start, Some(start + Duration::hours(24))); assert!(event.is_all_day_in(&Utc)); } #[test] fn midnight_one_hour_is_not_all_day() { let start = Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap(); let event = event_at(start, Some(start + Duration::hours(1))); assert!(!event.is_all_day_in(&Utc)); } #[test] fn ten_am_span_is_not_all_day() { let start = Utc.with_ymd_and_hms(2026, 7, 4, 10, 0, 0).unwrap(); let event = event_at(start, Some(start + Duration::hours(24))); assert!(!event.is_all_day_in(&Utc)); } #[test] fn multi_day_midnight_span_is_all_day() { let start = Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap(); let event = event_at(start, Some(start + Duration::days(3))); assert!(event.is_all_day_in(&Utc)); } #[test] fn missing_end_time_is_not_all_day() { let start = Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap(); let event = event_at(start, None); assert!(!event.is_all_day_in(&Utc)); } #[test] fn snap_single_day_round_trips_as_all_day() { // A mid-afternoon start with no end snaps to a single midnight-to-midnight day. let start = Utc.with_ymd_and_hms(2026, 7, 4, 14, 30, 0).unwrap(); let (s, e) = snap_all_day_span(start, None, &Utc); assert_eq!(s, Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap()); assert_eq!(e, Utc.with_ymd_and_hms(2026, 7, 5, 0, 0, 0).unwrap()); assert!(event_at(s, Some(e)).is_all_day_in(&Utc)); } #[test] fn snap_multi_day_pushes_end_to_day_after() { // Start on the 4th, end mid-day on the 6th → 4th 00:00 .. 7th 00:00 (inclusive of the 6th). let start = Utc.with_ymd_and_hms(2026, 7, 4, 9, 0, 0).unwrap(); let end = Utc.with_ymd_and_hms(2026, 7, 6, 15, 0, 0).unwrap(); let (s, e) = snap_all_day_span(start, Some(end), &Utc); assert_eq!(s, Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap()); assert_eq!(e, Utc.with_ymd_and_hms(2026, 7, 7, 0, 0, 0).unwrap()); assert!(event_at(s, Some(e)).is_all_day_in(&Utc)); } #[test] fn snap_is_idempotent() { let start = Utc.with_ymd_and_hms(2026, 7, 4, 14, 30, 0).unwrap(); let (s1, e1) = snap_all_day_span(start, None, &Utc); let (s2, e2) = snap_all_day_span(s1, Some(e1), &Utc); assert_eq!((s1, e1), (s2, e2)); } #[test] fn snap_keeps_midnight_end_exclusive_no_growth() { // Editing an existing single-day all-day event (stored 00:00..next-00:00) // must not grow it by a day — the midnight end is already exclusive. let start = Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap(); let end = Utc.with_ymd_and_hms(2026, 7, 5, 0, 0, 0).unwrap(); let (s, e) = snap_all_day_span(start, Some(end), &Utc); assert_eq!(s, start); assert_eq!(e, end); } }