Skip to main content

sioul_core/
areas.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What things are for, and which time is for what (docs/areas.md).
5//!
6//! Two axes. Who someone is (a stranger, blocked, or safe, neutral,
7//! restricted: `reach::Who`, from the lists and your address books,
8//! `porch::Senders`) says who may reach you, and one matrix
9//! (`attention::Attention`) when.
10//! Areas say what a source (an address, a site, a chat) or a task is for:
11//! work, your own admin, or leisure; and the time says what now is for. Five
12//! times: work and admin (the hours you set), meals and sleep (from Health),
13//! and leisure, every other time. So work does not reach your evenings, and
14//! admin waits for its own hours instead of spreading over your free time:
15//! recovery needs detachment from work (Sonnentag & Fritz 2007, 2015), a
16//! real risk for whoever works from home or for themselves.
17
18use serde::{Deserialize, Serialize};
19
20/// What a source or a task is for: work, your own admin, leisure; any of
21/// them together. An address for everything personal is for admin and
22/// leisure; the bank account a freelancer uses for both, work and admin.
23/// None at all: unsaid.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
25pub struct Area {
26    pub work: bool,
27    /// Your own admin: bills, letters, offices, health errands.
28    pub admin: bool,
29    /// What gives back: friends, family, chats, what you enjoy.
30    pub leisure: bool,
31}
32
33impl Area {
34    pub const WORK: Area = Area { work: true, admin: false, leisure: false };
35    pub const ADMIN: Area = Area { work: false, admin: true, leisure: false };
36    pub const LEISURE: Area = Area { work: false, admin: false, leisure: true };
37    /// Yours, admin or leisure.
38    pub const PERSONAL: Area = Area { work: false, admin: true, leisure: true };
39    /// Work and your own admin.
40    pub const MIXED: Area = Area { work: true, admin: true, leisure: false };
41    /// Every kind: hours open to anything (no hours set).
42    pub const ALL: Area = Area { work: true, admin: true, leisure: true };
43
44    /// "work", "admin", "leisure", joined by "+" or ","; "personal" is admin
45    /// and leisure, "mixed" work and admin; French words too. None when it says none.
46    pub fn parse(text: &str) -> Option<Area> {
47        let mut area = Area::default();
48        for word in text.split(['+', ',', ' ', '/']).map(str::trim).filter(|w| !w.is_empty()) {
49            match word.to_lowercase().as_str() {
50                "work" | "travail" | "pro" => area.work = true,
51                "admin" | "démarches" | "demarches" => area.admin = true,
52                "leisure" | "loisirs" | "fun" | "rest" => area.leisure = true,
53                "personal" | "perso" | "personnel" => (area.admin, area.leisure) = (true, true),
54                "mixed" => (area.work, area.admin) = (true, true),
55                _ => {}
56            }
57        }
58        (!area.is_empty()).then_some(area)
59    }
60
61    /// As written in files: "work", "admin+leisure"; "" for none.
62    pub fn id(self) -> String {
63        [(self.work, "work"), (self.admin, "admin"), (self.leisure, "leisure")].iter().filter(|(on, _)| *on).map(|(_, w)| *w).collect::<Vec<_>>().join("+")
64    }
65
66    pub fn is_empty(self) -> bool {
67        !(self.work || self.admin || self.leisure)
68    }
69
70    /// Both together.
71    pub fn with(self, other: Area) -> Area {
72        Area { work: self.work || other.work, admin: self.admin || other.admin, leisure: self.leisure || other.leisure }
73    }
74
75    /// Whether the two share a kind.
76    pub fn meets(self, other: Area) -> bool {
77        (self.work && other.work) || (self.admin && other.admin) || (self.leisure && other.leisure)
78    }
79
80    /// How many kinds it holds, none to three.
81    pub fn count(self) -> u32 {
82        u32::from(self.work) + u32::from(self.admin) + u32::from(self.leisure)
83    }
84}
85
86impl Serialize for Area {
87    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
88        serializer.serialize_str(&self.id())
89    }
90}
91
92impl<'de> serde::Deserialize<'de> for Area {
93    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Area, D::Error> {
94        let text = String::deserialize(deserializer)?;
95        Area::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("{text}: work, admin, leisure, joined by +")))
96    }
97}
98
99/// What now is for (docs/areas.md): one of five times, or several hours at
100/// once, or no hours set at all.
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum Time {
104    /// Working hours, and the work you asked for outside them ("A little longer", "Work now").
105    Work,
106    /// Hours for your own admin.
107    Admin,
108    /// Every other time: evenings, days without hours, time off, a day closed
109    /// early. What you enjoy; work and admin wait.
110    Leisure,
111    /// A meal, from getting it ready to its end (Health).
112    Meals,
113    /// The night, from winding down to waking, and naps (Health): nothing disturbs.
114    Sleep,
115    /// No working or admin hours set at all: everything comes, as before any
116    /// were set (meals and sleep still keep their time).
117    #[default]
118    Any,
119    /// Working and admin hours at once: what each of them brings comes, all together.
120    Several(Area),
121}
122
123impl Time {
124    /// The five times, in the order the matrix of who may write to you shows them.
125    pub const STATES: [Time; 5] = [Time::Work, Time::Admin, Time::Leisure, Time::Meals, Time::Sleep];
126
127    pub fn parse(text: &str) -> Option<Time> {
128        match text.trim().to_lowercase().as_str() {
129            "work" | "travail" => Some(Time::Work),
130            "admin" | "démarches" | "demarches" => Some(Time::Admin),
131            // "rest" and "personal" were the words for time outside every hours set.
132            "leisure" | "loisirs" | "free" | "rest" | "personal" | "perso" => Some(Time::Leisure),
133            "meals" | "meal" | "repas" => Some(Time::Meals),
134            "sleep" | "sommeil" | "night" | "nuit" => Some(Time::Sleep),
135            "any" => Some(Time::Any),
136            several if several.contains('+') => Area::parse(several).map(Time::of),
137            _ => None,
138        }
139    }
140
141    pub fn id(self) -> &'static str {
142        match self {
143            Time::Work => "work",
144            Time::Admin => "admin",
145            Time::Leisure => "leisure",
146            Time::Meals => "meals",
147            Time::Sleep => "sleep",
148            Time::Any => "any",
149            // Only work and admin have hours: they are the ones to overlap.
150            Time::Several(_) => "work+admin",
151        }
152    }
153
154    /// The time when the hours of `open` are open: work's, admin's, or both
155    /// at once; neither is leisure. Only work and admin have hours.
156    pub fn of(open: Area) -> Time {
157        match (open.work, open.admin) {
158            (false, false) => Time::Leisure,
159            (true, false) => Time::Work,
160            (false, true) => Time::Admin,
161            (true, true) => Time::Several(Area::MIXED),
162        }
163    }
164
165    /// Whether work's hours are among those open now.
166    pub fn works(self) -> bool {
167        matches!(self, Time::Work) || matches!(self, Time::Several(open) if open.work)
168    }
169
170    /// Whether offices keep hours now: work's or admin's.
171    pub fn offices(self) -> bool {
172        matches!(self, Time::Work | Time::Admin | Time::Any) || matches!(self, Time::Several(open) if open.work || open.admin)
173    }
174}
175
176/// Which times your week holds: work and admin when they have hours (until
177/// admin has hours of its own, it comes in work time as it always did; until
178/// work has some, it comes in admin's), meals and sleep when Health sets them.
179#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
180pub struct Week {
181    pub work_hours: bool,
182    pub admin_hours: bool,
183    /// Meals are set (Health).
184    pub meals: bool,
185    /// A night or naps are set (Health).
186    pub sleep: bool,
187}
188
189impl Week {
190    /// Whether `time` comes in this week: leisure always does.
191    pub fn has(self, time: Time) -> bool {
192        match time {
193            Time::Work => self.work_hours,
194            Time::Admin => self.admin_hours,
195            Time::Meals => self.meals,
196            Time::Sleep => self.sleep,
197            Time::Leisure | Time::Any => true,
198            Time::Several(open) => (open.work && self.work_hours) || (open.admin && self.admin_hours),
199        }
200    }
201}
202
203/// Whether something for `area` comes forward in `time`: for one of the
204/// things the time is for. Admin without hours of its own comes in work
205/// time; work without hours comes in admin's. Leisure, meals and sleep bring
206/// what is for leisure (a chat with friends), nothing else: a meal is a
207/// break, and during sleep nothing notifies anyway, as usual (`attention`);
208/// no task then (`quiet::QuietTasks`). Mail has its own rule, by who wrote
209/// (`attention::Attention::mail`).
210pub fn in_view(area: Area, time: Time, week: Week) -> bool {
211    match time {
212        Time::Work => area.work || (!week.admin_hours && area.admin),
213        Time::Admin => area.admin || (!week.work_hours && area.work),
214        Time::Leisure | Time::Meals | Time::Sleep => area.leisure,
215        Time::Any => true,
216        // Working and admin hours at once: what each of them brings.
217        Time::Several(open) => (open.work && in_view(area, Time::Work, week)) || (open.admin && in_view(area, Time::Admin, week)),
218    }
219}
220
221/// How tasks without an area of their own get one: by their categories and
222/// projects. Work first; then what is yours to enjoy; then what is yours either
223/// way (health: an errand, but never set aside on holidays); the rest is admin.
224#[derive(Debug, Clone, Default, PartialEq, Eq)]
225pub struct TaskAreas {
226    pub work_categories: Vec<String>,
227    pub leisure_categories: Vec<String>,
228    pub personal_categories: Vec<String>,
229    /// Projects for a client, and those marked work.
230    pub work_projects: Vec<String>,
231    /// Projects marked personal (family, friends, leisure, health).
232    pub personal_projects: Vec<String>,
233}
234
235impl TaskAreas {
236    /// The usual words: those in use (`words::current`; the packs built in,
237    /// French and English, until a configuration is read).
238    pub fn usual() -> TaskAreas {
239        TaskAreas::usual_in(&crate::words::current().tasks)
240    }
241
242    /// The usual words of these lists: work, leisure, and what is yours outside work.
243    pub fn usual_in(words: &crate::words::TaskWords) -> TaskAreas {
244        TaskAreas {
245            work_categories: words.work.clone(),
246            leisure_categories: words.leisure.clone(),
247            personal_categories: words.usual_personal.clone(),
248            work_projects: Vec::new(),
249            personal_projects: Vec::new(),
250        }
251    }
252
253    /// As your settings say: the categories of work and those that are yours
254    /// (`[quiet]`, else the languages' own: `words.tasks`; leisure among them
255    /// goes to leisure), and your projects' areas.
256    pub fn of_config(config: &crate::config::Config, projects: &[crate::projects::Project]) -> TaskAreas {
257        let words = crate::words::Words::of(config);
258        let leisure: Vec<String> = words.tasks.leisure.iter().map(|c| crate::words::folded(c)).collect();
259        let (leisure_categories, personal_categories): (Vec<String>, Vec<String>) = words.tasks.personal.iter().cloned().partition(|c| leisure.contains(&crate::words::folded(c)));
260        TaskAreas {
261            work_categories: words.tasks.work.clone(),
262            leisure_categories,
263            personal_categories,
264            work_projects: projects.iter().filter(|c| c.client.is_some() || c.area.as_deref() == Some("work")).map(|c| c.id.clone()).collect(),
265            personal_projects: projects.iter().filter(|c| c.area.as_deref() == Some("personal")).map(|c| c.id.clone()).collect(),
266        }
267    }
268
269    /// A task's area: its own (`X-SIOUL-AREA`), else what its categories,
270    /// projects and list say, together; else your own admin.
271    pub fn of(&self, task: &crate::tasks::Task) -> Area {
272        Area::parse(&task.area).unwrap_or_else(|| self.by_tags(task))
273    }
274
275    /// What a task's categories, projects and list say, its own area aside.
276    pub fn by_tags(&self, task: &crate::tasks::Task) -> Area {
277        let fold = |s: &str| crate::text::fold(s.trim()).into_iter().collect::<String>();
278        let has = |list: &[String]| {
279            let list: Vec<String> = list.iter().map(|c| fold(c)).collect();
280            task.categories.iter().any(|c| list.contains(&fold(c)))
281        };
282        let mut area = Area::default();
283        if has(&self.work_categories) || task.projects.iter().any(|c| self.work_projects.contains(c)) || task.list_id.starts_with("local/github") {
284            area = area.with(Area::WORK);
285        }
286        if has(&self.leisure_categories) {
287            area = area.with(Area::LEISURE);
288        }
289        if has(&self.personal_categories) || task.projects.iter().any(|c| self.personal_projects.contains(c)) {
290            area = area.with(Area::PERSONAL);
291        }
292        if area.is_empty() { Area::ADMIN } else { area }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn what_comes_when() {
302        let set = Week { work_hours: true, admin_hours: true, meals: true, sleep: true };
303        assert!(in_view(Area::WORK, Time::Work, set));
304        assert!(!in_view(Area::WORK, Time::Leisure, set), "never in leisure");
305        assert!(!in_view(Area::WORK, Time::Meals, set) && !in_view(Area::WORK, Time::Sleep, set), "nor while you eat or sleep");
306        assert!(!in_view(Area::ADMIN, Time::Work, set), "admin has its own hours");
307        assert!(in_view(Area::ADMIN, Time::Work, Week::default()), "until it has some");
308        assert!(in_view(Area::ADMIN, Time::Admin, set) && !in_view(Area::ADMIN, Time::Leisure, set), "leisure is free");
309        assert!(in_view(Area::LEISURE, Time::Sleep, set) && in_view(Area::LEISURE, Time::Meals, set), "a chat with friends");
310        assert!(in_view(Area::LEISURE, Time::Leisure, set) && !in_view(Area::LEISURE, Time::Admin, set) && !in_view(Area::LEISURE, Time::Work, set));
311        assert!(in_view(Area::PERSONAL, Time::Leisure, set) && in_view(Area::PERSONAL, Time::Admin, set) && !in_view(Area::PERSONAL, Time::Work, set));
312        assert!(in_view(Area::MIXED, Time::Work, set) && in_view(Area::MIXED, Time::Admin, set) && !in_view(Area::MIXED, Time::Leisure, set));
313        // Work and leisure together, a chat for both: in both their times.
314        let both = Area::WORK.with(Area::LEISURE);
315        assert!(in_view(both, Time::Work, set) && in_view(both, Time::Leisure, set) && !in_view(both, Time::Admin, set));
316        let no_work = Week { admin_hours: true, ..Week::default() };
317        assert!(in_view(Area::WORK, Time::Admin, no_work), "no work hours: work comes in admin's");
318        assert!(!in_view(Area::WORK, Time::Leisure, no_work) && !in_view(Area::WORK, Time::Sleep, no_work), "leisure and sleep stay free");
319        // Working and admin hours at once: both come.
320        let both = Time::of(Area::MIXED);
321        assert_eq!(both, Time::Several(Area::MIXED));
322        assert!(in_view(Area::ADMIN, both, set) && in_view(Area::WORK, both, set) && !in_view(Area::LEISURE, both, set));
323        assert_eq!((both.id(), Time::parse("work+admin")), ("work+admin", Some(both)));
324        assert!(both.offices() && both.works());
325        // Leisure has no hours: a window for it is none.
326        assert_eq!(Time::of(Area::LEISURE), Time::Leisure);
327        assert_eq!(Time::of(Area::PERSONAL), Time::Admin);
328        assert!(in_view(Area::LEISURE, Time::Any, Week::default()) && in_view(Area::WORK, Time::Any, Week::default()));
329        // The older words read; each time says its own.
330        assert_eq!((Time::parse("rest"), Time::parse("personal"), Time::parse("repas"), Time::parse("Sommeil")), (Some(Time::Leisure), Some(Time::Leisure), Some(Time::Meals), Some(Time::Sleep)));
331        assert_eq!(Time::STATES.map(Time::id), ["work", "admin", "leisure", "meals", "sleep"]);
332        assert!(!Time::Meals.offices() && !Time::Sleep.offices() && !Time::Leisure.works());
333        assert!(set.has(Time::Sleep) && !Week::default().has(Time::Meals) && Week::default().has(Time::Leisure));
334    }
335
336    #[test]
337    fn areas_read() {
338        assert_eq!(Area::parse(" Démarches "), Some(Area::ADMIN));
339        assert_eq!(Area::parse("work"), Some(Area::WORK));
340        assert_eq!(Area::parse("personal"), Some(Area::PERSONAL), "the older word");
341        assert_eq!(Area::parse("admin+leisure"), Some(Area::PERSONAL));
342        assert_eq!(Area::parse("LEISURE,WORK"), Some(Area::WORK.with(Area::LEISURE)));
343        assert_eq!(Area::parse(""), None);
344        assert_eq!(Area::MIXED.id(), "work+admin");
345        let areas = TaskAreas::usual();
346        let task = |categories: &[&str], area: &str| crate::tasks::Task { categories: categories.iter().map(|c| c.to_string()).collect(), area: area.into(), ..crate::tasks::Task::default() };
347        assert_eq!(areas.of(&task(&["Famille"], "")), Area::LEISURE);
348        assert_eq!(areas.of(&task(&["client"], "")), Area::WORK);
349        assert_eq!(areas.of(&task(&["santé"], "")), Area::PERSONAL);
350        assert_eq!(areas.of(&task(&["admin"], "")), Area::ADMIN);
351        assert_eq!(areas.of(&task(&[], "")), Area::ADMIN, "the rest is admin");
352        assert_eq!(areas.of(&task(&["client", "famille"], "")), Area::WORK.with(Area::LEISURE), "together");
353        assert_eq!(areas.of(&task(&["client"], "leisure")), Area::LEISURE, "its own first");
354    }
355}