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(
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 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 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 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}