Skip to main content

sioul_core/
pause.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The two pauses (docs/pauses.md): **Free time** (« Temps libre »), taken
5//! for a good moment, and **Paused** (« En pause »), for a moment that
6//! overwhelms.
7//!
8//! - Free time is leisure whatever the hour: as usual only your safe senders
9//!   reach you (or no one but Always through: "Nothing at all"; Free time is
10//!   a column of the matrix of what reaches you, `attention`), with doses,
11//!   codes you asked for and your events' alarms; leisure is offered, never a
12//!   list to finish. The working
13//!   time it took moves the end of today's work later by as much, never past
14//!   the wind-down less an hour, your latest end or the evening's time for
15//!   you, with light steps only after the usual end; "Keep my usual end" moves
16//!   nothing. Sleep comes first: it ends at the night's start.
17//! - The pause holds everything Sioul shows, whatever the time: no
18//!   notification but doses (unless set otherwise) and your alarms, the
19//!   window covered with a few literal lines. It asks nothing, moves nothing
20//!   into the evening and sends nothing. Coming back, the rest of today is
21//!   lighter.
22//!
23//! Both are the person's act: Sioul never starts them, never counts them,
24//! never infers anything from them (GP1, GP17, P26, P30). Their state is in
25//! `quiet.toml` (`quiet::Overrides`), shared with the time part; their setup
26//! in the configuration (`[free_time]`, `[pause]`), shared with the settings.
27
28use 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
38/// Minutes kept free of work before the wind-down when the end of work moves
39/// (GP10a: low-arousal time before sleep; one hour is a guess).
40pub const WIND_DOWN_BUFFER: i64 = 60;
41/// The latest end of work, unsaid: so many minutes after the usual end (GP10b, a guess).
42pub const LATEST_AFTER: u32 = 120;
43/// The breathing guide's pace, unsaid: breaths a minute (P16: about five to six to start).
44pub 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/// Free time as set up (`[free_time]`).
59#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
60pub struct FreeTimeSettings {
61    /// "Nothing at all": not even your safe senders (GP6); doses keep their own setting.
62    #[serde(default)]
63    pub nothing: bool,
64    /// The end of work moves later by the working time Free time took (GP10);
65    /// off, the usual end is kept, as "Keep my usual end" does (GP11).
66    #[serde(default = "yes")]
67    pub moves: bool,
68    /// The latest end of work, in minutes after the usual end (GP10b).
69    #[serde(default = "latest_after")]
70    pub latest_after: u32,
71    /// Movement and exercise among the offers; unsaid, on unless days are kept
72    /// even (energy-limiting illness, GP8).
73    #[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    /// Movement and exercise among Free time's offers: as set, else on unless
85    /// days are kept even, the setting for energy-limiting illness (GP8).
86    pub fn movement(&self, planning: &crate::config::PlanningSettings) -> bool {
87        self.movement.unwrap_or(!planning.even_days)
88    }
89}
90
91/// The pause as set up on a calm day (`[pause]`, P1).
92#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
93pub struct PauseSettings {
94    /// Dose reminders still come during the pause (P7, docs/health.md). The
95    /// matrix's cell now (`[attention] doses`, `attention::Attention::of`),
96    /// read from here once to seed it while no `[attention]` is written.
97    #[serde(default = "yes")]
98    pub doses: bool,
99    /// On the phone, starred contacts and repeat callers get through (P9).
100    /// Always through's pause cells now (`attention`), read from here once to seed them.
101    #[serde(default = "yes")]
102    pub people: bool,
103    /// What helps you, in your words, one a line; a line with a link or a
104    /// file's path opens it (P2, P15).
105    #[serde(default)]
106    pub helps: Vec<String>,
107    /// The breathing guide: off unless switched on (P16).
108    #[serde(default)]
109    pub breathing: bool,
110    /// Its pace, breaths a minute.
111    #[serde(default = "pace")]
112    pub pace: u32,
113    /// One line to come back to the present, in your words (P17).
114    #[serde(default)]
115    pub grounding: String,
116    /// The rest of today, coming back: "lighter" (unsaid), "rest", "as-is" (P23).
117    #[serde(default)]
118    pub after: Option<String>,
119    /// The country whose numbers show (an ISO code); unsaid, the country of
120    /// phone numbers, else the system's (P13).
121    #[serde(default)]
122    pub country: Option<String>,
123    /// GNOME: Sioul may switch GNOME's own Do Not Disturb, for both pauses (`dnd`).
124    #[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    /// Breaths a minute, within reason.
140    pub fn pace(&self) -> u32 {
141        self.pace.clamp(3, 10)
142    }
143}
144
145/// The rest of today, coming back from a pause (P23).
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum After {
148    /// The bad-day level: the room and budgets of a hazy day (about 60 %, capacity criterion 36).
149    Lighter,
150    /// The work day closed, as "Done for today", without its sheet.
151    Rest,
152    /// As planned.
153    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
174// ---------------------------------------------------------------- the state
175
176impl Overrides {
177    /// Free time pressed and not ended since (whether it still holds is
178    /// `quiet::mode`'s to say: the night's start ends it), from then.
179    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    /// Paused: pressed and not ended since, from then.
184    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    /// Where the end of work moved to on `date` (Unix seconds), unless kept.
189    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    /// Whether `date` is held lighter after a pause.
194    pub fn lighter_on(&self, date: Date) -> bool {
195        self.lighter.contains(&date)
196    }
197
198    /// Whether the Porch rests at `stamp`, after a pause (P24).
199    pub fn porch_rests(&self, stamp: i64) -> bool {
200        self.porch_rests_until.is_some_and(|until| stamp < until)
201    }
202}
203
204/// When Free time pressed at `since` ends by itself: at the night's start
205/// (sleep comes first), else at midnight; whichever comes first.
206pub 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
212/// The midnight after `stamp`, on the clock of `zone`.
213fn 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
226/// "Nothing at all" now: this Free time's own choice, else the setting.
227pub fn nothing_now(overrides: &Overrides, settings: &FreeTimeSettings) -> bool {
228    overrides.free_nothing.unwrap_or(settings.nothing)
229}
230
231// ---------------------------------------------------------------- the end of work
232
233/// What a day's evening holds, for the end of work to move (GP10).
234pub struct Evening<'a> {
235    /// The working hours: work's windows only.
236    pub work: &'a [AdminWindow],
237    /// Every hours, work's and admin's: the end of work moves around admin's.
238    pub hours: &'a [AdminWindow],
239    /// What holds time: events with their margins and pauses, meals, naps.
240    pub held: &'a [(i64, i64)],
241    /// Health's blocks: the night's start.
242    pub blocks: &'a Blocks,
243    /// The latest end of work, minutes after the usual end.
244    pub latest_after: u32,
245    /// The evening's slot of time for you, in minutes; 0 when slots are off.
246    pub slot: u32,
247}
248
249/// What today holds, read once, for `Evening`: the working hours, every
250/// hours, today's events (margins and pauses) with meals and naps, Health's
251/// blocks, and the settings.
252pub 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    /// From the configuration, Health's blocks around now and the events of today (more is fine).
263    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
275/// Minutes of `date`'s working time between `from` and `to` (Unix seconds):
276/// the working hours' room as the plan counts it, less meals, naps and events.
277pub 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
292/// Where the end of work goes on `date` when `lost` minutes of working time
293/// moved (GP10): from the usual end, over the evening's free gaps (events,
294/// meals and admin hours go around it), never past the earliest of the
295/// wind-down less an hour, the usual end and the latest end setting, the last
296/// moment that still leaves the evening's slot for you its room before the
297/// night, and midnight. (usual end, new end), Unix seconds; none without
298/// working hours that day.
299pub 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    // What the evening holds: events, meals, and admin's hours.
312    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    // The evening's time for you survives (GP10c): a gap that holds it stays before the night.
315    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
339/// Free time starts at `now` (Unix seconds), "Nothing at all" as set (GP1).
340pub fn start_free(overrides: &mut Overrides, now: i64) {
341    overrides.free_since = Some(now);
342    overrides.free_nothing = None;
343}
344
345/// Free time ends at `now`: the working time it took today is added to the
346/// day's, and the end of work moves by all of it, unless kept (`moves` off,
347/// or "Keep my usual end" today: GP10–GP11). The new end, when it moved.
348pub 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
370/// "Keep my usual end" (GP11, GP19): today's end of work stays where it was,
371/// no reason asked; what no longer fits goes to later days.
372pub fn keep_usual_end(overrides: &mut Overrides, today: Date) {
373    overrides.keep_end_on = Some(today);
374    overrides.extended_until = None;
375}
376
377/// Today's end of work moved, for the plan: (usual end, moved end), while Free
378/// time goes on as if it ended now ("work comes back when you do"), else as
379/// it ended. None when nothing moved.
380pub 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
393// ---------------------------------------------------------------- the emergency pause
394
395/// The pause starts at `now` (Unix seconds): it asks nothing (P6). `saw`:
396/// the latest press of do-not-disturb's switch known here (milliseconds; 0
397/// none), so that the pause ends a Reachable it knew of, whatever the clocks
398/// (`everywhere::now`).
399pub 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
404/// What coming back changes (P23–P24): the rest of today lighter (`lighter`),
405/// or closed (`rest`: when work comes back), and the Porch resting until
406/// `porch` (the next admin hours). Older lighter days are forgotten.
407pub 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
427/// "Lighten tomorrow", the one offer coming back (default: no, P23).
428pub 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
435/// "Forget the last pause": its stamps go (P26); what it changed stays as chosen.
436pub fn forget(overrides: &mut Overrides) {
437    overrides.paused_since = None;
438    overrides.paused_ended = None;
439    overrides.pause_saw = None;
440}
441
442/// Today's share of its room and heavy steps (the plan's `today_percent`,
443/// `heavy_today`): as the weather says, and no more than a hazy day's when
444/// today is held lighter after a pause.
445pub 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
455// ---------------------------------------------------------------- what is offered
456
457/// Whether a task is movement or exercise, by its categories (`words.tasks.movement`,
458/// the words in use: sport, walk, yoga…): left out of Free time's offers when
459/// movement is off (GP8).
460pub fn is_movement(task: &crate::tasks::Task) -> bool {
461    crate::words::named_in(&task.categories, &crate::words::current().tasks.movement)
462}
463
464// ---------------------------------------------------------------- the screen
465
466/// One line of what helps, as the screen shows it: the words, and what it opens.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
468pub struct Help {
469    pub text: String,
470    /// A link or a file to open; "" for none.
471    pub open: String,
472}
473
474/// A line of the list read: the words, and a link or a file's path in it if
475/// any ("The blue playlist https://…", "Photos ~/Pictures/Calm").
476pub 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/// A number on the screen: what it is, the number, how to reach it.
502#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
503pub struct Number {
504    /// "Emergency", "To talk to someone now, day and night".
505    pub label: String,
506    pub number: String,
507    /// "tel:112", "sms:85258?body=SHOUT".
508    pub url: String,
509    /// Who it is for, how: "by text, for deaf and hard-of-hearing people"; "" for none.
510    pub about: String,
511}
512
513/// The emergency numbers and crisis lines of every country Sioul knows
514/// (`data/crisis-lines.toml`), each with its source and the day it was checked.
515#[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    /// An ISO code ("FR"), or "EU" for the European Union as a whole.
523    pub code: String,
524    pub line: Vec<Line>,
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
528pub struct Line {
529    /// "emergency", "medical", "crisis", "text", "care"; "callback": the number
530    /// emergency services call back from, never shown to call (`callback`).
531    pub kind: String,
532    pub number: String,
533    /// The service's own name ("SAMU", "Samaritans"); "" for the emergency number.
534    #[serde(default)]
535    pub name: String,
536    /// How: "call", "text", or both.
537    pub how: Vec<String>,
538    /// The word to text, when one starts the conversation ("SHOUT").
539    #[serde(default)]
540    pub sms: String,
541    /// Who it is for, by language ("en", "fr"); none for everyone.
542    #[serde(default)]
543    pub about: BTreeMap<String, String>,
544    /// Where it was checked, and when ("2026-10-06").
545    pub source: String,
546    pub checked: String,
547}
548
549/// The kinds of numbers, in the order the screen gives them; a callback number is not given.
550pub 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    /// The country's numbers; Europe's 112 for a country not known here.
560    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
566/// The number emergency services of the country `code` call back from, where
567/// one is known (France: 0 800 112 112): a call from it may need to get
568/// through do-not-disturb (P9). None elsewhere; never Europe's for another country.
569pub 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
575/// The country whose numbers show: the pause's setting, else the country of
576/// phone numbers (Contacts), else the system's (never guessed from Sioul's
577/// language: an English speaker in France needs 3114 and 15). "" when none.
578pub 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
586/// The numbers of `code` as the screen gives them: the first line (the
587/// emergency number and the crisis line, one tap each), then the others,
588/// unfolded on demand.
589pub 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            // "In writing" says it already.
609            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/// The pause's screen, as the window draws it (P11–P17): its words, your
623/// list, the breathing guide and the grounding line as set, the numbers.
624#[derive(Debug, Clone, PartialEq, Serialize)]
625pub struct Screen {
626    /// Dose reminders still come during the pause: said, so that one is no surprise.
627    pub doses: bool,
628    pub helps: Vec<Help>,
629    pub breathing: bool,
630    /// Breaths a minute.
631    pub pace: u32,
632    pub grounding: String,
633    pub numbers: Vec<Number>,
634    pub more: Vec<Number>,
635    /// The country shown, its name; "" for Europe's 112.
636    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    /// Monday to Friday, 9:00 to 17:00.
676    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    /// Health as first set: breakfast 07:50–08:20, lunch 12:10–13:00, a nap
681    /// 14:00–14:35, dinner 19:00–20:00, the night from 22:00 (bed 23:00) to 07:00.
682    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        // Friday 2 October 2026.
701        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            // As usual (`attention`): doses come unless the setup said otherwise, an
707            // event's own alarm (an alarm you set), a code you just asked for (Q6); nothing else.
708            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        // Above "Done for today", "Work now" and Free time too.
717        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        // Ended: the hours again; pressed again later: paused again.
726        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        // As usual (`attention`): doses, codes, and events' alarms when their event falls in it; nothing else.
740        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        // As usual only the safe list: their calls and messages at once, their mail shown, not told; the others wait.
745        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        // Free time is a column now (Q15): a neutral row may come in it.
750        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        // "Nothing at all": the safe wait too, Always through still comes.
754        let nothing = Now { nothing: true, ..n.clone() };
755        assert_eq!((usual.person(Channel::Calls, Person::Safe, false, &nothing), usual.person(Channel::Calls, Person::Safe, true, &nothing)), (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        // Sleep still comes first: a nap, then the night, which ends it.
759        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        // Without a night, at midnight.
769        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        // Leisure tasks only, and the movement ones left out of the offers when movement is off.
772        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        // 14:00–16:00: the nap (14:00–14:35) is not working time: 85 minutes.
790        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        // An hour: until 18:00.
793        assert_eq!(extend(&ev, date, zone, 60).map(|(u, e)| (hm(u), hm(e))), Some(("17:00".into(), "18:00".into())));
794        // Two and a half hours: dinner (19:00–20:00) goes around; the usual end + 2 h caps it at 19:00.
795        assert_eq!(extend(&ev, date, zone, 150).map(|(_, e)| hm(e)), Some("19:00".into()));
796        // A later latest end: past dinner, then the wind-down less an hour (21:00) caps it.
797        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        // An evening full of events: the slot for you keeps its half hour before the night.
801        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        // No working hours that day: nothing to move.
807        let saturday = at("2026-10-03T11:00");
808        assert_eq!(extend(&ev, saturday.date(), zone, 60), None);
809        // Midnight caps it without a night.
810        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        // Coming back at 15:00: 25 minutes of work (the nap aside); the end moves to 17:25.
824        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        // Work until then, Sioul ends it: leisure after.
829        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        // A second free time the same day adds up: 15:30–16:30, an hour more.
836        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 my usual end": nothing moves, no reason asked; a later free time moves nothing that day.
839        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        // The setting "the end does not move": the same.
846        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        // "Done for today" ends the moved day.
850        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        // While it goes on, the plan sees the end as if work came back now.
857        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        // The next day starts as usual.
863        let tuesday = at("2026-10-06T17:10");
864        assert_eq!(mode(&work, &[], &o, &health(&tuesday), &tuesday).reason, Reason::Evening);
865    }
866
867    /// One task with `minutes`, light or not.
868    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        // Monday 5 October at 16:00, back from Free time; the end moved to 18:00.
875        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        // The light step fits today; the usual one takes the usual hour left, its rest goes on to Tuesday, said nowhere as a count.
885        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        // Budgets do not grow with it: what a day holds is the same either way.
890        assert_eq!(moved.capacity.day_limit(now.date(), 1.0), usual.capacity.day_limit(now.date(), 1.0));
891        // The day laid out: the light step may go after 17:00, the other never.
892        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        // Paused from 14:00 to 16:30 on Monday: no extension, the rest of the day lighter.
902        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        // The paused hours' work flows to later days: today keeps only what is left, lighter.
911        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 tomorrow", the one offer; old lighter days are forgotten.
921        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        // "Rest": the day closed, as "Done for today"; the Porch resting until the next admin hours.
929        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        // Forgotten: the stamps go, nothing else.
936        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        // As the sharing carries quiet.toml, key by key: a pause pressed on one
943        // device reads paused on the other; ended there, ended here.
944        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        // One key per field: the other device's "Come back" is one more key, later than the press.
958        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        // An older Sioul's file, without any of it, reads as no pause.
963        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        // Nothing of a pause is written when there was none.
966        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        // A country not known here: Europe's 112.
994        assert_eq!(lines.of("ZZ").map(|c| c.code.as_str()), Some("EU"));
995        // The screen's first line: the emergency number and the crisis line, one tap each.
996        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        // The country: the setting, else the phone numbers' country.
1007        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}