Skip to main content

sioul_core/
window.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Working hours: the days and hours work can reach you, each day on or off,
5//! from a start to an end. Mail keeps arriving in the background; the Porch
6//! shows it only in these hours and always says when they open again; outside
7//! them, quiet time (`quiet.rs`). Predictable looks, not silence: in a field
8//! trial, people given no notifications at all felt more anxious, while
9//! batching them three times a day helped (Fitz et al. 2019; docs/research.md).
10//! The same rows say when offices are open, for tasks that need one.
11
12use jiff::civil::{Date, Weekday};
13use jiff::{SignedDuration, Span, Zoned};
14use serde::Deserialize;
15
16/// One day's hours, as written in the configuration: `day`, `start`, `end`
17/// ("tuesday", "09:00", "17:00"); older files say `minutes` instead of `end`.
18#[derive(Debug, Clone, Deserialize)]
19pub struct AdminWindow {
20    /// "tuesday", "tue" or "mardi".
21    pub day: String,
22    /// "09:00".
23    pub start: String,
24    /// "17:00"; before the start, it ends the next day.
25    #[serde(default)]
26    pub end: Option<String>,
27    #[serde(default)]
28    pub minutes: u32,
29    /// What the hours are for: "work" when unsaid, "admin" or "leisure" (docs/areas.md).
30    #[serde(default)]
31    pub kind: Option<String>,
32}
33
34impl AdminWindow {
35    /// What its hours are for: "work", "admin" or "leisure".
36    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    /// This window's opening on `date`, if `date` is its weekday.
45    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        // "24:00" ends a day; it opens none.
51        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    /// When it closes after `opening`: its end as the clock says it, the next
56    /// day when it comes before the start ("24:00" is midnight), so a change
57    /// of hour in between moves nothing; older windows, `minutes` after.
58    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    /// Its weekday, when the day reads as one.
71    pub fn weekday(&self) -> Option<Weekday> {
72        parse_weekday(&self.day)
73    }
74
75    /// Its end, "17:00", from `end` or `minutes`.
76    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
86/// Whether `windows` have any hours on `date`'s weekday.
87pub fn open_day(windows: &[AdminWindow], date: Date) -> bool {
88    windows.iter().any(|w| w.weekday() == Some(date.weekday()))
89}
90
91/// Offices' usual hours when the configuration says none: Monday to Friday, 9:00 to 17:00.
92pub 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
96/// The days a set of hours opens, Monday first.
97pub 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
108/// "mo", "lu", "monday", "lundi": a day, Monday 0.
109fn 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
115/// "09:00", from "9:00" or "9h00" or "9".
116fn 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
125/// Opening hours written short, as a task keeps its office's: "mo-fr 09:00-17:00",
126/// "mo-fr 09:00-12:00, 14:00-17:00; sa 09:00-12:00". Days in two letters,
127/// English or French ("lu-ve"), or named; a range, or a list with commas.
128pub 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
161/// Hours as `parse_ranges` reads them, days with the same hours together: "mo-fr 09:00-17:00".
162pub 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
194/// English and French day names, full or in three letters.
195fn 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
208/// "09:30" → (9, 30); "24:00" ends a day; anything past it, or not a time, is none.
209fn 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
215/// Each window of `date`, as (opening, closing), the earliest first.
216pub 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
230/// Each window of `date` with what its hours are for, as (opening, closing,
231/// kind: "work", "admin", "leisure"), the earliest first.
232pub 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
246/// The hours of `date`, from its first opening to its last closing, when it has any.
247pub 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
262/// The window open right now, as (opening, closing).
263pub fn current(windows: &[AdminWindow], now: &Zoned) -> Option<(Zoned, Zoned)> {
264    // A window opened yesterday may still be open after midnight.
265    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
276/// When the next window opens, within the coming week.
277pub 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        // 2 October 2026 is a Friday.
318        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        // A task's office hours come from its file: "24:00" as a start, or no time at all, opens nothing.
330        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        // A night across the change of hour (Sunday 25 October 2026) ends at 6:00 on the clock; "24:00" is midnight.
336        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}