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 = "Vec::is_empty")]
162 pub lighter: Vec<Date>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub porch_rests_until: Option<i64>,
166}
167
168impl Overrides {
169 pub fn put_back(&self, previous: &Overrides, after: &Overrides) -> Overrides {
177 let table = |o: &Overrides| match toml::Value::try_from(o) {
178 Ok(toml::Value::Table(table)) => table,
179 _ => toml::Table::new(),
180 };
181 let (was, act, now) = (table(previous), table(after), table(self));
182 let mut out = now.clone();
183 let keys: std::collections::BTreeSet<&String> = was.keys().chain(act.keys()).collect();
184 let no_list = Vec::new();
185 for key in keys {
186 match (was.get(key), act.get(key)) {
187 (before, done) if before == done => {}
188 (before @ (Some(toml::Value::Array(_)) | None), done @ (Some(toml::Value::Array(_)) | None)) => {
189 let list = |v: Option<&toml::Value>| v.and_then(toml::Value::as_array).cloned().unwrap_or_default();
190 let back = crate::settings::rebased(&list(done), &list(before), now.get(key).and_then(toml::Value::as_array).unwrap_or(&no_list));
191 if back.is_empty() {
192 out.remove(key);
193 } else {
194 out.insert(key.clone(), toml::Value::Array(back));
195 }
196 }
197 (_, done) if now.get(key) != done => {}
199 (Some(before), _) => {
200 out.insert(key.clone(), before.clone());
201 }
202 (None, _) => {
203 out.remove(key);
204 }
205 }
206 }
207 let mut back: Overrides = toml::Value::Table(out).try_into().unwrap_or_else(|_| self.clone());
208 back.read = self.read.clone();
209 back
210 }
211
212 pub fn first_step_on(&self, today: Date) -> Option<(&str, &str)> {
214 let step = self.first_step.as_deref().filter(|s| !s.trim().is_empty())?;
215 self.first_step_day.is_none_or(|d| d == today).then(|| (step, self.first_step_task.as_deref().unwrap_or("")))
216 }
217
218 pub fn closed_today(&self, now: &Zoned) -> bool {
220 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())
221 }
222}
223
224impl Overrides {
225 pub fn default_path() -> PathBuf {
226 crate::config::state_dir().join("quiet.toml")
227 }
228
229 pub fn load(path: &Path) -> Overrides {
230 let text = std::fs::read_to_string(path).unwrap_or_default();
231 let mut overrides: Overrides = toml::from_str(&text).unwrap_or_default();
232 overrides.read = crate::filelock::Read::of(&text);
233 overrides
234 }
235
236 pub fn save(&self, path: &Path) -> Result<(), String> {
239 crate::filelock::save_merged(path, &self.read, self)
240 }
241}
242
243pub fn time_off_on(time_off: &[TimeOff], date: Date) -> Option<&TimeOff> {
245 time_off.iter().find(|t| t.from <= date && date <= t.until)
246}
247
248pub fn next_work(windows: &[AdminWindow], time_off: &[TimeOff], now: &Zoned) -> Option<Zoned> {
250 let mut from = now.clone();
251 for _ in 0..60 {
252 let opening = window::next_opening(windows, &from)?;
253 match time_off_on(time_off, opening.date()) {
254 None => return Some(opening),
255 Some(off) => {
257 let after = off.until.checked_add(Span::new().days(1)).ok()?;
258 from = after.to_zoned(now.time_zone().clone()).ok()?.checked_sub(Span::new().seconds(1)).ok()?;
259 }
260 }
261 }
262 None
263}
264
265pub fn after_today(windows: &[AdminWindow], time_off: &[TimeOff], now: &Zoned, morning: i8) -> Zoned {
268 let Some(tomorrow) = now.date().tomorrow().ok().and_then(|d| d.to_zoned(now.time_zone().clone()).ok()) else {
269 return now.clone();
270 };
271 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());
273 next_work(windows, time_off, &tomorrow).unwrap_or_else(|| at_morning.unwrap_or(tomorrow))
274}
275
276pub fn end_of_next_workday(work: &[AdminWindow], time_off: &[TimeOff], now: &Zoned) -> Zoned {
280 let zone = now.time_zone();
281 if time_off_on(time_off, now.date()).is_none()
282 && let Some((_, closing)) = window::hours_on(work, now.date(), zone).filter(|(_, closing)| closing.timestamp() > now.timestamp())
283 {
284 return closing;
285 }
286 if let Some(opening) = next_work(work, time_off, now)
287 && let Some((_, closing)) = window::hours_on(work, opening.date(), zone)
288 {
289 return closing;
290 }
291 now.date().tomorrow().ok().and_then(|d| d.to_zoned(zone.clone()).ok()).unwrap_or_else(|| now.clone())
292}
293
294pub fn until_text(tr: &crate::i18n::Translator, until: &Zoned, now: &Zoned) -> String {
296 let mut args = crate::i18n::args();
297 args.set("time", until.strftime("%H:%M").to_string());
298 if until.date() == now.date() {
299 until.strftime("%H:%M").to_string()
300 } else if now.date().tomorrow().is_ok_and(|d| d == until.date()) {
301 tr.text("until-tomorrow", Some(&args))
302 } else {
303 tr.date(until, false)
304 }
305}
306
307#[derive(Debug, Clone, Default, PartialEq)]
311pub struct Blocks {
312 pub kept: Vec<crate::needs::Kept>,
315 pub meals: bool,
317 pub sleep: bool,
319 pub night: bool,
322}
323
324impl Blocks {
325 pub fn of(needs: &crate::needs::Needs, days: &crate::needs::Days, held: &[(i64, i64)], now: &Zoned) -> Blocks {
328 let kept = needs.kept_around(now, days, held);
329 let meals = (needs.meals_on && needs.meals.iter().any(|m| m.on)) || kept.iter().any(|k| k.kind == "meal");
330 let naps = (needs.naps_on && needs.naps.iter().any(|n| n.on)) || kept.iter().any(|k| k.kind == "nap");
331 Blocks { kept, meals, sleep: needs.sleep_on || naps, night: needs.sleep_on }
332 }
333
334 pub fn read(now: &Zoned, events: &[crate::agenda::Occurrence]) -> Blocks {
336 let needs = crate::health::Health::load(&crate::health::Health::default_path()).needs;
337 let days = crate::needs::Days::load(&crate::needs::Days::default_path());
338 Blocks::of(&needs, &days, &crate::plan::event_spans(events, 0), now)
339 }
340
341 pub fn read_now(now: &Zoned) -> Blocks {
343 let midnight = now.date().to_zoned(now.time_zone().clone()).map_or_else(|_| now.timestamp().as_second(), |z| z.timestamp().as_second());
344 Blocks::read(now, &crate::agenda::occurrences(midnight - 86_400, midnight + 2 * 86_400))
345 }
346
347 pub fn at(&self, stamp: i64, kinds: &[&str]) -> Option<&crate::needs::Kept> {
349 self.kept.iter().find(|k| kinds.contains(&k.kind) && k.start <= stamp && stamp < k.end)
350 }
351
352 fn next_start(&self, stamp: i64) -> Option<i64> {
354 self.kept.iter().map(|k| k.start).filter(|start| *start > stamp).min()
355 }
356}
357
358struct ByHours {
360 time: Time,
361 reason: Reason,
362 until: Option<Zoned>,
363 label: String,
364}
365
366fn by_hours(windows: &[AdminWindow], time_off: &[TimeOff], overrides: &Overrides, now: &Zoned) -> ByHours {
369 let of = |kind: &str| -> Vec<AdminWindow> { windows.iter().filter(|w| w.kind() == kind).cloned().collect() };
370 let (work, admin) = (of("work"), of("admin"));
371 let at = |seconds: i64| jiff::Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(now.time_zone().clone()));
372 let made = |time: Time, reason: Reason, until: Option<Zoned>| ByHours { time, reason, until, label: String::new() };
373 let stamp = now.timestamp().as_second();
374 if let Some(rest) = overrides.rest_until.filter(|r| *r > stamp) {
375 return made(Time::Leisure, Reason::DoneForTheDay, at(rest));
376 }
377 if let Some(late) = overrides.work_until.filter(|w| *w > stamp) {
378 return made(Time::Work, Reason::WorkingLate, at(late));
379 }
380 if let Some(until) = overrides.work_now.filter(|w| *w > stamp) {
381 return made(Time::Work, Reason::WorkNow, at(until));
382 }
383 if windows.is_empty() && time_off.is_empty() {
384 return made(Time::Any, Reason::NoHours, None);
385 }
386 if let Some(off) = time_off_on(time_off, now.date()) {
387 return ByHours { time: Time::Leisure, reason: Reason::TimeOff, until: next_work(windows, time_off, now), label: off.label.clone() };
388 }
389 if windows.is_empty() {
390 return made(Time::Any, Reason::NoHours, None);
391 }
392 let (at_work, at_admin) = (window::current(&work, now), window::current(&admin, now));
394 let closing = [&at_work, &at_admin].into_iter().flatten().map(|(_, c)| c.clone()).min_by_key(Zoned::timestamp);
395 match Time::of(Area { work: at_work.is_some(), admin: at_admin.is_some(), leisure: false }) {
396 Time::Admin => made(Time::Admin, Reason::AdminTime, closing),
397 Time::Leisure => {
398 if let Some(end) = overrides.moved_end(now.date()).filter(|end| *end > stamp)
401 && window::hours_on(&work, now.date(), now.time_zone()).is_some_and(|(_, closing)| closing.timestamp().as_second() <= stamp)
402 {
403 return made(Time::Work, Reason::Extended, at(end));
404 }
405 let reason = if window::open_day(&work, now.date()) { Reason::Evening } else { Reason::DayOff };
406 made(Time::Leisure, reason, next_work(windows, time_off, now))
408 }
409 open => made(open, Reason::Working, closing),
411 }
412}
413
414fn earliest(a: Option<Zoned>, b: Option<Zoned>) -> Option<Zoned> {
416 match (a, b) {
417 (Some(a), Some(b)) => Some(if b.timestamp() < a.timestamp() { b } else { a }),
418 (a, b) => a.or(b),
419 }
420}
421
422pub fn mode(windows: &[AdminWindow], time_off: &[TimeOff], overrides: &Overrides, blocks: &Blocks, now: &Zoned) -> Mode {
430 let windows: Vec<AdminWindow> = windows.iter().filter(|w| w.kind() != "leisure").cloned().collect();
431 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 };
432 let at = |seconds: i64| jiff::Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(now.time_zone().clone()));
433 let stamp = now.timestamp().as_second();
434 let hours = by_hours(&windows, time_off, overrides, now);
435 if overrides.paused_from().is_some() {
437 return Mode { quiet: true, time: Time::Sleep, week, reason: Reason::Paused, until: None, back: hours.until.clone(), label: String::new() };
438 }
439 let back_after = |end: i64| if hours.time.works() { at(end) } else { hours.until.clone() };
441 if let Some(block) = blocks.at(stamp, &["sleep", "nap"]) {
443 let reason = if block.kind == "nap" {
444 Reason::Nap
445 } else if stamp < block.at {
446 Reason::WindingDown
447 } else {
448 Reason::Sleep
449 };
450 return Mode { quiet: true, time: Time::Sleep, week, reason, until: at(block.end), back: back_after(block.end), label: String::new() };
451 }
452 if let Some(since) = overrides.free_from().filter(|since| *since <= stamp) {
454 let end = crate::pause::free_until(since, blocks, now.time_zone());
455 if stamp < end {
456 return Mode { quiet: true, time: Time::Leisure, week, reason: Reason::FreeTime, until: at(end), back: None, label: String::new() };
457 }
458 }
459 if let Some(block) = blocks.at(stamp, &["meal"]) {
460 return Mode { quiet: true, time: Time::Meals, week, reason: Reason::Meal, until: at(block.end), back: back_after(block.end), label: String::new() };
461 }
462 let cut = matches!(hours.reason, Reason::Working | Reason::AdminTime | Reason::Evening | Reason::DayOff | Reason::NoHours | Reason::Extended);
465 let until = if cut { earliest(hours.until.clone(), blocks.next_start(stamp).and_then(at)) } else { hours.until.clone() };
466 Mode { quiet: !(hours.time.works() || hours.time == Time::Any), time: hours.time, week, reason: hours.reason, until, back: hours.until, label: hours.label }
467}
468
469pub struct Situation {
472 pub mode: Mode,
473 pub offices: crate::taskview::Offices,
474 pub closed: std::collections::BTreeSet<Date>,
476 pub office_days: [bool; 7],
478 pub quiet_tasks: QuietTasks,
480}
481
482#[derive(Debug, Clone, Default, PartialEq, Eq)]
489pub struct QuietTasks {
490 pub time: Time,
491 pub week: Week,
492 pub areas: TaskAreas,
493 pub no_movement: bool,
496}
497
498impl QuietTasks {
499 pub fn keeps(&self, task: &crate::tasks::Task) -> bool {
501 if self.time == Time::Sleep || (self.no_movement && crate::pause::is_movement(task)) {
502 return false;
503 }
504 let area = self.areas.of(task);
505 if task.office_hours && area.admin && !area.work {
506 return self.time.offices();
507 }
508 in_view(area, self.time, self.week)
509 }
510}
511
512impl Situation {
513 pub fn now(config: &crate::config::Config, overrides: &Overrides, blocks: &Blocks, now: &Zoned, tr: &crate::i18n::Translator, projects: &[crate::projects::Project]) -> Situation {
514 let mode = mode(&config.week_hours(), &config.time_off, overrides, blocks, now);
515 let office = config.office_hours();
516 let open = window::current(&office, now).is_some();
517 let next = if open { String::new() } else { window::next_opening(&office, now).map(|z| tr.when(&z)).unwrap_or_default() };
518 let mut office_days = [false; 7];
519 for w in &office {
520 if let Some(day) = w.weekday() {
521 office_days[day.to_monday_zero_offset() as usize] = true;
522 }
523 }
524 let today = now.date();
526 let horizon = today.checked_add(Span::new().days(400)).unwrap_or(today);
527 let mut closed = std::collections::BTreeSet::new();
528 for off in &config.time_off {
529 let mut day = off.from.max(today);
530 while day <= off.until && day <= horizon {
531 closed.insert(day);
532 let Ok(next) = day.tomorrow() else { break };
533 day = next;
534 }
535 }
536 if mode.reason == Reason::DoneForTheDay {
537 closed.insert(today);
538 }
539 let no_movement = mode.free() && !config.free_time.movement(&config.planning);
540 let quiet_tasks = QuietTasks { time: mode.time, week: mode.week, areas: TaskAreas::of_config(config, projects), no_movement };
541 Situation { mode, offices: crate::taskview::Offices { open, next, now: Some(now.clone()) }, closed, office_days, quiet_tasks }
542 }
543
544 pub fn quiet_tasks(&self) -> Option<QuietTasks> {
546 (self.mode.time != Time::Any).then(|| self.quiet_tasks.clone())
547 }
548}
549
550pub fn personal_task(task: &crate::tasks::Task, personal: &[String], personal_projects: &[String]) -> bool {
553 let fold = |s: &str| crate::text::fold(s.trim()).into_iter().collect::<String>();
554 let wanted: Vec<String> = personal.iter().map(|p| fold(p)).collect();
555 task.categories.iter().any(|c| wanted.contains(&fold(c))) || task.projects.iter().any(|c| personal_projects.contains(c))
556}
557
558#[cfg(test)]
559mod tests {
560
561 #[test]
565 fn an_undo_puts_back_only_what_the_act_changed() {
566 let day = |d: &str| d.parse::<Date>().unwrap();
567 let previous = Overrides { work_until: Some(5), lighter: vec![day("2026-10-12")], ..Overrides::default() };
568 let after = Overrides { rest_until: Some(100), lighter: vec![day("2026-10-12"), day("2026-10-13")], ..Overrides::default() };
569 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() };
570 let back = now.put_back(&previous, &after);
571 assert_eq!((back.work_until, back.rest_until, back.paused_since), (Some(5), None, Some(7)));
572 assert_eq!(back.lighter, [day("2026-10-12"), day("2026-10-14")]);
573 let now = Overrides { rest_until: Some(200), ..now };
575 assert_eq!(now.put_back(&previous, &after).rest_until, Some(200));
576 }
577 use super::*;
578 use crate::needs::{Days, Needs};
579
580 fn at(text: &str) -> Zoned {
581 text.parse().unwrap()
582 }
583
584 fn week() -> Vec<AdminWindow> {
585 ["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()
586 }
587
588 fn none() -> Blocks {
589 Blocks::default()
590 }
591
592 fn health() -> Needs {
596 Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() }
597 }
598
599 #[test]
600 fn work_rests_outside_its_hours() {
601 let none = Overrides::default();
602 let friday_noon = at("2026-10-02T12:00[Europe/Paris]");
604 let m = mode(&week(), &[], &none, &self::none(), &friday_noon);
605 assert_eq!((m.quiet, m.reason.clone(), m.time), (false, Reason::Working, Time::Work));
606 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-02T17:00:00");
607 let friday_evening = at("2026-10-02T19:00[Europe/Paris]");
608 let m = mode(&week(), &[], &none, &self::none(), &friday_evening);
609 assert_eq!((m.quiet, m.reason.clone(), m.time), (true, Reason::Evening, Time::Leisure), "the evening is leisure");
610 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-05T09:00:00", "until Monday morning");
611 let saturday = at("2026-10-03T11:00[Europe/Paris]");
612 assert_eq!(mode(&week(), &[], &none, &self::none(), &saturday).reason, Reason::DayOff);
613 let off = vec![TimeOff { id: String::new(), from: "2026-10-05".parse().unwrap(), until: "2026-10-09".parse().unwrap(), label: "Holidays".into() }];
615 let m = mode(&week(), &off, &none, &self::none(), &saturday);
616 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-12T09:00:00");
617 let m = mode(&week(), &off, &none, &self::none(), &at("2026-10-06T10:00[Europe/Paris]"));
618 assert_eq!((m.quiet, m.reason, m.label.as_str(), m.time), (true, Reason::TimeOff, "Holidays", Time::Leisure));
619 let rest = Overrides { rest_until: Some(at("2026-10-05T09:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
621 let m = mode(&week(), &[], &rest, &self::none(), &friday_noon);
622 assert_eq!((m.reason, m.time), (Reason::DoneForTheDay, Time::Leisure));
623 let late = Overrides { work_until: Some(at("2026-10-02T20:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
624 let m = mode(&week(), &[], &late, &self::none(), &friday_evening);
625 assert_eq!((m.quiet, m.reason, m.time), (false, Reason::WorkingLate, Time::Work));
626 assert_eq!(mode(&[], &[], &none, &self::none(), &saturday).reason, Reason::NoHours);
627 assert_eq!(after_today(&week(), &[], &friday_noon, 7).datetime().to_string(), "2026-10-05T09:00:00");
629 assert_eq!(after_today(&[], &[], &friday_noon, 7).datetime().to_string(), "2026-10-03T07:00:00");
630 assert_eq!(after_today(&[], &[], &friday_noon, 30).datetime().to_string(), "2026-10-03T00:00:00", "a morning hour that is none");
631 let tr = crate::i18n::Translator::new("en");
632 assert_eq!(until_text(&tr, &at("2026-10-02T17:00[Europe/Paris]"), &friday_noon), "17:00");
633 assert_eq!(until_text(&tr, &at("2026-10-03T09:00[Europe/Paris]"), &friday_noon), "tomorrow at 09:00");
634 assert_eq!(until_text(&tr, &at("2026-10-05T09:00[Europe/Paris]"), &friday_noon), "Monday 5 October at 09:00");
635 }
636
637 #[test]
638 fn work_now_until_the_next_working_day_is_over() {
639 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]"));
641 assert_eq!(end_of_next_workday(&week(), &[], &saturday).datetime().to_string(), "2026-10-05T17:00:00", "Monday's end");
642 assert_eq!(end_of_next_workday(&week(), &[], &friday_evening).datetime().to_string(), "2026-10-05T17:00:00");
643 assert_eq!(end_of_next_workday(&week(), &[], &friday_noon).datetime().to_string(), "2026-10-02T17:00:00", "today's, not over yet");
644 let mut split = week();
646 split.push(AdminWindow { day: "monday".into(), start: "19:00".into(), end: Some("20:00".into()), minutes: 0, kind: None });
647 assert_eq!(end_of_next_workday(&split, &[], &saturday).datetime().to_string(), "2026-10-05T20:00:00");
648 let off = vec![TimeOff { id: String::new(), from: "2026-10-05".parse().unwrap(), until: "2026-10-09".parse().unwrap(), label: "Holidays".into() }];
650 assert_eq!(end_of_next_workday(&week(), &off, &saturday).datetime().to_string(), "2026-10-12T17:00:00");
651 assert_eq!(end_of_next_workday(&[], &[], &saturday).datetime().to_string(), "2026-10-04T00:00:00");
652 let now = Overrides { work_now: Some(end_of_next_workday(&week(), &[], &saturday).timestamp().as_second()), ..Overrides::default() };
654 let m = mode(&week(), &[], &now, &none(), &saturday);
655 assert_eq!((m.time, m.reason, m.quiet), (Time::Work, Reason::WorkNow, false));
656 assert_eq!(mode(&week(), &[], &now, &none(), &at("2026-10-05T17:30[Europe/Paris]")).reason, Reason::Evening, "over with Monday");
657 }
658
659 #[test]
660 fn work_and_admin_at_once() {
661 let none = Overrides::default();
662 let mut hours = week();
663 hours.push(AdminWindow { day: "friday".into(), start: "16:00".into(), end: Some("18:00".into()), minutes: 0, kind: Some("admin".into()) });
664 let m = mode(&hours, &[], &none, &self::none(), &at("2026-10-02T16:30[Europe/Paris]"));
666 assert_eq!((m.time, m.quiet), (Time::Several(Area::MIXED), false));
667 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-02T17:00:00");
668 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));
669 let m = mode(&hours, &[], &none, &self::none(), &at("2026-10-02T17:30[Europe/Paris]"));
671 assert_eq!((m.time, m.reason, m.quiet), (Time::Admin, Reason::AdminTime, true));
672 }
673
674 #[test]
675 fn old_leisure_windows_read_and_left_aside() {
676 let config: crate::config::Config = toml::from_str(
678 "[[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",
679 )
680 .unwrap();
681 assert_eq!(config.windows.len(), 3, "kept");
682 assert_eq!(config.week_hours().iter().map(|w| w.kind()).collect::<Vec<_>>(), ["work", "admin"]);
683 let none = Overrides::default();
684 let saturday = at("2026-10-03T11:00[Europe/Paris]");
685 let m = mode(&config.week_hours(), &[], &none, &self::none(), &saturday);
686 assert_eq!((m.time, m.reason), (Time::Leisure, Reason::DayOff));
687 let m = mode(&config.windows, &[], &none, &self::none(), &saturday);
689 assert_eq!((m.time, m.reason, m.week.work_hours, m.week.admin_hours), (Time::Leisure, Reason::DayOff, true, true));
690 let m = mode(&config.windows, &[], &none, &self::none(), &at("2026-10-02T20:00[Europe/Paris]"));
692 assert_eq!((m.time, m.reason.clone()), (Time::Leisure, Reason::Evening));
693 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-09T09:00:00", "never Saturday's old free time");
694 }
695
696 #[test]
697 fn sleep_then_meals_then_the_hours() {
698 let none = Overrides::default();
699 let friday = |time: &str| at(&format!("2026-10-02T{time}[Europe/Paris]"));
700 let blocks = |now: &Zoned| Blocks::of(&health(), &Days::default(), &[], now);
701 let mode_at = |time: &str, overrides: &Overrides| {
702 let now = if time.starts_with("+") { at(&format!("2026-10-03T{}[Europe/Paris]", &time[1..])) } else { friday(time) };
703 mode(&week(), &[], overrides, &blocks(&now), &now)
704 };
705 let m = mode_at("10:00", &none);
707 assert_eq!((m.time, m.reason.clone(), m.quiet), (Time::Work, Reason::Working, false));
708 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-02T12:10:00");
709 let m = mode_at("12:30", &none);
711 assert_eq!((m.time, m.reason.clone(), m.quiet), (Time::Meals, Reason::Meal, true));
712 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()));
713 assert!(m.week.meals && m.week.sleep);
714 let m = mode_at("14:10", &none);
716 assert_eq!((m.time, m.reason.clone()), (Time::Sleep, Reason::Nap));
717 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-02T14:35:00");
718 let m = mode_at("20:30", &none);
720 assert_eq!((m.time, m.reason.clone(), m.until.unwrap().datetime().to_string()), (Time::Leisure, Reason::Evening, "2026-10-02T22:00:00".into()));
721 assert_eq!(m.back.unwrap().datetime().to_string(), "2026-10-05T09:00:00", "work comes back on Monday");
722 let m = mode_at("22:30", &none);
723 assert!(m.sleeps() && m.quiet);
724 assert_eq!((m.time, m.reason.clone(), m.until.unwrap().datetime().to_string()), (Time::Sleep, Reason::WindingDown, "2026-10-03T07:00:00".into()));
725 assert_eq!(mode_at("23:30", &none).reason, Reason::Sleep);
726 assert_eq!((mode_at("+06:00", &none).time, mode_at("+06:00", &none).reason), (Time::Sleep, Reason::Sleep), "the night ending this morning");
727 let work_now = Overrides { work_now: Some(friday("23:59").timestamp().as_second() + 86_400 * 3), ..Overrides::default() };
729 assert_eq!(mode_at("23:30", &work_now).time, Time::Sleep);
730 assert_eq!(mode_at("12:30", &work_now).time, Time::Meals);
731 assert_eq!(mode_at("10:00", &work_now).reason, Reason::WorkNow);
732 let done = Overrides { rest_until: Some(friday("23:59").timestamp().as_second()), ..Overrides::default() };
733 assert_eq!(mode_at("12:30", &done).time, Time::Meals);
734 assert_eq!(mode_at("11:00", &done).reason, Reason::DoneForTheDay);
735 let now = friday("12:30");
737 assert_eq!(mode(&[], &[], &none, &blocks(&now), &now).time, Time::Meals);
738 let now = friday("10:00");
739 let m = mode(&[], &[], &none, &blocks(&now), &now);
740 assert_eq!((m.time, m.quiet), (Time::Any, false));
741 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-02T12:10:00");
742 let mut days = Days::default();
744 days.change(now.date(), "meal:1", |b| b.off = true);
745 let noon = friday("12:30");
746 assert_eq!(mode(&week(), &[], &none, &Blocks::of(&health(), &days, &[], &noon), &noon).time, Time::Work);
747 let mut days = Days::default();
748 days.change(now.date(), "meal:1", |b| b.quiet = true);
749 assert_eq!(mode(&week(), &[], &none, &Blocks::of(&health(), &days, &[], &noon), &noon).time, Time::Meals);
750 let no_night = Needs { sleep_on: false, ..health() };
752 let late = friday("23:30");
753 let blocks = Blocks::of(&no_night, &Days::default(), &[], &late);
754 assert!(!blocks.night && blocks.sleep, "naps still");
755 assert_eq!(mode(&week(), &[], &none, &blocks, &late).time, Time::Leisure);
756 }
757
758 #[test]
759 fn the_first_step_on_its_day() {
760 let monday: Date = "2026-10-05".parse().unwrap();
761 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() };
762 assert_eq!(overrides.first_step_on(monday), Some(("Open the renewal form (15 min)", "renewal")));
763 assert_eq!(overrides.first_step_on("2026-10-06".parse().unwrap()), None, "never the day after");
764 let closed = Overrides { rest_from: Some(at("2026-10-02T12:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() };
765 assert!(closed.closed_today(&at("2026-10-02T19:00[Europe/Paris]")));
766 assert!(!closed.closed_today(&at("2026-10-03T09:00[Europe/Paris]")), "today's plan is taken back the same day only");
767 }
768
769 #[test]
770 fn what_is_yours() {
771 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() };
772 let personal = vec!["Famille".to_string(), "joy".into()];
773 assert!(personal_task(&task(&["famille"], &[]), &personal, &[]));
774 assert!(personal_task(&task(&[], &["garden"]), &personal, &["garden".into()]));
775 assert!(!personal_task(&task(&["you"], &["taxes"]), &personal, &["garden".into()]));
776 let areas = TaskAreas { work_categories: vec!["travail".into()], work_projects: vec!["client-x".into()], ..TaskAreas::usual() };
777 let week = Week { work_hours: true, ..Week::default() };
778 let night = QuietTasks { time: Time::Sleep, week, areas: areas.clone(), no_movement: false };
780 assert!(!night.keeps(&task(&[], &["taxes"])) && !night.keeps(&task(&["joy"], &[])) && !night.keeps(&task(&["Travail"], &[])));
781 for time in [Time::Leisure, Time::Meals] {
783 let free = QuietTasks { time, ..night.clone() };
784 assert!(!free.keeps(&task(&[], &["taxes"])) && free.keeps(&task(&["joy"], &[])) && free.keeps(&task(&["santé"], &[])));
785 assert!(!free.keeps(&crate::tasks::Task { office_hours: true, ..task(&[], &["taxes"]) }), "offices keep business hours");
786 }
787 let working = QuietTasks { time: Time::Work, ..night.clone() };
789 assert!(working.keeps(&task(&[], &["taxes"])) && working.keeps(&task(&["Travail"], &[])) && !working.keeps(&task(&["joy"], &[])));
790 let set = QuietTasks { time: Time::Work, week: Week { admin_hours: true, ..week }, areas, no_movement: false };
792 let free = QuietTasks { time: Time::Leisure, no_movement: true, ..set.clone() };
794 assert!(!free.keeps(&task(&["joy", "Yoga"], &[])) && free.keeps(&task(&["joy"], &[])));
795 assert!(!set.keeps(&task(&[], &["taxes"])));
796 assert!(set.keeps(&crate::tasks::Task { office_hours: true, ..task(&[], &["taxes"]) }));
797 }
798
799 #[test]
800 fn hours_for_admin() {
801 let none = Overrides::default();
802 let mut hours = week();
803 hours.push(AdminWindow { day: "friday".into(), start: "18:00".into(), end: Some("19:00".into()), minutes: 0, kind: Some("admin".into()) });
804 let m = mode(&hours, &[], &none, &self::none(), &at("2026-10-02T18:30[Europe/Paris]"));
805 assert_eq!((m.time, m.reason.clone(), m.quiet), (Time::Admin, Reason::AdminTime, true));
806 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-02T19:00:00");
807 let m = mode(&hours, &[], &none, &self::none(), &at("2026-10-02T20:00[Europe/Paris]"));
809 assert_eq!((m.time, m.reason.clone()), (Time::Leisure, Reason::Evening));
810 assert_eq!(m.until.unwrap().datetime().to_string(), "2026-10-05T09:00:00");
811 assert_eq!(mode(&hours, &[], &none, &self::none(), &at("2026-10-02T10:00[Europe/Paris]")).time, Time::Work);
812 assert!(m.week.work_hours && m.week.admin_hours && !m.week.meals && !m.week.sleep);
813 assert_eq!(mode(&[], &[], &none, &self::none(), &at("2026-10-02T10:00[Europe/Paris]")).time, Time::Any);
814 }
815
816 #[test]
817 fn nothing_disturbs_but_doses() {
818 use crate::attention::{Attention, Column, Kind, Level, Now, Row};
819 let sleeping = Mode { quiet: true, time: Time::Sleep, week: Week::default(), reason: Reason::Sleep, until: None, back: None, label: String::new() };
820 let awake = Mode { time: Time::Leisure, reason: Reason::Evening, ..sleeping.clone() };
821 let usual = Attention::usual();
823 let comes = |matrix: &Attention, kind: Kind, mode: &Mode| matrix.level(Row::Own(kind), &Now::of(mode)) == Level::Now;
824 assert!(!comes(&usual, Kind::Move, &sleeping) && !comes(&usual, Kind::Dates, &sleeping), "no notification during sleep");
825 assert!(comes(&usual, Kind::Doses, &sleeping), "a dose comes: you set its time");
826 let mut silent = Attention::usual();
827 silent.set(Row::Own(Kind::Doses), Column::Sleep, Level::Later).unwrap();
828 assert!(!comes(&silent, Kind::Doses, &sleeping), "unless doses stay silent then");
829 assert!(comes(&silent, Kind::Move, &awake) && comes(&silent, Kind::Doses, &awake));
830 let winding = Mode { reason: Reason::WindingDown, ..sleeping };
831 assert!(winding.sleeps() && !comes(&usual, Kind::Move, &winding));
832 }
833}