Skip to main content

max / goingson

12.2 KB · 357 lines History Blame Raw
1 //! Event domain types and DTOs.
2 //!
3 //! Events represent calendar entries that can be standalone or linked to a task
4 //! for time-blocking. They support recurrence patterns (Daily, Weekly, Monthly),
5 //! optional project and contact associations, and block-type classification
6 //! (focus, meeting, break, etc.).
7
8 use chrono::{DateTime, Utc};
9 use serde::{Deserialize, Serialize};
10 use crate::constants::DAYS_THRESHOLD_SHORT_FORMAT;
11 use crate::id_types::{EventId, UserId, ProjectId, ContactId, TaskId};
12 use super::shared::{BlockType, Recurrence, RecurrenceRule};
13
14 // ============ Event ============
15
16 /// A calendar event with optional time-blocking link to a task.
17 ///
18 /// Events can be standalone or linked to a task for time-blocking purposes.
19 /// When linked, the event represents a scheduled time slot for working on the task.
20 #[derive(Debug, Clone, Serialize, Deserialize)]
21 #[serde(rename_all = "camelCase")]
22 pub struct Event {
23 /// Unique identifier.
24 pub id: EventId,
25 /// Owner user ID (internal).
26 #[serde(skip_serializing)]
27 pub user_id: Option<UserId>,
28 /// Associated project, if any.
29 pub project_id: Option<ProjectId>,
30 /// Denormalized project name for display.
31 pub project_name: Option<String>,
32 /// Associated contact, if any.
33 pub contact_id: Option<ContactId>,
34 /// Denormalized contact name for display.
35 pub contact_name: Option<String>,
36 /// Event title.
37 pub title: String,
38 /// Event description/notes.
39 pub description: String,
40 /// When the event starts.
41 pub start_time: DateTime<Utc>,
42 /// When the event ends (optional for all-day events).
43 pub end_time: Option<DateTime<Utc>>,
44 /// Location (physical address or video link).
45 pub location: Option<String>,
46 /// If this is a time-block, the linked task ID.
47 pub linked_task_id: Option<TaskId>,
48 /// Recurrence pattern (legacy, used when recurrence_rule is absent).
49 pub recurrence: Recurrence,
50 /// Rich recurrence configuration (JSON). Takes precedence over `recurrence`.
51 pub recurrence_rule: Option<RecurrenceRule>,
52 /// Original event ID if this is a recurrence instance.
53 pub recurrence_parent_id: Option<EventId>,
54 /// True if this is a virtual instance produced by recurrence expansion (not persisted).
55 #[serde(default)]
56 pub is_recurring_instance: bool,
57 /// If this is a time block, the block type.
58 pub block_type: Option<BlockType>,
59 /// External sync source (e.g., "vcf", "ics", "google", "apple").
60 pub external_source: Option<String>,
61 /// External ID for dedup (e.g., UID from .ics, provider-specific ID).
62 pub external_id: Option<String>,
63 /// Whether this event is read-only (synced from external calendar).
64 pub is_read_only: bool,
65 /// If set, the event is snoozed (hidden from main views) until this time.
66 pub snoozed_until: Option<DateTime<Utc>>,
67 /// Seconds-before-start_time at which to fire desktop reminder notifications.
68 /// `[0, 300, 900]` = at time, 5 minutes before, 15 minutes before. Empty = no reminders.
69 #[serde(default)]
70 pub reminder_offsets_seconds: Vec<i64>,
71 }
72
73 impl Event {
74 /// Returns a formatted time string (e.g., "Jan 15, 14:00" or "Jan 15, 14:00 - 15:30").
75 pub fn time_formatted(&self) -> String {
76 let start = self.start_time.format("%b %d, %H:%M").to_string();
77 match &self.end_time {
78 Some(end) => format!("{} - {}", start, end.format("%H:%M")),
79 None => start,
80 }
81 }
82
83 /// Returns a relative date string: "Past", "Today", "Tomorrow", day name, or "Mon DD".
84 pub fn date_formatted(&self) -> String {
85 let now = Utc::now();
86 let days = (self.start_time.date_naive() - now.date_naive()).num_days();
87
88 if days < 0 {
89 "Past".to_string()
90 } else if days == 0 {
91 "Today".to_string()
92 } else if days == 1 {
93 "Tomorrow".to_string()
94 } else if days < DAYS_THRESHOLD_SHORT_FORMAT {
95 self.start_time.format("%A").to_string()
96 } else {
97 self.start_time.format("%b %d").to_string()
98 }
99 }
100
101 /// Returns the zero-padded day of the month (e.g., "07", "15").
102 pub fn day_number(&self) -> String {
103 self.start_time.format("%d").to_string()
104 }
105
106 /// Returns the start time as a Unix timestamp (seconds since epoch).
107 pub fn timestamp(&self) -> i64 {
108 self.start_time.timestamp()
109 }
110
111 /// Returns true if the event has a location set.
112 pub fn has_location(&self) -> bool {
113 self.location.is_some()
114 }
115
116 /// Returns the location string, or an empty string if unset.
117 pub fn location_or_empty(&self) -> &str {
118 self.location.as_deref().unwrap_or("")
119 }
120
121 /// Returns true if the event is associated with a project.
122 pub fn has_project(&self) -> bool {
123 self.project_name.is_some()
124 }
125
126 /// Returns the project name, or an empty string if unset.
127 pub fn project_name_or_empty(&self) -> &str {
128 self.project_name.as_deref().unwrap_or("")
129 }
130
131 /// Returns true if the event has a non-empty description.
132 pub fn has_description(&self) -> bool {
133 !self.description.is_empty()
134 }
135
136 /// Returns true if the event has a recurrence pattern set (not `None`).
137 pub fn has_recurrence(&self) -> bool {
138 self.recurrence_rule.is_some() || self.recurrence != Recurrence::None
139 }
140
141 /// Returns the effective recurrence rule, synthesizing from the legacy
142 /// column if no explicit rule is set.
143 pub fn effective_recurrence_rule(&self) -> Option<RecurrenceRule> {
144 RecurrenceRule::effective(self.recurrence_rule.as_ref(), &self.recurrence)
145 }
146
147 /// Returns true if this event is a time-block linked to a task.
148 pub fn is_linked_to_task(&self) -> bool {
149 self.linked_task_id.is_some()
150 }
151
152 /// True if `snoozed_until` is in the future.
153 pub fn is_snoozed(&self) -> bool {
154 self.snoozed_until.is_some_and(|t| t > Utc::now())
155 }
156 }
157
158 // ============ Event DTOs ============
159
160 /// Data for creating a new event.
161 #[derive(Debug, Clone, Serialize, Deserialize)]
162 pub struct NewEvent {
163 /// Owner user ID (set by the command layer for desktop).
164 pub user_id: Option<UserId>,
165 /// Associated project, if any.
166 pub project_id: Option<ProjectId>,
167 /// Associated contact, if any.
168 pub contact_id: Option<ContactId>,
169 /// Event title (required, validated non-empty).
170 pub title: String,
171 /// Event description or notes.
172 pub description: String,
173 /// When the event starts.
174 pub start_time: DateTime<Utc>,
175 /// When the event ends (optional for all-day or open-ended events).
176 pub end_time: Option<DateTime<Utc>>,
177 /// Location (physical address or video link).
178 pub location: Option<String>,
179 /// Linked task ID for time-blocking (set programmatically, not via form).
180 pub linked_task_id: Option<TaskId>,
181 /// Recurrence pattern (None, Daily, Weekly, Monthly).
182 pub recurrence: Recurrence,
183 /// Rich recurrence configuration (JSON).
184 pub recurrence_rule: Option<RecurrenceRule>,
185 /// Block type classification (focus, meeting, break, etc.).
186 pub block_type: Option<BlockType>,
187 /// Seconds-before-start_time at which to fire reminders. Empty = none.
188 #[serde(default)]
189 pub reminder_offsets_seconds: Vec<i64>,
190 }
191
192 /// Data for updating an existing event.
193 #[derive(Debug, Clone, Serialize, Deserialize)]
194 pub struct UpdateEvent {
195 /// Associated project, if any.
196 pub project_id: Option<ProjectId>,
197 /// Associated contact, if any.
198 pub contact_id: Option<ContactId>,
199 /// Event title (required, validated non-empty).
200 pub title: String,
201 /// Event description or notes.
202 pub description: String,
203 /// When the event starts.
204 pub start_time: DateTime<Utc>,
205 /// When the event ends (optional for all-day or open-ended events).
206 pub end_time: Option<DateTime<Utc>>,
207 /// Location (physical address or video link).
208 pub location: Option<String>,
209 /// Linked task ID for time-blocking (preserved from the existing event on update).
210 pub linked_task_id: Option<TaskId>,
211 /// Recurrence pattern (None, Daily, Weekly, Monthly).
212 pub recurrence: Recurrence,
213 /// Rich recurrence configuration (JSON).
214 pub recurrence_rule: Option<RecurrenceRule>,
215 /// Block type classification (focus, meeting, break, etc.).
216 pub block_type: Option<BlockType>,
217 /// Seconds-before-start_time at which to fire reminders. Empty = none.
218 #[serde(default)]
219 pub reminder_offsets_seconds: Vec<i64>,
220 }
221
222 impl NewEvent {
223 /// Creates a builder for constructing a new event.
224 ///
225 /// # Example
226 ///
227 /// ```rust
228 /// use goingson_core::NewEvent;
229 /// use chrono::{Duration, Utc};
230 ///
231 /// let start = Utc::now();
232 /// let event = NewEvent::builder("Team Meeting", start)
233 /// .end_time(start + Duration::hours(1))
234 /// .location("Conference Room A")
235 /// .build();
236 /// ```
237 pub fn builder(title: impl Into<String>, start_time: DateTime<Utc>) -> NewEventBuilder {
238 NewEventBuilder::new(title, start_time)
239 }
240 }
241
242 /// Builder for constructing [`NewEvent`] with sensible defaults.
243 #[derive(Debug, Clone)]
244 pub struct NewEventBuilder {
245 title: String,
246 start_time: DateTime<Utc>,
247 user_id: Option<UserId>,
248 project_id: Option<ProjectId>,
249 contact_id: Option<ContactId>,
250 description: String,
251 end_time: Option<DateTime<Utc>>,
252 location: Option<String>,
253 linked_task_id: Option<TaskId>,
254 recurrence: Recurrence,
255 recurrence_rule: Option<RecurrenceRule>,
256 block_type: Option<BlockType>,
257 }
258
259 impl NewEventBuilder {
260 /// Creates a new builder with the given title and start time.
261 pub fn new(title: impl Into<String>, start_time: DateTime<Utc>) -> Self {
262 Self {
263 title: title.into(),
264 start_time,
265 user_id: None,
266 project_id: None,
267 contact_id: None,
268 description: String::new(),
269 end_time: None,
270 location: None,
271 linked_task_id: None,
272 recurrence: Recurrence::default(),
273 recurrence_rule: None,
274 block_type: None,
275 }
276 }
277
278 /// Sets the user ID.
279 pub fn user_id(mut self, user_id: UserId) -> Self {
280 self.user_id = Some(user_id);
281 self
282 }
283
284 /// Sets the project ID.
285 pub fn project_id(mut self, project_id: ProjectId) -> Self {
286 self.project_id = Some(project_id);
287 self
288 }
289
290 /// Sets the contact ID.
291 pub fn contact_id(mut self, contact_id: ContactId) -> Self {
292 self.contact_id = Some(contact_id);
293 self
294 }
295
296 /// Sets the description.
297 pub fn description(mut self, description: impl Into<String>) -> Self {
298 self.description = description.into();
299 self
300 }
301
302 /// Sets the end time.
303 pub fn end_time(mut self, end_time: DateTime<Utc>) -> Self {
304 self.end_time = Some(end_time);
305 self
306 }
307
308 /// Sets the location.
309 pub fn location(mut self, location: impl Into<String>) -> Self {
310 self.location = Some(location.into());
311 self
312 }
313
314 /// Sets the linked task ID (for time-blocking).
315 pub fn linked_task_id(mut self, task_id: TaskId) -> Self {
316 self.linked_task_id = Some(task_id);
317 self
318 }
319
320 /// Sets the recurrence pattern.
321 pub fn recurrence(mut self, recurrence: Recurrence) -> Self {
322 self.recurrence = recurrence;
323 self
324 }
325
326 /// Sets the rich recurrence rule.
327 pub fn recurrence_rule(mut self, rule: RecurrenceRule) -> Self {
328 self.recurrence_rule = Some(rule);
329 self
330 }
331
332 /// Sets the block type.
333 pub fn block_type(mut self, block_type: BlockType) -> Self {
334 self.block_type = Some(block_type);
335 self
336 }
337
338 /// Builds the [`NewEvent`].
339 pub fn build(self) -> NewEvent {
340 NewEvent {
341 user_id: self.user_id,
342 project_id: self.project_id,
343 contact_id: self.contact_id,
344 title: self.title,
345 description: self.description,
346 start_time: self.start_time,
347 end_time: self.end_time,
348 location: self.location,
349 linked_task_id: self.linked_task_id,
350 recurrence: self.recurrence,
351 recurrence_rule: self.recurrence_rule,
352 block_type: self.block_type,
353 reminder_offsets_seconds: Vec::new(),
354 }
355 }
356 }
357