1use crate::areas::Area;
29use crate::i18n::Translator;
30use crate::quiet::{Blocks, Overrides};
31use crate::window::AdminWindow;
32use jiff::Zoned;
33use jiff::civil::Date;
34use jiff::tz::TimeZone;
35use serde::{Deserialize, Serialize};
36use std::collections::BTreeMap;
37
38pub const WIND_DOWN_BUFFER: i64 = 60;
41pub const LATEST_AFTER: u32 = 120;
43pub const PACE: u32 = 6;
45
46fn yes() -> bool {
47 true
48}
49
50fn latest_after() -> u32 {
51 LATEST_AFTER
52}
53
54fn pace() -> u32 {
55 PACE
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
60pub struct FreeTimeSettings {
61 #[serde(default)]
63 pub nothing: bool,
64 #[serde(default = "yes")]
67 pub moves: bool,
68 #[serde(default = "latest_after")]
70 pub latest_after: u32,
71 #[serde(default)]
74 pub movement: Option<bool>,
75}
76
77impl Default for FreeTimeSettings {
78 fn default() -> FreeTimeSettings {
79 FreeTimeSettings { nothing: false, moves: true, latest_after: LATEST_AFTER, movement: None }
80 }
81}
82
83impl FreeTimeSettings {
84 pub fn movement(&self, planning: &crate::config::PlanningSettings) -> bool {
87 self.movement.unwrap_or(!planning.even_days)
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
93pub struct PauseSettings {
94 #[serde(default = "yes")]
98 pub doses: bool,
99 #[serde(default = "yes")]
102 pub people: bool,
103 #[serde(default)]
106 pub helps: Vec<String>,
107 #[serde(default)]
109 pub breathing: bool,
110 #[serde(default = "pace")]
112 pub pace: u32,
113 #[serde(default)]
115 pub grounding: String,
116 #[serde(default)]
118 pub after: Option<String>,
119 #[serde(default)]
122 pub country: Option<String>,
123 #[serde(default)]
125 pub gnome: bool,
126}
127
128impl Default for PauseSettings {
129 fn default() -> PauseSettings {
130 PauseSettings { doses: true, people: true, helps: Vec::new(), breathing: false, pace: PACE, grounding: String::new(), after: None, country: None, gnome: false }
131 }
132}
133
134impl PauseSettings {
135 pub fn after(&self) -> After {
136 After::parse(self.after.as_deref().unwrap_or(""))
137 }
138
139 pub fn pace(&self) -> u32 {
141 self.pace.clamp(3, 10)
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum After {
148 Lighter,
150 Rest,
152 AsIs,
154}
155
156impl After {
157 pub fn parse(text: &str) -> After {
158 match text.trim() {
159 "rest" => After::Rest,
160 "as-is" => After::AsIs,
161 _ => After::Lighter,
162 }
163 }
164
165 pub fn id(self) -> &'static str {
166 match self {
167 After::Lighter => "lighter",
168 After::Rest => "rest",
169 After::AsIs => "as-is",
170 }
171 }
172}
173
174impl Overrides {
177 pub fn free_from(&self) -> Option<i64> {
180 self.free_since.filter(|since| self.free_ended.is_none_or(|ended| ended < *since))
181 }
182
183 pub fn paused_from(&self) -> Option<i64> {
185 self.paused_since.filter(|since| self.paused_ended.is_none_or(|ended| ended < *since))
186 }
187
188 pub fn moved_end(&self, date: Date) -> Option<i64> {
190 self.extended_until.filter(|_| self.free_day == Some(date) && self.keep_end_on != Some(date))
191 }
192
193 pub fn lighter_on(&self, date: Date) -> bool {
195 self.lighter.contains(&date)
196 }
197
198 pub fn porch_rests(&self, stamp: i64) -> bool {
200 self.porch_rests_until.is_some_and(|until| stamp < until)
201 }
202}
203
204pub fn free_until(since: i64, blocks: &Blocks, zone: &TimeZone) -> i64 {
207 let midnight = next_midnight(since, zone);
208 let night = blocks.kept.iter().filter(|k| k.kind == "sleep" && k.start > since).map(|k| k.start).min();
209 night.map_or(midnight, |night| night.min(midnight))
210}
211
212fn next_midnight(stamp: i64, zone: &TimeZone) -> i64 {
214 jiff::Timestamp::from_second(stamp)
215 .ok()
216 .map(|t| t.to_zoned(zone.clone()).date())
217 .and_then(|d| d.tomorrow().ok())
218 .and_then(|d| d.to_zoned(zone.clone()).ok())
219 .map_or(stamp + 86_400, |z| z.timestamp().as_second())
220}
221
222fn midnight_of(date: Date, zone: &TimeZone) -> i64 {
223 date.to_zoned(zone.clone()).map_or(0, |z| z.timestamp().as_second())
224}
225
226pub fn nothing_now(overrides: &Overrides, settings: &FreeTimeSettings) -> bool {
228 overrides.free_nothing.unwrap_or(settings.nothing)
229}
230
231pub struct Evening<'a> {
235 pub work: &'a [AdminWindow],
237 pub hours: &'a [AdminWindow],
239 pub held: &'a [(i64, i64)],
241 pub blocks: &'a Blocks,
243 pub latest_after: u32,
245 pub slot: u32,
247}
248
249pub struct Day {
253 pub work: Vec<AdminWindow>,
254 pub hours: Vec<AdminWindow>,
255 pub held: Vec<(i64, i64)>,
256 pub blocks: Blocks,
257 pub latest_after: u32,
258 pub slot: u32,
259}
260
261impl Day {
262 pub fn of(config: &crate::config::Config, blocks: Blocks, events: &[crate::agenda::Occurrence]) -> Day {
264 let mut held = crate::plan::event_spans(events, crate::plan::PAUSE);
265 held.extend(blocks.kept.iter().filter(|k| k.kind != "sleep").map(|k| (k.start, k.end)));
266 let slot = if config.planning.gain_slots() { crate::capacity::GAIN_SLOT_MINUTES } else { 0 };
267 Day { work: config.working_hours(), hours: config.week_hours(), held, blocks, latest_after: config.free_time.latest_after, slot }
268 }
269
270 pub fn evening(&self) -> Evening<'_> {
271 Evening { work: &self.work, hours: &self.hours, held: &self.held, blocks: &self.blocks, latest_after: self.latest_after, slot: self.slot }
272 }
273}
274
275pub fn worked_away(work: &[AdminWindow], held: &[(i64, i64)], date: Date, zone: &TimeZone, from: i64, to: i64) -> u32 {
278 if to <= from {
279 return 0;
280 }
281 let inside: Vec<(i64, i64, Area)> = crate::plan::stretches(work, date, zone, false)
282 .into_iter()
283 .filter_map(|(a, b, kinds)| {
284 let (a, b) = (a.max(from), b.min(to));
285 (b > a).then_some((a, b, kinds))
286 })
287 .collect();
288 let seconds: i64 = crate::plan::less(&inside, held).iter().map(|(a, b, _)| b - a).sum();
289 u32::try_from(seconds / 60).unwrap_or(0)
290}
291
292pub fn extend(evening: &Evening, date: Date, zone: &TimeZone, lost: u32) -> Option<(i64, i64)> {
300 let (_, closing) = crate::window::hours_on(evening.work, date, zone)?;
301 let usual = closing.timestamp().as_second();
302 if lost == 0 {
303 return Some((usual, usual));
304 }
305 let midnight = date.tomorrow().ok().map_or(usual + 86_400, |d| midnight_of(d, zone));
306 let night = evening.blocks.kept.iter().filter(|k| k.kind == "sleep" && k.start >= usual).map(|k| k.start).min();
307 let mut cap = (usual + i64::from(evening.latest_after) * 60).min(midnight);
308 if let Some(night) = night {
309 cap = cap.min(night - WIND_DOWN_BUFFER * 60);
310 }
311 let mut busy = evening.held.to_vec();
313 busy.extend(crate::plan::stretches(evening.hours, date, zone, false).into_iter().filter(|s| !s.2.work).map(|s| (s.0, s.1)));
314 if evening.slot > 0
316 && let Some(night) = night
317 {
318 let slot = i64::from(evening.slot) * 60;
319 let latest = crate::plan::less(&[(usual, night, Area::ALL)], &busy).iter().filter(|g| g.1 - g.0 >= slot).map(|g| g.1 - slot).max();
320 if let Some(latest) = latest {
321 cap = cap.min(latest);
322 }
323 }
324 if cap <= usual {
325 return Some((usual, usual));
326 }
327 let (mut owed, mut end) = (i64::from(lost) * 60, usual);
328 for (a, b, _) in crate::plan::less(&[(usual, cap, Area::ALL)], &busy) {
329 let take = owed.min(b - a);
330 end = a + take;
331 owed -= take;
332 if owed == 0 {
333 break;
334 }
335 }
336 Some((usual, end))
337}
338
339pub fn start_free(overrides: &mut Overrides, now: i64) {
341 overrides.free_since = Some(now);
342 overrides.free_nothing = None;
343}
344
345pub fn end_free(overrides: &mut Overrides, evening: &Evening, now: &Zoned, moves: bool) -> Option<i64> {
349 let stamp = now.timestamp().as_second();
350 let since = overrides.free_from()?;
351 overrides.free_ended = Some(stamp);
352 let (today, zone) = (now.date(), now.time_zone());
353 if overrides.free_day != Some(today) {
354 overrides.free_day = Some(today);
355 overrides.free_lost = None;
356 overrides.extended_until = None;
357 }
358 let to = stamp.min(free_until(since, evening.blocks, zone));
359 let lost = overrides.free_lost.unwrap_or(0) + worked_away(evening.work, evening.held, today, zone, since.max(midnight_of(today, zone)), to);
360 overrides.free_lost = Some(lost);
361 if !moves || overrides.keep_end_on == Some(today) {
362 overrides.extended_until = None;
363 return None;
364 }
365 let (usual, end) = extend(evening, today, zone, lost)?;
366 overrides.extended_until = (end > usual).then_some(end);
367 overrides.extended_until
368}
369
370pub fn keep_usual_end(overrides: &mut Overrides, today: Date) {
373 overrides.keep_end_on = Some(today);
374 overrides.extended_until = None;
375}
376
377pub fn moved_today(overrides: &Overrides, evening: &Evening, now: &Zoned, moves: bool) -> Option<(i64, i64)> {
381 let today = now.date();
382 let end = if overrides.free_from().is_some() {
383 let mut pending = overrides.clone();
384 end_free(&mut pending, evening, now, moves)
385 } else {
386 overrides.moved_end(today)
387 }?;
388 let (_, closing) = crate::window::hours_on(evening.work, today, now.time_zone())?;
389 let usual = closing.timestamp().as_second();
390 (end > usual).then_some((usual, end))
391}
392
393pub fn start_pause(overrides: &mut Overrides, now: i64) {
397 overrides.paused_since = Some(now);
398}
399
400pub fn end_pause(overrides: &mut Overrides, now: &Zoned, after: After, rest: Option<i64>, porch: Option<i64>) {
404 let stamp = now.timestamp().as_second();
405 overrides.paused_ended = Some(stamp);
406 let today = now.date();
407 overrides.lighter.retain(|d| *d >= today);
408 match after {
409 After::Lighter if !overrides.lighter.contains(&today) => overrides.lighter.push(today),
410 After::Rest => {
411 if let Some(back) = rest {
412 overrides.work_until = None;
413 overrides.work_now = None;
414 overrides.rest_until = Some(back);
415 overrides.rest_from = Some(stamp);
416 }
417 }
418 _ => {}
419 }
420 overrides.porch_rests_until = porch.filter(|p| *p > stamp);
421}
422
423pub fn lighten(overrides: &mut Overrides, date: Date) {
425 if !overrides.lighter.contains(&date) {
426 overrides.lighter.push(date);
427 overrides.lighter.sort();
428 }
429}
430
431pub fn forget(overrides: &mut Overrides) {
433 overrides.paused_since = None;
434 overrides.paused_ended = None;
435}
436
437pub fn today_level(overrides: &Overrides, today: Date, weather: crate::today::Weather) -> (u32, u32) {
441 let (room, heavy) = (weather.room(), weather.heavy());
442 if overrides.lighter_on(today) {
443 let haze = crate::today::Weather::Haze;
444 (room.min(haze.room()), heavy.min(haze.heavy()))
445 } else {
446 (room, heavy)
447 }
448}
449
450pub fn is_movement(task: &crate::tasks::Task) -> bool {
456 crate::words::named_in(&task.categories, &crate::words::current().tasks.movement)
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
463pub struct Help {
464 pub text: String,
465 pub open: String,
467}
468
469pub fn help(line: &str) -> Help {
472 let is_link = |word: &str| {
473 let scheme = word.split_once(':').filter(|(s, rest)| s.len() >= 3 && s.chars().all(|c| c.is_ascii_alphanumeric() || "+.-".contains(c)) && !rest.is_empty());
474 word.contains("://") || word.starts_with('/') || word.starts_with("~/") || scheme.is_some()
475 };
476 let words: Vec<&str> = line.split_whitespace().collect();
477 match words.iter().position(|w| is_link(w)) {
478 Some(at) => {
479 let link = words[at];
480 let open = if let Some(rest) = link.strip_prefix("~/") {
481 format!("file://{}", crate::config::expand_home(&format!("~/{rest}")).display())
482 } else if link.starts_with('/') {
483 format!("file://{link}")
484 } else {
485 link.to_string()
486 };
487 let text: Vec<&str> = words.iter().enumerate().filter(|(i, _)| *i != at).map(|(_, w)| *w).collect();
488 let text = text.join(" ").trim_end_matches([':', '-', '–', '—', ',']).trim().to_string();
489 let text = if text.is_empty() { link.trim_end_matches('/').rsplit('/').next().unwrap_or(link).to_string() } else { text };
490 Help { text, open }
491 }
492 None => Help { text: words.join(" "), open: String::new() },
493 }
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
498pub struct Number {
499 pub label: String,
501 pub number: String,
502 pub url: String,
504 pub about: String,
506}
507
508#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
511pub struct CrisisLines {
512 pub country: Vec<Country>,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
516pub struct Country {
517 pub code: String,
519 pub line: Vec<Line>,
520}
521
522#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
523pub struct Line {
524 pub kind: String,
527 pub number: String,
528 #[serde(default)]
530 pub name: String,
531 pub how: Vec<String>,
533 #[serde(default)]
535 pub sms: String,
536 #[serde(default)]
538 pub about: BTreeMap<String, String>,
539 pub source: String,
541 pub checked: String,
542}
543
544pub const KINDS: [&str; 6] = ["emergency", "crisis", "medical", "text", "care", "callback"];
546
547const CRISIS_LINES: &str = include_str!("../data/crisis-lines.toml");
548
549impl CrisisLines {
550 pub fn built_in() -> CrisisLines {
551 toml::from_str(CRISIS_LINES).unwrap_or(CrisisLines { country: Vec::new() })
552 }
553
554 pub fn of(&self, code: &str) -> Option<&Country> {
556 let code = code.trim();
557 self.country.iter().find(|c| c.code.eq_ignore_ascii_case(code)).or_else(|| self.country.iter().find(|c| c.code == "EU"))
558 }
559}
560
561pub fn callback(code: &str) -> Option<String> {
565 let lines = CrisisLines::built_in();
566 let country = lines.country.iter().find(|c| c.code.eq_ignore_ascii_case(code.trim()))?;
567 country.line.iter().find(|l| l.kind == "callback").map(|l| l.number.clone())
568}
569
570pub fn country(settings: &PauseSettings, contacts_region: Option<&str>) -> String {
574 let chosen = |s: Option<&str>| s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_ascii_uppercase);
575 chosen(settings.country.as_deref())
576 .or_else(|| chosen(contacts_region))
577 .or_else(|| ["LC_ALL", "LC_TELEPHONE", "LANG"].iter().filter_map(|v| std::env::var(v).ok()).find_map(|v| crate::phones::region_of_locale(&v)).map(|r| r.code.to_ascii_uppercase()))
578 .unwrap_or_default()
579}
580
581pub fn numbers(lines: &CrisisLines, code: &str, tr: &Translator) -> (Vec<Number>, Vec<Number>) {
585 let Some(country) = lines.of(code) else { return (Vec::new(), Vec::new()) };
586 let mut ordered: Vec<&Line> = country.line.iter().filter(|l| l.kind != "callback").collect();
587 ordered.sort_by_key(|l| KINDS.iter().position(|k| *k == l.kind).unwrap_or(KINDS.len()));
588 let shown = |line: &Line| {
589 let text_only = !line.how.iter().any(|h| h == "call");
590 let url = if text_only {
591 let digits: String = line.number.chars().filter(char::is_ascii_digit).collect();
592 if line.sms.is_empty() { format!("sms:{digits}") } else { format!("sms:{digits}?body={}", line.sms) }
593 } else {
594 format!("tel:{}", line.number.chars().filter(char::is_ascii_digit).collect::<String>())
595 };
596 let mut args = crate::i18n::args();
597 args.set("word", line.sms.clone());
598 let how = if line.how.len() > 1 {
599 tr.text("pause-number-call-or-text", None)
600 } else if text_only && !line.sms.is_empty() {
601 tr.text("pause-number-text-word", Some(&args))
602 } else if text_only && line.kind != "text" {
603 tr.text("pause-number-by-text", None)
605 } else {
606 String::new()
607 };
608 let about = [line.name.clone(), line.about.get(tr.language()).cloned().unwrap_or_default(), how].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" · ");
609 Number { label: tr.text(&format!("pause-number-{}", line.kind), None), number: line.number.clone(), url, about }
610 };
611 let first: Vec<&Line> = ["emergency", "crisis"].iter().filter_map(|k| ordered.iter().copied().find(|l| l.kind == *k)).collect();
612 let main = first.iter().map(|l| shown(l)).collect();
613 let more = ordered.iter().filter(|l| !first.iter().any(|f| std::ptr::eq(*f, **l))).map(|l| shown(l)).collect();
614 (main, more)
615}
616
617#[derive(Debug, Clone, PartialEq, Serialize)]
620pub struct Screen {
621 pub doses: bool,
623 pub helps: Vec<Help>,
624 pub breathing: bool,
625 pub pace: u32,
627 pub grounding: String,
628 pub numbers: Vec<Number>,
629 pub more: Vec<Number>,
630 pub country: String,
632}
633
634pub fn screen(settings: &PauseSettings, contacts_region: Option<&str>, tr: &Translator) -> Screen {
635 let code = country(settings, contacts_region);
636 let lines = CrisisLines::built_in();
637 let (numbers, more) = numbers(&lines, &code, tr);
638 let known = lines.country.iter().any(|c| c.code.eq_ignore_ascii_case(&code) && c.code != "EU");
639 Screen {
640 doses: settings.doses,
641 helps: settings.helps.iter().filter(|l| !l.trim().is_empty()).map(|l| help(l)).collect(),
642 breathing: settings.breathing,
643 pace: settings.pace(),
644 grounding: settings.grounding.trim().to_string(),
645 numbers,
646 more,
647 country: if known { tr.text(&format!("country-{}", code.to_ascii_lowercase()), None) } else { String::new() },
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use crate::areas::{TaskAreas, Time};
655 use crate::needs::{Days, Needs};
656 use crate::plan::{Settings, plan};
657 use crate::attention::{Attention, Column, Kind, Level, Now, Person, Row};
658 use crate::quiet::{Reason, mode};
659 use crate::reach::Channel;
660 use std::collections::BTreeSet;
661
662 fn at(text: &str) -> Zoned {
663 format!("{text}[Europe/Paris]").parse().unwrap()
664 }
665
666 fn stamp(text: &str) -> i64 {
667 at(text).timestamp().as_second()
668 }
669
670 fn week() -> Vec<AdminWindow> {
672 ["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()
673 }
674
675 fn health(now: &Zoned) -> Blocks {
678 Blocks::of(&Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() }, &Days::default(), &[], now)
679 }
680
681 fn evening<'a>(work: &'a [AdminWindow], held: &'a [(i64, i64)], blocks: &'a Blocks) -> Evening<'a> {
682 Evening { work, hours: work, held, blocks, latest_after: LATEST_AFTER, slot: 30 }
683 }
684
685 fn kept(blocks: &Blocks) -> Vec<(i64, i64)> {
686 blocks.kept.iter().filter(|k| k.kind != "sleep").map(|k| (k.start, k.end)).collect()
687 }
688
689 fn hm(seconds: i64) -> String {
690 jiff::Timestamp::from_second(seconds).unwrap().to_zoned(TimeZone::get("Europe/Paris").unwrap()).strftime("%H:%M").to_string()
691 }
692
693 #[test]
694 fn the_pause_comes_first_and_doses_and_alarms_still_come() {
695 let paused = Overrides { paused_since: Some(stamp("2026-10-02T10:00")), ..Overrides::default() };
697 for time in ["10:30", "12:30", "14:10", "20:30", "23:30"] {
698 let now = at(&format!("2026-10-02T{time}"));
699 let m = mode(&week(), &[], &paused, &health(&now), &now);
700 assert_eq!((m.reason.clone(), m.time, m.quiet), (Reason::Paused, Time::Sleep, true), "{time}: above work, meals, naps, evenings and the night");
701 let (usual, n) = (Attention::usual(), Now::of(&m));
704 let comes = |matrix: &Attention, kind: Kind| matrix.level(Row::Own(kind), &n) == Level::Now;
705 let mut held = Attention::usual();
706 held.set(Row::Own(Kind::Doses), Column::Pause, Level::Later).unwrap();
707 assert!(comes(&usual, Kind::Doses) && !comes(&held, Kind::Doses));
708 assert!(comes(&usual, Kind::Alarms) && comes(&usual, Kind::Codes));
709 assert!(!comes(&usual, Kind::Move) && !comes(&usual, Kind::Needs));
710 }
711 let now = at("2026-10-02T11:00");
713 for others in [
714 Overrides { rest_until: Some(stamp("2026-10-05T09:00")), ..paused.clone() },
715 Overrides { work_now: Some(stamp("2026-10-02T17:00")), ..paused.clone() },
716 Overrides { free_since: Some(stamp("2026-10-02T10:30")), ..paused.clone() },
717 ] {
718 assert_eq!(mode(&week(), &[], &others, &health(&now), &now).reason, Reason::Paused);
719 }
720 let ended = Overrides { paused_ended: Some(stamp("2026-10-02T10:45")), ..paused.clone() };
722 assert_eq!(mode(&week(), &[], &ended, &health(&now), &now).reason, Reason::Working);
723 let again = Overrides { paused_since: Some(stamp("2026-10-02T10:50")), ..ended };
724 assert_eq!(mode(&week(), &[], &again, &health(&now), &now).reason, Reason::Paused);
725 }
726
727 #[test]
728 fn free_time_is_leisure_with_the_safe_list_only() {
729 let now = at("2026-10-02T15:00");
730 let free = Overrides { free_since: Some(stamp("2026-10-02T14:40")), ..Overrides::default() };
731 let m = mode(&week(), &[], &free, &health(&now), &now);
732 assert_eq!((m.reason.clone(), m.time, m.quiet), (Reason::FreeTime, Time::Leisure, true), "leisure in working hours");
733 assert_eq!(m.until.as_ref().map(|z| z.strftime("%H:%M").to_string()), Some("22:00".into()), "until the night's start");
734 let (usual, n) = (Attention::usual(), Now::of(&m));
736 let level = |kind: Kind| usual.level(Row::Own(kind), &n);
737 assert!(level(Kind::Doses) == Level::Now && level(Kind::Codes) == Level::Now && level(Kind::Alarms) == Level::Event);
738 assert!(level(Kind::Move) != Level::Now);
739 assert_eq!((usual.person(Channel::Calls, Person::Safe, false, &n), usual.person(Channel::Mail, Person::Safe, false, &n)), (Level::Now, Level::Quiet));
741 for person in [Person::Neutral, Person::Restricted, Person::Stranger] {
742 assert_eq!(usual.person(Channel::Mail, person, false, &n), Level::Later, "{person:?}");
743 }
744 let mut cells = Attention::usual();
746 cells.set(Row::People(Channel::Mail, Person::Neutral), Column::Free, Level::Now).unwrap();
747 assert_eq!(cells.person(Channel::Mail, Person::Neutral, false, &n), Level::Now);
748 let nothing = Now { nothing: true, ..n.clone() };
750 assert_eq!((usual.person(Channel::Calls, Person::Safe, false, ¬hing), usual.person(Channel::Calls, Person::Safe, true, ¬hing)), (Level::Later, Level::Now));
751 assert!(nothing_now(&Overrides { free_nothing: Some(true), ..free.clone() }, &FreeTimeSettings::default()));
752 assert!(nothing_now(&free, &FreeTimeSettings { nothing: true, ..FreeTimeSettings::default() }));
753 let nap = at("2026-10-02T14:10");
755 let early = Overrides { free_since: Some(stamp("2026-10-02T13:30")), ..Overrides::default() };
756 assert_eq!(mode(&week(), &[], &early, &health(&nap), &nap).reason, Reason::Nap);
757 let late = at("2026-10-02T21:30");
758 assert_eq!(mode(&week(), &[], &free, &health(&late), &late).reason, Reason::FreeTime);
759 let night = at("2026-10-02T22:30");
760 assert_eq!(mode(&week(), &[], &free, &health(&night), &night).reason, Reason::WindingDown);
761 let morning = at("2026-10-03T10:00");
762 assert_eq!(mode(&week(), &[], &free, &health(&morning), &morning).reason, Reason::DayOff, "over with the night");
763 let no_night = Blocks::default();
765 assert_eq!(free_until(stamp("2026-10-02T14:40"), &no_night, now.time_zone()), stamp("2026-10-03T00:00"));
766 let walk = crate::tasks::Task { categories: vec!["Marche".into()], ..crate::tasks::Task::default() };
768 assert!(is_movement(&walk) && !is_movement(&crate::tasks::Task { categories: vec!["joy".into()], ..crate::tasks::Task::default() }));
769 let planning = crate::config::PlanningSettings { even_days: true, ..Default::default() };
770 assert!(!FreeTimeSettings::default().movement(&planning), "energy-limiting illness: no exercise offered");
771 assert!(FreeTimeSettings::default().movement(&crate::config::PlanningSettings::default()));
772 assert!(FreeTimeSettings { movement: Some(true), ..FreeTimeSettings::default() }.movement(&planning), "unless asked");
773 }
774
775 #[test]
776 fn the_end_of_work_moves_by_the_working_time_taken_within_its_caps() {
777 let now = at("2026-10-02T16:00");
778 let blocks = health(&now);
779 let held = kept(&blocks);
780 let work = week();
781 let ev = evening(&work, &held, &blocks);
782 let zone = now.time_zone();
783 let date = now.date();
784 assert_eq!(worked_away(&work, &held, date, zone, stamp("2026-10-02T14:00"), stamp("2026-10-02T16:00")), 85);
786 assert_eq!(worked_away(&work, &held, date, zone, stamp("2026-10-02T17:30"), stamp("2026-10-02T18:30")), 0, "after work: nothing moves");
787 assert_eq!(extend(&ev, date, zone, 60).map(|(u, e)| (hm(u), hm(e))), Some(("17:00".into(), "18:00".into())));
789 assert_eq!(extend(&ev, date, zone, 150).map(|(_, e)| hm(e)), Some("19:00".into()));
791 let late = Evening { latest_after: 300, ..evening(&work, &held, &blocks) };
793 assert_eq!(extend(&late, date, zone, 150).map(|(_, e)| hm(e)), Some("20:30".into()));
794 assert_eq!(extend(&late, date, zone, 400).map(|(_, e)| hm(e)), Some("21:00".into()), "never past the wind-down less an hour");
795 let mut busy = held.clone();
797 busy.push((stamp("2026-10-02T18:00"), stamp("2026-10-02T19:00")));
798 busy.push((stamp("2026-10-02T20:00"), stamp("2026-10-02T21:40")));
799 let full = Evening { latest_after: 300, held: &busy, ..evening(&work, &held, &blocks) };
800 assert_eq!(extend(&full, date, zone, 400).map(|(_, e)| hm(e)), Some("17:30".into()), "half of the gap at 17:00 kept for you: 21:40–22:00 is too short");
801 let saturday = at("2026-10-03T11:00");
803 assert_eq!(extend(&ev, saturday.date(), zone, 60), None);
804 let none = Blocks::default();
806 let no_night = Evening { latest_after: 600, blocks: &none, ..evening(&work, &held, &blocks) };
807 assert_eq!(extend(&no_night, date, zone, 600).map(|(_, e)| hm(e)), Some("00:00".into()));
808 }
809
810 #[test]
811 fn free_time_ending_moves_the_end_and_keep_my_usual_end_moves_nothing() {
812 let blocks = health(&at("2026-10-02T12:00"));
813 let held = kept(&blocks);
814 let work = week();
815 let ev = evening(&work, &held, &blocks);
816 let mut o = Overrides::default();
817 start_free(&mut o, stamp("2026-10-02T14:00"));
818 let back = at("2026-10-02T15:00");
820 assert_eq!(end_free(&mut o, &ev, &back, true).map(hm), Some("17:25".into()));
821 assert!(o.free_from().is_none());
822 assert_eq!(o.free_lost, Some(25));
823 let at_17 = at("2026-10-02T17:10");
825 let m = mode(&work, &[], &o, &health(&at_17), &at_17);
826 assert_eq!((m.reason.clone(), m.time, m.quiet), (Reason::Extended, Time::Work, false));
827 assert_eq!(m.until.map(|z| z.strftime("%H:%M").to_string()), Some("17:25".into()));
828 let after = at("2026-10-02T17:30");
829 assert_eq!(mode(&work, &[], &o, &health(&after), &after).reason, Reason::Evening);
830 start_free(&mut o, stamp("2026-10-02T15:30"));
832 assert_eq!(end_free(&mut o, &ev, &at("2026-10-02T16:30"), true).map(hm), Some("18:25".into()));
833 keep_usual_end(&mut o, back.date());
835 assert_eq!(o.moved_end(back.date()), None);
836 let at_17 = at("2026-10-02T17:10");
837 assert_eq!(mode(&work, &[], &o, &health(&at_17), &at_17).reason, Reason::Evening);
838 start_free(&mut o, stamp("2026-10-02T16:35"));
839 assert_eq!(end_free(&mut o, &ev, &at("2026-10-02T16:50"), true), None);
840 let mut kept_end = Overrides::default();
842 start_free(&mut kept_end, stamp("2026-10-05T10:00"));
843 assert_eq!(end_free(&mut kept_end, &ev, &at("2026-10-05T11:00"), false), None);
844 let mut done = Overrides::default();
846 start_free(&mut done, stamp("2026-10-05T10:00"));
847 end_free(&mut done, &ev, &at("2026-10-05T11:00"), true);
848 done.rest_until = Some(stamp("2026-10-06T09:00"));
849 let monday = at("2026-10-05T17:10");
850 assert_eq!(mode(&work, &[], &done, &health(&monday), &monday).reason, Reason::DoneForTheDay);
851 let mut going = Overrides::default();
853 start_free(&mut going, stamp("2026-10-05T15:00"));
854 let pending = moved_today(&going, &ev, &at("2026-10-05T16:00"), true).map(|(u, e)| (hm(u), hm(e)));
855 assert_eq!(pending, Some(("17:00".into(), "18:00".into())));
856 assert!(going.free_from().is_some(), "only seen, not ended");
857 let tuesday = at("2026-10-06T17:10");
859 assert_eq!(mode(&work, &[], &o, &health(&tuesday), &tuesday).reason, Reason::Evening);
860 }
861
862 fn task(uid: &str, minutes: u32, light: bool) -> crate::tasks::Task {
864 crate::tasks::Task { uid: uid.into(), title: uid.into(), estimate: minutes, energy: if light { "light".into() } else { String::new() }, ..crate::tasks::Task::default() }
865 }
866
867 #[test]
868 fn the_extension_adds_hours_for_light_steps_and_what_does_not_fit_moves_on() {
869 let now = at("2026-10-05T16:00");
871 let windows = week();
872 let usual = Settings::of_hours(&windows, TaskAreas::usual()).with_events(&now, &[]);
873 let moved = Settings::of_hours(&windows, TaskAreas::usual()).with_extension(Some((stamp("2026-10-05T17:00"), stamp("2026-10-05T18:00")))).with_events(&now, &[]);
874 assert_eq!(usual.room_on(now.date()).total() + 60, moved.room_on(now.date()).total(), "an hour more today");
875 assert_eq!(moved.light_only, 60);
876 let tasks = vec![task("hard", 90, false), task("easy", 45, true)];
877 let (none, set) = (BTreeMap::new(), BTreeSet::new());
878 let made = plan(&tasks, now.date(), &moved, &none, &set);
879 assert_eq!(made.items["easy"].start, Some(now.date()));
881 assert_eq!(made.items["hard"].finish, Some(now.date().tomorrow().unwrap()));
882 let today_minutes: u32 = made.days.get(&now.date()).map(|d| d.iter().filter(|(uid, _)| uid == "hard").map(|(_, m)| *m).sum()).unwrap_or(0);
883 assert!((1..=60).contains(&today_minutes), "a step that is not light never takes the extension: {today_minutes}");
884 assert_eq!(moved.capacity.day_limit(now.date(), 1.0), usual.capacity.day_limit(now.date(), 1.0));
886 let day = crate::dayview::day(&now, &[], &made, &tasks, &moved);
888 let five = stamp("2026-10-05T17:00");
889 assert!(day.blocks.iter().filter(|b| b.kind == "task" && b.key == "hard").all(|b| b.end <= five), "{:?}", day.blocks);
890 assert!(day.blocks.iter().any(|b| b.kind == "task" && b.key == "easy" && b.start >= five), "the light step after the usual end: {:?}", day.blocks);
891 assert!(day.hours.last().is_some_and(|h| h.end == stamp("2026-10-05T18:00")), "today's hours run to 18:00");
892 }
893
894 #[test]
895 fn the_pause_moves_work_to_later_days_never_into_the_evening() {
896 let mut o = Overrides::default();
898 start_pause(&mut o, stamp("2026-10-05T14:00"));
899 let back = at("2026-10-05T16:30");
900 end_pause(&mut o, &back, After::Lighter, None, None);
901 assert!(o.paused_from().is_none());
902 assert_eq!(o.moved_end(back.date()), None);
903 let evening = at("2026-10-05T17:30");
904 assert_eq!(mode(&week(), &[], &o, &health(&evening), &evening).reason, Reason::Evening, "the day is not extended");
905 let windows = week();
907 let mut settings = Settings::of_hours(&windows, TaskAreas::usual()).with_events(&back, &[]);
908 (settings.today_percent, settings.heavy_today) = today_level(&o, back.date(), crate::today::Weather::Clear);
909 assert_eq!((settings.today_percent, settings.heavy_today), (60, 1), "the bad-day level");
910 let tasks = vec![task("report", 120, false)];
911 let made = plan(&tasks, back.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
912 let today: u32 = made.days.get(&back.date()).map(|d| d.iter().map(|(_, m)| *m).sum()).unwrap_or(0);
913 assert!(today <= 30 * 60 / 100 + 5, "a lighter half hour at most: {today}");
914 assert!(made.items["report"].finish > Some(back.date()));
915 lighten(&mut o, back.date().tomorrow().unwrap());
917 assert_eq!(today_level(&o, back.date().tomorrow().unwrap(), crate::today::Weather::Clear), (60, 1));
918 assert_eq!(today_level(&o, back.date().tomorrow().unwrap(), crate::today::Weather::Fog), (30, 0), "never heavier than the weather said");
919 let mut later = o.clone();
920 start_pause(&mut later, stamp("2026-10-08T10:00"));
921 end_pause(&mut later, &at("2026-10-08T10:20"), After::AsIs, None, None);
922 assert!(later.lighter.is_empty(), "{:?}", later.lighter);
923 let mut rest = Overrides::default();
925 start_pause(&mut rest, stamp("2026-10-05T10:00"));
926 end_pause(&mut rest, &at("2026-10-05T10:30"), After::Rest, Some(stamp("2026-10-06T09:00")), Some(stamp("2026-10-05T14:00")));
927 let noon = at("2026-10-05T12:00");
928 assert_eq!(mode(&week(), &[], &rest, &health(&noon), &noon).reason, Reason::DoneForTheDay);
929 assert!(rest.porch_rests(stamp("2026-10-05T13:00")) && !rest.porch_rests(stamp("2026-10-05T14:00")));
930 forget(&mut rest);
932 assert_eq!((rest.paused_since, rest.paused_ended), (None, None));
933 }
934
935 #[test]
936 fn the_pauses_travel_between_devices() {
937 let mut here = Overrides::default();
940 start_pause(&mut here, 1_759_390_000);
941 start_free(&mut here, 1_759_380_000);
942 here.free_ended = Some(1_759_385_000);
943 here.free_day = Some("2026-10-02".parse().unwrap());
944 here.free_lost = Some(25);
945 here.extended_until = Some(1_759_420_000);
946 here.lighter = vec!["2026-10-02".parse().unwrap()];
947 here.porch_rests_until = Some(1_759_430_000);
948 let text = toml::to_string(&here).unwrap();
949 let there: Overrides = toml::from_str(&text).unwrap();
950 assert_eq!(there, here, "{text}");
951 assert!(there.paused_from().is_some() && there.free_from().is_none());
952 let mut merged: toml::Table = toml::from_str(&text).unwrap();
954 merged.insert("paused_ended".into(), toml::Value::Integer(1_759_391_000));
955 let ended: Overrides = toml::from_str(&toml::to_string(&merged).unwrap()).unwrap();
956 assert!(ended.paused_from().is_none());
957 let old: Overrides = toml::from_str("work_until = 5\n").unwrap();
959 assert!(old.paused_from().is_none() && old.free_from().is_none() && old.lighter.is_empty());
960 assert_eq!(toml::to_string(&Overrides::default()).unwrap().trim(), "");
962 }
963
964 #[test]
965 fn crisis_lines_are_well_formed() {
966 let lines = CrisisLines::built_in();
967 assert!(!lines.country.is_empty(), "data/crisis-lines.toml reads");
968 let mut codes = BTreeSet::new();
969 let today = jiff::Zoned::now().date();
970 for country in &lines.country {
971 assert!(codes.insert(country.code.clone()), "{} twice", country.code);
972 assert!(country.code == "EU" || (country.code.len() == 2 && country.code.chars().all(|c| c.is_ascii_uppercase())), "{}", country.code);
973 assert!(country.line.iter().any(|l| l.kind == "emergency"), "{}: an emergency number", country.code);
974 for line in &country.line {
975 assert!(KINDS.contains(&line.kind.as_str()), "{}: {}", country.code, line.kind);
976 assert!(!line.number.is_empty() && line.number.chars().all(|c| c.is_ascii_digit() || c == ' '), "{}: {}", country.code, line.number);
977 assert!(!line.how.is_empty() && line.how.iter().all(|h| h == "call" || h == "text"), "{}: {:?}", line.number, line.how);
978 assert!(line.source.starts_with("https://"), "{}: {}", line.number, line.source);
979 let checked: Date = line.checked.parse().unwrap_or_else(|_| panic!("{}: {}", line.number, line.checked));
980 assert!(checked <= today && checked >= Date::constant(2026, 1, 1), "{}: {}", line.number, line.checked);
981 assert!(line.about.keys().all(|l| l == "en" || l == "fr"), "{}", line.number);
982 }
983 }
984 let has = |code: &str, number: &str| lines.of(code).is_some_and(|c| c.code == code && c.line.iter().any(|l| l.number == number));
985 for (code, number) in [("FR", "3114"), ("FR", "15"), ("FR", "112"), ("FR", "114"), ("EU", "112"), ("GB", "116 123"), ("GB", "999"), ("US", "988"), ("US", "911"), ("CA", "988"), ("CA", "911")] {
986 assert!(has(code, number), "{code} {number}");
987 }
988 assert_eq!(lines.of("ZZ").map(|c| c.code.as_str()), Some("EU"));
990 let tr = Translator::new("en");
992 let (first, more) = numbers(&lines, "FR", &tr);
993 assert_eq!(first.iter().map(|n| (n.number.as_str(), n.url.as_str())).collect::<Vec<_>>(), [("112", "tel:112"), ("3114", "tel:3114")]);
994 assert!(more.iter().any(|n| n.number == "15") && more.iter().any(|n| n.number == "114" && n.url == "sms:114"));
995 assert!(first.iter().chain(&more).all(|n| n.number != "0 800 112 112"), "the callback number is never given to call");
996 assert_eq!(callback("fr").as_deref(), Some("0 800 112 112"));
997 assert_eq!(callback("GB"), None);
998 let (_, uk) = numbers(&lines, "GB", &tr);
999 assert!(uk.iter().any(|n| n.url == "sms:85258?body=SHOUT"), "{uk:?}");
1000 assert!(first.iter().chain(&more).all(|n| !n.label.starts_with("pause-")), "{first:?} {more:?}");
1001 assert_eq!(country(&PauseSettings { country: Some("gb".into()), ..PauseSettings::default() }, Some("FR")), "GB");
1003 assert_eq!(country(&PauseSettings::default(), Some("ca")), "CA");
1004 }
1005
1006 #[test]
1007 fn what_helps_opens_what_you_chose() {
1008 assert_eq!(help("Headphones on"), Help { text: "Headphones on".into(), open: String::new() });
1009 assert_eq!(help("The blue playlist: https://music.example/blue"), Help { text: "The blue playlist".into(), open: "https://music.example/blue".into() });
1010 assert_eq!(help("/home/me/Pictures/Calm").text, "Calm");
1011 assert_eq!(help("Photos /home/me/Pictures/Calm").open, "file:///home/me/Pictures/Calm");
1012 assert_eq!(help("Notes: nothing else").open, "", "a colon is not a link");
1013 let s = screen(&PauseSettings { helps: vec!["Tea".into(), " ".into()], country: Some("FR".into()), ..PauseSettings::default() }, None, &Translator::new("fr"));
1014 assert_eq!((s.helps.len(), s.breathing, s.pace, s.country.as_str()), (1, false, PACE, "France"), "the breathing guide off unless switched on");
1015 }
1016}