Skip to main content

pidge_core/
availability.rs

1//! Free-slot computation over a busy list within working hours.
2//!
3//! Walks each local working day inside a UTC range, subtracts merged busy
4//! intervals, and keeps gaps at least as long as the requested duration.
5
6use chrono::{DateTime, Datelike, Duration, TimeZone, Utc};
7use chrono_tz::Tz;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct Busy {
11    pub start: DateTime<Utc>,
12    pub end: DateTime<Utc>,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct Slot {
17    pub start: DateTime<Utc>,
18    pub end: DateTime<Utc>,
19}
20
21/// Working hours in local time. `weekdays[0]` is Monday.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct WorkingHours {
24    pub start_hour: u32,
25    pub end_hour: u32,
26    pub weekdays: [bool; 7],
27}
28
29impl Default for WorkingHours {
30    fn default() -> Self {
31        Self {
32            start_hour: 8,
33            end_hour: 18,
34            weekdays: [true, true, true, true, true, false, false],
35        }
36    }
37}
38
39/// Compute free slots within `range`, honoring `hours` (interpreted in `tz`)
40/// and subtracting merged `busy` intervals. Returns at most `max` slots.
41///
42/// A working day whose start or end local time does not exist in `tz` (a DST
43/// gap) contributes no slots for that day, rather than panicking.
44pub fn free_slots(
45    busy: &[Busy],
46    range: (DateTime<Utc>, DateTime<Utc>),
47    duration: Duration,
48    hours: &WorkingHours,
49    tz: Tz,
50    max: usize,
51) -> Vec<Slot> {
52    let mut merged: Vec<Busy> = busy.to_vec();
53    merged.sort_by_key(|b| b.start);
54    let mut busy_merged: Vec<Busy> = Vec::new();
55    for b in merged {
56        match busy_merged.last_mut() {
57            Some(last) if b.start <= last.end => last.end = last.end.max(b.end),
58            _ => busy_merged.push(b),
59        }
60    }
61
62    let mut out = Vec::new();
63    let mut day = range.0.with_timezone(&tz).date_naive();
64    let last_day = range.1.with_timezone(&tz).date_naive();
65    while day <= last_day && out.len() < max {
66        if hours.weekdays[day.weekday().num_days_from_monday() as usize] {
67            // Hour 24 is the next local midnight (`end_hour: 24`).
68            let mk = |h: u32| -> Option<DateTime<Utc>> {
69                let (date, h) = if h >= 24 {
70                    (day + Duration::days(1), 0)
71                } else {
72                    (day, h)
73                };
74                tz.from_local_datetime(&date.and_hms_opt(h, 0, 0)?)
75                    .earliest()
76                    .map(|d| d.with_timezone(&Utc))
77            };
78            if let (Some(start), Some(end)) = (mk(hours.start_hour), mk(hours.end_hour)) {
79                let mut cursor = start.max(range.0);
80                let day_end = end.min(range.1);
81                for b in busy_merged.iter().filter(|b| b.start < day_end) {
82                    if b.end <= cursor {
83                        continue;
84                    }
85                    if b.start - cursor >= duration {
86                        out.push(Slot {
87                            start: cursor,
88                            end: b.start,
89                        });
90                    }
91                    cursor = cursor.max(b.end);
92                }
93                if day_end - cursor >= duration {
94                    out.push(Slot {
95                        start: cursor,
96                        end: day_end,
97                    });
98                }
99            }
100        }
101        day += Duration::days(1);
102    }
103    out.truncate(max);
104    out
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use chrono::{Duration, TimeZone};
111
112    fn tz() -> Tz {
113        chrono_tz::Europe::Stockholm
114    }
115    fn l(d: u32, h: u32, m: u32) -> DateTime<Utc> {
116        tz().with_ymd_and_hms(2026, 9, d, h, m, 0)
117            .unwrap()
118            .with_timezone(&Utc)
119    }
120
121    #[test]
122    fn one_day_with_a_meeting_yields_two_gaps() {
123        let busy = vec![Busy {
124            start: l(23, 10, 0),
125            end: l(23, 11, 0),
126        }];
127        let slots = free_slots(
128            &busy,
129            (l(23, 0, 0), l(24, 0, 0)),
130            Duration::minutes(60),
131            &WorkingHours::default(),
132            tz(),
133            20,
134        );
135        assert_eq!(
136            slots,
137            vec![
138                Slot {
139                    start: l(23, 8, 0),
140                    end: l(23, 10, 0)
141                },
142                Slot {
143                    start: l(23, 11, 0),
144                    end: l(23, 18, 0)
145                },
146            ]
147        );
148    }
149
150    #[test]
151    fn weekends_and_short_gaps_are_skipped() {
152        // 26/27 Sep 2026 are Saturday/Sunday.
153        let busy = vec![Busy {
154            start: l(25, 8, 0),
155            end: l(25, 17, 30),
156        }];
157        let slots = free_slots(
158            &busy,
159            (l(25, 0, 0), l(28, 0, 0)),
160            Duration::minutes(60),
161            &WorkingHours::default(),
162            tz(),
163            20,
164        );
165        assert!(slots.is_empty());
166    }
167
168    #[test]
169    fn overlapping_busy_intervals_merge() {
170        let busy = vec![
171            Busy {
172                start: l(23, 9, 0),
173                end: l(23, 12, 0),
174            },
175            Busy {
176                start: l(23, 11, 0),
177                end: l(23, 13, 0),
178            },
179        ];
180        let slots = free_slots(
181            &busy,
182            (l(23, 0, 0), l(24, 0, 0)),
183            Duration::minutes(30),
184            &WorkingHours::default(),
185            tz(),
186            20,
187        );
188        assert_eq!(
189            slots,
190            vec![
191                Slot {
192                    start: l(23, 8, 0),
193                    end: l(23, 9, 0)
194                },
195                Slot {
196                    start: l(23, 13, 0),
197                    end: l(23, 18, 0)
198                },
199            ]
200        );
201    }
202
203    #[test]
204    fn end_hour_24_runs_to_local_midnight() {
205        let hours = WorkingHours {
206            start_hour: 20,
207            end_hour: 24,
208            ..WorkingHours::default()
209        };
210        let slots = free_slots(
211            &[],
212            (l(23, 0, 0), l(24, 0, 0)),
213            Duration::minutes(60),
214            &hours,
215            tz(),
216            20,
217        );
218        assert_eq!(
219            slots,
220            vec![Slot {
221                start: l(23, 20, 0),
222                end: l(24, 0, 0)
223            }]
224        );
225    }
226
227    #[test]
228    fn dst_gap_day_yields_no_slot_and_does_not_panic() {
229        // 2018-11-04 in America/Sao_Paulo: DST started, clocks jumped from
230        // midnight to 01:00, so local hour 00:00 does not exist that day.
231        let sp = chrono_tz::America::Sao_Paulo;
232        let hours = WorkingHours {
233            start_hour: 0,
234            end_hour: 18,
235            weekdays: [true, true, true, true, true, true, true],
236        };
237        let start = sp
238            .with_ymd_and_hms(2018, 11, 3, 12, 0, 0)
239            .unwrap()
240            .with_timezone(&Utc);
241        let end = sp
242            .with_ymd_and_hms(2018, 11, 5, 12, 0, 0)
243            .unwrap()
244            .with_timezone(&Utc);
245        let slots = free_slots(&[], (start, end), Duration::minutes(60), &hours, sp, 20);
246        assert!(
247            slots.iter().all(|s| {
248                let d = s.start.with_timezone(&sp).date_naive();
249                d != chrono::NaiveDate::from_ymd_opt(2018, 11, 4).unwrap()
250            }),
251            "expected no slot on the DST-gap day, got {slots:?}"
252        );
253    }
254}