1use 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#[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
39pub 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 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 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 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 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 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}