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).
396pub fn start_pause(overrides: &mut Overrides, now: i64) {
397    overrides.paused_since = Some(now);
398}
399
400/// What coming back changes (P23–P24): the rest of today lighter (`lighter`),
401/// or closed (`rest`: when work comes back), and the Porch resting until
402/// `porch` (the next admin hours). Older lighter days are forgotten.
403pub fn end_pause(overrides: &mut Overrides, now: &Zoned, after: After, rest: Option<i64>, porch: Option<i64>) {
404    let stamp = now.timestamp().as_second();
405    overrides.paused_ended = Some(stamp);
406    let today = now.date();
407    overrides.lighter.retain(|d| *d >= today);
408    match after {
409        After::Lighter if !overrides.lighter.contains(&today) => overrides.lighter.push(today),
410        After::Rest => {
411            if let Some(back) = rest {
412                overrides.work_until = None;
413                overrides.work_now = None;
414                overrides.rest_until = Some(back);
415                overrides.rest_from = Some(stamp);
416            }
417        }
418        _ => {}
419    }
420    overrides.porch_rests_until = porch.filter(|p| *p > stamp);
421}
422
423/// "Lighten tomorrow", the one offer coming back (default: no, P23).
424pub fn lighten(overrides: &mut Overrides, date: Date) {
425    if !overrides.lighter.contains(&date) {
426        overrides.lighter.push(date);
427        overrides.lighter.sort();
428    }
429}
430
431/// "Forget the last pause": its stamps go (P26); what it changed stays as chosen.
432pub fn forget(overrides: &mut Overrides) {
433    overrides.paused_since = None;
434    overrides.paused_ended = None;
435}
436
437/// Today's share of its room and heavy steps (the plan's `today_percent`,
438/// `heavy_today`): as the weather says, and no more than a hazy day's when
439/// today is held lighter after a pause.
440pub fn today_level(overrides: &Overrides, today: Date, weather: crate::today::Weather) -> (u32, u32) {
441    let (room, heavy) = (weather.room(), weather.heavy());
442    if overrides.lighter_on(today) {
443        let haze = crate::today::Weather::Haze;
444        (room.min(haze.room()), heavy.min(haze.heavy()))
445    } else {
446        (room, heavy)
447    }
448}
449
450// ---------------------------------------------------------------- what is offered
451
452/// Whether a task is movement or exercise, by its categories (`words.tasks.movement`,
453/// the words in use: sport, walk, yoga…): left out of Free time's offers when
454/// movement is off (GP8).
455pub fn is_movement(task: &crate::tasks::Task) -> bool {
456    crate::words::named_in(&task.categories, &crate::words::current().tasks.movement)
457}
458
459// ---------------------------------------------------------------- the screen
460
461/// One line of what helps, as the screen shows it: the words, and what it opens.
462#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
463pub struct Help {
464    pub text: String,
465    /// A link or a file to open; "" for none.
466    pub open: String,
467}
468
469/// A line of the list read: the words, and a link or a file's path in it if
470/// any ("The blue playlist https://…", "Photos ~/Pictures/Calm").
471pub fn help(line: &str) -> Help {
472    let is_link = |word: &str| {
473        let scheme = word.split_once(':').filter(|(s, rest)| s.len() >= 3 && s.chars().all(|c| c.is_ascii_alphanumeric() || "+.-".contains(c)) && !rest.is_empty());
474        word.contains("://") || word.starts_with('/') || word.starts_with("~/") || scheme.is_some()
475    };
476    let words: Vec<&str> = line.split_whitespace().collect();
477    match words.iter().position(|w| is_link(w)) {
478        Some(at) => {
479            let link = words[at];
480            let open = if let Some(rest) = link.strip_prefix("~/") {
481                format!("file://{}", crate::config::expand_home(&format!("~/{rest}")).display())
482            } else if link.starts_with('/') {
483                format!("file://{link}")
484            } else {
485                link.to_string()
486            };
487            let text: Vec<&str> = words.iter().enumerate().filter(|(i, _)| *i != at).map(|(_, w)| *w).collect();
488            let text = text.join(" ").trim_end_matches([':', '-', '–', '—', ',']).trim().to_string();
489            let text = if text.is_empty() { link.trim_end_matches('/').rsplit('/').next().unwrap_or(link).to_string() } else { text };
490            Help { text, open }
491        }
492        None => Help { text: words.join(" "), open: String::new() },
493    }
494}
495
496/// A number on the screen: what it is, the number, how to reach it.
497#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
498pub struct Number {
499    /// "Emergency", "To talk to someone now, day and night".
500    pub label: String,
501    pub number: String,
502    /// "tel:112", "sms:85258?body=SHOUT".
503    pub url: String,
504    /// Who it is for, how: "by text, for deaf and hard-of-hearing people"; "" for none.
505    pub about: String,
506}
507
508/// The emergency numbers and crisis lines of every country Sioul knows
509/// (`data/crisis-lines.toml`), each with its source and the day it was checked.
510#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
511pub struct CrisisLines {
512    pub country: Vec<Country>,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
516pub struct Country {
517    /// An ISO code ("FR"), or "EU" for the European Union as a whole.
518    pub code: String,
519    pub line: Vec<Line>,
520}
521
522#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
523pub struct Line {
524    /// "emergency", "medical", "crisis", "text", "care"; "callback": the number
525    /// emergency services call back from, never shown to call (`callback`).
526    pub kind: String,
527    pub number: String,
528    /// The service's own name ("SAMU", "Samaritans"); "" for the emergency number.
529    #[serde(default)]
530    pub name: String,
531    /// How: "call", "text", or both.
532    pub how: Vec<String>,
533    /// The word to text, when one starts the conversation ("SHOUT").
534    #[serde(default)]
535    pub sms: String,
536    /// Who it is for, by language ("en", "fr"); none for everyone.
537    #[serde(default)]
538    pub about: BTreeMap<String, String>,
539    /// Where it was checked, and when ("2026-10-06").
540    pub source: String,
541    pub checked: String,
542}
543
544/// The kinds of numbers, in the order the screen gives them; a callback number is not given.
545pub const KINDS: [&str; 6] = ["emergency", "crisis", "medical", "text", "care", "callback"];
546
547const CRISIS_LINES: &str = include_str!("../data/crisis-lines.toml");
548
549impl CrisisLines {
550    pub fn built_in() -> CrisisLines {
551        toml::from_str(CRISIS_LINES).unwrap_or(CrisisLines { country: Vec::new() })
552    }
553
554    /// The country's numbers; Europe's 112 for a country not known here.
555    pub fn of(&self, code: &str) -> Option<&Country> {
556        let code = code.trim();
557        self.country.iter().find(|c| c.code.eq_ignore_ascii_case(code)).or_else(|| self.country.iter().find(|c| c.code == "EU"))
558    }
559}
560
561/// The number emergency services of the country `code` call back from, where
562/// one is known (France: 0 800 112 112): a call from it may need to get
563/// through do-not-disturb (P9). None elsewhere; never Europe's for another country.
564pub fn callback(code: &str) -> Option<String> {
565    let lines = CrisisLines::built_in();
566    let country = lines.country.iter().find(|c| c.code.eq_ignore_ascii_case(code.trim()))?;
567    country.line.iter().find(|l| l.kind == "callback").map(|l| l.number.clone())
568}
569
570/// The country whose numbers show: the pause's setting, else the country of
571/// phone numbers (Contacts), else the system's (never guessed from Sioul's
572/// language: an English speaker in France needs 3114 and 15). "" when none.
573pub fn country(settings: &PauseSettings, contacts_region: Option<&str>) -> String {
574    let chosen = |s: Option<&str>| s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_ascii_uppercase);
575    chosen(settings.country.as_deref())
576        .or_else(|| chosen(contacts_region))
577        .or_else(|| ["LC_ALL", "LC_TELEPHONE", "LANG"].iter().filter_map(|v| std::env::var(v).ok()).find_map(|v| crate::phones::region_of_locale(&v)).map(|r| r.code.to_ascii_uppercase()))
578        .unwrap_or_default()
579}
580
581/// The numbers of `code` as the screen gives them: the first line (the
582/// emergency number and the crisis line, one tap each), then the others,
583/// unfolded on demand.
584pub fn numbers(lines: &CrisisLines, code: &str, tr: &Translator) -> (Vec<Number>, Vec<Number>) {
585    let Some(country) = lines.of(code) else { return (Vec::new(), Vec::new()) };
586    let mut ordered: Vec<&Line> = country.line.iter().filter(|l| l.kind != "callback").collect();
587    ordered.sort_by_key(|l| KINDS.iter().position(|k| *k == l.kind).unwrap_or(KINDS.len()));
588    let shown = |line: &Line| {
589        let text_only = !line.how.iter().any(|h| h == "call");
590        let url = if text_only {
591            let digits: String = line.number.chars().filter(char::is_ascii_digit).collect();
592            if line.sms.is_empty() { format!("sms:{digits}") } else { format!("sms:{digits}?body={}", line.sms) }
593        } else {
594            format!("tel:{}", line.number.chars().filter(char::is_ascii_digit).collect::<String>())
595        };
596        let mut args = crate::i18n::args();
597        args.set("word", line.sms.clone());
598        let how = if line.how.len() > 1 {
599            tr.text("pause-number-call-or-text", None)
600        } else if text_only && !line.sms.is_empty() {
601            tr.text("pause-number-text-word", Some(&args))
602        } else if text_only && line.kind != "text" {
603            // "In writing" says it already.
604            tr.text("pause-number-by-text", None)
605        } else {
606            String::new()
607        };
608        let about = [line.name.clone(), line.about.get(tr.language()).cloned().unwrap_or_default(), how].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" · ");
609        Number { label: tr.text(&format!("pause-number-{}", line.kind), None), number: line.number.clone(), url, about }
610    };
611    let first: Vec<&Line> = ["emergency", "crisis"].iter().filter_map(|k| ordered.iter().copied().find(|l| l.kind == *k)).collect();
612    let main = first.iter().map(|l| shown(l)).collect();
613    let more = ordered.iter().filter(|l| !first.iter().any(|f| std::ptr::eq(*f, **l))).map(|l| shown(l)).collect();
614    (main, more)
615}
616
617/// The pause's screen, as the window draws it (P11–P17): its words, your
618/// list, the breathing guide and the grounding line as set, the numbers.
619#[derive(Debug, Clone, PartialEq, Serialize)]
620pub struct Screen {
621    /// Dose reminders still come during the pause: said, so that one is no surprise.
622    pub doses: bool,
623    pub helps: Vec<Help>,
624    pub breathing: bool,
625    /// Breaths a minute.
626    pub pace: u32,
627    pub grounding: String,
628    pub numbers: Vec<Number>,
629    pub more: Vec<Number>,
630    /// The country shown, its name; "" for Europe's 112.
631    pub country: String,
632}
633
634pub fn screen(settings: &PauseSettings, contacts_region: Option<&str>, tr: &Translator) -> Screen {
635    let code = country(settings, contacts_region);
636    let lines = CrisisLines::built_in();
637    let (numbers, more) = numbers(&lines, &code, tr);
638    let known = lines.country.iter().any(|c| c.code.eq_ignore_ascii_case(&code) && c.code != "EU");
639    Screen {
640        doses: settings.doses,
641        helps: settings.helps.iter().filter(|l| !l.trim().is_empty()).map(|l| help(l)).collect(),
642        breathing: settings.breathing,
643        pace: settings.pace(),
644        grounding: settings.grounding.trim().to_string(),
645        numbers,
646        more,
647        country: if known { tr.text(&format!("country-{}", code.to_ascii_lowercase()), None) } else { String::new() },
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use crate::areas::{TaskAreas, Time};
655    use crate::needs::{Days, Needs};
656    use crate::plan::{Settings, plan};
657    use crate::attention::{Attention, Column, Kind, Level, Now, Person, Row};
658    use crate::quiet::{Reason, mode};
659    use crate::reach::Channel;
660    use std::collections::BTreeSet;
661
662    fn at(text: &str) -> Zoned {
663        format!("{text}[Europe/Paris]").parse().unwrap()
664    }
665
666    fn stamp(text: &str) -> i64 {
667        at(text).timestamp().as_second()
668    }
669
670    /// Monday to Friday, 9:00 to 17:00.
671    fn week() -> Vec<AdminWindow> {
672        ["monday", "tuesday", "wednesday", "thursday", "friday"].iter().map(|d| AdminWindow { day: d.to_string(), start: "09:00".into(), end: Some("17:00".into()), minutes: 0, kind: None }).collect()
673    }
674
675    /// Health as first set: breakfast 07:50–08:20, lunch 12:10–13:00, a nap
676    /// 14:00–14:35, dinner 19:00–20:00, the night from 22:00 (bed 23:00) to 07:00.
677    fn health(now: &Zoned) -> Blocks {
678        Blocks::of(&Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() }, &Days::default(), &[], now)
679    }
680
681    fn evening<'a>(work: &'a [AdminWindow], held: &'a [(i64, i64)], blocks: &'a Blocks) -> Evening<'a> {
682        Evening { work, hours: work, held, blocks, latest_after: LATEST_AFTER, slot: 30 }
683    }
684
685    fn kept(blocks: &Blocks) -> Vec<(i64, i64)> {
686        blocks.kept.iter().filter(|k| k.kind != "sleep").map(|k| (k.start, k.end)).collect()
687    }
688
689    fn hm(seconds: i64) -> String {
690        jiff::Timestamp::from_second(seconds).unwrap().to_zoned(TimeZone::get("Europe/Paris").unwrap()).strftime("%H:%M").to_string()
691    }
692
693    #[test]
694    fn the_pause_comes_first_and_doses_and_alarms_still_come() {
695        // Friday 2 October 2026.
696        let paused = Overrides { paused_since: Some(stamp("2026-10-02T10:00")), ..Overrides::default() };
697        for time in ["10:30", "12:30", "14:10", "20:30", "23:30"] {
698            let now = at(&format!("2026-10-02T{time}"));
699            let m = mode(&week(), &[], &paused, &health(&now), &now);
700            assert_eq!((m.reason.clone(), m.time, m.quiet), (Reason::Paused, Time::Sleep, true), "{time}: above work, meals, naps, evenings and the night");
701            // As usual (`attention`): doses come unless the setup said otherwise, an
702            // event's own alarm (an alarm you set), a code you just asked for (Q6); nothing else.
703            let (usual, n) = (Attention::usual(), Now::of(&m));
704            let comes = |matrix: &Attention, kind: Kind| matrix.level(Row::Own(kind), &n) == Level::Now;
705            let mut held = Attention::usual();
706            held.set(Row::Own(Kind::Doses), Column::Pause, Level::Later).unwrap();
707            assert!(comes(&usual, Kind::Doses) && !comes(&held, Kind::Doses));
708            assert!(comes(&usual, Kind::Alarms) && comes(&usual, Kind::Codes));
709            assert!(!comes(&usual, Kind::Move) && !comes(&usual, Kind::Needs));
710        }
711        // Above "Done for today", "Work now" and Free time too.
712        let now = at("2026-10-02T11:00");
713        for others in [
714            Overrides { rest_until: Some(stamp("2026-10-05T09:00")), ..paused.clone() },
715            Overrides { work_now: Some(stamp("2026-10-02T17:00")), ..paused.clone() },
716            Overrides { free_since: Some(stamp("2026-10-02T10:30")), ..paused.clone() },
717        ] {
718            assert_eq!(mode(&week(), &[], &others, &health(&now), &now).reason, Reason::Paused);
719        }
720        // Ended: the hours again; pressed again later: paused again.
721        let ended = Overrides { paused_ended: Some(stamp("2026-10-02T10:45")), ..paused.clone() };
722        assert_eq!(mode(&week(), &[], &ended, &health(&now), &now).reason, Reason::Working);
723        let again = Overrides { paused_since: Some(stamp("2026-10-02T10:50")), ..ended };
724        assert_eq!(mode(&week(), &[], &again, &health(&now), &now).reason, Reason::Paused);
725    }
726
727    #[test]
728    fn free_time_is_leisure_with_the_safe_list_only() {
729        let now = at("2026-10-02T15:00");
730        let free = Overrides { free_since: Some(stamp("2026-10-02T14:40")), ..Overrides::default() };
731        let m = mode(&week(), &[], &free, &health(&now), &now);
732        assert_eq!((m.reason.clone(), m.time, m.quiet), (Reason::FreeTime, Time::Leisure, true), "leisure in working hours");
733        assert_eq!(m.until.as_ref().map(|z| z.strftime("%H:%M").to_string()), Some("22:00".into()), "until the night's start");
734        // As usual (`attention`): doses, codes, and events' alarms when their event falls in it; nothing else.
735        let (usual, n) = (Attention::usual(), Now::of(&m));
736        let level = |kind: Kind| usual.level(Row::Own(kind), &n);
737        assert!(level(Kind::Doses) == Level::Now && level(Kind::Codes) == Level::Now && level(Kind::Alarms) == Level::Event);
738        assert!(level(Kind::Move) != Level::Now);
739        // As usual only the safe list: their calls and messages at once, their mail shown, not told; the others wait.
740        assert_eq!((usual.person(Channel::Calls, Person::Safe, false, &n), usual.person(Channel::Mail, Person::Safe, false, &n)), (Level::Now, Level::Quiet));
741        for person in [Person::Neutral, Person::Restricted, Person::Stranger] {
742            assert_eq!(usual.person(Channel::Mail, person, false, &n), Level::Later, "{person:?}");
743        }
744        // Free time is a column now (Q15): a neutral row may come in it.
745        let mut cells = Attention::usual();
746        cells.set(Row::People(Channel::Mail, Person::Neutral), Column::Free, Level::Now).unwrap();
747        assert_eq!(cells.person(Channel::Mail, Person::Neutral, false, &n), Level::Now);
748        // "Nothing at all": the safe wait too, Always through still comes.
749        let nothing = Now { nothing: true, ..n.clone() };
750        assert_eq!((usual.person(Channel::Calls, Person::Safe, false, &nothing), usual.person(Channel::Calls, Person::Safe, true, &nothing)), (Level::Later, Level::Now));
751        assert!(nothing_now(&Overrides { free_nothing: Some(true), ..free.clone() }, &FreeTimeSettings::default()));
752        assert!(nothing_now(&free, &FreeTimeSettings { nothing: true, ..FreeTimeSettings::default() }));
753        // Sleep still comes first: a nap, then the night, which ends it.
754        let nap = at("2026-10-02T14:10");
755        let early = Overrides { free_since: Some(stamp("2026-10-02T13:30")), ..Overrides::default() };
756        assert_eq!(mode(&week(), &[], &early, &health(&nap), &nap).reason, Reason::Nap);
757        let late = at("2026-10-02T21:30");
758        assert_eq!(mode(&week(), &[], &free, &health(&late), &late).reason, Reason::FreeTime);
759        let night = at("2026-10-02T22:30");
760        assert_eq!(mode(&week(), &[], &free, &health(&night), &night).reason, Reason::WindingDown);
761        let morning = at("2026-10-03T10:00");
762        assert_eq!(mode(&week(), &[], &free, &health(&morning), &morning).reason, Reason::DayOff, "over with the night");
763        // Without a night, at midnight.
764        let no_night = Blocks::default();
765        assert_eq!(free_until(stamp("2026-10-02T14:40"), &no_night, now.time_zone()), stamp("2026-10-03T00:00"));
766        // Leisure tasks only, and the movement ones left out of the offers when movement is off.
767        let walk = crate::tasks::Task { categories: vec!["Marche".into()], ..crate::tasks::Task::default() };
768        assert!(is_movement(&walk) && !is_movement(&crate::tasks::Task { categories: vec!["joy".into()], ..crate::tasks::Task::default() }));
769        let planning = crate::config::PlanningSettings { even_days: true, ..Default::default() };
770        assert!(!FreeTimeSettings::default().movement(&planning), "energy-limiting illness: no exercise offered");
771        assert!(FreeTimeSettings::default().movement(&crate::config::PlanningSettings::default()));
772        assert!(FreeTimeSettings { movement: Some(true), ..FreeTimeSettings::default() }.movement(&planning), "unless asked");
773    }
774
775    #[test]
776    fn the_end_of_work_moves_by_the_working_time_taken_within_its_caps() {
777        let now = at("2026-10-02T16:00");
778        let blocks = health(&now);
779        let held = kept(&blocks);
780        let work = week();
781        let ev = evening(&work, &held, &blocks);
782        let zone = now.time_zone();
783        let date = now.date();
784        // 14:00–16:00: the nap (14:00–14:35) is not working time: 85 minutes.
785        assert_eq!(worked_away(&work, &held, date, zone, stamp("2026-10-02T14:00"), stamp("2026-10-02T16:00")), 85);
786        assert_eq!(worked_away(&work, &held, date, zone, stamp("2026-10-02T17:30"), stamp("2026-10-02T18:30")), 0, "after work: nothing moves");
787        // An hour: until 18:00.
788        assert_eq!(extend(&ev, date, zone, 60).map(|(u, e)| (hm(u), hm(e))), Some(("17:00".into(), "18:00".into())));
789        // Two and a half hours: dinner (19:00–20:00) goes around; the usual end + 2 h caps it at 19:00.
790        assert_eq!(extend(&ev, date, zone, 150).map(|(_, e)| hm(e)), Some("19:00".into()));
791        // A later latest end: past dinner, then the wind-down less an hour (21:00) caps it.
792        let late = Evening { latest_after: 300, ..evening(&work, &held, &blocks) };
793        assert_eq!(extend(&late, date, zone, 150).map(|(_, e)| hm(e)), Some("20:30".into()));
794        assert_eq!(extend(&late, date, zone, 400).map(|(_, e)| hm(e)), Some("21:00".into()), "never past the wind-down less an hour");
795        // An evening full of events: the slot for you keeps its half hour before the night.
796        let mut busy = held.clone();
797        busy.push((stamp("2026-10-02T18:00"), stamp("2026-10-02T19:00")));
798        busy.push((stamp("2026-10-02T20:00"), stamp("2026-10-02T21:40")));
799        let full = Evening { latest_after: 300, held: &busy, ..evening(&work, &held, &blocks) };
800        assert_eq!(extend(&full, date, zone, 400).map(|(_, e)| hm(e)), Some("17:30".into()), "half of the gap at 17:00 kept for you: 21:40–22:00 is too short");
801        // No working hours that day: nothing to move.
802        let saturday = at("2026-10-03T11:00");
803        assert_eq!(extend(&ev, saturday.date(), zone, 60), None);
804        // Midnight caps it without a night.
805        let none = Blocks::default();
806        let no_night = Evening { latest_after: 600, blocks: &none, ..evening(&work, &held, &blocks) };
807        assert_eq!(extend(&no_night, date, zone, 600).map(|(_, e)| hm(e)), Some("00:00".into()));
808    }
809
810    #[test]
811    fn free_time_ending_moves_the_end_and_keep_my_usual_end_moves_nothing() {
812        let blocks = health(&at("2026-10-02T12:00"));
813        let held = kept(&blocks);
814        let work = week();
815        let ev = evening(&work, &held, &blocks);
816        let mut o = Overrides::default();
817        start_free(&mut o, stamp("2026-10-02T14:00"));
818        // Coming back at 15:00: 25 minutes of work (the nap aside); the end moves to 17:25.
819        let back = at("2026-10-02T15:00");
820        assert_eq!(end_free(&mut o, &ev, &back, true).map(hm), Some("17:25".into()));
821        assert!(o.free_from().is_none());
822        assert_eq!(o.free_lost, Some(25));
823        // Work until then, Sioul ends it: leisure after.
824        let at_17 = at("2026-10-02T17:10");
825        let m = mode(&work, &[], &o, &health(&at_17), &at_17);
826        assert_eq!((m.reason.clone(), m.time, m.quiet), (Reason::Extended, Time::Work, false));
827        assert_eq!(m.until.map(|z| z.strftime("%H:%M").to_string()), Some("17:25".into()));
828        let after = at("2026-10-02T17:30");
829        assert_eq!(mode(&work, &[], &o, &health(&after), &after).reason, Reason::Evening);
830        // A second free time the same day adds up: 15:30–16:30, an hour more.
831        start_free(&mut o, stamp("2026-10-02T15:30"));
832        assert_eq!(end_free(&mut o, &ev, &at("2026-10-02T16:30"), true).map(hm), Some("18:25".into()));
833        // "Keep my usual end": nothing moves, no reason asked; a later free time moves nothing that day.
834        keep_usual_end(&mut o, back.date());
835        assert_eq!(o.moved_end(back.date()), None);
836        let at_17 = at("2026-10-02T17:10");
837        assert_eq!(mode(&work, &[], &o, &health(&at_17), &at_17).reason, Reason::Evening);
838        start_free(&mut o, stamp("2026-10-02T16:35"));
839        assert_eq!(end_free(&mut o, &ev, &at("2026-10-02T16:50"), true), None);
840        // The setting "the end does not move": the same.
841        let mut kept_end = Overrides::default();
842        start_free(&mut kept_end, stamp("2026-10-05T10:00"));
843        assert_eq!(end_free(&mut kept_end, &ev, &at("2026-10-05T11:00"), false), None);
844        // "Done for today" ends the moved day.
845        let mut done = Overrides::default();
846        start_free(&mut done, stamp("2026-10-05T10:00"));
847        end_free(&mut done, &ev, &at("2026-10-05T11:00"), true);
848        done.rest_until = Some(stamp("2026-10-06T09:00"));
849        let monday = at("2026-10-05T17:10");
850        assert_eq!(mode(&work, &[], &done, &health(&monday), &monday).reason, Reason::DoneForTheDay);
851        // While it goes on, the plan sees the end as if work came back now.
852        let mut going = Overrides::default();
853        start_free(&mut going, stamp("2026-10-05T15:00"));
854        let pending = moved_today(&going, &ev, &at("2026-10-05T16:00"), true).map(|(u, e)| (hm(u), hm(e)));
855        assert_eq!(pending, Some(("17:00".into(), "18:00".into())));
856        assert!(going.free_from().is_some(), "only seen, not ended");
857        // The next day starts as usual.
858        let tuesday = at("2026-10-06T17:10");
859        assert_eq!(mode(&work, &[], &o, &health(&tuesday), &tuesday).reason, Reason::Evening);
860    }
861
862    /// One task with `minutes`, light or not.
863    fn task(uid: &str, minutes: u32, light: bool) -> crate::tasks::Task {
864        crate::tasks::Task { uid: uid.into(), title: uid.into(), estimate: minutes, energy: if light { "light".into() } else { String::new() }, ..crate::tasks::Task::default() }
865    }
866
867    #[test]
868    fn the_extension_adds_hours_for_light_steps_and_what_does_not_fit_moves_on() {
869        // Monday 5 October at 16:00, back from Free time; the end moved to 18:00.
870        let now = at("2026-10-05T16:00");
871        let windows = week();
872        let usual = Settings::of_hours(&windows, TaskAreas::usual()).with_events(&now, &[]);
873        let moved = Settings::of_hours(&windows, TaskAreas::usual()).with_extension(Some((stamp("2026-10-05T17:00"), stamp("2026-10-05T18:00")))).with_events(&now, &[]);
874        assert_eq!(usual.room_on(now.date()).total() + 60, moved.room_on(now.date()).total(), "an hour more today");
875        assert_eq!(moved.light_only, 60);
876        let tasks = vec![task("hard", 90, false), task("easy", 45, true)];
877        let (none, set) = (BTreeMap::new(), BTreeSet::new());
878        let made = plan(&tasks, now.date(), &moved, &none, &set);
879        // The light step fits today; the usual one takes the usual hour left, its rest goes on to Tuesday, said nowhere as a count.
880        assert_eq!(made.items["easy"].start, Some(now.date()));
881        assert_eq!(made.items["hard"].finish, Some(now.date().tomorrow().unwrap()));
882        let today_minutes: u32 = made.days.get(&now.date()).map(|d| d.iter().filter(|(uid, _)| uid == "hard").map(|(_, m)| *m).sum()).unwrap_or(0);
883        assert!((1..=60).contains(&today_minutes), "a step that is not light never takes the extension: {today_minutes}");
884        // Budgets do not grow with it: what a day holds is the same either way.
885        assert_eq!(moved.capacity.day_limit(now.date(), 1.0), usual.capacity.day_limit(now.date(), 1.0));
886        // The day laid out: the light step may go after 17:00, the other never.
887        let day = crate::dayview::day(&now, &[], &made, &tasks, &moved);
888        let five = stamp("2026-10-05T17:00");
889        assert!(day.blocks.iter().filter(|b| b.kind == "task" && b.key == "hard").all(|b| b.end <= five), "{:?}", day.blocks);
890        assert!(day.blocks.iter().any(|b| b.kind == "task" && b.key == "easy" && b.start >= five), "the light step after the usual end: {:?}", day.blocks);
891        assert!(day.hours.last().is_some_and(|h| h.end == stamp("2026-10-05T18:00")), "today's hours run to 18:00");
892    }
893
894    #[test]
895    fn the_pause_moves_work_to_later_days_never_into_the_evening() {
896        // Paused from 14:00 to 16:30 on Monday: no extension, the rest of the day lighter.
897        let mut o = Overrides::default();
898        start_pause(&mut o, stamp("2026-10-05T14:00"));
899        let back = at("2026-10-05T16:30");
900        end_pause(&mut o, &back, After::Lighter, None, None);
901        assert!(o.paused_from().is_none());
902        assert_eq!(o.moved_end(back.date()), None);
903        let evening = at("2026-10-05T17:30");
904        assert_eq!(mode(&week(), &[], &o, &health(&evening), &evening).reason, Reason::Evening, "the day is not extended");
905        // The paused hours' work flows to later days: today keeps only what is left, lighter.
906        let windows = week();
907        let mut settings = Settings::of_hours(&windows, TaskAreas::usual()).with_events(&back, &[]);
908        (settings.today_percent, settings.heavy_today) = today_level(&o, back.date(), crate::today::Weather::Clear);
909        assert_eq!((settings.today_percent, settings.heavy_today), (60, 1), "the bad-day level");
910        let tasks = vec![task("report", 120, false)];
911        let made = plan(&tasks, back.date(), &settings, &BTreeMap::new(), &BTreeSet::new());
912        let today: u32 = made.days.get(&back.date()).map(|d| d.iter().map(|(_, m)| *m).sum()).unwrap_or(0);
913        assert!(today <= 30 * 60 / 100 + 5, "a lighter half hour at most: {today}");
914        assert!(made.items["report"].finish > Some(back.date()));
915        // "Lighten tomorrow", the one offer; old lighter days are forgotten.
916        lighten(&mut o, back.date().tomorrow().unwrap());
917        assert_eq!(today_level(&o, back.date().tomorrow().unwrap(), crate::today::Weather::Clear), (60, 1));
918        assert_eq!(today_level(&o, back.date().tomorrow().unwrap(), crate::today::Weather::Fog), (30, 0), "never heavier than the weather said");
919        let mut later = o.clone();
920        start_pause(&mut later, stamp("2026-10-08T10:00"));
921        end_pause(&mut later, &at("2026-10-08T10:20"), After::AsIs, None, None);
922        assert!(later.lighter.is_empty(), "{:?}", later.lighter);
923        // "Rest": the day closed, as "Done for today"; the Porch resting until the next admin hours.
924        let mut rest = Overrides::default();
925        start_pause(&mut rest, stamp("2026-10-05T10:00"));
926        end_pause(&mut rest, &at("2026-10-05T10:30"), After::Rest, Some(stamp("2026-10-06T09:00")), Some(stamp("2026-10-05T14:00")));
927        let noon = at("2026-10-05T12:00");
928        assert_eq!(mode(&week(), &[], &rest, &health(&noon), &noon).reason, Reason::DoneForTheDay);
929        assert!(rest.porch_rests(stamp("2026-10-05T13:00")) && !rest.porch_rests(stamp("2026-10-05T14:00")));
930        // Forgotten: the stamps go, nothing else.
931        forget(&mut rest);
932        assert_eq!((rest.paused_since, rest.paused_ended), (None, None));
933    }
934
935    #[test]
936    fn the_pauses_travel_between_devices() {
937        // As the sharing carries quiet.toml, key by key: a pause pressed on one
938        // device reads paused on the other; ended there, ended here.
939        let mut here = Overrides::default();
940        start_pause(&mut here, 1_759_390_000);
941        start_free(&mut here, 1_759_380_000);
942        here.free_ended = Some(1_759_385_000);
943        here.free_day = Some("2026-10-02".parse().unwrap());
944        here.free_lost = Some(25);
945        here.extended_until = Some(1_759_420_000);
946        here.lighter = vec!["2026-10-02".parse().unwrap()];
947        here.porch_rests_until = Some(1_759_430_000);
948        let text = toml::to_string(&here).unwrap();
949        let there: Overrides = toml::from_str(&text).unwrap();
950        assert_eq!(there, here, "{text}");
951        assert!(there.paused_from().is_some() && there.free_from().is_none());
952        // One key per field: the other device's "Come back" is one more key, later than the press.
953        let mut merged: toml::Table = toml::from_str(&text).unwrap();
954        merged.insert("paused_ended".into(), toml::Value::Integer(1_759_391_000));
955        let ended: Overrides = toml::from_str(&toml::to_string(&merged).unwrap()).unwrap();
956        assert!(ended.paused_from().is_none());
957        // An older Sioul's file, without any of it, reads as no pause.
958        let old: Overrides = toml::from_str("work_until = 5\n").unwrap();
959        assert!(old.paused_from().is_none() && old.free_from().is_none() && old.lighter.is_empty());
960        // Nothing of a pause is written when there was none.
961        assert_eq!(toml::to_string(&Overrides::default()).unwrap().trim(), "");
962    }
963
964    #[test]
965    fn crisis_lines_are_well_formed() {
966        let lines = CrisisLines::built_in();
967        assert!(!lines.country.is_empty(), "data/crisis-lines.toml reads");
968        let mut codes = BTreeSet::new();
969        let today = jiff::Zoned::now().date();
970        for country in &lines.country {
971            assert!(codes.insert(country.code.clone()), "{} twice", country.code);
972            assert!(country.code == "EU" || (country.code.len() == 2 && country.code.chars().all(|c| c.is_ascii_uppercase())), "{}", country.code);
973            assert!(country.line.iter().any(|l| l.kind == "emergency"), "{}: an emergency number", country.code);
974            for line in &country.line {
975                assert!(KINDS.contains(&line.kind.as_str()), "{}: {}", country.code, line.kind);
976                assert!(!line.number.is_empty() && line.number.chars().all(|c| c.is_ascii_digit() || c == ' '), "{}: {}", country.code, line.number);
977                assert!(!line.how.is_empty() && line.how.iter().all(|h| h == "call" || h == "text"), "{}: {:?}", line.number, line.how);
978                assert!(line.source.starts_with("https://"), "{}: {}", line.number, line.source);
979                let checked: Date = line.checked.parse().unwrap_or_else(|_| panic!("{}: {}", line.number, line.checked));
980                assert!(checked <= today && checked >= Date::constant(2026, 1, 1), "{}: {}", line.number, line.checked);
981                assert!(line.about.keys().all(|l| l == "en" || l == "fr"), "{}", line.number);
982            }
983        }
984        let has = |code: &str, number: &str| lines.of(code).is_some_and(|c| c.code == code && c.line.iter().any(|l| l.number == number));
985        for (code, number) in [("FR", "3114"), ("FR", "15"), ("FR", "112"), ("FR", "114"), ("EU", "112"), ("GB", "116 123"), ("GB", "999"), ("US", "988"), ("US", "911"), ("CA", "988"), ("CA", "911")] {
986            assert!(has(code, number), "{code} {number}");
987        }
988        // A country not known here: Europe's 112.
989        assert_eq!(lines.of("ZZ").map(|c| c.code.as_str()), Some("EU"));
990        // The screen's first line: the emergency number and the crisis line, one tap each.
991        let tr = Translator::new("en");
992        let (first, more) = numbers(&lines, "FR", &tr);
993        assert_eq!(first.iter().map(|n| (n.number.as_str(), n.url.as_str())).collect::<Vec<_>>(), [("112", "tel:112"), ("3114", "tel:3114")]);
994        assert!(more.iter().any(|n| n.number == "15") && more.iter().any(|n| n.number == "114" && n.url == "sms:114"));
995        assert!(first.iter().chain(&more).all(|n| n.number != "0 800 112 112"), "the callback number is never given to call");
996        assert_eq!(callback("fr").as_deref(), Some("0 800 112 112"));
997        assert_eq!(callback("GB"), None);
998        let (_, uk) = numbers(&lines, "GB", &tr);
999        assert!(uk.iter().any(|n| n.url == "sms:85258?body=SHOUT"), "{uk:?}");
1000        assert!(first.iter().chain(&more).all(|n| !n.label.starts_with("pause-")), "{first:?} {more:?}");
1001        // The country: the setting, else the phone numbers' country.
1002        assert_eq!(country(&PauseSettings { country: Some("gb".into()), ..PauseSettings::default() }, Some("FR")), "GB");
1003        assert_eq!(country(&PauseSettings::default(), Some("ca")), "CA");
1004    }
1005
1006    #[test]
1007    fn what_helps_opens_what_you_chose() {
1008        assert_eq!(help("Headphones on"), Help { text: "Headphones on".into(), open: String::new() });
1009        assert_eq!(help("The blue playlist: https://music.example/blue"), Help { text: "The blue playlist".into(), open: "https://music.example/blue".into() });
1010        assert_eq!(help("/home/me/Pictures/Calm").text, "Calm");
1011        assert_eq!(help("Photos /home/me/Pictures/Calm").open, "file:///home/me/Pictures/Calm");
1012        assert_eq!(help("Notes: nothing else").open, "", "a colon is not a link");
1013        let s = screen(&PauseSettings { helps: vec!["Tea".into(), "  ".into()], country: Some("FR".into()), ..PauseSettings::default() }, None, &Translator::new("fr"));
1014        assert_eq!((s.helps.len(), s.breathing, s.pace, s.country.as_str()), (1, false, PACE, "France"), "the breathing guide off unless switched on");
1015    }
1016}