1use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
25pub struct Area {
26 pub work: bool,
27 pub admin: bool,
29 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 pub const PERSONAL: Area = Area { work: false, admin: true, leisure: true };
39 pub const MIXED: Area = Area { work: true, admin: true, leisure: false };
41 pub const ALL: Area = Area { work: true, admin: true, leisure: true };
43
44 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 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 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 pub fn meets(self, other: Area) -> bool {
77 (self.work && other.work) || (self.admin && other.admin) || (self.leisure && other.leisure)
78 }
79
80 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum Time {
104 Work,
106 Admin,
108 Leisure,
111 Meals,
113 Sleep,
115 #[default]
118 Any,
119 Several(Area),
121}
122
123impl Time {
124 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 "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 Time::Several(_) => "work+admin",
151 }
152 }
153
154 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 pub fn works(self) -> bool {
167 matches!(self, Time::Work) || matches!(self, Time::Several(open) if open.work)
168 }
169
170 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
180pub struct Week {
181 pub work_hours: bool,
182 pub admin_hours: bool,
183 pub meals: bool,
185 pub sleep: bool,
187}
188
189impl Week {
190 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
203pub 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 Time::Several(open) => (open.work && in_view(area, Time::Work, week)) || (open.admin && in_view(area, Time::Admin, week)),
218 }
219}
220
221#[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 pub work_projects: Vec<String>,
231 pub personal_projects: Vec<String>,
233}
234
235impl TaskAreas {
236 pub fn usual() -> TaskAreas {
239 TaskAreas::usual_in(&crate::words::current().tasks)
240 }
241
242 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 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 pub fn of(&self, task: &crate::tasks::Task) -> Area {
272 Area::parse(&task.area).unwrap_or_else(|| self.by_tags(task))
273 }
274
275 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 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 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 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 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}