Skip to main content

sioul_core/
reach.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Who reaches you, on which channel (docs/porch.md, "Who may reach you,
5//! and when"), and the clock that says what time it is, ahead.
6//!
7//! Five states, one vocabulary everywhere: a **stranger** (in none of your
8//! address books and on no list), the **blocked** (never, on any channel),
9//! and for the people in your address books three levels: **safe**,
10//! **neutral** (anyone in your address books, unless you chose otherwise) and
11//! **restricted**. Who someone is, the lists and the address books say
12//! (`porch::Senders`): their own entry first, then their card's, its
13//! categories, a domain or a number's prefix.
14//!
15//! When each reaches you is the matrix of what reaches you's to say
16//! (`attention`, docs/attention.md): a row per state on each channel.
17//!
18//! The clock reads no file once made: a phone writes the calls' frames ahead
19//! from it (`calls::frames`), which its call screening reads within milliseconds.
20
21use crate::areas::{Time, Week};
22use crate::config::{Config, TimeOff};
23use crate::needs::{Days, Kept, Needs};
24use crate::porch::Standing;
25use crate::quiet::{Blocks, Mode, Overrides};
26use crate::window::AdminWindow;
27use jiff::{Timestamp, Zoned};
28use serde::{Deserialize, Serialize};
29
30/// How far ahead a clock's events are read, in days.
31pub const HORIZON_DAYS: i64 = 8;
32
33/// Who someone is to you.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum Who {
37    /// In none of your address books and on no list.
38    Stranger,
39    /// Never, on any channel: set aside for good.
40    Blocked,
41    /// Family, friends, chosen colleagues: by default, at any time.
42    Safe,
43    /// Anyone in your address books, unless you chose otherwise.
44    Neutral,
45    /// Those you hear from only at chosen times.
46    Restricted,
47}
48
49impl Who {
50    /// The five, in the order they are shown: the three levels, strangers, the blocked.
51    pub const ALL: [Who; 5] = [Who::Safe, Who::Neutral, Who::Restricted, Who::Stranger, Who::Blocked];
52
53    pub fn id(self) -> &'static str {
54        match self {
55            Who::Stranger => "stranger",
56            Who::Blocked => "blocked",
57            Who::Safe => "safe",
58            Who::Neutral => "neutral",
59            Who::Restricted => "restricted",
60        }
61    }
62
63    pub fn read(text: &str) -> Option<Who> {
64        Who::ALL.into_iter().find(|w| w.id() == text.trim().to_ascii_lowercase())
65    }
66
67    /// The list it is written on; a stranger is on none.
68    pub fn standing(self) -> Option<Standing> {
69        match self {
70            Who::Safe => Some(Standing::Safe),
71            Who::Neutral => Some(Standing::Neutral),
72            Who::Restricted => Some(Standing::Restricted),
73            Who::Blocked => Some(Standing::Blocked),
74            Who::Stranger => None,
75        }
76    }
77
78    /// Who a list makes someone.
79    pub fn of(standing: Standing) -> Who {
80        match standing {
81            Standing::Safe => Who::Safe,
82            Standing::Neutral => Who::Neutral,
83            Standing::Restricted => Who::Restricted,
84            Standing::Blocked => Who::Blocked,
85        }
86    }
87}
88
89/// How someone reaches you.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum Channel {
93    Mail,
94    /// Phone calls (docs/research/call-screening.md).
95    Calls,
96    /// Messages from other apps: texts, chats (docs/research/android-chats.md).
97    Messages,
98}
99
100impl Channel {
101    pub const ALL: [Channel; 3] = [Channel::Mail, Channel::Calls, Channel::Messages];
102
103    pub fn id(self) -> &'static str {
104        match self {
105            Channel::Mail => "mail",
106            Channel::Calls => "calls",
107            Channel::Messages => "messages",
108        }
109    }
110
111    pub fn read(text: &str) -> Option<Channel> {
112        Channel::ALL.into_iter().find(|c| c.id() == text.trim().to_ascii_lowercase())
113    }
114}
115
116/// What the matrix of what reaches you needs of a moment ahead
117/// (`attention::Now::of_moment`): what now is for, the week's hours, the pauses.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct Moment {
120    /// What now is for (`quiet::Mode::time`); the night's wind-down and naps are sleep.
121    pub time: Time,
122    /// Which hours the week has: while admin has none, work time takes its cells; the reverse too.
123    pub week: Week,
124    /// The pause (« En pause »): its own column, whatever the time.
125    pub paused: bool,
126    /// Free time (« Temps libre »): its own column.
127    pub free: bool,
128    /// Free time's "Nothing at all": the states' rows wait, Always through aside.
129    pub nothing: bool,
130}
131
132impl Moment {
133    /// From the time now (`quiet::mode`), and Free time's "Nothing at all" (`pause::nothing_now`).
134    pub fn of(mode: &Mode, nothing: bool) -> Moment {
135        Moment { time: mode.time, week: mode.week, paused: mode.paused(), free: mode.free(), nothing }
136    }
137}
138
139/// A span of time with one moment: from `start` to `end`, Unix seconds.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub struct Frame {
142    pub start: i64,
143    pub end: i64,
144    pub moment: Moment,
145}
146
147/// The time at any instant, as `quiet::mode` decides it, and where it
148/// changes: the hours, time off and the overrides as kept now, Health's meals,
149/// naps and nights for the day asked (with each day's changes), the events
150/// that push meals. The pause stays on until you come back; Free time ends by
151/// itself (the night's start, else midnight).
152#[derive(Debug, Clone)]
153pub struct Clock {
154    windows: Vec<AdminWindow>,
155    time_off: Vec<TimeOff>,
156    overrides: Overrides,
157    needs: Needs,
158    days: Days,
159    held: Vec<(i64, i64)>,
160    /// Free time's "Nothing at all", for this Free time.
161    nothing: bool,
162}
163
164impl Clock {
165    /// From what is read already: the overrides (`quiet.toml`), Health's needs
166    /// and each day's changes, the events' spans with their margins (`plan::event_spans`).
167    pub fn new(config: &Config, overrides: Overrides, needs: Needs, days: Days, held: Vec<(i64, i64)>) -> Clock {
168        let nothing = crate::pause::nothing_now(&overrides, &config.free_time);
169        Clock { windows: config.week_hours(), time_off: config.time_off.clone(), overrides, needs, days, held, nothing }
170    }
171
172    /// Read from the files: the overrides, Health, each day's changes, and the
173    /// events from the day before `now` to `HORIZON_DAYS` after it.
174    pub fn load(config: &Config, now: &Zoned) -> Clock {
175        let overrides = Overrides::load(&Overrides::default_path());
176        let needs = crate::health::Health::load(&crate::health::Health::default_path()).needs;
177        let days = Days::load(&Days::default_path());
178        let stamp = now.timestamp().as_second();
179        let events = crate::agenda::occurrences(stamp - 86_400, stamp + (HORIZON_DAYS + 1) * 86_400);
180        Clock::new(config, overrides, needs, days, crate::plan::event_spans(&events, 0))
181    }
182
183    /// Health's blocks around `at`: its day's and the next.
184    pub fn blocks(&self, at: &Zoned) -> Blocks {
185        Blocks::of(&self.needs, &self.days, &self.held, at)
186    }
187
188    /// The time at `at`.
189    pub fn mode(&self, at: &Zoned) -> Mode {
190        crate::quiet::mode(&self.windows, &self.time_off, &self.overrides, &self.blocks(at), at)
191    }
192
193    /// The matrix's moment at `at`.
194    pub fn moment(&self, at: &Zoned) -> Moment {
195        Moment::of(&self.mode(at), self.nothing)
196    }
197
198    /// The moment at `at`, and when it may change next: the time's own end, or
199    /// a meal's, a nap's or a night's start or end, or Free time's start (a
200    /// table made from midnight after it was pressed), whichever comes first;
201    /// none in the pause, which lasts until you come back.
202    pub fn step(&self, at: &Zoned) -> (Moment, Option<Zoned>) {
203        let blocks = self.blocks(at);
204        let mode = crate::quiet::mode(&self.windows, &self.time_off, &self.overrides, &blocks, at);
205        let moment = Moment::of(&mode, self.nothing);
206        if mode.paused() {
207            return (moment, None);
208        }
209        let stamp = at.timestamp().as_second();
210        let free = self.overrides.free_from().filter(|since| *since > stamp);
211        let edge = blocks.kept.iter().flat_map(|k| [k.start, k.end]).chain(free).filter(|t| *t > stamp).min().and_then(|t| Timestamp::from_second(t).ok()).map(|t| t.to_zoned(at.time_zone().clone()));
212        let next = match (mode.until, edge) {
213            (Some(a), Some(b)) => Some(if b.timestamp() < a.timestamp() { b } else { a }),
214            (a, b) => a.or(b),
215        };
216        (moment, next.filter(|n| n.timestamp() > at.timestamp()))
217    }
218
219    /// The moments from `from` to `until`, each with its span; two spans in a
220    /// row with the same moment are one. A pause lasts to `until`.
221    pub fn frames(&self, from: &Zoned, until: &Zoned) -> Vec<Frame> {
222        let end = until.timestamp().as_second();
223        let mut out: Vec<Frame> = Vec::new();
224        let mut at = from.clone();
225        // A change a minute at most, over the span: never a loop without end.
226        for _ in 0..4096 {
227            let start = at.timestamp().as_second();
228            if start >= end {
229                break;
230            }
231            let (moment, next) = self.step(&at);
232            let stop = next.as_ref().map_or(end, |n| n.timestamp().as_second().min(end));
233            match out.last_mut() {
234                Some(last) if last.moment == moment && last.end == start => last.end = stop,
235                _ => out.push(Frame { start, end: stop, moment }),
236            }
237            match next {
238                Some(next) => at = next,
239                None => break,
240            }
241        }
242        out
243    }
244
245    /// Health's blocks between `from` and `to` (Unix seconds as `Zoned`):
246    /// meals, naps and nights, each once, in order of start.
247    pub fn kept(&self, from: &Zoned, to: &Zoned) -> Vec<Kept> {
248        let (start, end) = (from.timestamp().as_second(), to.timestamp().as_second());
249        let mut out: Vec<Kept> = Vec::new();
250        let mut day = from.date();
251        while let Ok(noon) = day.at(12, 0, 0, 0).to_zoned(from.time_zone().clone()) {
252            if noon.timestamp().as_second() - 86_400 > end {
253                break;
254            }
255            for kept in self.blocks(&noon).kept {
256                if kept.end > start && kept.start < end && !out.iter().any(|k| k.key == kept.key && k.start == kept.start) {
257                    out.push(kept);
258                }
259            }
260            let Ok(next) = day.tomorrow() else { break };
261            day = next;
262        }
263        out.sort_by_key(|k| k.start);
264        out
265    }
266}
267
268/// The country of numbers written without one: the contacts' setting, else
269/// the system's locale, else Sioul's language's (`phones::chosen`).
270pub fn region(config: &Config) -> Option<&'static crate::phones::Region> {
271    let language = config.language.clone().unwrap_or_else(crate::i18n::system_language);
272    // As each language's `qt-locale` says (sioul.ftl): read without loading its sentences.
273    let locale = if language.to_ascii_lowercase().starts_with("fr") { "fr_FR" } else { "en_GB" };
274    crate::phones::chosen(config.contacts.region.as_deref(), locale)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use jiff::tz::TimeZone;
281
282    fn at(text: &str) -> Zoned {
283        text.parse().unwrap()
284    }
285
286    #[test]
287    fn five_states_and_three_channels() {
288        assert_eq!(Who::ALL.map(Who::id), ["safe", "neutral", "restricted", "stranger", "blocked"]);
289        assert_eq!((Who::read(" Stranger "), Who::read("grey")), (Some(Who::Stranger), None));
290        assert_eq!(Who::Stranger.standing(), None);
291        assert_eq!(Who::of(Standing::Restricted), Who::Restricted);
292        assert_eq!(serde_json::to_string(&Who::Stranger).unwrap(), "\"stranger\"");
293        assert_eq!((Channel::read("Calls"), Channel::read("letters")), (Some(Channel::Calls), None));
294    }
295
296    /// Monday to Friday, 9:00–17:00.
297    fn weekdays() -> Config {
298        let text = ["monday", "tuesday", "wednesday", "thursday", "friday"].map(|d| format!("[[window]]\nday = \"{d}\"\nstart = \"09:00\"\nend = \"17:00\"\n")).join("\n");
299        toml::from_str(&text).unwrap()
300    }
301
302    /// Meals and nights as Health first sets them: breakfast 07:50–08:20, lunch
303    /// 12:10–13:00, dinner 19:00–20:00, the night from 22:00 to 07:00.
304    fn health() -> Needs {
305        Needs { meals_on: true, sleep_on: true, ..Needs::default() }
306    }
307
308    #[test]
309    fn the_clock_cuts_at_meals_and_nights() {
310        let config = weekdays();
311        let clock = Clock::new(&config, Overrides::default(), health(), Days::default(), Vec::new());
312        // Friday 2 October 2026 at 10:00: work, until lunch is got ready.
313        let friday = at("2026-10-02T10:00[Europe/Paris]");
314        let (now, next) = clock.step(&friday);
315        assert_eq!((now.time, next.map(|n| n.datetime().to_string())), (Time::Work, Some("2026-10-02T12:10:00".into())));
316        // Done for the day: leisure until Monday, yet the meals and the night still cut it.
317        let done = Overrides { rest_until: Some(at("2026-10-05T09:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
318        let clock = Clock::new(&config, done, health(), Days::default(), Vec::new());
319        let paris = TimeZone::get("Europe/Paris").unwrap();
320        let hour = |s: i64| Timestamp::from_second(s).unwrap().to_zoned(paris.clone()).strftime("%a %H:%M").to_string();
321        let frames = clock.frames(&at("2026-10-02T18:00[Europe/Paris]"), &at("2026-10-03T08:00[Europe/Paris]"));
322        let said: Vec<(String, Time)> = frames.iter().map(|f| (hour(f.start), f.moment.time)).collect();
323        let want = [("Fri 18:00", Time::Leisure), ("Fri 19:00", Time::Meals), ("Fri 20:00", Time::Leisure), ("Fri 22:00", Time::Sleep), ("Sat 07:00", Time::Leisure), ("Sat 07:50", Time::Meals)];
324        assert_eq!(said, want.map(|(h, t)| (h.to_string(), t)).to_vec());
325        assert_eq!(frames.last().map(|f| hour(f.end)), Some("Sat 08:00".into()));
326        assert_eq!(clock.kept(&at("2026-10-02T18:00[Europe/Paris]"), &at("2026-10-03T08:00[Europe/Paris]")).iter().map(|k| k.kind).collect::<Vec<_>>(), ["meal", "sleep", "meal"]);
327        // Free time pressed at 10:30 on a working day: a frame of its own from then, read from midnight.
328        let free = Overrides { free_since: Some(at("2026-10-02T10:30[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
329        let clock = Clock::new(&config, free, health(), Days::default(), Vec::new());
330        let frames = clock.frames(&at("2026-10-02T00:00[Europe/Paris]"), &at("2026-10-02T12:00[Europe/Paris]"));
331        assert_eq!(frames.iter().map(|f| (hour(f.start), f.moment.free)).collect::<Vec<_>>(), [("Fri 00:00".to_string(), false), ("Fri 07:00".into(), false), ("Fri 07:50".into(), false), ("Fri 08:20".into(), false), ("Fri 09:00".into(), false), ("Fri 10:30".into(), true)]);
332        // The pause: one frame to the end, with no end of its own.
333        let paused = Overrides { paused_since: Some(friday.timestamp().as_second() - 60), ..Overrides::default() };
334        let clock = Clock::new(&config, paused, health(), Days::default(), Vec::new());
335        let frames = clock.frames(&friday, &at("2026-10-03T10:00[Europe/Paris]"));
336        assert_eq!((frames.len(), frames[0].moment.paused), (1, true));
337        assert_eq!(clock.step(&friday).1, None);
338    }
339}