Skip to main content

max / goingson

19.1 KB · 529 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, Duration, TimeZone, Timelike, 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 /// True if this event looks like an all-day event in the given timezone.
158 ///
159 /// An event is all-day when it has an end time, spans at least 23 hours,
160 /// and starts at local midnight (00:00). The 23-hour floor (rather than a
161 /// strict 24) tolerates DST transitions and near-midnight authoring; a
162 /// multi-day midnight-to-midnight span therefore also qualifies.
163 pub fn is_all_day_in<Tz: TimeZone>(&self, tz: &Tz) -> bool {
164 let Some(end) = self.end_time else {
165 return false;
166 };
167 if end - self.start_time < Duration::hours(23) {
168 return false;
169 }
170 let local_start = self.start_time.with_timezone(tz);
171 local_start.hour() == 0 && local_start.minute() == 0
172 }
173 }
174
175 /// Snap a start / optional-end instant to the canonical all-day span in `tz`.
176 ///
177 /// The start is pulled back to local midnight of its own day. The end becomes the
178 /// *exclusive* local-midnight boundary after the last covered day:
179 /// - no end given → the day after the start (a single-day span);
180 /// - an end already at local midnight is treated as already-exclusive and kept
181 /// as-is (so re-snapping a stored all-day span is idempotent — it does not grow
182 /// by a day on every edit);
183 /// - a mid-day end covers the whole of its day, so it rounds up to the next midnight.
184 ///
185 /// Both bounds are returned as UTC instants — the shape [`Event::is_all_day_in`]
186 /// detects, so a snapped event round-trips as all-day.
187 ///
188 /// On a DST spring-forward gap the civil midnight doesn't exist; it falls back to
189 /// treating the naive value as UTC rather than panicking (mirrors the app's
190 /// `tz::local_civil_to_utc`).
191 pub fn snap_all_day_span<Tz: TimeZone>(
192 start: DateTime<Utc>,
193 end: Option<DateTime<Utc>>,
194 tz: &Tz,
195 ) -> (DateTime<Utc>, DateTime<Utc>) {
196 let start_day = start.with_timezone(tz).date_naive();
197 let end_exclusive = match end {
198 None => start_day.succ_opt().unwrap_or(start_day),
199 Some(e) => {
200 let local_end = e.with_timezone(tz);
201 let end_day = local_end.date_naive();
202 if local_end.time() == chrono::NaiveTime::MIN {
203 end_day // already the exclusive midnight boundary
204 } else {
205 end_day.succ_opt().unwrap_or(end_day)
206 }
207 }
208 };
209 (local_midnight_utc(start_day, tz), local_midnight_utc(end_exclusive, tz))
210 }
211
212 /// Convert a calendar date's local midnight in `tz` to the corresponding UTC instant.
213 fn local_midnight_utc<Tz: TimeZone>(date: chrono::NaiveDate, tz: &Tz) -> DateTime<Utc> {
214 let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid");
215 tz.from_local_datetime(&naive)
216 .earliest()
217 .map(|dt| dt.with_timezone(&Utc))
218 .unwrap_or_else(|| DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc))
219 }
220
221 // ============ Event DTOs ============
222
223 /// Data for creating a new event.
224 #[derive(Debug, Clone, Serialize, Deserialize)]
225 pub struct NewEvent {
226 /// Owner user ID (set by the command layer for desktop).
227 pub user_id: Option<UserId>,
228 /// Associated project, if any.
229 pub project_id: Option<ProjectId>,
230 /// Associated contact, if any.
231 pub contact_id: Option<ContactId>,
232 /// Event title (required, validated non-empty).
233 pub title: String,
234 /// Event description or notes.
235 pub description: String,
236 /// When the event starts.
237 pub start_time: DateTime<Utc>,
238 /// When the event ends (optional for all-day or open-ended events).
239 pub end_time: Option<DateTime<Utc>>,
240 /// Location (physical address or video link).
241 pub location: Option<String>,
242 /// Linked task ID for time-blocking (set programmatically, not via form).
243 pub linked_task_id: Option<TaskId>,
244 /// Recurrence pattern (None, Daily, Weekly, Monthly).
245 pub recurrence: Recurrence,
246 /// Rich recurrence configuration (JSON).
247 pub recurrence_rule: Option<RecurrenceRule>,
248 /// Block type classification (focus, meeting, break, etc.).
249 pub block_type: Option<BlockType>,
250 /// Seconds-before-start_time at which to fire reminders. Empty = none.
251 #[serde(default)]
252 pub reminder_offsets_seconds: Vec<i64>,
253 }
254
255 /// Data for updating an existing event.
256 #[derive(Debug, Clone, Serialize, Deserialize)]
257 pub struct UpdateEvent {
258 /// Associated project, if any.
259 pub project_id: Option<ProjectId>,
260 /// Associated contact, if any.
261 pub contact_id: Option<ContactId>,
262 /// Event title (required, validated non-empty).
263 pub title: String,
264 /// Event description or notes.
265 pub description: String,
266 /// When the event starts.
267 pub start_time: DateTime<Utc>,
268 /// When the event ends (optional for all-day or open-ended events).
269 pub end_time: Option<DateTime<Utc>>,
270 /// Location (physical address or video link).
271 pub location: Option<String>,
272 /// Linked task ID for time-blocking (preserved from the existing event on update).
273 pub linked_task_id: Option<TaskId>,
274 /// Recurrence pattern (None, Daily, Weekly, Monthly).
275 pub recurrence: Recurrence,
276 /// Rich recurrence configuration (JSON).
277 pub recurrence_rule: Option<RecurrenceRule>,
278 /// Block type classification (focus, meeting, break, etc.).
279 pub block_type: Option<BlockType>,
280 /// Seconds-before-start_time at which to fire reminders. Empty = none.
281 #[serde(default)]
282 pub reminder_offsets_seconds: Vec<i64>,
283 }
284
285 impl NewEvent {
286 /// Creates a builder for constructing a new event.
287 ///
288 /// # Example
289 ///
290 /// ```rust
291 /// use goingson_core::NewEvent;
292 /// use chrono::{Duration, Utc};
293 ///
294 /// let start = Utc::now();
295 /// let event = NewEvent::builder("Team Meeting", start)
296 /// .end_time(start + Duration::hours(1))
297 /// .location("Conference Room A")
298 /// .build();
299 /// ```
300 pub fn builder(title: impl Into<String>, start_time: DateTime<Utc>) -> NewEventBuilder {
301 NewEventBuilder::new(title, start_time)
302 }
303 }
304
305 /// Builder for constructing [`NewEvent`] with sensible defaults.
306 #[derive(Debug, Clone)]
307 pub struct NewEventBuilder {
308 title: String,
309 start_time: DateTime<Utc>,
310 user_id: Option<UserId>,
311 project_id: Option<ProjectId>,
312 contact_id: Option<ContactId>,
313 description: String,
314 end_time: Option<DateTime<Utc>>,
315 location: Option<String>,
316 linked_task_id: Option<TaskId>,
317 recurrence: Recurrence,
318 recurrence_rule: Option<RecurrenceRule>,
319 block_type: Option<BlockType>,
320 }
321
322 impl NewEventBuilder {
323 /// Creates a new builder with the given title and start time.
324 pub fn new(title: impl Into<String>, start_time: DateTime<Utc>) -> Self {
325 Self {
326 title: title.into(),
327 start_time,
328 user_id: None,
329 project_id: None,
330 contact_id: None,
331 description: String::new(),
332 end_time: None,
333 location: None,
334 linked_task_id: None,
335 recurrence: Recurrence::default(),
336 recurrence_rule: None,
337 block_type: None,
338 }
339 }
340
341 /// Sets the user ID.
342 pub fn user_id(mut self, user_id: UserId) -> Self {
343 self.user_id = Some(user_id);
344 self
345 }
346
347 /// Sets the project ID.
348 pub fn project_id(mut self, project_id: ProjectId) -> Self {
349 self.project_id = Some(project_id);
350 self
351 }
352
353 /// Sets the contact ID.
354 pub fn contact_id(mut self, contact_id: ContactId) -> Self {
355 self.contact_id = Some(contact_id);
356 self
357 }
358
359 /// Sets the description.
360 pub fn description(mut self, description: impl Into<String>) -> Self {
361 self.description = description.into();
362 self
363 }
364
365 /// Sets the end time.
366 pub fn end_time(mut self, end_time: DateTime<Utc>) -> Self {
367 self.end_time = Some(end_time);
368 self
369 }
370
371 /// Sets the location.
372 pub fn location(mut self, location: impl Into<String>) -> Self {
373 self.location = Some(location.into());
374 self
375 }
376
377 /// Sets the linked task ID (for time-blocking).
378 pub fn linked_task_id(mut self, task_id: TaskId) -> Self {
379 self.linked_task_id = Some(task_id);
380 self
381 }
382
383 /// Sets the recurrence pattern.
384 pub fn recurrence(mut self, recurrence: Recurrence) -> Self {
385 self.recurrence = recurrence;
386 self
387 }
388
389 /// Sets the rich recurrence rule.
390 pub fn recurrence_rule(mut self, rule: RecurrenceRule) -> Self {
391 self.recurrence_rule = Some(rule);
392 self
393 }
394
395 /// Sets the block type.
396 pub fn block_type(mut self, block_type: BlockType) -> Self {
397 self.block_type = Some(block_type);
398 self
399 }
400
401 /// Builds the [`NewEvent`].
402 pub fn build(self) -> NewEvent {
403 NewEvent {
404 user_id: self.user_id,
405 project_id: self.project_id,
406 contact_id: self.contact_id,
407 title: self.title,
408 description: self.description,
409 start_time: self.start_time,
410 end_time: self.end_time,
411 location: self.location,
412 linked_task_id: self.linked_task_id,
413 recurrence: self.recurrence,
414 recurrence_rule: self.recurrence_rule,
415 block_type: self.block_type,
416 reminder_offsets_seconds: Vec::new(),
417 }
418 }
419 }
420
421 #[cfg(test)]
422 mod is_all_day_tests {
423 use super::*;
424 use chrono::TimeZone;
425
426 /// Builds a minimal event with the given start and optional end, in UTC.
427 fn event_at(start: DateTime<Utc>, end: Option<DateTime<Utc>>) -> Event {
428 Event {
429 id: EventId::new(),
430 user_id: None,
431 project_id: None,
432 project_name: None,
433 contact_id: None,
434 contact_name: None,
435 title: "Test".to_string(),
436 description: String::new(),
437 start_time: start,
438 end_time: end,
439 location: None,
440 linked_task_id: None,
441 recurrence: Recurrence::None,
442 recurrence_rule: None,
443 recurrence_parent_id: None,
444 is_recurring_instance: false,
445 block_type: None,
446 external_source: None,
447 external_id: None,
448 is_read_only: false,
449 snoozed_until: None,
450 reminder_offsets_seconds: Vec::new(),
451 }
452 }
453
454 #[test]
455 fn midnight_full_day_is_all_day() {
456 let start = Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap();
457 let event = event_at(start, Some(start + Duration::hours(24)));
458 assert!(event.is_all_day_in(&Utc));
459 }
460
461 #[test]
462 fn midnight_one_hour_is_not_all_day() {
463 let start = Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap();
464 let event = event_at(start, Some(start + Duration::hours(1)));
465 assert!(!event.is_all_day_in(&Utc));
466 }
467
468 #[test]
469 fn ten_am_span_is_not_all_day() {
470 let start = Utc.with_ymd_and_hms(2026, 7, 4, 10, 0, 0).unwrap();
471 let event = event_at(start, Some(start + Duration::hours(24)));
472 assert!(!event.is_all_day_in(&Utc));
473 }
474
475 #[test]
476 fn multi_day_midnight_span_is_all_day() {
477 let start = Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap();
478 let event = event_at(start, Some(start + Duration::days(3)));
479 assert!(event.is_all_day_in(&Utc));
480 }
481
482 #[test]
483 fn missing_end_time_is_not_all_day() {
484 let start = Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap();
485 let event = event_at(start, None);
486 assert!(!event.is_all_day_in(&Utc));
487 }
488
489 #[test]
490 fn snap_single_day_round_trips_as_all_day() {
491 // A mid-afternoon start with no end snaps to a single midnight-to-midnight day.
492 let start = Utc.with_ymd_and_hms(2026, 7, 4, 14, 30, 0).unwrap();
493 let (s, e) = snap_all_day_span(start, None, &Utc);
494 assert_eq!(s, Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap());
495 assert_eq!(e, Utc.with_ymd_and_hms(2026, 7, 5, 0, 0, 0).unwrap());
496 assert!(event_at(s, Some(e)).is_all_day_in(&Utc));
497 }
498
499 #[test]
500 fn snap_multi_day_pushes_end_to_day_after() {
501 // Start on the 4th, end mid-day on the 6th → 4th 00:00 .. 7th 00:00 (inclusive of the 6th).
502 let start = Utc.with_ymd_and_hms(2026, 7, 4, 9, 0, 0).unwrap();
503 let end = Utc.with_ymd_and_hms(2026, 7, 6, 15, 0, 0).unwrap();
504 let (s, e) = snap_all_day_span(start, Some(end), &Utc);
505 assert_eq!(s, Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap());
506 assert_eq!(e, Utc.with_ymd_and_hms(2026, 7, 7, 0, 0, 0).unwrap());
507 assert!(event_at(s, Some(e)).is_all_day_in(&Utc));
508 }
509
510 #[test]
511 fn snap_is_idempotent() {
512 let start = Utc.with_ymd_and_hms(2026, 7, 4, 14, 30, 0).unwrap();
513 let (s1, e1) = snap_all_day_span(start, None, &Utc);
514 let (s2, e2) = snap_all_day_span(s1, Some(e1), &Utc);
515 assert_eq!((s1, e1), (s2, e2));
516 }
517
518 #[test]
519 fn snap_keeps_midnight_end_exclusive_no_growth() {
520 // Editing an existing single-day all-day event (stored 00:00..next-00:00)
521 // must not grow it by a day — the midnight end is already exclusive.
522 let start = Utc.with_ymd_and_hms(2026, 7, 4, 0, 0, 0).unwrap();
523 let end = Utc.with_ymd_and_hms(2026, 7, 5, 0, 0, 0).unwrap();
524 let (s, e) = snap_all_day_span(start, Some(end), &Utc);
525 assert_eq!(s, start);
526 assert_eq!(e, end);
527 }
528 }
529