Skip to main content

truth_engine/
freebusy.rs

1//! Compute free time slots from event lists.
2//!
3//! Sorts events by start time, merges overlapping busy periods, then computes
4//! the gaps between merged periods within a given time window.
5
6use crate::expander::ExpandedEvent;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10/// A free time slot.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct FreeSlot {
13    pub start: DateTime<Utc>,
14    pub end: DateTime<Utc>,
15    pub duration_minutes: i64,
16}
17
18/// Merge overlapping or adjacent busy periods, clipped to the given window.
19///
20/// Returns a sorted, non-overlapping list of (start, end) intervals.
21pub(crate) fn merge_busy_periods(
22    events: &[ExpandedEvent],
23    window_start: DateTime<Utc>,
24    window_end: DateTime<Utc>,
25) -> Vec<(DateTime<Utc>, DateTime<Utc>)> {
26    // Collect events clipped to the window, discarding events entirely outside.
27    let mut intervals: Vec<(DateTime<Utc>, DateTime<Utc>)> = events
28        .iter()
29        .filter(|e| e.start < window_end && e.end > window_start)
30        .map(|e| (e.start.max(window_start), e.end.min(window_end)))
31        .collect();
32
33    if intervals.is_empty() {
34        return Vec::new();
35    }
36
37    // Sort by start time (then by end time for stability).
38    intervals.sort_by_key(|&(start, end)| (start, end));
39
40    // Merge overlapping intervals.
41    let mut merged: Vec<(DateTime<Utc>, DateTime<Utc>)> = Vec::new();
42    for (start, end) in intervals {
43        if let Some(last) = merged.last_mut() {
44            if start <= last.1 {
45                // Overlapping or adjacent — extend the current interval.
46                last.1 = last.1.max(end);
47                continue;
48            }
49        }
50        merged.push((start, end));
51    }
52
53    merged
54}
55
56/// Find free time slots within a given time window, given a list of busy events.
57///
58/// Events may overlap -- overlapping busy periods are merged before computing gaps.
59/// Returns free slots sorted by start time.
60pub fn find_free_slots(
61    events: &[ExpandedEvent],
62    window_start: DateTime<Utc>,
63    window_end: DateTime<Utc>,
64) -> Vec<FreeSlot> {
65    let merged = merge_busy_periods(events, window_start, window_end);
66
67    let mut free_slots = Vec::new();
68    let mut cursor = window_start;
69
70    for (busy_start, busy_end) in &merged {
71        if cursor < *busy_start {
72            let duration_minutes = (*busy_start - cursor).num_minutes();
73            free_slots.push(FreeSlot {
74                start: cursor,
75                end: *busy_start,
76                duration_minutes,
77            });
78        }
79        cursor = cursor.max(*busy_end);
80    }
81
82    // Trailing free slot after the last busy period.
83    if cursor < window_end {
84        let duration_minutes = (window_end - cursor).num_minutes();
85        free_slots.push(FreeSlot {
86            start: cursor,
87            end: window_end,
88            duration_minutes,
89        });
90    }
91
92    free_slots
93}
94
95/// Find the first free slot of at least `min_duration_minutes` within the window.
96///
97/// Delegates to [`find_free_slots`] and returns the first slot meeting the minimum
98/// duration requirement.
99pub fn find_first_free_slot(
100    events: &[ExpandedEvent],
101    window_start: DateTime<Utc>,
102    window_end: DateTime<Utc>,
103    min_duration_minutes: i64,
104) -> Option<FreeSlot> {
105    find_free_slots(events, window_start, window_end)
106        .into_iter()
107        .find(|slot| slot.duration_minutes >= min_duration_minutes)
108}