1use jiff::civil::{Date, Weekday};
13use jiff::{SignedDuration, Span, Zoned};
14use serde::Deserialize;
15
16#[derive(Debug, Clone, Deserialize)]
19pub struct AdminWindow {
20 pub day: String,
22 pub start: String,
24 #[serde(default)]
26 pub end: Option<String>,
27 #[serde(default)]
28 pub minutes: u32,
29 #[serde(default)]
31 pub kind: Option<String>,
32}
33
34impl AdminWindow {
35 pub fn kind(&self) -> &str {
37 match self.kind.as_deref().map(str::trim) {
38 Some("admin") => "admin",
39 Some("leisure") => "leisure",
40 _ => "work",
41 }
42 }
43
44 fn opening_on(&self, date: Date, now: &Zoned) -> Option<Zoned> {
46 let (hour, minute) = parse_time(&self.start)?;
47 if parse_weekday(&self.day)? != date.weekday() {
48 return None;
49 }
50 let time = jiff::civil::Time::new(hour, minute, 0, 0).ok()?;
52 date.to_datetime(time).to_zoned(now.time_zone().clone()).ok()
53 }
54
55 fn closing(&self, opening: &Zoned) -> Option<Zoned> {
59 let Some((hour, minute)) = self.end.as_deref().and_then(parse_time) else {
60 return opening.checked_add(SignedDuration::from_mins(i64::from(self.minutes))).ok();
61 };
62 let (from_hour, from_minute) = parse_time(&self.start)?;
63 let later = i32::from(hour) * 60 + i32::from(minute) > i32::from(from_hour) * 60 + i32::from(from_minute);
64 let day = if later { opening.date() } else { opening.date().tomorrow().ok()? };
65 let (day, hour) = if hour == 24 { (day.tomorrow().ok()?, 0) } else { (day, hour) };
66 let closing = day.to_datetime(jiff::civil::Time::new(hour, minute, 0, 0).ok()?).to_zoned(opening.time_zone().clone()).ok()?;
67 (closing.timestamp() > opening.timestamp()).then_some(closing)
68 }
69
70 pub fn weekday(&self) -> Option<Weekday> {
72 parse_weekday(&self.day)
73 }
74
75 pub fn end_text(&self) -> String {
77 if let Some(end) = self.end.clone().filter(|e| parse_time(e).is_some()) {
78 return end;
79 }
80 let Some((hour, minute)) = parse_time(&self.start) else { return String::new() };
81 let total = (i64::from(hour) * 60 + i64::from(minute) + i64::from(self.minutes)) % (24 * 60);
82 format!("{:02}:{:02}", total / 60, total % 60)
83 }
84}
85
86pub fn open_day(windows: &[AdminWindow], date: Date) -> bool {
88 windows.iter().any(|w| w.weekday() == Some(date.weekday()))
89}
90
91pub fn default_office_hours() -> Vec<AdminWindow> {
93 ["monday", "tuesday", "wednesday", "thursday", "friday"].iter().map(|d| AdminWindow { day: d.to_string(), start: "09:00".into(), end: Some("17:00".into()), minutes: 0, kind: None }).collect()
94}
95
96pub fn days_open(windows: &[AdminWindow]) -> [bool; 7] {
98 let mut days = [false; 7];
99 for day in windows.iter().filter_map(AdminWindow::weekday) {
100 days[day.to_monday_zero_offset() as usize] = true;
101 }
102 days
103}
104
105const SHORT: [&str; 7] = ["mo", "tu", "we", "th", "fr", "sa", "su"];
106const NAMES: [&str; 7] = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
107
108fn day_index(text: &str) -> Option<usize> {
110 let text = text.trim().to_lowercase();
111 let french = ["lu", "ma", "me", "je", "ve", "sa", "di"];
112 SHORT.iter().position(|d| *d == text).or_else(|| french.iter().position(|d| *d == text)).or_else(|| parse_weekday(&text).map(|w| w.to_monday_zero_offset() as usize))
113}
114
115fn tidy_time(text: &str) -> Option<String> {
117 let text = text.trim().to_lowercase().replace('h', ":");
118 let (hour, minute) = match text.split_once(':') {
119 Some((h, m)) => (h.parse::<u8>().ok()?, if m.is_empty() { 0 } else { m.parse::<u8>().ok()? }),
120 None => (text.parse::<u8>().ok()?, 0),
121 };
122 (hour <= 24 && minute < 60).then(|| format!("{hour:02}:{minute:02}"))
123}
124
125pub fn parse_ranges(text: &str) -> Vec<AdminWindow> {
129 let mut out = Vec::new();
130 for part in text.split(';') {
131 let Some((days, hours)) = part.trim().split_once(char::is_whitespace) else { continue };
132 let mut chosen = Vec::new();
133 for group in days.split(',') {
134 match group.split_once('-') {
135 Some((from, to)) => {
136 if let (Some(from), Some(to)) = (day_index(from), day_index(to)) {
137 let mut day = from;
138 loop {
139 chosen.push(day);
140 if day == to {
141 break;
142 }
143 day = (day + 1) % 7;
144 }
145 }
146 }
147 None => chosen.extend(day_index(group)),
148 }
149 }
150 for range in hours.split(',') {
151 let Some((start, end)) = range.trim().split_once('-') else { continue };
152 let (Some(start), Some(end)) = (tidy_time(start), tidy_time(end)) else { continue };
153 for &day in &chosen {
154 out.push(AdminWindow { day: NAMES[day].into(), start: start.clone(), end: Some(end.clone()), minutes: 0, kind: None });
155 }
156 }
157 }
158 out
159}
160
161pub fn ranges_text(windows: &[AdminWindow]) -> String {
163 let mut hours: Vec<Vec<String>> = vec![Vec::new(); 7];
164 for w in windows {
165 if let Some(day) = w.weekday() {
166 let range = format!("{}-{}", w.start.trim(), w.end_text());
167 let slot = &mut hours[day.to_monday_zero_offset() as usize];
168 if !slot.contains(&range) {
169 slot.push(range);
170 }
171 }
172 }
173 for slot in &mut hours {
174 slot.sort();
175 }
176 let mut parts = Vec::new();
177 let mut day = 0;
178 while day < 7 {
179 if hours[day].is_empty() {
180 day += 1;
181 continue;
182 }
183 let mut last = day;
184 while last + 1 < 7 && hours[last + 1] == hours[day] {
185 last += 1;
186 }
187 let days = if last == day { SHORT[day].to_string() } else { format!("{}-{}", SHORT[day], SHORT[last]) };
188 parts.push(format!("{days} {}", hours[day].join(", ")));
189 day = last + 1;
190 }
191 parts.join("; ")
192}
193
194fn parse_weekday(day: &str) -> Option<Weekday> {
196 match day.trim().to_lowercase().as_str() {
197 "monday" | "mon" | "lundi" => Some(Weekday::Monday),
198 "tuesday" | "tue" | "mardi" => Some(Weekday::Tuesday),
199 "wednesday" | "wed" | "mercredi" => Some(Weekday::Wednesday),
200 "thursday" | "thu" | "jeudi" => Some(Weekday::Thursday),
201 "friday" | "fri" | "vendredi" => Some(Weekday::Friday),
202 "saturday" | "sat" | "samedi" => Some(Weekday::Saturday),
203 "sunday" | "sun" | "dimanche" => Some(Weekday::Sunday),
204 _ => None,
205 }
206}
207
208fn parse_time(text: &str) -> Option<(i8, i8)> {
210 let (hour, minute) = text.trim().split_once(':')?;
211 let (hour, minute): (i8, i8) = (hour.trim().parse().ok()?, minute.trim().parse().ok()?);
212 ((0..24).contains(&hour) && (0..60).contains(&minute) || (hour, minute) == (24, 0)).then_some((hour, minute))
213}
214
215pub fn spans_on(windows: &[AdminWindow], date: Date, zone: &jiff::tz::TimeZone) -> Vec<(Zoned, Zoned)> {
217 let Ok(midnight) = date.to_zoned(zone.clone()) else { return Vec::new() };
218 let mut spans: Vec<(Zoned, Zoned)> = windows
219 .iter()
220 .filter_map(|w| {
221 let opening = w.opening_on(date, &midnight)?;
222 let closing = w.closing(&opening)?;
223 Some((opening, closing))
224 })
225 .collect();
226 spans.sort_by_key(|(opening, _)| opening.timestamp());
227 spans
228}
229
230pub fn kinds_on<'a>(windows: &'a [AdminWindow], date: Date, zone: &jiff::tz::TimeZone) -> Vec<(Zoned, Zoned, &'a str)> {
233 let Ok(midnight) = date.to_zoned(zone.clone()) else { return Vec::new() };
234 let mut spans: Vec<(Zoned, Zoned, &str)> = windows
235 .iter()
236 .filter_map(|w| {
237 let opening = w.opening_on(date, &midnight)?;
238 let closing = w.closing(&opening)?;
239 Some((opening, closing, w.kind()))
240 })
241 .collect();
242 spans.sort_by_key(|(opening, _, _)| opening.timestamp());
243 spans
244}
245
246pub fn hours_on(windows: &[AdminWindow], date: Date, zone: &jiff::tz::TimeZone) -> Option<(Zoned, Zoned)> {
248 let midnight = date.to_zoned(zone.clone()).ok()?;
249 let spans: Vec<(Zoned, Zoned)> = windows
250 .iter()
251 .filter_map(|w| {
252 let opening = w.opening_on(date, &midnight)?;
253 let closing = w.closing(&opening)?;
254 Some((opening, closing))
255 })
256 .collect();
257 let opening = spans.iter().map(|(o, _)| o).min()?.clone();
258 let closing = spans.iter().map(|(_, c)| c).max()?.clone();
259 Some((opening, closing))
260}
261
262pub fn current(windows: &[AdminWindow], now: &Zoned) -> Option<(Zoned, Zoned)> {
264 let days = [now.date().yesterday().ok(), Some(now.date())];
266 windows
267 .iter()
268 .flat_map(|w| days.iter().flatten().filter_map(move |d| {
269 let opening = w.opening_on(*d, now)?;
270 let closing = w.closing(&opening)?;
271 Some((opening, closing))
272 }))
273 .find(|(opening, closing)| opening.timestamp() <= now.timestamp() && now.timestamp() < closing.timestamp())
274}
275
276pub fn next_opening(windows: &[AdminWindow], now: &Zoned) -> Option<Zoned> {
278 (0..8)
279 .filter_map(|d| now.date().checked_add(Span::new().days(d)).ok())
280 .flat_map(|date| windows.iter().filter_map(move |w| w.opening_on(date, now)))
281 .filter(|opening| opening.timestamp() > now.timestamp())
282 .min_by_key(Zoned::timestamp)
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 fn windows() -> Vec<AdminWindow> {
290 vec![
291 AdminWindow { day: "mardi".into(), start: "10:00".into(), end: None, minutes: 45, kind: None },
292 AdminWindow { day: "friday".into(), start: "10:00".into(), end: Some("10:45".into()), minutes: 0, kind: None },
293 ]
294 }
295
296 fn at(text: &str) -> Zoned {
297 text.parse().unwrap()
298 }
299
300 #[test]
301 fn opening_hours_written_short() {
302 let hours = parse_ranges("lu-ve 9h-12h, 14:00-17:00; sa 09:00-12:00");
303 assert_eq!(hours.len(), 11);
304 assert_eq!(ranges_text(&hours), "mo-fr 09:00-12:00, 14:00-17:00; sa 09:00-12:00");
305 assert_eq!(days_open(&hours), [true, true, true, true, true, true, false]);
306 assert_eq!(ranges_text(&parse_ranges("mo,we,fr 10:00-11:30")), "mo 10:00-11:30; we 10:00-11:30; fr 10:00-11:30");
307 assert_eq!(ranges_text(&parse_ranges("sa-mo 08:00-09:00")), "mo 08:00-09:00; sa-su 08:00-09:00", "across the week's end");
308 assert!(parse_ranges("whenever").is_empty());
309 let saturday = at("2026-10-03T10:00[Europe/Paris]");
310 assert!(current(&hours, &saturday).is_some());
311 assert!(current(&hours, &at("2026-10-03T13:00[Europe/Paris]")).is_none(), "closed at lunch");
312 }
313
314 #[test]
315 fn next_and_current() {
316 let w = windows();
317 let morning = at("2026-10-02T09:00[Europe/Paris]");
319 assert_eq!(next_opening(&w, &morning).unwrap().datetime().to_string(), "2026-10-02T10:00:00");
320 assert!(current(&w, &morning).is_none());
321 let during = at("2026-10-02T10:20[Europe/Paris]");
322 assert_eq!(current(&w, &during).unwrap().1.datetime().to_string(), "2026-10-02T10:45:00");
323 let after = at("2026-10-02T11:00[Europe/Paris]");
324 assert_eq!(next_opening(&w, &after).unwrap().datetime().to_string(), "2026-10-06T10:00:00");
325 }
326
327 #[test]
328 fn odd_hours_and_changes_of_hour() {
329 let odd = parse_ranges("mo 24:00-23:00; tu 9:75-10:00");
331 let monday = at("2026-10-05T10:00[Europe/Paris]");
332 assert!(current(&odd, &monday).is_none() && next_opening(&odd, &monday).is_none());
333 let bad = AdminWindow { day: "monday".into(), start: "25:00".into(), end: Some("26:00".into()), minutes: 0, kind: None };
334 assert!(spans_on(&[bad], monday.date(), monday.time_zone()).is_empty());
335 let night = AdminWindow { day: "saturday".into(), start: "22:00".into(), end: Some("06:00".into()), minutes: 0, kind: None };
337 let evening = AdminWindow { day: "saturday".into(), start: "20:00".into(), end: Some("24:00".into()), minutes: 0, kind: None };
338 let spans = spans_on(&[night, evening], "2026-10-24".parse().unwrap(), monday.time_zone());
339 let ends: Vec<String> = spans.iter().map(|(_, c)| c.datetime().to_string()).collect();
340 assert_eq!(ends, vec!["2026-10-25T00:00:00", "2026-10-25T06:00:00"]);
341 }
342}