1use crate::areas::{Area, TaskAreas, Time, Week, in_view};
25use crate::config::TimeOff;
26use crate::window::{self, AdminWindow};
27use jiff::civil::Date;
28use jiff::{Span, Zoned};
29use serde::{Deserialize, Serialize};
30use std::path::{Path, PathBuf};
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34#[serde(rename_all = "kebab-case")]
35pub enum Reason {
36 Working,
38 NoHours,
40 WorkingLate,
42 WorkNow,
44 Evening,
46 DayOff,
48 TimeOff,
50 DoneForTheDay,
52 AdminTime,
54 Meal,
56 Sleep,
58 WindingDown,
60 Nap,
62 FreeTime,
64 Paused,
66 Extended,
68}
69
70#[derive(Debug, Clone, PartialEq)]
72pub struct Mode {
73 pub quiet: bool,
75 pub time: Time,
77 pub week: Week,
79 pub reason: Reason,
80 pub until: Option<Zoned>,
82 pub back: Option<Zoned>,
85 pub label: String,
87}
88
89impl Mode {
90 pub fn sleeps(&self) -> bool {
93 self.time == Time::Sleep
94 }
95
96 pub fn paused(&self) -> bool {
98 self.reason == Reason::Paused
99 }
100
101 pub fn free(&self) -> bool {
103 self.reason == Reason::FreeTime
104 }
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
109pub struct Overrides {
110 #[serde(skip)]
112 pub read: crate::filelock::Read,
113 #[serde(default)]
115 pub work_until: Option<i64>,
116 #[serde(default)]
118 pub rest_until: Option<i64>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub work_now: Option<i64>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub rest_from: Option<i64>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub first_step: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub first_step_task: Option<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub first_step_day: Option<Date>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub free_since: Option<i64>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub free_ended: Option<i64>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub free_nothing: Option<bool>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub free_day: Option<Date>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub free_lost: Option<u32>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub extended_until: Option<i64>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub keep_end_on: Option<Date>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub paused_since: Option<i64>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub paused_ended: Option<i64>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub pause_saw: Option<i64>,
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub lighter: Vec<Date>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub porch_rests_until: Option<i64>,
172}
173
174impl Overrides {
175 pub fn put_back(&self, previous: &Overrides, after: &Overrides) -> Overrides {
183 let table = |o: &Overrides| match toml::Value::try_from(o) {
184 Ok(toml::Value::Table(table)) => table,
185 _ => toml::Table::new(),
186 };
187 let (was, act, now) = (table(previous), table(after), table(self));
188 let mut out = now.clone();
189 let keys: std::collections::BTreeSet<&String> = was.keys().chain(act.keys()).collect();
190 let no_list = Vec::new();
191 for key in keys {
192 match (was.get(key), act.get(key)) {
193 (before, done) if before == done => {}
194 (before @ (Some(toml::Value::Array(_)) | None), done @ (Some(toml::Value::Array(_)) | None)) => {
195 let list = |v: Option<&toml::Value>| v.and_then(toml::Value::as_array).cloned().unwrap_or_default();
196 let back = crate::settings::rebased(&list(done), &list(before), now.get(key).and_then(toml::Value::as_array).unwrap_or(&no_list));
197 if back.is_empty() {
198 out.remove(key);
199 } else {
200 out.insert(key.clone(), toml::Value::Array(back));
201 }
202 }
203 (_, done) if now.get(key) != done => {}
205 (Some(before), _) => {
206 out.insert(key.clone(), before.clone());
207 }
208 (None, _) => {
209 out.remove(key);
210 }
211 }
212 }
213 let mut back: Overrides = toml::Value::Table(out).try_into().unwrap_or_else(|_| self.clone());
214 back.read = self.read.clone();
215 back
216 }
217
218 pub fn first_step_on(&self, today: Date) -> Option<(&str, &str)> {
220 let step = self.first_step.as_deref().filter(|s| !s.trim().is_empty())?;
221 self.first_step_day.is_none_or(|d| d == today).then(|| (step, self.first_step_task.as_deref().unwrap_or("")))
222 }
223
224 pub fn closed_today(&self, now: &Zoned) -> bool {
226 self.rest_from.and_then(|t| jiff::Timestamp::from_second(t).ok()).is_some_and(|t| t.to_zoned(now.time_zone().clone()).date() == now.date())
227 }
228}
229
230impl Overrides {
231 pub fn default_path() -> PathBuf {
232 crate::config::state_dir().join("quiet.toml")
233 }
234
235 pub fn load(path: &Path) -> Overrides {
236 let text = std::fs::read_to_string(path).unwrap_or_default();
237 let mut overrides: Overrides = toml::from_str(&text).unwrap_or_default();
238 overrides.read = crate::filelock::Read::of(&text);
239 overrides
240 }
241
242 pub fn save(&self, path: &Path) -> Result<(), String> {
245 crate::filelock::save_merged(path, &self.read, self)
246 }
247}
248
249pub fn time_off_on(time_off: &[TimeOff], date: Date) -> Option<&TimeOff> {
251 time_off.iter().find(|t| t.from <= date && date <= t.until)
252}
253
254pub fn next_work(windows: &[AdminWindow], time_off: &[TimeOff], now: &Zoned) -> Option<Zoned> {
256 let mut from = now.clone();
257 for _ in 0..60 {
258 let opening = window::next_opening(windows, &from)?;
259 match time_off_on(time_off, opening.date()) {
260 None => return Some(opening),
261 Some(off) => {
263 let after = off.until.checked_add(Span::new().days(1)).ok()?;
264 from = after.to_zoned(now.time_zone().clone()).ok()?.checked_sub(Span::new().seconds(1)).ok()?;
265 }
266 }
267 }
268 None
269}
270
271pub fn after_today(windows: &[AdminWindow], time_off: &[TimeOff], now: &Zoned, morning: i8) -> Zoned {
274 let Some(tomorrow) = now.date().tomorrow().ok().and_then(|d| d.to_zoned(now.time_zone().clone()).ok()) else {
275 return now.clone();
276 };
277 let at_morning = jiff::civil::Time::new(morning, 0, 0, 0).ok().and_then(|t| tomorrow.date().to_datetime(t).to_zoned(now.time_zone().clone()).ok());
279 next_work(windows, time_off, &tomorrow).unwrap_or_else(|| at_morning.unwrap_or(tomorrow))
280}
281
282pub fn end_of_next_workday(work: &[AdminWindow], time_off: &[TimeOff], now: &Zoned) -> Zoned {
286 let zone = now.time_zone();
287 if time_off_on(time_off, now.date()).is_none()
288 && let Some((_, closing)) = window::hours_on(work, now.date(), zone).filter(|(_, closing)| closing.timestamp() > now.timestamp())
289 {
290 return closing;
291 }
292 if let Some(opening) = next_work(work, time_off, now)
293 && let Some((_, closing)) = window::hours_on(work, opening.date(), zone)
294 {
295 return closing;
296 }
297 now.date().tomorrow().ok().and_then(|d| d.to_zoned(zone.clone()).ok()).unwrap_or_else(|| now.clone())
298}
299
300pub fn until_text(tr: &crate::i18n::Translator, until: &Zoned, now: &Zoned) -> String {
302 let mut args = crate::i18n::args();
303 args.set("time", until.strftime("%H:%M").to_string());
304 if until.date() == now.date() {
305 until.strftime("%H:%M").to_string()
306 } else if now.date().tomorrow().is_ok_and(|d| d == until.date()) {
307 tr.text("until-tomorrow", Some(&args))
308 } else {
309 tr.date(until, false)
310 }
311}
312
313#[derive(Debug, Clone, Default, PartialEq)]
317pub struct Blocks {
318 pub kept: Vec<crate::needs::Kept>,
321 pub meals: bool,
323 pub sleep: bool,
325 pub night: bool,
328}
329
330impl Blocks {
331 pub fn of(needs: &crate::needs::Needs, days: &crate::needs::Days, held: &[(i64, i64)], now: &Zoned) -> Blocks {
334 let kept = needs.kept_around(now, days, held);
335 let meals = (needs.meals_on && needs.meals.iter().any(|m| m.on)) || kept.iter().any(|k| k.kind == "meal");
336 let naps = (needs.naps_on && needs.naps.iter().any(|n| n.on)) || kept.iter().any(|k| k.kind == "nap");
337 Blocks { kept, meals, sleep: needs.sleep_on || naps, night: needs.sleep_on }
338 }
339
340 pub fn read(now: &Zoned, events: &[crate::agenda::Occurrence]) -> Blocks {
342 let needs = crate::health::Health::load(&crate::health::Health::default_path()).needs;
343 let days = crate::needs::Days::load(&crate::needs::Days::default_path());
344 Blocks::of(&needs, &days, &crate::plan::event_spans(events, 0), now)
345 }
346
347 pub fn read_now(now: &Zoned) -> Blocks {
349 let midnight = now.date().to_zoned(now.time_zone().clone()).map_or_else(|_| now.timestamp().as_second(), |z| z.timestamp().as_second());
350 Blocks::read(now, &crate::agenda::occurrences(midnight - 86_400, midnight + 2 * 86_400))
351 }
352
353 pub fn at(&self, stamp: i64, kinds: &[&str]) -> Option<&crate::needs::Kept> {
355 self.kept.iter().find(|k| kinds.contains(&k.kind) && k.start <= stamp && stamp < k.end)
356 }
357
358 fn next_start(&self, stamp: i64) -> Option<i64> {
360 self.kept.iter().map(|k| k.start).filter(|start| *start > stamp).min()
361 }
362}
363
364struct ByHours {
366 time: Time,
367 reason: Reason,
368 until: Option<Zoned>,
369 label: String,
370}
371
372fn by_hours(windows: &[AdminWindow], time_off: &[TimeOff], overrides: &Overrides, now: &Zoned) -> ByHours {
375 let of = |kind: &str| -> Vec<AdminWindow> { windows.iter().filter(|w| w.kind() == kind).cloned().collect() };
376 let (work, admin) = (of("work"), of("admin"));
377 let at = |seconds: i64| jiff::Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(now.time_zone().clone()));
378 let made = |time: Time, reason: Reason, until: Option<Zoned>| ByHours { time, reason, until, label: String::new() };
379 let stamp = now.timestamp().as_second();
380 if let Some(rest) = overrides.rest_until.filter(|r| *r > stamp) {
381 return made(Time::Leisure, Reason::DoneForTheDay, at(rest));
382 }
383 if let Some(late) = overrides.work_until.filter(|w| *w > stamp) {
384 return made(Time::Work, Reason::WorkingLate, at(late));
385 }
386 if let Some(until) = overrides.work_now.filter(|w| *w > stamp) {
387 return made(Time::Work, Reason::WorkNow, at(until));
388 }
389 if windows.is_empty() && time_off.is_empty() {
390 return made(Time::Any, Reason::NoHours, None);
391 }
392 if let Some(off) = time_off_on(time_off, now.date()) {
393 return ByHours { time: Time::Leisure, reason: Reason::TimeOff, until: next_work(windows, time_off, now), label: off.label.clone() };
394 }
395 if windows.is_empty() {
396 return made(Time::Any, Reason::NoHours, None);
397 }
398 let (at_work, at_admin) = (window::current(&work, now), window::current(&admin, now));
400 let closing = [&at_work, &at_admin].into_iter().flatten().map(|(_, c)| c.clone()).min_by_key(Zoned::timestamp);
401 match Time::of(Area { work: at_work.is_some(), admin: at_admin.is_some(), leisure: false }) {
402 Time::Admin => made(Time::Admin, Reason::AdminTime, closing),
403 Time::Leisure => {
404 if let Some(end) = overrides.moved_end(now.date()).filter(|end| *end > stamp)
407 && window::hours_on(&work, now.date(), now.time_zone()).is_some_and(|(_, closing)| closing.timestamp().as_second() <= stamp)
408 {
409 return made(Time::Work, Reason::Extended, at(end));
410 }
411 let reason = if window::open_day(&work, now.date()) { Reason::Evening } else { Reason::DayOff };
412 made(Time::Leisure, reason, next_work(windows, time_off, now))
414 }
415 open => made(open, Reason::Working, closing),
417 }
418}
419
420fn earliest(a: Option<Zoned>, b: Option<Zoned>) -> Option<Zoned> {
422 match (a, b) {
423 (Some(a), Some(b)) => Some(if b.timestamp() < a.timestamp() { b } else { a }),
424 (a, b) => a.or(b),
425 }
426}
427
428pub fn mode(windows: &[AdminWindow], time_off: &[TimeOff], overrides: &Overrides, blocks: &Blocks, now: &Zoned) -> Mode {
436 let windows: Vec<AdminWindow> = windows.iter().filter(|w| w.kind() != "leisure").cloned().collect();
437 let week = Week { work_hours: windows.iter().any(|w| w.kind() == "work"), admin_hours: windows.iter().any(|w| w.kind() == "admin"), meals: blocks.meals, sleep: blocks.sleep };
438 let at = |seconds: i64| jiff::Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(now.time_zone().clone()));
439 let stamp = now.timestamp().as_second();
440 let hours = by_hours(&windows, time_off, overrides, now);
441 if overrides.paused_from().is_some() {
443 return Mode { quiet: true, time: Time::Sleep, week, reason: Reason::Paused, until: None, back: hours.until.clone(), label: String::new() };
444 }
445 let back_after = |end: i64| if hours.time.works() { at(end) } else { hours.until.clone() };
447 if let Some(block) = blocks.at(stamp, &["sleep", "nap"]) {
449 let reason = if block.kind == "nap" {
450 Reason::Nap
451 } else if stamp < block.at {
452 Reason::WindingDown
453 } else {
454 Reason::Sleep
455 };
456 return Mode { quiet: true, time: Time::Sleep, week, reason, until: at(block.end), back: back_after(block.end), label: String::new() };
457 }
458 if let Some(since) = overrides.free_from().filter(|since| *since <= stamp) {
460 let end = crate::pause::free_until(since, blocks, now.time_zone());
461 if stamp < end {
462 return Mode { quiet: true, time: Time::Leisure, week, reason: Reason::FreeTime, until: at(end), back: None, label: String::new() };
463 }
464 }
465 if let Some(block) = blocks.at(stamp, &["meal"]) {
466 return Mode { quiet: true, time: Time::Meals, week, reason: Reason::Meal, until: at(block.end), back: back_after(block.end), label: String::new() };
467 }
468 let cut = matches!(hours.reason, Reason::Working | Reason::AdminTime | Reason::Evening | Reason::DayOff | Reason::NoHours | Reason::Extended);
471 let until = if cut { earliest(hours.until.clone(), blocks.next_start(stamp).and_then(at)) } else { hours.until.clone() };
472 Mode { quiet: !(hours.time.works() || hours.time == Time::Any), time: hours.time, week, reason: hours.reason, until, back: hours.until, label: hours.label }
473}
474
475pub struct Situation {
478 pub mode: Mode,
479 pub offices: crate::taskview::Offices,
480 pub closed: std::collections::BTreeSet<Date>,
482 pub office_days: [bool; 7],
484 pub quiet_tasks: QuietTasks,
486}
487
488#[derive(Debug, Clone, Default, PartialEq, Eq)]
495pub struct QuietTasks {
496 pub time: Time,
497 pub week: Week,
498 pub areas: TaskAreas,
499 pub no_movement: bool,
502}
503
504impl QuietTasks {
505 pub fn keeps(&self, task: &crate::tasks::Task) -> bool {
507 if self.time == Time::Sleep || (self.no_movement && crate::pause::is_movement(task)) {
508 return false;
509 }
510 let area = self.areas.of(task);
511 if task.office_hours && area.admin && !area.work {
512 return self.time.offices();
513 }
514 in_view(area, self.time, self.week)
515 }
516}
517
518impl Situation {
519 pub fn now(config: &crate::config::Config, overrides: &Overrides, blocks: &Blocks, now: &Zoned, tr: &crate::i18n::Translator, projects: &[crate::projects::Project]) -> Situation {
520 let mode = mode(&config.week_hours(), &config.time_off, overrides, blocks, now);
521 let office = config.office_hours();
522 let open = window::current(&office, now).is_some();
523 let next = if open { String::new() } else { window::next_opening(&office, now).map(|z| tr.when(&z)).unwrap_or_default() };
524 let mut office_days = [false; 7];
525 for w in &office {
526 if let Some(day) = w.weekday() {
527 office_days[day.to_monday_zero_offset() as usize] = true;
528 }
529 }
530 let today = now.date();
532 let horizon = today.checked_add(Span::new().days(400)).unwrap_or(today);
533 let mut closed = std::collections::BTreeSet::new();
534 for off in &config.time_off {
535 let mut day = off.from.max(today);
536 while day <= off.until && day <= horizon {
537 closed.insert(day);
538 let Ok(next) = day.tomorrow() else { break };
539 day = next;
540 }
541 }
542 if mode.reason == Reason::DoneForTheDay {
543 closed.insert(today);
544 }
545 let no_movement = mode.free() && !config.free_time.movement(&config.planning);
546 let quiet_tasks = QuietTasks { time: mode.time, week: mode.week, areas: TaskAreas::of_config(config, projects), no_movement };
547 Situation { mode, offices: crate::taskview::Offices { open, next, now: Some(now.clone()) }, closed, office_days, quiet_tasks }
548 }
549
550 pub fn quiet_tasks(&self) -> Option<QuietTasks> {
552 (self.mode.time != Time::Any).then(|| self.quiet_tasks.clone())
553 }
554}
555
556pub fn personal_task(task: &crate::tasks::Task, personal: &[String], personal_projects: &[String]) -> bool {
559 let fold = |s: &str| crate::text::fold(s.trim()).into_iter().collect::<String>();
560 let wanted: Vec<String> = personal.iter().map(|p| fold(p)).collect();
561 task.categories.iter().any(|c| wanted.contains(&fold(c))) || task.projects.iter().any(|c| personal_projects.contains(c))
562}
563
564#[cfg(test)]
565mod tests {
566
567 #[test]
571 fn an_undo_puts_back_only_what_the_act_changed() {
572 let day = |d: &str| d.parse::<Date>().unwrap();
573 let previous = Overrides { work_until: Some(5), lighter: vec![day("2026-10-12")], ..Overrides::default() };
574 let after = Overrides { rest_until: Some(100), lighter: vec![day("2026-10-12"), day("2026-10-13")], ..Overrides::default() };
575 let now = Overrides { rest_until: Some(100), paused_since: Some(7), lighter: vec![day("2026-10-12"), day("2026-10-13"), day("2026-10-14")], ..Overrides::default() };
576 let back = now.put_back(&previous, &after);
577 assert_eq!((back.work_until, back.rest_until, back.paused_since), (Some(5), None, Some(7)));
578 assert_eq!(back.lighter, [day("2026-10-12"), day("2026-10-14")]);
579 let now = Overrides { rest_until: Some(200), ..now };
581 assert_eq!(now.put_back(&previous, &after).rest_until, Some(200));
582 }
583 use super::*;
584 use crate::needs::{Days, Needs};
585
586 fn at(text: &str) -> Zoned {
587 text.parse().unwrap()
588 }
589
590 fn week() -> Vec<AdminWindow> {
591 ["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()
592 }
593
594 fn none() -> Blocks {
595 Blocks::default()
596 }
597
598 fn health() -> Needs {
602 Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() }
603 }
604
605 #[test]
606 fn work_rests_outside_its_hours() {
607 let none = Overrides::default();
608 let friday_noon = at("2026-10-02T12:00[Europe/Paris]");
610 let m = mode(&week(), &[], &none, &self::none(), &friday_noon);
611 assert_eq!((m.quiet, m.reason.clone(), m.time), (false, Reason::Working, Time::Work));
612 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-02T17:00:00");
613 let friday_evening = at("2026-10-02T19:00[Europe/Paris]");
614 let m = mode(&week(), &[], &none, &self::none(), &friday_evening);
615 assert_eq!((m.quiet, m.reason.clone(), m.time), (true, Reason::Evening, Time::Leisure), "the evening is leisure");
616 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-05T09:00:00", "until Monday morning");
617 let saturday = at("2026-10-03T11:00[Europe/Paris]");
618 assert_eq!(mode(&week(), &[], &none, &self::none(), &saturday).reason, Reason::DayOff);
619 let off = vec![TimeOff { id: String::new(), from: "2026-10-05".parse().unwrap(), until: "2026-10-09".parse().unwrap(), label: "Holidays".into() }];
621 let m = mode(&week(), &off, &none, &self::none(), &saturday);
622 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-12T09:00:00");
623 let m = mode(&week(), &off, &none, &self::none(), &at("2026-10-06T10:00[Europe/Paris]"));
624 assert_eq!((m.quiet, m.reason, m.label.as_str(), m.time), (true, Reason::TimeOff, "Holidays", Time::Leisure));
625 let rest = Overrides { rest_until: Some(at("2026-10-05T09:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
627 let m = mode(&week(), &[], &rest, &self::none(), &friday_noon);
628 assert_eq!((m.reason, m.time), (Reason::DoneForTheDay, Time::Leisure));
629 let late = Overrides { work_until: Some(at("2026-10-02T20:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
630 let m = mode(&week(), &[], &late, &self::none(), &friday_evening);
631 assert_eq!((m.quiet, m.reason, m.time), (false, Reason::WorkingLate, Time::Work));
632 assert_eq!(mode(&[], &[], &none, &self::none(), &saturday).reason, Reason::NoHours);
633 assert_eq!(after_today(&week(), &[], &friday_noon, 7).datetime().to_string(), "2026-10-05T09:00:00");
635 assert_eq!(after_today(&[], &[], &friday_noon, 7).datetime().to_string(), "2026-10-03T07:00:00");
636 assert_eq!(after_today(&[], &[], &friday_noon, 30).datetime().to_string(), "2026-10-03T00:00:00", "a morning hour that is none");
637 let tr = crate::i18n::Translator::new("en");
638 assert_eq!(until_text(&tr, &at("2026-10-02T17:00[Europe/Paris]"), &friday_noon), "17:00");
639 assert_eq!(until_text(&tr, &at("2026-10-03T09:00[Europe/Paris]"), &friday_noon), "tomorrow at 09:00");
640 assert_eq!(until_text(&tr, &at("2026-10-05T09:00[Europe/Paris]"), &friday_noon), "Monday 5 October at 09:00");
641 }
642
643 #[test]
644 fn work_now_until_the_next_working_day_is_over() {
645 let (friday_evening, saturday, friday_noon) = (at("2026-10-02T19:00[Europe/Paris]"), at("2026-10-03T11:00[Europe/Paris]"), at("2026-10-02T12:00[Europe/Paris]"));
647 assert_eq!(end_of_next_workday(&week(), &[], &saturday).datetime().to_string(), "2026-10-05T17:00:00", "Monday's end");
648 assert_eq!(end_of_next_workday(&week(), &[], &friday_evening).datetime().to_string(), "2026-10-05T17:00:00");
649 assert_eq!(end_of_next_workday(&week(), &[], &friday_noon).datetime().to_string(), "2026-10-02T17:00:00", "today's, not over yet");
650 let mut split = week();
652 split.push(AdminWindow { day: "monday".into(), start: "19:00".into(), end: Some("20:00".into()), minutes: 0, kind: None });
653 assert_eq!(end_of_next_workday(&split, &[], &saturday).datetime().to_string(), "2026-10-05T20:00:00");
654 let off = vec![TimeOff { id: String::new(), from: "2026-10-05".parse().unwrap(), until: "2026-10-09".parse().unwrap(), label: "Holidays".into() }];
656 assert_eq!(end_of_next_workday(&week(), &off, &saturday).datetime().to_string(), "2026-10-12T17:00:00");
657 assert_eq!(end_of_next_workday(&[], &[], &saturday).datetime().to_string(), "2026-10-04T00:00:00");
658 let now = Overrides { work_now: Some(end_of_next_workday(&week(), &[], &saturday).timestamp().as_second()), ..Overrides::default() };
660 let m = mode(&week(), &[], &now, &none(), &saturday);
661 assert_eq!((m.time, m.reason, m.quiet), (Time::Work, Reason::WorkNow, false));
662 assert_eq!(mode(&week(), &[], &now, &none(), &at("2026-10-05T17:30[Europe/Paris]")).reason, Reason::Evening, "over with Monday");
663 }
664
665 #[test]
666 fn work_and_admin_at_once() {
667 let none = Overrides::default();
668 let mut hours = week();
669 hours.push(AdminWindow { day: "friday".into(), start: "16:00".into(), end: Some("18:00".into()), minutes: 0, kind: Some("admin".into()) });
670 let m = mode(&hours, &[], &none, &self::none(), &at("2026-10-02T16:30[Europe/Paris]"));
672 assert_eq!((m.time, m.quiet), (Time::Several(Area::MIXED), false));
673 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-02T17:00:00");
674 assert!(in_view(Area::ADMIN, m.time, m.week) && in_view(Area::WORK, m.time, m.week) && !in_view(Area::LEISURE, m.time, m.week));
675 let m = mode(&hours, &[], &none, &self::none(), &at("2026-10-02T17:30[Europe/Paris]"));
677 assert_eq!((m.time, m.reason, m.quiet), (Time::Admin, Reason::AdminTime, true));
678 }
679
680 #[test]
681 fn old_leisure_windows_read_and_left_aside() {
682 let config: crate::config::Config = toml::from_str(
684 "[[window]]\nday = \"friday\"\nstart = \"09:00\"\nend = \"17:00\"\n\n[[window]]\nday = \"saturday\"\nstart = \"10:00\"\nend = \"18:00\"\nkind = \"leisure\"\n\n[[window]]\nday = \"friday\"\nstart = \"18:00\"\nend = \"19:00\"\nkind = \"admin\"\n",
685 )
686 .unwrap();
687 assert_eq!(config.windows.len(), 3, "kept");
688 assert_eq!(config.week_hours().iter().map(|w| w.kind()).collect::<Vec<_>>(), ["work", "admin"]);
689 let none = Overrides::default();
690 let saturday = at("2026-10-03T11:00[Europe/Paris]");
691 let m = mode(&config.week_hours(), &[], &none, &self::none(), &saturday);
692 assert_eq!((m.time, m.reason), (Time::Leisure, Reason::DayOff));
693 let m = mode(&config.windows, &[], &none, &self::none(), &saturday);
695 assert_eq!((m.time, m.reason, m.week.work_hours, m.week.admin_hours), (Time::Leisure, Reason::DayOff, true, true));
696 let m = mode(&config.windows, &[], &none, &self::none(), &at("2026-10-02T20:00[Europe/Paris]"));
698 assert_eq!((m.time, m.reason.clone()), (Time::Leisure, Reason::Evening));
699 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-09T09:00:00", "never Saturday's old free time");
700 }
701
702 #[test]
703 fn sleep_then_meals_then_the_hours() {
704 let none = Overrides::default();
705 let friday = |time: &str| at(&format!("2026-10-02T{time}[Europe/Paris]"));
706 let blocks = |now: &Zoned| Blocks::of(&health(), &Days::default(), &[], now);
707 let mode_at = |time: &str, overrides: &Overrides| {
708 let now = if time.starts_with("+") { at(&format!("2026-10-03T{}[Europe/Paris]", &time[1..])) } else { friday(time) };
709 mode(&week(), &[], overrides, &blocks(&now), &now)
710 };
711 let m = mode_at("10:00", &none);
713 assert_eq!((m.time, m.reason.clone(), m.quiet), (Time::Work, Reason::Working, false));
714 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-02T12:10:00");
715 let m = mode_at("12:30", &none);
717 assert_eq!((m.time, m.reason.clone(), m.quiet), (Time::Meals, Reason::Meal, true));
718 assert_eq!((m.until.unwrap().datetime().to_string(), m.back.unwrap().datetime().to_string()), ("2026-10-02T13:00:00".into(), "2026-10-02T13:00:00".into()));
719 assert!(m.week.meals && m.week.sleep);
720 let m = mode_at("14:10", &none);
722 assert_eq!((m.time, m.reason.clone()), (Time::Sleep, Reason::Nap));
723 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-02T14:35:00");
724 let m = mode_at("20:30", &none);
726 assert_eq!((m.time, m.reason.clone(), m.until.unwrap().datetime().to_string()), (Time::Leisure, Reason::Evening, "2026-10-02T22:00:00".into()));
727 assert_eq!(m.back.unwrap().datetime().to_string(), "2026-10-05T09:00:00", "work comes back on Monday");
728 let m = mode_at("22:30", &none);
729 assert!(m.sleeps() && m.quiet);
730 assert_eq!((m.time, m.reason.clone(), m.until.unwrap().datetime().to_string()), (Time::Sleep, Reason::WindingDown, "2026-10-03T07:00:00".into()));
731 assert_eq!(mode_at("23:30", &none).reason, Reason::Sleep);
732 assert_eq!((mode_at("+06:00", &none).time, mode_at("+06:00", &none).reason), (Time::Sleep, Reason::Sleep), "the night ending this morning");
733 let work_now = Overrides { work_now: Some(friday("23:59").timestamp().as_second() + 86_400 * 3), ..Overrides::default() };
735 assert_eq!(mode_at("23:30", &work_now).time, Time::Sleep);
736 assert_eq!(mode_at("12:30", &work_now).time, Time::Meals);
737 assert_eq!(mode_at("10:00", &work_now).reason, Reason::WorkNow);
738 let done = Overrides { rest_until: Some(friday("23:59").timestamp().as_second()), ..Overrides::default() };
739 assert_eq!(mode_at("12:30", &done).time, Time::Meals);
740 assert_eq!(mode_at("11:00", &done).reason, Reason::DoneForTheDay);
741 let now = friday("12:30");
743 assert_eq!(mode(&[], &[], &none, &blocks(&now), &now).time, Time::Meals);
744 let now = friday("10:00");
745 let m = mode(&[], &[], &none, &blocks(&now), &now);
746 assert_eq!((m.time, m.quiet), (Time::Any, false));
747 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-02T12:10:00");
748 let mut days = Days::default();
750 days.change(now.date(), "meal:1", |b| b.off = true);
751 let noon = friday("12:30");
752 assert_eq!(mode(&week(), &[], &none, &Blocks::of(&health(), &days, &[], &noon), &noon).time, Time::Work);
753 let mut days = Days::default();
754 days.change(now.date(), "meal:1", |b| b.quiet = true);
755 assert_eq!(mode(&week(), &[], &none, &Blocks::of(&health(), &days, &[], &noon), &noon).time, Time::Meals);
756 let no_night = Needs { sleep_on: false, ..health() };
758 let late = friday("23:30");
759 let blocks = Blocks::of(&no_night, &Days::default(), &[], &late);
760 assert!(!blocks.night && blocks.sleep, "naps still");
761 assert_eq!(mode(&week(), &[], &none, &blocks, &late).time, Time::Leisure);
762 }
763
764 #[test]
765 fn the_first_step_on_its_day() {
766 let monday: Date = "2026-10-05".parse().unwrap();
767 let overrides = Overrides { first_step: Some("Open the renewal form (15 min)".into()), first_step_task: Some("renewal".into()), first_step_day: Some(monday), ..Overrides::default() };
768 assert_eq!(overrides.first_step_on(monday), Some(("Open the renewal form (15 min)", "renewal")));
769 assert_eq!(overrides.first_step_on("2026-10-06".parse().unwrap()), None, "never the day after");
770 let closed = Overrides { rest_from: Some(at("2026-10-02T12:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
771 assert!(closed.closed_today(&at("2026-10-02T19:00[Europe/Paris]")));
772 assert!(!closed.closed_today(&at("2026-10-03T09:00[Europe/Paris]")), "today's plan is taken back the same day only");
773 }
774
775 #[test]
776 fn what_is_yours() {
777 let task = |cats: &[&str], projects: &[&str]| crate::tasks::Task { categories: cats.iter().map(|s| s.to_string()).collect(), projects: projects.iter().map(|s| s.to_string()).collect(), ..crate::tasks::Task::default() };
778 let personal = vec!["Famille".to_string(), "joy".into()];
779 assert!(personal_task(&task(&["famille"], &[]), &personal, &[]));
780 assert!(personal_task(&task(&[], &["garden"]), &personal, &["garden".into()]));
781 assert!(!personal_task(&task(&["you"], &["taxes"]), &personal, &["garden".into()]));
782 let areas = TaskAreas { work_categories: vec!["travail".into()], work_projects: vec!["client-x".into()], ..TaskAreas::usual() };
783 let week = Week { work_hours: true, ..Week::default() };
784 let night = QuietTasks { time: Time::Sleep, week, areas: areas.clone(), no_movement: false };
786 assert!(!night.keeps(&task(&[], &["taxes"])) && !night.keeps(&task(&["joy"], &[])) && !night.keeps(&task(&["Travail"], &[])));
787 for time in [Time::Leisure, Time::Meals] {
789 let free = QuietTasks { time, ..night.clone() };
790 assert!(!free.keeps(&task(&[], &["taxes"])) && free.keeps(&task(&["joy"], &[])) && free.keeps(&task(&["santé"], &[])));
791 assert!(!free.keeps(&crate::tasks::Task { office_hours: true, ..task(&[], &["taxes"]) }), "offices keep business hours");
792 }
793 let working = QuietTasks { time: Time::Work, ..night.clone() };
795 assert!(working.keeps(&task(&[], &["taxes"])) && working.keeps(&task(&["Travail"], &[])) && !working.keeps(&task(&["joy"], &[])));
796 let set = QuietTasks { time: Time::Work, week: Week { admin_hours: true, ..week }, areas, no_movement: false };
798 let free = QuietTasks { time: Time::Leisure, no_movement: true, ..set.clone() };
800 assert!(!free.keeps(&task(&["joy", "Yoga"], &[])) && free.keeps(&task(&["joy"], &[])));
801 assert!(!set.keeps(&task(&[], &["taxes"])));
802 assert!(set.keeps(&crate::tasks::Task { office_hours: true, ..task(&[], &["taxes"]) }));
803 }
804
805 #[test]
806 fn hours_for_admin() {
807 let none = Overrides::default();
808 let mut hours = week();
809 hours.push(AdminWindow { day: "friday".into(), start: "18:00".into(), end: Some("19:00".into()), minutes: 0, kind: Some("admin".into()) });
810 let m = mode(&hours, &[], &none, &self::none(), &at("2026-10-02T18:30[Europe/Paris]"));
811 assert_eq!((m.time, m.reason.clone(), m.quiet), (Time::Admin, Reason::AdminTime, true));
812 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-02T19:00:00");
813 let m = mode(&hours, &[], &none, &self::none(), &at("2026-10-02T20:00[Europe/Paris]"));
815 assert_eq!((m.time, m.reason.clone()), (Time::Leisure, Reason::Evening));
816 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-05T09:00:00");
817 assert_eq!(mode(&hours, &[], &none, &self::none(), &at("2026-10-02T10:00[Europe/Paris]")).time, Time::Work);
818 assert!(m.week.work_hours && m.week.admin_hours && !m.week.meals && !m.week.sleep);
819 assert_eq!(mode(&[], &[], &none, &self::none(), &at("2026-10-02T10:00[Europe/Paris]")).time, Time::Any);
820 }
821
822 #[test]
823 fn nothing_disturbs_but_doses() {
824 use crate::attention::{Attention, Column, Kind, Level, Now, Row};
825 let sleeping = Mode { quiet: true, time: Time::Sleep, week: Week::default(), reason: Reason::Sleep, until: None, back: None, label: String::new() };
826 let awake = Mode { time: Time::Leisure, reason: Reason::Evening, ..sleeping.clone() };
827 let usual = Attention::usual();
829 let comes = |matrix: &Attention, kind: Kind, mode: &Mode| matrix.level(Row::Own(kind), &Now::of(mode)) == Level::Now;
830 assert!(!comes(&usual, Kind::Move, &sleeping) && !comes(&usual, Kind::Dates, &sleeping), "no notification during sleep");
831 assert!(comes(&usual, Kind::Doses, &sleeping), "a dose comes: you set its time");
832 let mut silent = Attention::usual();
833 silent.set(Row::Own(Kind::Doses), Column::Sleep, Level::Later).unwrap();
834 assert!(!comes(&silent, Kind::Doses, &sleeping), "unless doses stay silent then");
835 assert!(comes(&silent, Kind::Move, &awake) && comes(&silent, Kind::Doses, &awake));
836 let winding = Mode { reason: Reason::WindingDown, ..sleeping };
837 assert!(winding.sleeps() && !comes(&usual, Kind::Move, &winding));
838 }
839}