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. The walk
44/// visits every day of `range`, so callers bound it (`parse_range` caps
45/// ranges at ten years); a range reaching the end of the calendar stops
46/// there instead of overflowing.
47pub fn free_slots(
48    busy: &[Busy],
49    range: (DateTime<Utc>, DateTime<Utc>),
50    duration: Duration,
51    hours: &WorkingHours,
52    tz: Tz,
53    max: usize,
54) -> Vec<Slot> {
55    let mut merged: Vec<Busy> = busy.to_vec();
56    merged.sort_by_key(|b| b.start);
57    let mut busy_merged: Vec<Busy> = Vec::new();
58    for b in merged {
59        match busy_merged.last_mut() {
60            Some(last) if b.start <= last.end => last.end = last.end.max(b.end),
61            _ => busy_merged.push(b),
62        }
63    }
64
65    let mut out = Vec::new();
66    let mut day = range.0.with_timezone(&tz).date_naive();
67    let last_day = range.1.with_timezone(&tz).date_naive();
68    while day <= last_day && out.len() < max {
69        if hours.weekdays[day.weekday().num_days_from_monday() as usize] {
70            // Hour 24 is the next local midnight (`end_hour: 24`).
71            let mk = |h: u32| -> Option<DateTime<Utc>> {
72                let (date, h) = if h >= 24 {
73                    (day.succ_opt()?, 0)
74                } else {
75                    (day, h)
76                };
77                tz.from_local_datetime(&date.and_hms_opt(h, 0, 0)?)
78                    .earliest()
79                    .map(|d| d.with_timezone(&Utc))
80            };
81            if let (Some(start), Some(end)) = (mk(hours.start_hour), mk(hours.end_hour)) {
82                let mut cursor = start.max(range.0);
83                let day_end = end.min(range.1);
84                for b in busy_merged.iter().filter(|b| b.start < day_end) {
85                    if b.end <= cursor {
86                        continue;
87                    }
88                    if b.start - cursor >= duration {
89                        out.push(Slot {
90                            start: cursor,
91                            end: b.start,
92                        });
93                    }
94                    cursor = cursor.max(b.end);
95                }
96                if day_end - cursor >= duration {
97                    out.push(Slot {
98                        start: cursor,
99                        end: day_end,
100                    });
101                }
102            }
103        }
104        // The last representable date ends the walk instead of overflowing.
105        let Some(next) = day.succ_opt() else {
106            break;
107        };
108        day = next;
109    }
110    out.truncate(max);
111    out
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use chrono::{Duration, TimeZone};
118
119    fn tz() -> Tz {
120        chrono_tz::Europe::Stockholm
121    }
122    fn l(d: u32, h: u32, m: u32) -> DateTime<Utc> {
123        tz().with_ymd_and_hms(2026, 9, d, h, m, 0)
124            .unwrap()
125            .with_timezone(&Utc)
126    }
127
128    #[test]
129    fn one_day_with_a_meeting_yields_two_gaps() {
130        let busy = vec![Busy {
131            start: l(23, 10, 0),
132            end: l(23, 11, 0),
133        }];
134        let slots = free_slots(
135            &busy,
136            (l(23, 0, 0), l(24, 0, 0)),
137            Duration::minutes(60),
138            &WorkingHours::default(),
139            tz(),
140            20,
141        );
142        assert_eq!(
143            slots,
144            vec![
145                Slot {
146                    start: l(23, 8, 0),
147                    end: l(23, 10, 0)
148                },
149                Slot {
150                    start: l(23, 11, 0),
151                    end: l(23, 18, 0)
152                },
153            ]
154        );
155    }
156
157    #[test]
158    fn weekends_and_short_gaps_are_skipped() {
159        // 26/27 Sep 2026 are Saturday/Sunday.
160        let busy = vec![Busy {
161            start: l(25, 8, 0),
162            end: l(25, 17, 30),
163        }];
164        let slots = free_slots(
165            &busy,
166            (l(25, 0, 0), l(28, 0, 0)),
167            Duration::minutes(60),
168            &WorkingHours::default(),
169            tz(),
170            20,
171        );
172        assert!(slots.is_empty());
173    }
174
175    #[test]
176    fn overlapping_busy_intervals_merge() {
177        let busy = vec![
178            Busy {
179                start: l(23, 9, 0),
180                end: l(23, 12, 0),
181            },
182            Busy {
183                start: l(23, 11, 0),
184                end: l(23, 13, 0),
185            },
186        ];
187        let slots = free_slots(
188            &busy,
189            (l(23, 0, 0), l(24, 0, 0)),
190            Duration::minutes(30),
191            &WorkingHours::default(),
192            tz(),
193            20,
194        );
195        assert_eq!(
196            slots,
197            vec![
198                Slot {
199                    start: l(23, 8, 0),
200                    end: l(23, 9, 0)
201                },
202                Slot {
203                    start: l(23, 13, 0),
204                    end: l(23, 18, 0)
205                },
206            ]
207        );
208    }
209
210    #[test]
211    fn end_hour_24_runs_to_local_midnight() {
212        let hours = WorkingHours {
213            start_hour: 20,
214            end_hour: 24,
215            ..WorkingHours::default()
216        };
217        let slots = free_slots(
218            &[],
219            (l(23, 0, 0), l(24, 0, 0)),
220            Duration::minutes(60),
221            &hours,
222            tz(),
223            20,
224        );
225        assert_eq!(
226            slots,
227            vec![Slot {
228                start: l(23, 20, 0),
229                end: l(24, 0, 0)
230            }]
231        );
232    }
233
234    #[test]
235    fn a_range_at_the_end_of_the_calendar_does_not_panic() {
236        let utc = chrono_tz::UTC;
237        let last = chrono::NaiveDate::MAX;
238        let at = |d: chrono::NaiveDate, h: u32| {
239            utc.from_utc_datetime(&d.and_hms_opt(h, 0, 0).unwrap())
240                .with_timezone(&Utc)
241        };
242        let hours = WorkingHours {
243            start_hour: 20,
244            end_hour: 24,
245            weekdays: [true; 7],
246        };
247        let start = last.pred_opt().unwrap();
248        let slots = free_slots(
249            &[],
250            (at(start, 0), at(last, 23)),
251            Duration::minutes(60),
252            &hours,
253            utc,
254            20,
255        );
256        // The day before the last one has its evening; the last one has no
257        // following midnight to end on.
258        assert_eq!(
259            slots,
260            vec![Slot {
261                start: at(start, 20),
262                end: at(last, 0)
263            }]
264        );
265    }
266
267    #[test]
268    fn dst_gap_day_yields_no_slot_and_does_not_panic() {
269        // 2018-11-04 in America/Sao_Paulo: DST started, clocks jumped from
270        // midnight to 01:00, so local hour 00:00 does not exist that day.
271        let sp = chrono_tz::America::Sao_Paulo;
272        let hours = WorkingHours {
273            start_hour: 0,
274            end_hour: 18,
275            weekdays: [true, true, true, true, true, true, true],
276        };
277        let start = sp
278            .with_ymd_and_hms(2018, 11, 3, 12, 0, 0)
279            .unwrap()
280            .with_timezone(&Utc);
281        let end = sp
282            .with_ymd_and_hms(2018, 11, 5, 12, 0, 0)
283            .unwrap()
284            .with_timezone(&Utc);
285        let slots = free_slots(&[], (start, end), Duration::minutes(60), &hours, sp, 20);
286        assert!(
287            slots.iter().all(|s| {
288                let d = s.start.with_timezone(&sp).date_naive();
289                d != chrono::NaiveDate::from_ymd_opt(2018, 11, 4).unwrap()
290            }),
291            "expected no slot on the DST-gap day, got {slots:?}"
292        );
293    }
294}