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, saw: i64) {
400 overrides.paused_since = Some(now);
401 overrides.pause_saw = (saw > 0).then_some(saw);
402}
403
404pub fn end_pause(overrides: &mut Overrides, now: &Zoned, after: After, rest: Option<i64>, porch: Option<i64>) {
408 let stamp = now.timestamp().as_second();
409 overrides.paused_ended = Some(stamp);
410 let today = now.date();
411 overrides.lighter.retain(|d| *d >= today);
412 match after {
413 After::Lighter if !overrides.lighter.contains(&today) => overrides.lighter.push(today),
414 After::Rest => {
415 if let Some(back) = rest {
416 overrides.work_until = None;
417 overrides.work_now = None;
418 overrides.rest_until = Some(back);
419 overrides.rest_from = Some(stamp);
420 }
421 }
422 _ => {}
423 }
424 overrides.porch_rests_until = porch.filter(|p| *p > stamp);
425}
426
427pub fn lighten(overrides: &mut Overrides, date: Date) {
429 if !overrides.lighter.contains(&date) {
430 overrides.lighter.push(date);
431 overrides.lighter.sort();
432 }
433}
434
435pub fn forget(overrides: &mut Overrides) {
437 overrides.paused_since = None;
438 overrides.paused_ended = None;
439 overrides.pause_saw = None;
440}
441
442pub fn today_level(overrides: &Overrides, today: Date, weather: crate::today::Weather) -> (u32, u32) {
446 let (room, heavy) = (weather.room(), weather.heavy());
447 if overrides.lighter_on(today) {
448 let haze = crate::today::Weather::Haze;
449 (room.min(haze.room()), heavy.min(haze.heavy()))
450 } else {
451 (room, heavy)
452 }
453}
454
455pub fn is_movement(task: &crate::tasks::Task) -> bool {
461 crate::words::named_in(&task.categories, &crate::words::current().tasks.movement)
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
468pub struct Help {
469 pub text: String,
470 pub open: String,
472}
473
474pub fn help(line: &str) -> Help {
477 let is_link = |word: &str| {
478 let scheme = word.split_once(':').filter(|(s, rest)| s.len() >= 3 && s.chars().all(|c| c.is_ascii_alphanumeric() || "+.-".contains(c)) && !rest.is_empty());
479 word.contains("://") || word.starts_with('/') || word.starts_with("~/") || scheme.is_some()
480 };
481 let words: Vec<&str> = line.split_whitespace().collect();
482 match words.iter().position(|w| is_link(w)) {
483 Some(at) => {
484 let link = words[at];
485 let open = if let Some(rest) = link.strip_prefix("~/") {
486 format!("file://{}", crate::config::expand_home(&format!("~/{rest}")).display())
487 } else if link.starts_with('/') {
488 format!("file://{link}")
489 } else {
490 link.to_string()
491 };
492 let text: Vec<&str> = words.iter().enumerate().filter(|(i, _)| *i != at).map(|(_, w)| *w).collect();
493 let text = text.join(" ").trim_end_matches([':', '-', '–', '—', ',']).trim().to_string();
494 let text = if text.is_empty() { link.trim_end_matches('/').rsplit('/').next().unwrap_or(link).to_string() } else { text };
495 Help { text, open }
496 }
497 None => Help { text: words.join(" "), open: String::new() },
498 }
499}
500
501#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
503pub struct Number {
504 pub label: String,
506 pub number: String,
507 pub url: String,
509 pub about: String,
511}
512
513#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
516pub struct CrisisLines {
517 pub country: Vec<Country>,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
521pub struct Country {
522 pub code: String,
524 pub line: Vec<Line>,
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
528pub struct Line {
529 pub kind: String,
532 pub number: String,
533 #[serde(default)]
535 pub name: String,
536 pub how: Vec<String>,
538 #[serde(default)]
540 pub sms: String,
541 #[serde(default)]
543 pub about: BTreeMap<String, String>,
544 pub source: String,
546 pub checked: String,
547}
548
549pub const KINDS: [&str; 6] = ["emergency", "crisis", "medical", "text", "care", "callback"];
551
552const CRISIS_LINES: &str = include_str!("../data/crisis-lines.toml");
553
554impl CrisisLines {
555 pub fn built_in() -> CrisisLines {
556 toml::from_str(CRISIS_LINES).unwrap_or(CrisisLines { country: Vec::new() })
557 }
558
559 pub fn of(&self, code: &str) -> Option<&Country> {
561 let code = code.trim();
562 self.country.iter().find(|c| c.code.eq_ignore_ascii_case(code)).or_else(|| self.country.iter().find(|c| c.code == "EU"))
563 }
564}
565
566pub fn callback(code: &str) -> Option<String> {
570 let lines = CrisisLines::built_in();
571 let country = lines.country.iter().find(|c| c.code.eq_ignore_ascii_case(code.trim()))?;
572 country.line.iter().find(|l| l.kind == "callback").map(|l| l.number.clone())
573}
574
575pub fn country(settings: &PauseSettings, contacts_region: Option<&str>) -> String {
579 let chosen = |s: Option<&str>| s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_ascii_uppercase);
580 chosen(settings.country.as_deref())
581 .or_else(|| chosen(contacts_region))
582 .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()))
583 .unwrap_or_default()
584}
585
586pub fn numbers(lines: &CrisisLines, code: &str, tr: &Translator) -> (Vec<Number>, Vec<Number>) {
590 let Some(country) = lines.of(code) else { return (Vec::new(), Vec::new()) };
591 let mut ordered: Vec<&Line> = country.line.iter().filter(|l| l.kind != "callback").collect();
592 ordered.sort_by_key(|l| KINDS.iter().position(|k| *k == l.kind).unwrap_or(KINDS.len()));
593 let shown = |line: &Line| {
594 let text_only = !line.how.iter().any(|h| h == "call");
595 let url = if text_only {
596 let digits: String = line.number.chars().filter(char::is_ascii_digit).collect();
597 if line.sms.is_empty() { format!("sms:{digits}") } else { format!("sms:{digits}?body={}", line.sms) }
598 } else {
599 format!("tel:{}", line.number.chars().filter(char::is_ascii_digit).collect::<String>())
600 };
601 let mut args = crate::i18n::args();
602 args.set("word", line.sms.clone());
603 let how = if line.how.len() > 1 {
604 tr.text("pause-number-call-or-text", None)
605 } else if text_only && !line.sms.is_empty() {
606 tr.text("pause-number-text-word", Some(&args))
607 } else if text_only && line.kind != "text" {
608 tr.text("pause-number-by-text", None)
610 } else {
611 String::new()
612 };
613 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(" · ");
614 Number { label: tr.text(&format!("pause-number-{}", line.kind), None), number: line.number.clone(), url, about }
615 };
616 let first: Vec<&Line> = ["emergency", "crisis"].iter().filter_map(|k| ordered.iter().copied().find(|l| l.kind == *k)).collect();
617 let main = first.iter().map(|l| shown(l)).collect();
618 let more = ordered.iter().filter(|l| !first.iter().any(|f| std::ptr::eq(*f, **l))).map(|l| shown(l)).collect();
619 (main, more)
620}
621
622#[derive(Debug, Clone, PartialEq, Serialize)]
625pub struct Screen {
626 pub doses: bool,
628 pub helps: Vec<Help>,
629 pub breathing: bool,
630 pub pace: u32,
632 pub grounding: String,
633 pub numbers: Vec<Number>,
634 pub more: Vec<Number>,
635 pub country: String,
637}
638
639pub fn screen(settings: &PauseSettings, contacts_region: Option<&str>, tr: &Translator) -> Screen {
640 let code = country(settings, contacts_region);
641 let lines = CrisisLines::built_in();
642 let (numbers, more) = numbers(&lines, &code, tr);
643 let known = lines.country.iter().any(|c| c.code.eq_ignore_ascii_case(&code) && c.code != "EU");
644 Screen {
645 doses: settings.doses,
646 helps: settings.helps.iter().filter(|l| !l.trim().is_empty()).map(|l| help(l)).collect(),
647 breathing: settings.breathing,
648 pace: settings.pace(),
649 grounding: settings.grounding.trim().to_string(),
650 numbers,
651 more,
652 country: if known { tr.text(&format!("country-{}", code.to_ascii_lowercase()), None) } else { String::new() },
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659 use crate::areas::{TaskAreas, Time};
660 use crate::needs::{Days, Needs};
661 use crate::plan::{Settings, plan};
662 use crate::attention::{Attention, Column, Kind, Level, Now, Person, Row};
663 use crate::quiet::{Reason, mode};
664 use crate::reach::Channel;
665 use std::collections::BTreeSet;
666
667 fn at(text: &str) -> Zoned {
668 format!("{text}[Europe/Paris]").parse().unwrap()
669 }
670
671 fn stamp(text: &str) -> i64 {
672 at(text).timestamp().as_second()
673 }
674
675 fn week() -> Vec<AdminWindow> {
677 ["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()
678 }
679
680 fn health(now: &Zoned) -> Blocks {
683 Blocks::of(&Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() }, &Days::default(), &[], now)
684 }
685
686 fn evening<'a>(work: &'a [AdminWindow], held: &'a [(i64, i64)], blocks: &'a Blocks) -> Evening<'a> {
687 Evening { work, hours: work, held, blocks, latest_after: LATEST_AFTER, slot: 30 }
688 }
689
690 fn kept(blocks: &Blocks) -> Vec<(i64, i64)> {
691 blocks.kept.iter().filter(|k| k.kind != "sleep").map(|k| (k.start, k.end)).collect()
692 }
693
694 fn hm(seconds: i64) -> String {
695 jiff::Timestamp::from_second(seconds).unwrap().to_zoned(TimeZone::get("Europe/Paris").unwrap()).strftime("%H:%M").to_string()
696 }
697
698 #[test]
699 fn the_pause_comes_first_and_doses_and_alarms_still_come() {
700 let paused = Overrides { paused_since: Some(stamp("2026-10-02T10:00")), ..Overrides::default() };
702 for time in ["10:30", "12:30", "14:10", "20:30", "23:30"] {
703 let now = at(&format!("2026-10-02T{time}"));
704 let m = mode(&week(), &[], &paused, &health(&now), &now);
705 assert_eq!((m.reason.clone(), m.time, m.quiet), (Reason::Paused, Time::Sleep, true), "{time}: above work, meals, naps, evenings and the night");
706 let (usual, n) = (Attention::usual(), Now::of(&m));
709 let comes = |matrix: &Attention, kind: Kind| matrix.level(Row::Own(kind), &n) == Level::Now;
710 let mut held = Attention::usual();
711 held.set(Row::Own(Kind::Doses), Column::Pause, Level::Later).unwrap();
712 assert!(comes(&usual, Kind::Doses) && !comes(&held, Kind::Doses));
713 assert!(comes(&usual, Kind::Alarms) && comes(&usual, Kind::Codes));
714 assert!(!comes(&usual, Kind::Move) && !comes(&usual, Kind::Needs));
715 }
716 let now = at("2026-10-02T11:00");
718 for others in [
719 Overrides { rest_until: Some(stamp("2026-10-05T09:00")), ..paused.clone() },
720 Overrides { work_now: Some(stamp("2026-10-02T17:00")), ..paused.clone() },
721 Overrides { free_since: Some(stamp("2026-10-02T10:30")), ..paused.clone() },
722 ] {
723 assert_eq!(mode(&week(), &[], &others, &health(&now), &now).reason, Reason::Paused);
724 }
725 let ended = Overrides { paused_ended: Some(stamp("2026-10-02T10:45")), ..paused.clone() };
727 assert_eq!(mode(&week(), &[], &ended, &health(&now), &now).reason, Reason::Working);
728 let again = Overrides { paused_since: Some(stamp("2026-10-02T10:50")), ..ended };
729 assert_eq!(mode(&week(), &[], &again, &health(&now), &now).reason, Reason::Paused);
730 }
731
732 #[test]
733 fn free_time_is_leisure_with_the_safe_list_only() {
734 let now = at("2026-10-02T15:00");
735 let free = Overrides { free_since: Some(stamp("2026-10-02T14:40")), ..Overrides::default() };
736 let m = mode(&week(), &[], &free, &health(&now), &now);
737 assert_eq!((m.reason.clone(), m.time, m.quiet), (Reason::FreeTime, Time::Leisure, true), "leisure in working hours");
738 assert_eq!(m.until.as_ref().map(|z| z.strftime("%H:%M").to_string()), Some("22:00".into()), "until the night's start");
739 let (usual, n) = (Attention::usual(), Now::of(&m));
741 let level = |kind: Kind| usual.level(Row::Own(kind), &n);
742 assert!(level(Kind::Doses) == Level::Now && level(Kind::Codes) == Level::Now && level(Kind::Alarms) == Level::Event);
743 assert!(level(Kind::Move) != Level::Now);
744 assert_eq!((usual.person(Channel::Calls, Person::Safe, false, &n), usual.person(Channel::Mail, Person::Safe, false, &n)), (Level::Now, Level::Quiet));
746 for person in [Person::Neutral, Person::Restricted, Person::Stranger] {
747 assert_eq!(usual.person(Channel::Mail, person, false, &n), Level::Later, "{person:?}");
748 }
749 let mut cells = Attention::usual();
751 cells.set(Row::People(Channel::Mail, Person::Neutral), Column::Free, Level::Now).unwrap();
752 assert_eq!(cells.person(Channel::Mail, Person::Neutral, false, &n), Level::Now);
753 let nothing = Now { nothing: true, ..n.clone() };
755 assert_eq!((usual.person(Channel::Calls, Person::Safe, false, ¬hing), usual.person(Channel::Calls, Person::Safe, true, ¬hing)), (Level::Later, Level::Now));
756 assert!(nothing_now(&Overrides { free_nothing: Some(true), ..free.clone() }, &FreeTimeSettings::default()));
757 assert!(nothing_now(&free, &FreeTimeSettings { nothing: true, ..FreeTimeSettings::default() }));
758 let nap = at("2026-10-02T14:10");
760 let early = Overrides { free_since: Some(stamp("2026-10-02T13:30")), ..Overrides::default() };
761 assert_eq!(mode(&week(), &[], &early, &health(&nap), &nap).reason, Reason::Nap);
762 let late = at("2026-10-02T21:30");
763 assert_eq!(mode(&week(), &[], &free, &health(&late), &late).reason, Reason::FreeTime);
764 let night = at("2026-10-02T22:30");
765 assert_eq!(mode(&week(), &[], &free, &health(&night), &night).reason, Reason::WindingDown);
766 let morning = at("2026-10-03T10:00");
767 assert_eq!(mode(&week(), &[], &free, &health(&morning), &morning).reason, Reason::DayOff, "over with the night");
768 let no_night = Blocks::default();
770 assert_eq!(free_until(stamp("2026-10-02T14:40"), &no_night, now.time_zone()), stamp("2026-10-03T00:00"));
771 let walk = crate::tasks::Task { categories: vec!["Marche".into()], ..crate::tasks::Task::default() };
773 assert!(is_movement(&walk) && !is_movement(&crate::tasks::Task { categories: vec!["joy".into()], ..crate::tasks::Task::default() }));
774 let planning = crate::config::PlanningSettings { even_days: true, ..Default::default() };
775 assert!(!FreeTimeSettings::default().movement(&planning), "energy-limiting illness: no exercise offered");
776 assert!(FreeTimeSettings::default().movement(&crate::config::PlanningSettings::default()));
777 assert!(FreeTimeSettings { movement: Some(true), ..FreeTimeSettings::default() }.movement(&planning), "unless asked");
778 }
779
780 #[test]
781 fn the_end_of_work_moves_by_the_working_time_taken_within_its_caps() {
782 let now = at("2026-10-02T16:00");
783 let blocks = health(&now);
784 let held = kept(&blocks);
785 let work = week();
786 let ev = evening(&work, &held, &blocks);
787 let zone = now.time_zone();
788 let date = now.date();
789 assert_eq!(worked_away(&work, &held, date, zone, stamp("2026-10-02T14:00"), stamp("2026-10-02T16:00")), 85);
791 assert_eq!(worked_away(&work, &held, date, zone, stamp("2026-10-02T17:30"), stamp("2026-10-02T18:30")), 0, "after work: nothing moves");
792 assert_eq!(extend(&ev, date, zone, 60).map(|(u, e)| (hm(u), hm(e))), Some(("17:00".into(), "18:00".into())));
794 assert_eq!(extend(&ev, date, zone, 150).map(|(_, e)| hm(e)), Some("19:00".into()));
796 let late = Evening { latest_after: 300, ..evening(&work, &held, &blocks) };
798 assert_eq!(extend(&late, date, zone, 150).map(|(_, e)| hm(e)), Some("20:30".into()));
799 assert_eq!(extend(&late, date, zone, 400).map(|(_, e)| hm(e)), Some("21:00".into()), "never past the wind-down less an hour");
800 let mut busy = held.clone();
802 busy.push((stamp("2026-10-02T18:00"), stamp("2026-10-02T19:00")));
803 busy.push((stamp("2026-10-02T20:00"), stamp("2026-10-02T21:40")));
804 let full = Evening { latest_after: 300, held: &busy, ..evening(&work, &held, &blocks) };
805 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");
806 let saturday = at("2026-10-03T11:00");
808 assert_eq!(extend(&ev, saturday.date(), zone, 60), None);
809 let none = Blocks::default();
811 let no_night = Evening { latest_after: 600, blocks: &none, ..evening(&work, &held, &blocks) };
812 assert_eq!(extend(&no_night, date, zone, 600).map(|(_, e)| hm(e)), Some("00:00".into()));
813 }
814
815 #[test]
816 fn free_time_ending_moves_the_end_and_keep_my_usual_end_moves_nothing() {
817 let blocks = health(&at("2026-10-02T12:00"));
818 let held = kept(&blocks);
819 let work = week();
820 let ev = evening(&work, &held, &blocks);
821 let mut o = Overrides::default();
822 start_free(&mut o, stamp("2026-10-02T14:00"));
823 let back = at("2026-10-02T15:00");
825 assert_eq!(end_free(&mut o, &ev, &back, true).map(hm), Some("17:25".into()));
826 assert!(o.free_from().is_none());
827 assert_eq!(o.free_lost, Some(25));
828 let at_17 = at("2026-10-02T17:10");
830 let m = mode(&work, &[], &o, &health(&at_17), &at_17);
831 assert_eq!((m.reason.clone(), m.time, m.quiet), (Reason::Extended, Time::Work, false));
832 assert_eq!(m.until.map(|z| z.strftime("%H:%M").to_string()), Some("17:25".into()));
833 let after = at("2026-10-02T17:30");
834 assert_eq!(mode(&work, &[], &o, &health(&after), &after).reason, Reason::Evening);
835 start_free(&mut o, stamp("2026-10-02T15:30"));
837 assert_eq!(end_free(&mut o, &ev, &at("2026-10-02T16:30"), true).map(hm), Some("18:25".into()));
838 keep_usual_end(&mut o, back.date());
840 assert_eq!(o.moved_end(back.date()), None);
841 let at_17 = at("2026-10-02T17:10");
842 assert_eq!(mode(&work, &[], &o, &health(&at_17), &at_17).reason, Reason::Evening);
843 start_free(&mut o, stamp("2026-10-02T16:35"));
844 assert_eq!(end_free(&mut o, &ev, &at("2026-10-02T16:50"), true), None);
845 let mut kept_end = Overrides::default();
847 start_free(&mut kept_end, stamp("2026-10-05T10:00"));
848 assert_eq!(end_free(&mut kept_end, &ev, &at("2026-10-05T11:00"), false), None);
849 let mut done = Overrides::default();
851 start_free(&mut done, stamp("2026-10-05T10:00"));
852 end_free(&mut done, &ev, &at("2026-10-05T11:00"), true);
853 done.rest_until = Some(stamp("2026-10-06T09:00"));
854 let monday = at("2026-10-05T17:10");
855 assert_eq!(mode(&work, &[], &done, &health(&monday), &monday).reason, Reason::DoneForTheDay);
856 let mut going = Overrides::default();
858 start_free(&mut going, stamp("2026-10-05T15:00"));
859 let pending = moved_today(&going, &ev, &at("2026-10-05T16:00"), true).map(|(u, e)| (hm(u), hm(e)));
860 assert_eq!(pending, Some(("17:00".into(), "18:00".into())));
861 assert!(going.free_from().is_some(), "only seen, not ended");
862 let tuesday = at("2026-10-06T17:10");
864 assert_eq!(mode(&work, &[], &o, &health(&tuesday), &tuesday).reason, Reason::Evening);
865 }
866
867 fn task(uid: &str, minutes: u32, light: bool) -> crate::tasks::Task {
869 crate::tasks::Task { uid: uid.into(), title: uid.into(), estimate: minutes, energy: if light { "light".into() } else { String::new() }, ..crate::tasks::Task::default() }
870 }
871
872 #[test]
873 fn the_extension_adds_hours_for_light_steps_and_what_does_not_fit_moves_on() {
874 let now = at("2026-10-05T16:00");
876 let windows = week();
877 let usual = Settings::of_hours(&windows, TaskAreas::usual()).with_events(&now, &[]);
878 let moved = Settings::of_hours(&windows, TaskAreas::usual()).with_extension(Some((stamp("2026-10-05T17:00"), stamp("2026-10-05T18:00")))).with_events(&now, &[]);
879 assert_eq!(usual.room_on(now.date()).total() + 60, moved.room_on(now.date()).total(), "an hour more today");
880 assert_eq!(moved.light_only, 60);
881 let tasks = vec![task("hard", 90, false), task("easy", 45, true)];
882 let (none, set) = (BTreeMap::new(), BTreeSet::new());
883 let made = plan(&tasks, now.date(), &moved, &none, &set);
884 assert_eq!(made.items["easy"].start, Some(now.date()));
886 assert_eq!(made.items["hard"].finish, Some(now.date().tomorrow().unwrap()));
887 let today_minutes: u32 = made.days.get(&now.date()).map(|d| d.iter().filter(|(uid, _)| uid == "hard").map(|(_, m)| *m).sum()).unwrap_or(0);
888 assert!((1..=60).contains(&today_minutes), "a step that is not light never takes the extension: {today_minutes}");
889 assert_eq!(moved.capacity.day_limit(now.date(), 1.0), usual.capacity.day_limit(now.date(), 1.0));
891 let day = crate::dayview::day(&now, &[], &made, &tasks, &moved);
893 let five = stamp("2026-10-05T17:00");
894 assert!(day.blocks.iter().filter(|b| b.kind == "task" && b.key == "hard").all(|b| b.end <= five), "{:?}", day.blocks);
895 assert!(day.blocks.iter().any(|b| b.kind == "task" && b.key == "easy" && b.start >= five), "the light step after the usual end: {:?}", day.blocks);
896 assert!(day.hours.last().is_some_and(|h| h.end == stamp("2026-10-05T18:00")), "today's hours run to 18:00");
897 }
898
899 #[test]
900 fn the_pause_moves_work_to_later_days_never_into_the_evening() {
901 let mut o = Overrides::default();
903 start_pause(&mut o, stamp("2026-10-05T14:00"), 0);
904 let back = at("2026-10-05T16:30");
905 end_pause(&mut o, &back, After::Lighter, None, None);
906 assert!(o.paused_from().is_none());
907 assert_eq!(o.moved_end(back.date()), None);
908 let evening = at("2026-10-05T17:30");
909 assert_eq!(mode(&week(), &[], &o, &health(&evening), &evening).reason, Reason::Evening, "the day is not extended");
910 let windows = week();
912 let mut settings = Settings::of_hours(&windows, TaskAreas::usual()).with_events(&back, &[]);
913 (settings.today_percent, settings.heavy_today) = today_level(&o, back.date(), crate::today::Weather::Clear);
914 assert_eq!((settings.today_percent, settings.heavy_today), (60, 1), "the bad-day level");
915 let tasks = vec![task("report", 120, false)];
916 let made = plan(&tasks, back.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
917 let today: u32 = made.days.get(&back.date()).map(|d| d.iter().map(|(_, m)| *m).sum()).unwrap_or(0);
918 assert!(today <= 30 * 60 / 100 + 5, "a lighter half hour at most: {today}");
919 assert!(made.items["report"].finish > Some(back.date()));
920 lighten(&mut o, back.date().tomorrow().unwrap());
922 assert_eq!(today_level(&o, back.date().tomorrow().unwrap(), crate::today::Weather::Clear), (60, 1));
923 assert_eq!(today_level(&o, back.date().tomorrow().unwrap(), crate::today::Weather::Fog), (30, 0), "never heavier than the weather said");
924 let mut later = o.clone();
925 start_pause(&mut later, stamp("2026-10-08T10:00"), 0);
926 end_pause(&mut later, &at("2026-10-08T10:20"), After::AsIs, None, None);
927 assert!(later.lighter.is_empty(), "{:?}", later.lighter);
928 let mut rest = Overrides::default();
930 start_pause(&mut rest, stamp("2026-10-05T10:00"), 0);
931 end_pause(&mut rest, &at("2026-10-05T10:30"), After::Rest, Some(stamp("2026-10-06T09:00")), Some(stamp("2026-10-05T14:00")));
932 let noon = at("2026-10-05T12:00");
933 assert_eq!(mode(&week(), &[], &rest, &health(&noon), &noon).reason, Reason::DoneForTheDay);
934 assert!(rest.porch_rests(stamp("2026-10-05T13:00")) && !rest.porch_rests(stamp("2026-10-05T14:00")));
935 forget(&mut rest);
937 assert_eq!((rest.paused_since, rest.paused_ended), (None, None));
938 }
939
940 #[test]
941 fn the_pauses_travel_between_devices() {
942 let mut here = Overrides::default();
945 start_pause(&mut here, 1_759_390_000, 0);
946 start_free(&mut here, 1_759_380_000);
947 here.free_ended = Some(1_759_385_000);
948 here.free_day = Some("2026-10-02".parse().unwrap());
949 here.free_lost = Some(25);
950 here.extended_until = Some(1_759_420_000);
951 here.lighter = vec!["2026-10-02".parse().unwrap()];
952 here.porch_rests_until = Some(1_759_430_000);
953 let text = toml::to_string(&here).unwrap();
954 let there: Overrides = toml::from_str(&text).unwrap();
955 assert_eq!(there, here, "{text}");
956 assert!(there.paused_from().is_some() && there.free_from().is_none());
957 let mut merged: toml::Table = toml::from_str(&text).unwrap();
959 merged.insert("paused_ended".into(), toml::Value::Integer(1_759_391_000));
960 let ended: Overrides = toml::from_str(&toml::to_string(&merged).unwrap()).unwrap();
961 assert!(ended.paused_from().is_none());
962 let old: Overrides = toml::from_str("work_until = 5\n").unwrap();
964 assert!(old.paused_from().is_none() && old.free_from().is_none() && old.lighter.is_empty());
965 assert_eq!(toml::to_string(&Overrides::default()).unwrap().trim(), "");
967 }
968
969 #[test]
970 fn crisis_lines_are_well_formed() {
971 let lines = CrisisLines::built_in();
972 assert!(!lines.country.is_empty(), "data/crisis-lines.toml reads");
973 let mut codes = BTreeSet::new();
974 let today = jiff::Zoned::now().date();
975 for country in &lines.country {
976 assert!(codes.insert(country.code.clone()), "{} twice", country.code);
977 assert!(country.code == "EU" || (country.code.len() == 2 && country.code.chars().all(|c| c.is_ascii_uppercase())), "{}", country.code);
978 assert!(country.line.iter().any(|l| l.kind == "emergency"), "{}: an emergency number", country.code);
979 for line in &country.line {
980 assert!(KINDS.contains(&line.kind.as_str()), "{}: {}", country.code, line.kind);
981 assert!(!line.number.is_empty() && line.number.chars().all(|c| c.is_ascii_digit() || c == ' '), "{}: {}", country.code, line.number);
982 assert!(!line.how.is_empty() && line.how.iter().all(|h| h == "call" || h == "text"), "{}: {:?}", line.number, line.how);
983 assert!(line.source.starts_with("https://"), "{}: {}", line.number, line.source);
984 let checked: Date = line.checked.parse().unwrap_or_else(|_| panic!("{}: {}", line.number, line.checked));
985 assert!(checked <= today && checked >= Date::constant(2026, 1, 1), "{}: {}", line.number, line.checked);
986 assert!(line.about.keys().all(|l| l == "en" || l == "fr"), "{}", line.number);
987 }
988 }
989 let has = |code: &str, number: &str| lines.of(code).is_some_and(|c| c.code == code && c.line.iter().any(|l| l.number == number));
990 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")] {
991 assert!(has(code, number), "{code} {number}");
992 }
993 assert_eq!(lines.of("ZZ").map(|c| c.code.as_str()), Some("EU"));
995 let tr = Translator::new("en");
997 let (first, more) = numbers(&lines, "FR", &tr);
998 assert_eq!(first.iter().map(|n| (n.number.as_str(), n.url.as_str())).collect::<Vec<_>>(), [("112", "tel:112"), ("3114", "tel:3114")]);
999 assert!(more.iter().any(|n| n.number == "15") && more.iter().any(|n| n.number == "114" && n.url == "sms:114"));
1000 assert!(first.iter().chain(&more).all(|n| n.number != "0 800 112 112"), "the callback number is never given to call");
1001 assert_eq!(callback("fr").as_deref(), Some("0 800 112 112"));
1002 assert_eq!(callback("GB"), None);
1003 let (_, uk) = numbers(&lines, "GB", &tr);
1004 assert!(uk.iter().any(|n| n.url == "sms:85258?body=SHOUT"), "{uk:?}");
1005 assert!(first.iter().chain(&more).all(|n| !n.label.starts_with("pause-")), "{first:?} {more:?}");
1006 assert_eq!(country(&PauseSettings { country: Some("gb".into()), ..PauseSettings::default() }, Some("FR")), "GB");
1008 assert_eq!(country(&PauseSettings::default(), Some("ca")), "CA");
1009 }
1010
1011 #[test]
1012 fn what_helps_opens_what_you_chose() {
1013 assert_eq!(help("Headphones on"), Help { text: "Headphones on".into(), open: String::new() });
1014 assert_eq!(help("The blue playlist: https://music.example/blue"), Help { text: "The blue playlist".into(), open: "https://music.example/blue".into() });
1015 assert_eq!(help("/home/me/Pictures/Calm").text, "Calm");
1016 assert_eq!(help("Photos /home/me/Pictures/Calm").open, "file:///home/me/Pictures/Calm");
1017 assert_eq!(help("Notes: nothing else").open, "", "a colon is not a link");
1018 let s = screen(&PauseSettings { helps: vec!["Tea".into(), " ".into()], country: Some("FR".into()), ..PauseSettings::default() }, None, &Translator::new("fr"));
1019 assert_eq!((s.helps.len(), s.breathing, s.pace, s.country.as_str()), (1, false, PACE, "France"), "the breathing guide off unless switched on");
1020 }
1021}