Skip to main content

sioul_core/
needs.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The day's needs, set first: meals, naps and night sleep (docs/health.md,
5//! "Meals, rest and sleep"). Their times are kept free: the plan lays no task
6//! in them (`plan::Settings::with_needs`), the day shows meals and naps, and
7//! two notices come for each, at most: a heads-up about the work a quarter of
8//! an hour before ("No new big task"), then one at the time. Any day can
9//! differ from the usual one: a block moved, its times changed, quiet, taken
10//! out, or one added for that day only (`Days`, shared with your devices).
11//! Nothing about eating or sleeping is recorded, and nothing is said when one passes.
12//!
13//! From the literature on eating disorders and irregular eating (notes in
14//! docs/research/meal-prompts.md): by the clock, at times the person sets,
15//! never by hunger (CBT-E, Murphy et al. 2010; NICE NG69); every part set by
16//! the person, per block (Lindgreen, Lomborg & Clausen 2018); neutral words,
17//! no praise, no numbers, no "missed" (Eikey 2021; Levinson et al. 2017); the
18//! heads-up about the work, so it can be left on time (Leroy & Glomb 2018);
19//! naps with time to wake up after (Hilditch et al. 2017); sleep with a
20//! wind-down before it (Harvey et al. 2021).
21
22use jiff::Zoned;
23use jiff::civil::{Date, Time};
24use jiff::tz::TimeZone;
25use serde::{Deserialize, Serialize};
26use std::collections::{BTreeMap, BTreeSet};
27use std::path::{Path, PathBuf};
28
29fn yes() -> bool {
30    true
31}
32
33fn every_day() -> [bool; 7] {
34    [true; 7]
35}
36
37fn quarter() -> u32 {
38    15
39}
40
41fn an_hour() -> u32 {
42    60
43}
44
45/// A time kept free, again and again: a meal or a nap.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Block {
48    /// What it is called, as you like ("Lunch", "Break"); "" for the usual name.
49    #[serde(default, skip_serializing_if = "String::is_empty")]
50    pub name: String,
51    /// When it starts proper: eating, or the nap ("12:30").
52    pub at: String,
53    /// Minutes it lasts.
54    pub minutes: u32,
55    /// Minutes before, to get it ready (a meal); kept free too.
56    #[serde(default)]
57    pub before: u32,
58    /// Minutes after, to come back (waking up from a nap); kept free too.
59    #[serde(default)]
60    pub after: u32,
61    /// The weekdays it comes, Monday first.
62    #[serde(default = "every_day")]
63    pub days: [bool; 7],
64    /// Off: neither kept free nor said.
65    #[serde(default = "yes")]
66    pub on: bool,
67    /// Its two notices; off, it is kept free all the same.
68    #[serde(default = "yes")]
69    pub notices: bool,
70}
71
72impl Block {
73    fn new(at: &str, minutes: u32, before: u32, after: u32) -> Block {
74        Block { name: String::new(), at: at.into(), minutes, before, after, days: every_day(), on: true, notices: true }
75    }
76}
77
78/// The night: from winding down to waking up.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct Sleep {
81    /// Bedtime ("23:00").
82    pub bed: String,
83    /// Waking up ("07:00").
84    pub wake: String,
85    /// Minutes of winding down before bed, kept free: about an hour in the
86    /// programmes studied (Harvey et al. 2021).
87    #[serde(default = "an_hour")]
88    pub wind_down: u32,
89    /// Its two notices.
90    #[serde(default = "yes")]
91    pub notices: bool,
92    /// The mornings an alarm rings when the night ends, Monday first: the
93    /// weekday it rings on, not the one the night starts on. Rung by a phone
94    /// (Android) only; none by default (`Needs::alarm_of`).
95    #[serde(default, skip_serializing_if = "none_ticked")]
96    pub alarm: [bool; 7],
97}
98
99fn none_ticked(days: &[bool; 7]) -> bool {
100    !days.contains(&true)
101}
102
103impl Default for Sleep {
104    fn default() -> Sleep {
105        Sleep { bed: "23:00".into(), wake: "07:00".into(), wind_down: an_hour(), notices: true, alarm: [false; 7] }
106    }
107}
108
109fn three_meals() -> Vec<Block> {
110    vec![Block::new("08:00", 20, 10, 0), Block::new("12:30", 30, 20, 0), Block::new("19:30", 30, 30, 0)]
111}
112
113fn a_nap() -> Vec<Block> {
114    vec![Block::new("14:00", 20, 0, 15)]
115}
116
117/// Meals, naps and the night, each kind on or off; three meals when meals
118/// are first turned on, one of them in the evening (Murphy et al. 2010).
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct Needs {
121    #[serde(default)]
122    pub meals_on: bool,
123    #[serde(default = "three_meals")]
124    pub meals: Vec<Block>,
125    #[serde(default)]
126    pub naps_on: bool,
127    #[serde(default = "a_nap")]
128    pub naps: Vec<Block>,
129    #[serde(default)]
130    pub sleep_on: bool,
131    #[serde(default)]
132    pub sleep: Sleep,
133    /// Minutes between the heads-up and the time it starts.
134    #[serde(default = "quarter")]
135    pub heads_up: u32,
136    /// Minutes "Later" moves a block, today only.
137    #[serde(default = "quarter")]
138    pub later: u32,
139}
140
141impl Needs {
142    /// The settings' change, from what the page showed (`shown`) to what it
143    /// sends (`self`), set over the settings as the file holds them now
144    /// (`current`): each field the page changed, the page's; the others as
145    /// they are now; the meals and naps as lists (`settings::rebased`), so
146    /// that one another device added meanwhile stays.
147    pub fn rebased(&self, shown: &Needs, current: &Needs) -> Needs {
148        fn field<T: PartialEq + Clone>(value: &T, shown: &T, current: &T) -> T {
149            if value == shown { current.clone() } else { value.clone() }
150        }
151        Needs {
152            meals_on: field(&self.meals_on, &shown.meals_on, &current.meals_on),
153            meals: crate::settings::rebased(&shown.meals, &self.meals, &current.meals),
154            naps_on: field(&self.naps_on, &shown.naps_on, &current.naps_on),
155            naps: crate::settings::rebased(&shown.naps, &self.naps, &current.naps),
156            sleep_on: field(&self.sleep_on, &shown.sleep_on, &current.sleep_on),
157            sleep: field(&self.sleep, &shown.sleep, &current.sleep),
158            heads_up: field(&self.heads_up, &shown.heads_up, &current.heads_up),
159            later: field(&self.later, &shown.later, &current.later),
160        }
161    }
162}
163
164impl Default for Needs {
165    fn default() -> Needs {
166        Needs { meals_on: false, meals: three_meals(), naps_on: false, naps: a_nap(), sleep_on: false, sleep: Sleep::default(), heads_up: quarter(), later: quarter() }
167    }
168}
169
170/// One time kept free on a day.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
172pub struct Kept {
173    /// "meal:0", "nap:1", "sleep": what "Later" and "Skip today" name.
174    pub key: String,
175    /// "meal", "nap", "sleep".
176    pub kind: &'static str,
177    /// Its place among its kind, from 0.
178    pub index: usize,
179    /// Its name as set; "" for the usual one.
180    pub name: String,
181    /// Kept free from getting it ready, or winding down (Unix seconds)…
182    pub start: i64,
183    /// …through when it starts proper (eating, the nap, bed)…
184    pub at: i64,
185    /// …to its end, waking up included.
186    pub end: i64,
187    pub notices: bool,
188}
189
190/// The alarm at the end of a night (`Needs::alarm_of`).
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub struct Alarm {
193    /// When it rings: the night's end, waking (Unix seconds).
194    pub at: i64,
195    /// When the night starts, winding down (Unix seconds).
196    pub night_start: i64,
197    /// "No alarm" asked for that night: it does not ring.
198    pub skipped: bool,
199}
200
201/// How many days ahead a phone is given its alarms at waking.
202pub const WAKINGS_AHEAD_DAYS: i64 = 8;
203
204/// The notices given today, on this device only, never shared
205/// (`$XDG_STATE_HOME/sioul/needs-today.toml`). Nothing about eating or
206/// sleeping itself. The day's moves and skips live with each day's changes
207/// now (`Days`); those an older Sioul wrote here are read once, to move them there.
208#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
209pub struct Today {
210    #[serde(default, deserialize_with = "crate::budget::dates::optional", skip_serializing_if = "Option::is_none")]
211    pub day: Option<Date>,
212    /// Blocks moved, by key, in minutes (an older Sioul's).
213    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
214    pub shifts: BTreeMap<String, i64>,
215    /// Blocks skipped today (an older Sioul's): no notice, kept free all the same.
216    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
217    pub skipped: BTreeSet<String>,
218    /// Notices given: `<key>:heads-up`, `<key>:start`.
219    #[serde(default)]
220    pub sent: BTreeSet<String>,
221}
222
223/// One block on one day: a usual one changed that day only, by its key
224/// ("meal:1", "nap:0"; "sleep", the night starting that evening), or one
225/// added that day only ("added-…"), its kind said (docs/health.md, "Meals,
226/// rest and sleep"). Times on the clock and as set: a usual time changed
227/// later moves the usual days, never this one.
228#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
229pub struct DayBlock {
230    /// One added that day: "meal" or "nap"; "" for a usual one.
231    #[serde(default, skip_serializing_if = "String::is_empty")]
232    pub kind: String,
233    /// One added that day: its name.
234    #[serde(default, skip_serializing_if = "String::is_empty")]
235    pub name: String,
236    /// When it starts proper that day: eating, the nap, bed ("13:30"); "" as usual.
237    #[serde(default, skip_serializing_if = "String::is_empty")]
238    pub at: String,
239    /// The night: waking ("08:30"); "" as usual.
240    #[serde(default, skip_serializing_if = "String::is_empty")]
241    pub wake: String,
242    /// That day's minutes: it lasts, to get it ready (the night: to wind
243    /// down), to come back after it; none as usual.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub minutes: Option<u32>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub before: Option<u32>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub after: Option<u32>,
250    /// "Not that day": no notice; kept free all the same.
251    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
252    pub quiet: bool,
253    /// Taken out that day: neither kept free nor said.
254    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
255    pub off: bool,
256    /// The night: no alarm the morning it ends, its weekday ticked or not
257    /// ("No alarm at 07:00"). "Not that day" (`quiet`) keeps it: waking is
258    /// asked for on its own, never silenced by another choice.
259    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
260    pub no_alarm: bool,
261}
262
263/// Whether a block's key names one added for a day only.
264pub fn is_added(key: &str) -> bool {
265    key.starts_with("added-")
266}
267
268/// How long a past day's changes are kept: two weeks.
269const KEPT_DAYS: i64 = 14;
270
271/// What a person reads first in the days' file.
272const DAYS_HEADER: &str = "# Meals, naps and nights changed for one day only (Sioul's Health page).\n# The usual ones are in health.toml. Times on the clock; \"off\": not that day;\n# \"quiet\": no notice, still kept free.\n";
273
274/// Each day's changes, by day: `$XDG_DATA_HOME/sioul/health-days.toml`,
275/// shared with your own devices as the health file is (the sharing's health
276/// part). Days more than two weeks past are dropped.
277#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(transparent)]
279pub struct Days(pub BTreeMap<Date, BTreeMap<String, DayBlock>>);
280
281impl Days {
282    pub fn default_path() -> PathBuf {
283        crate::config::data_dir().join("health-days.toml")
284    }
285
286    /// The days' changes; none when there is no file. One that no longer
287    /// reads (edited by hand) is copied aside first (`health-days.toml.unreadable`):
288    /// saving over it then loses nothing.
289    pub fn load(path: &Path) -> Days {
290        match std::fs::read_to_string(path).map(|t| toml::from_str::<Days>(&t)) {
291            Ok(Ok(days)) => days,
292            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Days::default(),
293            _ => {
294                let _ = std::fs::copy(path, path.with_extension("toml.unreadable"));
295                Days::default()
296            }
297        }
298    }
299
300    /// Read, changed by `change` and written back, all under the file's lock,
301    /// which the sharing takes too: a change is set over the file as it is
302    /// then, never over a copy read earlier. Returns what `change` returns.
303    pub fn change_file<R>(path: &Path, today: Date, change: impl FnOnce(&mut Days) -> R) -> Result<R, String> {
304        crate::filelock::with_lock(path, || {
305            let mut days = Days::load(path);
306            let out = change(&mut days);
307            days.save(path, today)?;
308            Ok(out)
309        })
310    }
311
312    /// Written next to its place, then moved: never half a file. Yours alone.
313    /// Days more than two weeks before `today` go, and blocks left as usual.
314    /// Under the file's lock, which the sharing takes too.
315    pub fn save(&self, path: &Path, today: Date) -> Result<(), String> {
316        crate::filelock::with_lock(path, || self.write(path, today))
317    }
318
319    fn write(&self, path: &Path, today: Date) -> Result<(), String> {
320        let mut kept = self.clone();
321        let oldest = today.checked_sub(jiff::Span::new().days(KEPT_DAYS)).unwrap_or(today);
322        kept.0.retain(|day, blocks| {
323            blocks.retain(|_, block| *block != DayBlock::default());
324            *day >= oldest && !blocks.is_empty()
325        });
326        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
327        if let Some(parent) = path.parent() {
328            std::fs::create_dir_all(parent).map_err(fail)?;
329        }
330        let text = format!("{DAYS_HEADER}\n{}", toml::to_string(&kept).map_err(|e| e.to_string())?);
331        let temporary = path.with_extension("toml.new");
332        std::fs::write(&temporary, text).map_err(fail)?;
333        crate::health::keep_private(&temporary);
334        std::fs::rename(&temporary, path).map_err(fail)
335    }
336
337    /// A block's change on a day, if any.
338    pub fn get(&self, date: Date, key: &str) -> Option<&DayBlock> {
339        self.0.get(&date)?.get(key)
340    }
341
342    /// The block `key` of `date` changed by `change`; back to usual when
343    /// nothing is left of the change.
344    pub fn change(&mut self, date: Date, key: &str, change: impl FnOnce(&mut DayBlock)) {
345        let blocks = self.0.entry(date).or_default();
346        let block = blocks.entry(key.to_string()).or_default();
347        change(block);
348        if *block == DayBlock::default() {
349            blocks.remove(key);
350        }
351        if blocks.is_empty() {
352            self.0.remove(&date);
353        }
354    }
355
356    /// A block's change taken back: as usual that day (an added one: gone).
357    pub fn forget(&mut self, date: Date, key: &str) {
358        if let Some(blocks) = self.0.get_mut(&date) {
359            blocks.remove(key);
360            if blocks.is_empty() {
361                self.0.remove(&date);
362            }
363        }
364    }
365
366    /// A usual block taken out of the settings (its kind, its place): its
367    /// changes go, and those of the blocks after it follow them to their new
368    /// place ("meal:2" becomes "meal:1").
369    pub fn removed_usual(&mut self, kind: &str, index: usize) {
370        for blocks in self.0.values_mut() {
371            *blocks = std::mem::take(blocks)
372                .into_iter()
373                .filter_map(|(key, block)| match key.split_once(':').filter(|(k, _)| *k == kind).map(|(_, i)| i.parse::<usize>()) {
374                    Some(Ok(i)) if i == index => None,
375                    Some(Ok(i)) if i > index => Some((format!("{kind}:{}", i - 1), block)),
376                    _ => Some((key, block)),
377                })
378                .collect();
379        }
380        self.0.retain(|_, blocks| !blocks.is_empty());
381    }
382
383    /// A key for a block added on `date`, from now in milliseconds: two devices
384    /// adding one the same day never name theirs alike.
385    pub fn new_key(&self, date: Date, now_ms: i64) -> String {
386        let mut n = now_ms;
387        while self.get(date, &format!("added-{n}")).is_some() {
388            n += 1;
389        }
390        format!("added-{n}")
391    }
392}
393
394/// What a day's block is asked to become, that day only, as the Health page
395/// asks it (`Needs::change_day`).
396#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
397#[serde(default)]
398pub struct DayEdit {
399    /// "meal:1", "nap:0", "sleep", "added-…"; none for "add".
400    pub key: String,
401    /// "later", "move", "times", "quiet", "loud", "off", "on", "usual", "add";
402    /// the night's "no-alarm" and "alarm" (no alarm the morning it ends, or back).
403    pub action: String,
404    /// "12:10", "13:00": from getting it ready (winding down), to its end (waking).
405    pub from: String,
406    pub to: String,
407    /// The night: bedtime.
408    pub bed: String,
409    /// A meal's minutes to get it ready; a nap's to come back.
410    pub before: Option<u32>,
411    pub after: Option<u32>,
412    /// "later" by these minutes; 0: as many as "Later" moves by (`Needs::later`).
413    pub minutes: i64,
414    /// One added: "meal" or "nap", and its name.
415    pub kind: String,
416    pub name: String,
417}
418
419/// Why a day's block was left as it was.
420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
421pub enum DayProblem {
422    /// The day has passed: it stays as it was.
423    Past,
424    /// Times that do not read, or an end before the start.
425    Times,
426    /// Shorter than five minutes.
427    TooShort,
428    /// A meal or a nap moved out of its day.
429    OtherDay,
430    /// An action this version does not know.
431    Unknown,
432}
433
434/// `at` (Unix seconds) on the clock in `zone`: "12:30".
435fn clock(at: i64, zone: &TimeZone) -> String {
436    jiff::Timestamp::from_second(at).map(|t| t.to_zoned(zone.clone()).strftime("%H:%M").to_string()).unwrap_or_default()
437}
438
439/// "8:5" as typed, "08:05" as kept.
440fn hh_mm(time: Time) -> String {
441    format!("{:02}:{:02}", time.hour(), time.minute())
442}
443
444impl Today {
445    pub fn default_path() -> PathBuf {
446        crate::config::state_dir().join("needs-today.toml")
447    }
448
449    /// Today's, or nothing when it was written another day.
450    pub fn load(path: &Path, date: Date) -> Today {
451        let today: Today = std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
452        if today.day == Some(date) { today } else { Today { day: Some(date), ..Today::default() } }
453    }
454
455    /// The notices due `now` (Unix seconds) among `kept`, each marked given:
456    /// (block, true) for the heads-up, `heads_up` minutes before, within ten
457    /// minutes of its time and never once the block began; (block, false) at
458    /// its time, within a quarter of an hour. Each once; none for a block
459    /// skipped today or whose notices are off.
460    pub fn due<'a>(&mut self, kept: &'a [Kept], heads_up: u32, now: i64) -> Vec<(&'a Kept, bool)> {
461        let mut out = Vec::new();
462        for block in kept.iter().filter(|k| k.notices && !self.skipped.contains(&k.key)) {
463            let ahead = block.start - i64::from(heads_up) * 60;
464            if heads_up > 0 && now >= ahead && now < block.start && now - ahead < 10 * 60 && self.sent.insert(format!("{}:heads-up", block.key)) {
465                out.push((block, true));
466            }
467            if now >= block.start && now - block.start < 15 * 60 && self.sent.insert(format!("{}:start", block.key)) {
468                out.push((block, false));
469            }
470        }
471        out
472    }
473
474    pub fn save(&self, path: &Path) -> Result<(), String> {
475        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
476        if let Some(parent) = path.parent() {
477            std::fs::create_dir_all(parent).map_err(fail)?;
478        }
479        std::fs::write(path, toml::to_string(self).map_err(|e| e.to_string())?).map_err(fail)
480    }
481}
482
483/// "18:30" → 18:30.
484fn time_of(text: &str) -> Option<Time> {
485    let (hour, minute) = text.trim().split_once(':')?;
486    Time::new(hour.trim().parse().ok()?, minute.trim().parse().ok()?, 0, 0).ok()
487}
488
489/// `date` at `time` in `zone`, Unix seconds (a time skipped by a change of hour, after it).
490fn at(date: Date, time: Time, zone: &TimeZone) -> Option<i64> {
491    date.to_datetime(time).to_zoned(zone.clone()).ok().map(|z| z.timestamp().as_second())
492}
493
494impl Needs {
495    /// What is kept free around `now`, in order of start: today's and
496    /// tomorrow's meals and naps (those added for the day too), and the
497    /// nights ending this morning, starting tonight and tomorrow night; each
498    /// as its day has it (`days`: moved, times changed), meals pushed past
499    /// the events in `held` (their times with their margins, Unix seconds).
500    /// A block taken out that day is not there; a quiet one is (it is still
501    /// kept: only its notices are off). For whatever asks whether a meal, a
502    /// nap or the night is on at some time (the hours' states): kept stable.
503    pub fn kept_around(&self, now: &Zoned, days: &Days, held: &[(i64, i64)]) -> Vec<Kept> {
504        let zone = now.time_zone();
505        let today = now.date();
506        let midnight = at(today, Time::midnight(), zone).unwrap_or(0);
507        let mut out = Vec::new();
508        for date in [Some(today), today.tomorrow().ok()].into_iter().flatten() {
509            let pushed = self.past_events_on(date, zone, days, held);
510            out.extend(self.day_blocks(date, zone, days, &|key: &str| pushed.get(key).copied().unwrap_or(0)));
511        }
512        if self.sleep_on {
513            for night in [today.yesterday().ok(), Some(today), today.tomorrow().ok()].into_iter().flatten() {
514                if let Some(mut kept) = self.night_of(night, zone, days, 0) {
515                    // The night ending this morning said its notices yesterday, unless it began after midnight.
516                    kept.notices = kept.notices && kept.start >= midnight;
517                    out.push(kept);
518                }
519            }
520        }
521        out.sort_by_key(|k| k.start);
522        out
523    }
524
525    /// What is kept free on `date`, in order: each meal and nap on that
526    /// weekday, and the nights touching it (the one ending that morning, the
527    /// one starting that evening). `shift` moves a block that day only, in
528    /// minutes, by its key ("Later").
529    pub fn kept_on(&self, date: Date, zone: &TimeZone, shift: &dyn Fn(&str) -> i64) -> Vec<Kept> {
530        self.kept_with(date, zone, &Days::default(), shift)
531    }
532
533    /// The same with each day's changes (`days`): a block moved, its times
534    /// changed, quiet (no notice) or off that day, and those added that day.
535    /// Notices are said by the day a block starts on: the night ending this
536    /// morning said its own yesterday, unless its bedtime was after midnight.
537    pub fn kept_with(&self, date: Date, zone: &TimeZone, days: &Days, shift: &dyn Fn(&str) -> i64) -> Vec<Kept> {
538        let mut out = self.day_blocks(date, zone, days, shift);
539        if self.sleep_on {
540            let midnight = at(date, Time::midnight(), zone).unwrap_or(0);
541            let next = date.tomorrow().ok().and_then(|d| at(d, Time::midnight(), zone)).unwrap_or(midnight + 86_400);
542            for night in [date.yesterday().ok(), Some(date)].into_iter().flatten() {
543                let Some(mut kept) = self.night_of(night, zone, days, if night == date { shift("sleep") } else { 0 }) else { continue };
544                if kept.end > midnight && kept.start < next {
545                    kept.notices = kept.notices && kept.start >= midnight;
546                    out.push(kept);
547                }
548            }
549        }
550        out.sort_by_key(|k| k.start);
551        out
552    }
553
554    /// The blocks that are `date`'s own, in order: its meals and naps, those
555    /// added that day, and the night starting that evening (its bedtime after
556    /// midnight: the next morning). What the Health page lists for a day.
557    pub fn blocks_of(&self, date: Date, zone: &TimeZone, days: &Days, shift: &dyn Fn(&str) -> i64) -> Vec<Kept> {
558        let mut out = self.day_blocks(date, zone, days, shift);
559        if self.sleep_on
560            && let Some(night) = self.night_of(date, zone, days, shift("sleep"))
561        {
562            out.push(night);
563        }
564        out.sort_by_key(|k| k.start);
565        out
566    }
567
568    /// `date`'s meals and naps on that weekday, as changed that day, and those added that day.
569    fn day_blocks(&self, date: Date, zone: &TimeZone, days: &Days, shift: &dyn Fn(&str) -> i64) -> Vec<Kept> {
570        let mut out = Vec::new();
571        let weekday = date.weekday().to_monday_zero_offset() as usize;
572        let changes = days.0.get(&date);
573        let kept = |key: String, kind: &'static str, index: usize, name: String, start_at: i64, (minutes, before, after): (u32, u32, u32), notices: bool| Kept {
574            start: start_at - i64::from(before) * 60,
575            at: start_at,
576            end: start_at + i64::from(minutes + after) * 60,
577            key,
578            kind,
579            index,
580            name,
581            notices,
582        };
583        let mut usual = |kind: &'static str, on: bool, list: &[Block]| {
584            if !on {
585                return;
586            }
587            for (index, block) in list.iter().enumerate().filter(|(_, b)| b.on && b.days[weekday]) {
588                let key = format!("{kind}:{index}");
589                let change = changes.and_then(|c| c.get(&key));
590                if change.is_some_and(|c| c.off) {
591                    continue;
592                }
593                let time = change.map(|c| c.at.as_str()).filter(|t| !t.is_empty()).unwrap_or(&block.at);
594                let Some(time) = time_of(time) else { continue };
595                let Some(start_at) = at(date, time, zone) else { continue };
596                let lengths = (change.and_then(|c| c.minutes).unwrap_or(block.minutes), change.and_then(|c| c.before).unwrap_or(block.before), change.and_then(|c| c.after).unwrap_or(block.after));
597                let notices = block.notices && !change.is_some_and(|c| c.quiet);
598                let shifted = start_at + shift(&key) * 60;
599                out.push(kept(key, kind, index, block.name.clone(), shifted, lengths, notices));
600            }
601        };
602        usual("meal", self.meals_on, &self.meals);
603        usual("nap", self.naps_on, &self.naps);
604        // Added that day only, whatever the usual ones' switches say.
605        for (key, block) in changes.into_iter().flatten().filter(|(key, b)| is_added(key) && !b.off) {
606            let (kind, minutes) = match block.kind.as_str() {
607                "meal" => ("meal", 30),
608                "nap" => ("nap", 20),
609                _ => continue,
610            };
611            let Some(start_at) = time_of(&block.at).and_then(|time| at(date, time, zone)) else { continue };
612            let lengths = (block.minutes.unwrap_or(minutes), block.before.unwrap_or(0), block.after.unwrap_or(0));
613            out.push(kept(key.clone(), kind, 0, block.name.clone(), start_at + shift(key) * 60, lengths, !block.quiet));
614        }
615        out
616    }
617
618    /// The night starting the evening of `night` (its bedtime after
619    /// midnight: the next morning), as changed that day, moved `shift`
620    /// minutes; none when off that day.
621    fn night_of(&self, night: Date, zone: &TimeZone, days: &Days, shift: i64) -> Option<Kept> {
622        let change = days.get(night, "sleep");
623        if change.is_some_and(|c| c.off) {
624            return None;
625        }
626        let set = |own: Option<&str>, usual: &str| own.filter(|t| !t.is_empty()).map_or_else(|| usual.to_string(), str::to_string);
627        let bed = time_of(&set(change.map(|c| c.at.as_str()), &self.sleep.bed))?;
628        let wake = time_of(&set(change.map(|c| c.wake.as_str()), &self.sleep.wake))?;
629        let wind_down = change.and_then(|c| c.before).unwrap_or(self.sleep.wind_down);
630        let bed_day = if bed.hour() < 12 { night.tomorrow().unwrap_or(night) } else { night };
631        let bed_at = at(bed_day, bed, zone)?;
632        let mut wake_at = at(bed_day, wake, zone)?;
633        if wake_at <= bed_at {
634            wake_at = at(bed_day.tomorrow().unwrap_or(bed_day), wake, zone).unwrap_or(wake_at + 86_400);
635        }
636        let (bed_at, wake_at) = (bed_at + shift * 60, wake_at + shift * 60);
637        Some(Kept {
638            key: "sleep".into(),
639            kind: "sleep",
640            index: 0,
641            name: String::new(),
642            start: bed_at - i64::from(wind_down) * 60,
643            at: bed_at,
644            end: wake_at,
645            notices: self.sleep.notices && !change.is_some_and(|c| c.quiet),
646        })
647    }
648
649    /// The alarm at the end of the night starting the evening of `night`, as
650    /// that day has it (its waking moved or changed): when the weekday of
651    /// the morning it ends on is ticked (`Sleep::alarm`). None when no night
652    /// is set, when it is taken out that day, or when that weekday is not
653    /// ticked; `skipped` when "No alarm" was asked for that night. A night
654    /// "Not that day" (`quiet`) still rings. Zoned: the waking is on the
655    /// clock in `zone`, whatever the change of hour that night.
656    pub fn alarm_of(&self, night: Date, zone: &TimeZone, days: &Days) -> Option<Alarm> {
657        if !self.sleep_on || none_ticked(&self.sleep.alarm) {
658            return None;
659        }
660        let kept = self.night_of(night, zone, days, 0)?;
661        let morning = jiff::Timestamp::from_second(kept.end).ok()?.to_zoned(zone.clone()).date();
662        if !self.sleep.alarm[morning.weekday().to_monday_zero_offset() as usize] {
663            return None;
664        }
665        Some(Alarm { at: kept.end, night_start: kept.start, skipped: days.get(night, "sleep").is_some_and(|c| c.no_alarm) })
666    }
667
668    /// The alarms that ring after `now`, in order, until the same time
669    /// `ahead` days later (days on the clock, not 24 hours): each night's end,
670    /// as its day has it (`alarm_of`), those skipped left out.
671    pub fn wakings(&self, now: &Zoned, days: &Days, ahead: i64) -> Vec<Alarm> {
672        let zone = now.time_zone();
673        let (stamp, until) = (now.timestamp().as_second(), now.checked_add(jiff::Span::new().days(ahead)).map_or(i64::MAX, |z| z.timestamp().as_second()));
674        let mut out: Vec<Alarm> = Vec::new();
675        // From the night before yesterday (one ending late this morning, its bedtime past noon or after midnight).
676        let mut night = now.date().checked_sub(jiff::Span::new().days(2)).unwrap_or(now.date());
677        for _ in 0..ahead + 3 {
678            if let Some(alarm) = self.alarm_of(night, zone, days).filter(|a| !a.skipped && a.at > stamp && a.at <= until) {
679                out.push(alarm);
680            }
681            let Ok(next) = night.tomorrow() else { break };
682            night = next;
683        }
684        out.sort_by_key(|a| a.at);
685        out.dedup_by_key(|a| a.at);
686        out
687    }
688
689    /// The day's meals pushed past the events they would fall in (`held`:
690    /// the events' times with their margins, Unix seconds), as minutes by
691    /// key: to the end of the event, by whole five minutes; a meal pushed
692    /// into a next event goes past it too. Not a meal whose time you set for
693    /// that day, nor one added that day: you chose it, the day in view.
694    pub fn past_events_on(&self, date: Date, zone: &TimeZone, days: &Days, held: &[(i64, i64)]) -> BTreeMap<String, i64> {
695        let mut shifts: BTreeMap<String, i64> = BTreeMap::new();
696        let chosen = |key: &str| is_added(key) || days.get(date, key).is_some_and(|c| !c.at.is_empty());
697        for _ in 0..8 {
698            let current = shifts.clone();
699            let mut pushed = false;
700            for block in self.day_blocks(date, zone, days, &|key: &str| current.get(key).copied().unwrap_or(0)).iter().filter(|k| k.kind == "meal" && !chosen(&k.key)) {
701                if let Some(end) = held.iter().filter(|&&(from, to)| from < block.end && to > block.start).map(|&(_, to)| to).max() {
702                    *shifts.entry(block.key.clone()).or_insert(0) += (end - block.start + 299) / 300 * 5;
703                    pushed = true;
704                }
705            }
706            if !pushed {
707                break;
708            }
709        }
710        shifts
711    }
712
713    /// Moves given as minutes by key (`moved`), each meal pushed past the
714    /// events it would fall in (`held`: their times with their margins, Unix
715    /// seconds): from those moves, later only, to the end of the event, by
716    /// whole five minutes; a meal pushed into a next event goes past it too.
717    /// The days' changes go through `past_events_on`.
718    pub fn past_events(&self, date: Date, zone: &TimeZone, moved: &BTreeMap<String, i64>, held: &[(i64, i64)]) -> BTreeMap<String, i64> {
719        let mut shifts = moved.clone();
720        for _ in 0..8 {
721            let current = shifts.clone();
722            let mut pushed = false;
723            for block in self.kept_on(date, zone, &|key: &str| current.get(key).copied().unwrap_or(0)).iter().filter(|k| k.kind == "meal") {
724                if let Some(end) = held.iter().filter(|&&(from, to)| from < block.end && to > block.start).map(|&(_, to)| to).max() {
725                    *shifts.entry(block.key.clone()).or_insert(0) += (end - block.start + 299) / 300 * 5;
726                    pushed = true;
727                }
728            }
729            if !pushed {
730                break;
731            }
732        }
733        shifts
734    }
735
736    /// The spans kept free on `date`, for the plan: no task in them.
737    pub fn busy_on(&self, date: Date, zone: &TimeZone, shift: &dyn Fn(&str) -> i64) -> Vec<(i64, i64)> {
738        self.kept_on(date, zone, shift).into_iter().map(|k| (k.start, k.end)).collect()
739    }
740
741    /// A block's lengths on `date`, as that day has them, in minutes: it
742    /// lasts, to get it ready (the night: to wind down), to come back after
743    /// it. None for a key that names no block.
744    pub fn lengths(&self, date: Date, key: &str, days: &Days) -> Option<(u32, u32, u32)> {
745        let change = days.get(date, key);
746        if is_added(key) {
747            let c = change?;
748            return Some((c.minutes.unwrap_or(if c.kind == "nap" { 20 } else { 30 }), c.before.unwrap_or(0), c.after.unwrap_or(0)));
749        }
750        if key == "sleep" {
751            return Some((0, change.and_then(|c| c.before).unwrap_or(self.sleep.wind_down), 0));
752        }
753        let (kind, index) = key.split_once(':')?;
754        let index: usize = index.parse().ok()?;
755        let block = match kind {
756            "meal" => self.meals.get(index)?,
757            "nap" => self.naps.get(index)?,
758            _ => return None,
759        };
760        Some((change.and_then(|c| c.minutes).unwrap_or(block.minutes), change.and_then(|c| c.before).unwrap_or(block.before), change.and_then(|c| c.after).unwrap_or(block.after)))
761    }
762
763    /// `edit` made on `days`, for `date` only, as the Health page asks:
764    /// "later" (by `minutes`, else as many as "Later" moves by), "move" (to
765    /// start at `from`), "times" (`from`–`to`; a meal's minutes to get it
766    /// ready, a nap's to come back, the night's bedtime), "quiet" and "loud"
767    /// (no notice that day, or back), "off" and "on" (taken out that day, or
768    /// put back; one added that day goes), "usual" (as usual that day), "add"
769    /// (one for that day only, a meal or a rest); the night's "no-alarm" and
770    /// "alarm" (no alarm the morning it ends, or back). A day before `now`'s
771    /// stays as it was, but for the alarm of a night still going on (after
772    /// midnight, yesterday's). Moves count from the block as the page shows
773    /// it (meals pushed past the events in `held`), and are kept as times on
774    /// the clock; lengths only where they differ from the usual ones.
775    pub fn change_day(&self, days: &mut Days, date: Date, edit: &DayEdit, now: &Zoned, held: &[(i64, i64)]) -> Result<(), DayProblem> {
776        let alarm = matches!(edit.action.as_str(), "no-alarm" | "alarm");
777        let going_on = || edit.key == "sleep" && self.night_of(date, now.time_zone(), days, 0).is_some_and(|k| k.end > now.timestamp().as_second());
778        if date < now.date() && !(alarm && going_on()) {
779            return Err(DayProblem::Past);
780        }
781        let zone = now.time_zone().clone();
782        let pushed = self.past_events_on(date, &zone, days, held);
783        let blocks = self.blocks_of(date, &zone, days, &|key: &str| pushed.get(key).copied().unwrap_or(0));
784        let current = blocks.iter().find(|k| k.key == edit.key);
785        let key = edit.key.as_str();
786        let night = key == "sleep";
787        // A time on that day; the night's before noon, the next morning's.
788        let on_day = |time: Time, night: bool| -> Option<i64> {
789            let day = if night && time.hour() < 12 { date.tomorrow().ok()? } else { date };
790            at(day, time, &zone)
791        };
792        let time = |text: &str, night: bool| time_of(text).and_then(|t| on_day(t, night)).ok_or(DayProblem::Times);
793        // That day's lengths only where they differ from the usual ones; an added one's all.
794        let added = is_added(key);
795        let usual = if added { None } else { self.lengths(date, key, &Days::default()) };
796        let own = |value: u32, usual: Option<u32>| if usual == Some(value) && !added { None } else { Some(value) };
797        // Minutes from `start` to `end`, less what comes after: five at least.
798        let lasting = |start: i64, end: i64, after: u32| u32::try_from((end - start) / 60 - i64::from(after)).ok().filter(|m| *m >= 5).ok_or(DayProblem::TooShort);
799        match edit.action.as_str() {
800            "later" | "move" => {
801                let Some(k) = current else { return Ok(()) };
802                let delta = if edit.action == "later" { 60 * if edit.minutes == 0 { i64::from(self.later) } else { edit.minutes } } else { time(&edit.from, night)? - k.start };
803                // A meal or a nap stays on its day.
804                if !night && jiff::Timestamp::from_second(k.at + delta).map(|t| t.to_zoned(zone.clone()).date()).ok() != Some(date) {
805                    return Err(DayProblem::OtherDay);
806                }
807                days.change(date, key, |b| {
808                    b.at = clock(k.at + delta, &zone);
809                    if night {
810                        b.wake = clock(k.end + delta, &zone);
811                    }
812                });
813            }
814            "times" => {
815                let (Some(k), Some((_, before, after))) = (current, self.lengths(date, key, days)) else { return Ok(()) };
816                let (from, to) = (time_of(&edit.from).ok_or(DayProblem::Times)?, time_of(&edit.to).ok_or(DayProblem::Times)?);
817                if night {
818                    let bed = time_of(&edit.bed).ok_or(DayProblem::Times)?;
819                    let (from_at, bed_at) = (on_day(from, true).ok_or(DayProblem::Times)?, on_day(bed, true).ok_or(DayProblem::Times)?);
820                    let wind_down = u32::try_from((bed_at - from_at) / 60).map_err(|_| DayProblem::Times)?;
821                    days.change(date, key, |b| {
822                        b.at = hh_mm(bed);
823                        b.wake = hh_mm(to);
824                        b.before = own(wind_down, usual.map(|u| u.1));
825                    });
826                } else {
827                    let (from_at, to_at) = (on_day(from, false).ok_or(DayProblem::Times)?, on_day(to, false).ok_or(DayProblem::Times)?);
828                    if to_at <= from_at {
829                        return Err(DayProblem::Times);
830                    }
831                    let before = if k.kind == "meal" { edit.before.unwrap_or(before) } else { before };
832                    let after = if k.kind == "nap" { edit.after.unwrap_or(after) } else { after };
833                    let start = from_at + i64::from(before) * 60;
834                    let minutes = lasting(start, to_at, after)?;
835                    days.change(date, key, |b| {
836                        b.at = clock(start, &zone);
837                        b.minutes = own(minutes, usual.map(|u| u.0));
838                        b.before = own(before, usual.map(|u| u.1));
839                        b.after = own(after, usual.map(|u| u.2));
840                    });
841                }
842            }
843            "quiet" | "loud" => days.change(date, key, |b| b.quiet = edit.action == "quiet"),
844            "off" if added => days.forget(date, key),
845            "off" => days.change(date, key, |b| b.off = true),
846            "on" => days.change(date, key, |b| b.off = false),
847            "usual" => {
848                if !added {
849                    days.forget(date, key);
850                }
851            }
852            "add" => {
853                let kind = if edit.kind == "nap" { "nap" } else { "meal" };
854                let (from_at, to_at) = (time(&edit.from, false)?, time(&edit.to, false)?);
855                if to_at <= from_at {
856                    return Err(DayProblem::Times);
857                }
858                let before = if kind == "meal" { edit.before.unwrap_or(0) } else { 0 };
859                let after = if kind == "nap" { edit.after.unwrap_or(0) } else { 0 };
860                let start = from_at + i64::from(before) * 60;
861                let minutes = lasting(start, to_at, after)?;
862                let new = days.new_key(date, now.timestamp().as_millisecond());
863                days.change(date, &new, |b| {
864                    b.kind = kind.to_string();
865                    b.name = edit.name.trim().to_string();
866                    b.at = clock(start, &zone);
867                    b.minutes = Some(minutes);
868                    b.before = Some(before);
869                    b.after = Some(after);
870                });
871            }
872            // The alarm at the end of that night: none that morning, or back.
873            "no-alarm" | "alarm" if night => days.change(date, key, |b| b.no_alarm = edit.action == "no-alarm"),
874            _ => return Err(DayProblem::Unknown),
875        }
876        Ok(())
877    }
878
879    /// Meals planned more than four hours apart, as (end of one, start of the
880    /// next) on a usual day: said quietly where they are set, never in a
881    /// notice (Murphy et al. 2010: rarely more than four hours between).
882    pub fn long_gaps(&self) -> Vec<(String, String)> {
883        if !self.meals_on {
884            return Vec::new();
885        }
886        // Minutes of the day: when each meal starts, and ends.
887        let minutes = |t: Time| i64::from(t.hour()) * 60 + i64::from(t.minute());
888        let mut times: Vec<(i64, i64)> = self.meals.iter().filter(|m| m.on).filter_map(|m| time_of(&m.at).map(|t| (minutes(t), minutes(t) + i64::from(m.minutes)))).collect();
889        times.sort_unstable();
890        let clock = |m: i64| format!("{:02}:{:02}", m / 60 % 24, m % 60);
891        times.windows(2).filter(|w| w[1].0 - w[0].1 > 4 * 60).map(|w| (clock(w[0].1), clock(w[1].0))).collect()
892    }
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    fn zone() -> TimeZone {
900        TimeZone::get("Europe/Paris").unwrap()
901    }
902
903    fn hm(seconds: i64) -> String {
904        jiff::Timestamp::from_second(seconds).unwrap().to_zoned(zone()).strftime("%d %H:%M").to_string()
905    }
906
907    fn none(_: &str) -> i64 {
908        0
909    }
910
911    #[test]
912    fn meals_pushed_past_events() {
913        let monday: Date = "2026-10-05".parse().unwrap();
914        let needs = Needs { meals_on: true, ..Needs::default() };
915        let at = |text: &str| format!("2026-10-05T{text}").parse::<jiff::civil::DateTime>().unwrap().to_zoned(zone()).unwrap().timestamp().as_second();
916        // A meeting 12:00–13:15, a quarter of an hour to come back: lunch (made from 12:10) from 13:30.
917        let shifts = needs.past_events(monday, &zone(), &BTreeMap::new(), &[(at("12:00"), at("13:30"))]);
918        assert_eq!(shifts.get("meal:1"), Some(&80));
919        assert_eq!(shifts.get("meal:0"), None, "breakfast untouched");
920        // Moved already to 14:00 by you: past the meeting, left there.
921        let mine = BTreeMap::from([("meal:1".to_string(), 110)]);
922        assert_eq!(needs.past_events(monday, &zone(), &mine, &[(at("12:00"), at("13:30"))]).get("meal:1"), Some(&110));
923        // Pushed into a second event: past it too, on whole five minutes.
924        let shifts = needs.past_events(monday, &zone(), &BTreeMap::new(), &[(at("12:00"), at("13:30")), (at("13:40"), at("14:02"))]);
925        let kept = needs.kept_on(monday, &zone(), &|key: &str| shifts.get(key).copied().unwrap_or(0));
926        assert_eq!(hm(kept.iter().find(|k| k.key == "meal:1").unwrap().start), "05 14:05");
927    }
928
929    #[test]
930    fn meals_naps_and_nights_kept_free() {
931        let monday: Date = "2026-10-05".parse().unwrap();
932        // Off, nothing kept.
933        assert!(Needs::default().kept_on(monday, &zone(), &none).is_empty());
934        let needs = Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() };
935        let kept: Vec<(String, String, String, String)> = needs.kept_on(monday, &zone(), &none).iter().map(|k| (k.key.clone(), hm(k.start), hm(k.at), hm(k.end))).collect();
936        assert_eq!(
937            kept,
938            [
939                // Sunday's night, ending this morning; winding down from 22:00.
940                ("sleep".to_string(), "04 22:00".to_string(), "04 23:00".to_string(), "05 07:00".to_string()),
941                ("meal:0".into(), "05 07:50".into(), "05 08:00".into(), "05 08:20".into()),
942                ("meal:1".into(), "05 12:10".into(), "05 12:30".into(), "05 13:00".into()),
943                // A nap, and a quarter of an hour to come back after it.
944                ("nap:0".into(), "05 14:00".into(), "05 14:00".into(), "05 14:35".into()),
945                ("meal:2".into(), "05 19:00".into(), "05 19:30".into(), "05 20:00".into()),
946                ("sleep".into(), "05 22:00".into(), "05 23:00".into(), "06 07:00".into()),
947            ]
948        );
949        // Only the evening's night says its notices: the one ending this morning said them yesterday.
950        assert!(needs.kept_on(monday, &zone(), &none).iter().filter(|k| k.kind == "sleep").map(|k| k.notices).eq([false, true]));
951        // "Later": lunch moved a quarter of an hour, today only.
952        let later = |key: &str| if key == "meal:1" { 15 } else { 0 };
953        let lunch = needs.kept_on(monday, &zone(), &later).into_iter().find(|k| k.key == "meal:1").unwrap();
954        assert_eq!((hm(lunch.start), hm(lunch.end)), ("05 12:25".into(), "05 13:15".into()));
955        // A block off on Sundays, and one off altogether.
956        let mut weekdays = needs.clone();
957        weekdays.meals[0].days[6] = false;
958        weekdays.meals[2].on = false;
959        let sunday: Date = "2026-10-11".parse().unwrap();
960        let keys: Vec<String> = weekdays.kept_on(sunday, &zone(), &none).iter().filter(|k| k.kind == "meal").map(|k| k.key.clone()).collect();
961        assert_eq!(keys, ["meal:1"]);
962        // A bedtime after midnight belongs to the night before.
963        let late = Needs { sleep: Sleep { bed: "01:00".into(), wake: "09:00".into(), wind_down: 30, ..Sleep::default() }, ..needs.clone() };
964        let nights: Vec<(String, String)> = late.kept_on(monday, &zone(), &none).iter().filter(|k| k.kind == "sleep").map(|k| (hm(k.start), hm(k.end))).collect();
965        assert_eq!(nights, [("05 00:30".to_string(), "05 09:00".to_string())], "the next one starts on the 6th: Tuesday's");
966        // Winding down from before midnight: the evening keeps it free.
967        let midnight = Needs { sleep: Sleep { bed: "00:30".into(), wake: "08:00".into(), wind_down: 60, ..Sleep::default() }, ..needs.clone() };
968        let evening = midnight.kept_on(monday, &zone(), &none).into_iter().filter(|k| k.kind == "sleep").last().unwrap();
969        assert_eq!((hm(evening.start), hm(evening.at)), ("05 23:30".to_string(), "06 00:30".to_string()));
970    }
971
972    #[test]
973    fn two_notices_each_once_near_their_time() {
974        let monday: Date = "2026-10-05".parse().unwrap();
975        let needs = Needs { meals_on: true, ..Needs::default() };
976        let kept = needs.kept_on(monday, &zone(), &none);
977        let at = |text: &str| -> i64 { format!("2026-10-05T{text}[Europe/Paris]").parse::<jiff::Zoned>().unwrap().timestamp().as_second() };
978        let mut today = Today::default();
979        // Lunch is got ready from 12:10: the heads-up at 11:55, once.
980        let given = |today: &mut Today, time: &str| -> Vec<(String, bool)> { today.due(&kept, 15, at(time)).into_iter().map(|(k, h)| (k.key.clone(), h)).collect() };
981        assert!(given(&mut today, "11:54").is_empty());
982        assert_eq!(given(&mut today, "11:56"), [("meal:1".to_string(), true)]);
983        assert!(given(&mut today, "11:57").is_empty(), "once");
984        assert_eq!(given(&mut today, "12:11"), [("meal:1".to_string(), false)]);
985        assert!(given(&mut today, "12:12").is_empty());
986        // Sioul opened late: no heads-up once the block began, the one at its time within a quarter of an hour, none after.
987        let mut late = Today::default();
988        assert_eq!(given(&mut late, "19:05"), [("meal:2".to_string(), false)]);
989        assert!(given(&mut Today::default(), "19:20").is_empty(), "past its quarter of an hour: nothing, never 'missed'");
990        // Not today: nothing at all.
991        let mut skipped = Today { skipped: ["meal:1".to_string()].into(), ..Today::default() };
992        assert!(given(&mut skipped, "11:56").is_empty() && given(&mut skipped, "12:11").is_empty());
993    }
994
995    fn day(text: &str) -> Date {
996        text.parse().unwrap()
997    }
998
999    /// Each block as "key start–end", " quiet" when it gives no notice.
1000    fn rows(kept: &[Kept]) -> Vec<String> {
1001        kept.iter().map(|k| format!("{} {}–{}{}", k.key, hm(k.start), hm(k.end), if k.notices { "" } else { " quiet" })).collect()
1002    }
1003
1004    #[test]
1005    fn a_day_changed_is_that_day_only() {
1006        let (wednesday, thursday) = (day("2026-10-07"), day("2026-10-08"));
1007        let needs = Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() };
1008        let mut days = Days::default();
1009        days.change(wednesday, "meal:1", |b| b.at = "13:30".into());
1010        days.change(wednesday, "nap:0", |b| b.off = true);
1011        days.change(wednesday, "meal:0", |b| b.quiet = true);
1012        days.change(wednesday, "sleep", |b| {
1013            b.at = "00:30".into();
1014            b.wake = "08:30".into();
1015        });
1016        let picnic = days.new_key(wednesday, 1_791_300_000_000);
1017        days.change(wednesday, &picnic, |b| {
1018            b.kind = "meal".into();
1019            b.name = "Picnic".into();
1020            b.at = "16:00".into();
1021            b.minutes = Some(45);
1022        });
1023        // Wednesday: breakfast without notices, lunch at 13:30 (getting it ready from 13:10), no
1024        // nap, the picnic, and a bedtime after midnight: its notices are Wednesday's.
1025        let picnic_row = format!("{picnic} 07 16:00–07 16:45");
1026        assert_eq!(
1027            rows(&needs.kept_with(wednesday, &zone(), &days, &none)),
1028            ["sleep 06 22:00–07 07:00 quiet", "meal:0 07 07:50–07 08:20 quiet", "meal:1 07 13:10–07 14:00", picnic_row.as_str(), "meal:2 07 19:00–07 20:00", "sleep 07 23:30–08 08:30"]
1029        );
1030        // Thursday as usual; Wednesday's night ends its morning, its notices said the day before.
1031        assert_eq!(
1032            rows(&needs.kept_with(thursday, &zone(), &days, &none)),
1033            ["sleep 07 23:30–08 08:30 quiet", "meal:0 08 07:50–08 08:20", "meal:1 08 12:10–08 13:00", "nap:0 08 14:00–08 14:35", "meal:2 08 19:00–08 20:00", "sleep 08 22:00–09 07:00"]
1034        );
1035        // Without the days' changes, Wednesday is a usual day.
1036        assert_eq!(rows(&needs.kept_on(wednesday, &zone(), &none))[2], "meal:1 07 12:10–07 13:00");
1037        // A day's own blocks, as the page lists them: not the night ending that morning.
1038        let own: Vec<String> = needs.blocks_of(wednesday, &zone(), &days, &none).iter().map(|k| k.key.clone()).collect();
1039        assert_eq!(own, ["meal:0", "meal:1", picnic.as_str(), "meal:2", "sleep"]);
1040        // The usual lunch changed afterwards (12:00, forty minutes): Wednesday keeps its own time
1041        // and takes the new length, which it did not change; Thursday takes both.
1042        let mut later = needs.clone();
1043        later.meals[1].at = "12:00".into();
1044        later.meals[1].minutes = 40;
1045        let lunch = |date: Date| later.kept_with(date, &zone(), &days, &none).into_iter().find(|k| k.key == "meal:1").map(|k| format!("{}–{}", hm(k.start), hm(k.end)));
1046        assert_eq!(lunch(wednesday).as_deref(), Some("07 13:10–07 14:10"));
1047        assert_eq!(lunch(thursday).as_deref(), Some("08 11:40–08 12:40"));
1048        // Breakfast taken out of the settings: lunch's change follows lunch, now the first meal.
1049        let mut rekeyed = days.clone();
1050        rekeyed.removed_usual("meal", 0);
1051        assert!(rekeyed.get(wednesday, "meal:0").is_some_and(|b| b.at == "13:30"));
1052        assert!(rekeyed.get(wednesday, "meal:1").is_none());
1053        assert!(rekeyed.get(wednesday, "nap:0").is_some_and(|b| b.off), "naps untouched");
1054        // A change taken back leaves nothing behind.
1055        days.change(wednesday, "meal:0", |b| b.quiet = false);
1056        days.forget(wednesday, "meal:1");
1057        assert!(days.get(wednesday, "meal:0").is_none() && days.get(wednesday, "meal:1").is_none());
1058    }
1059
1060    #[test]
1061    fn a_day_changed_from_the_page() {
1062        let needs = Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() };
1063        let now: jiff::Zoned = "2026-10-06T10:00[Europe/Paris]".parse().unwrap();
1064        let wednesday = day("2026-10-07");
1065        let mut days = Days::default();
1066        let asked = |action: &str, key: &str| DayEdit { action: action.into(), key: key.into(), ..DayEdit::default() };
1067        let mut change = |date: Date, edit: DayEdit| needs.change_day(&mut days, date, &edit, &now, &[]);
1068        // A past day stays as it was.
1069        assert_eq!(change(day("2026-10-05"), asked("off", "meal:1")), Err(DayProblem::Past));
1070        // "15 min later", twice: eating from 13:00, getting it ready from 12:40.
1071        change(wednesday, asked("later", "meal:1")).unwrap();
1072        change(wednesday, asked("later", "meal:1")).unwrap();
1073        // Moved to 11:30, from getting it ready: eating from 11:50.
1074        change(wednesday, DayEdit { from: "11:30".into(), ..asked("move", "meal:1") }).unwrap();
1075        // A meal moved past midnight would leave its day.
1076        assert_eq!(change(wednesday, DayEdit { from: "23:50".into(), ..asked("move", "meal:2") }), Err(DayProblem::OtherDay));
1077        // The nap quiet that day, then back; then taken out; dinner's times: 18:00 to 19:00, ten minutes to get it ready.
1078        change(wednesday, asked("quiet", "nap:0")).unwrap();
1079        change(wednesday, asked("loud", "nap:0")).unwrap();
1080        change(wednesday, asked("off", "nap:0")).unwrap();
1081        change(wednesday, DayEdit { from: "18:00".into(), to: "19:00".into(), before: Some(10), ..asked("times", "meal:2") }).unwrap();
1082        // The night: winding down from 23:00, bed at 00:30, up at 08:30.
1083        change(wednesday, DayEdit { from: "23:00".into(), bed: "00:30".into(), to: "08:30".into(), ..asked("times", "sleep") }).unwrap();
1084        // One added that day: a rest from 16:00 to 16:30, a quarter of an hour to come back after it.
1085        change(wednesday, DayEdit { kind: "nap".into(), name: " Rest ".into(), from: "16:00".into(), to: "16:30".into(), after: Some(15), ..asked("add", "") }).unwrap();
1086        assert_eq!(change(wednesday, DayEdit { from: "16:00".into(), to: "16:03".into(), ..asked("add", "") }), Err(DayProblem::TooShort));
1087        assert_eq!(change(wednesday, DayEdit { from: "16:00".into(), to: "15:00".into(), ..asked("times", "meal:0") }), Err(DayProblem::Times));
1088        assert_eq!(change(wednesday, asked("forget", "meal:0")), Err(DayProblem::Unknown));
1089        let shown = rows(&needs.blocks_of(wednesday, &zone(), &days, &none));
1090        let rest = days.0[&wednesday].keys().find(|k| is_added(k)).unwrap().clone();
1091        let rest_row = format!("{rest} 07 16:00–07 16:30");
1092        assert_eq!(shown, ["meal:0 07 07:50–07 08:20", "meal:1 07 11:30–07 12:20", rest_row.as_str(), "meal:2 07 18:00–07 19:00", "sleep 07 23:00–08 08:30"]);
1093        // Kept as times, lengths only where they differ from the usual ones (dinner: 50 minutes, ten to get it ready).
1094        let dinner = days.get(wednesday, "meal:2").unwrap();
1095        assert_eq!((dinner.at.as_str(), dinner.minutes, dinner.before), ("18:10", Some(50), Some(10)));
1096        let night = days.get(wednesday, "sleep").unwrap();
1097        assert_eq!((night.at.as_str(), night.wake.as_str(), night.before), ("00:30", "08:30", Some(90)));
1098        assert!(days.get(wednesday, "nap:0").is_some_and(|b| b.off && !b.quiet));
1099        assert_eq!(days.get(wednesday, &rest).map(|b| (b.name.as_str(), b.minutes)), Some(("Rest", Some(15))));
1100        // Back to usual, put back, the added one removed: Wednesday as any day.
1101        let mut change = |edit: DayEdit| needs.change_day(&mut days, wednesday, &edit, &now, &[]);
1102        for (action, key) in [("usual", "meal:1"), ("usual", "meal:2"), ("usual", "sleep"), ("on", "nap:0"), ("off", rest.as_str())] {
1103            change(asked(action, key)).unwrap();
1104        }
1105        assert!(days.0.is_empty(), "{days:?}");
1106    }
1107
1108    #[test]
1109    fn kept_around_now() {
1110        let needs = Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() };
1111        let now: jiff::Zoned = "2026-10-07T11:00[Europe/Paris]".parse().unwrap();
1112        let mut days = Days::default();
1113        days.change(now.date(), "nap:0", |b| b.off = true);
1114        days.change(now.date(), "meal:0", |b| b.quiet = true);
1115        let at = |text: &str| format!("2026-10-07T{text}").parse::<jiff::civil::DateTime>().unwrap().to_zoned(zone()).unwrap().timestamp().as_second();
1116        let kept = rows(&needs.kept_around(&now, &days, &[(at("12:00"), at("13:30"))]));
1117        // The night ending this morning, today (breakfast quiet, lunch after the meeting, no nap),
1118        // tonight, tomorrow, tomorrow night; each block once.
1119        assert_eq!(
1120            kept,
1121            [
1122                "sleep 06 22:00–07 07:00 quiet",
1123                "meal:0 07 07:50–07 08:20 quiet",
1124                "meal:1 07 13:30–07 14:20",
1125                "meal:2 07 19:00–07 20:00",
1126                "sleep 07 22:00–08 07:00",
1127                "meal:0 08 07:50–08 08:20",
1128                "meal:1 08 12:10–08 13:00",
1129                "nap:0 08 14:00–08 14:35",
1130                "meal:2 08 19:00–08 20:00",
1131                "sleep 08 22:00–09 07:00",
1132            ]
1133        );
1134    }
1135
1136    #[test]
1137    fn meals_pushed_past_events_unless_set_that_day() {
1138        let monday = day("2026-10-05");
1139        let needs = Needs { meals_on: true, ..Needs::default() };
1140        let at = |text: &str| format!("2026-10-05T{text}").parse::<jiff::civil::DateTime>().unwrap().to_zoned(zone()).unwrap().timestamp().as_second();
1141        let meeting = [(at("12:00"), at("13:30"))];
1142        assert_eq!(needs.past_events_on(monday, &zone(), &Days::default(), &meeting).get("meal:1"), Some(&80));
1143        // Set for that day (eating from 11:50, into the meeting): chosen with the day in view, it stays.
1144        let mut set = Days::default();
1145        set.change(monday, "meal:1", |b| b.at = "11:50".into());
1146        assert!(needs.past_events_on(monday, &zone(), &set, &meeting).is_empty());
1147        // Quiet only: its time is the usual one, pushed as ever.
1148        let mut quiet = Days::default();
1149        quiet.change(monday, "meal:1", |b| b.quiet = true);
1150        assert_eq!(needs.past_events_on(monday, &zone(), &quiet, &meeting).get("meal:1"), Some(&80));
1151    }
1152
1153    #[test]
1154    fn the_days_file_reads_as_written() {
1155        let dir = std::env::temp_dir().join(format!("sioul-needs-days-{}", std::process::id()));
1156        let _ = std::fs::remove_dir_all(&dir);
1157        std::fs::create_dir_all(&dir).unwrap();
1158        let path = dir.join("health-days.toml");
1159        assert_eq!(Days::load(&path), Days::default(), "none yet");
1160        let mut days = Days::default();
1161        days.change(day("2026-10-07"), "meal:1", |b| b.at = "13:30".into());
1162        days.change(day("2026-10-08"), "sleep", |b| {
1163            b.at = "00:30".into();
1164            b.wake = "08:30".into();
1165        });
1166        days.change(day("2026-09-01"), "nap:0", |b| b.off = true);
1167        days.save(&path, day("2026-10-06")).unwrap();
1168        let text = std::fs::read_to_string(&path).unwrap();
1169        assert!(text.starts_with("# Meals, naps and nights"), "{text}");
1170        assert!(text.contains("\"meal:1\"]\nat = \"13:30\"\n"), "{text}");
1171        let read = Days::load(&path);
1172        assert_eq!(read.get(day("2026-10-08"), "sleep").map(|b| b.wake.as_str()), Some("08:30"));
1173        assert!(!read.0.contains_key(&day("2026-09-01")), "more than two weeks past: dropped");
1174        // Written by hand: a table per block.
1175        let by_hand: Days = toml::from_str("[2026-10-09.\"nap:0\"]\nquiet = true\n\n[2026-10-09.added-5]\nkind = \"nap\"\nname = \"Rest\"\nat = \"16:00\"\n").unwrap();
1176        assert!(by_hand.get(day("2026-10-09"), "nap:0").is_some_and(|b| b.quiet));
1177        assert_eq!(by_hand.get(day("2026-10-09"), "added-5").map(|b| b.name.as_str()), Some("Rest"));
1178        // Broken by hand: kept aside, nothing read.
1179        std::fs::write(&path, "[2026-10-07\n").unwrap();
1180        assert_eq!(Days::load(&path), Days::default());
1181        assert!(dir.join("health-days.toml.unreadable").exists());
1182        let _ = std::fs::remove_dir_all(&dir);
1183    }
1184
1185    #[test]
1186    fn long_gaps_said_where_meals_are_set() {
1187        let needs = Needs { meals_on: true, ..Needs::default() };
1188        // 08:20 to 12:30 is 4 h 10; 13:00 to 19:30 is 6 h 30.
1189        assert_eq!(needs.long_gaps(), [("08:20".to_string(), "12:30".to_string()), ("13:00".to_string(), "19:30".to_string())]);
1190        let mut snack = needs.clone();
1191        snack.meals.push(Block::new("16:00", 15, 5, 0));
1192        snack.meals[0].at = "08:30".into();
1193        assert!(snack.long_gaps().is_empty(), "a snack between, breakfast later: no gap over four hours");
1194        // Read as written by hand: a block's unsaid parts take their usual values.
1195        let read: Needs = toml::from_str("meals_on = true\n[[meals]]\nat = \"12:00\"\nminutes = 30\n").unwrap();
1196        assert!(read.meals[0].on && read.meals[0].notices && read.meals[0].days == [true; 7] && !read.sleep_on && read.heads_up == 15);
1197    }
1198
1199    /// Each alarm as "Mon 05 07:00", on the clock in Paris.
1200    fn rung(alarms: &[Alarm]) -> Vec<String> {
1201        alarms.iter().map(|a| jiff::Timestamp::from_second(a.at).unwrap().to_zoned(zone()).strftime("%a %d %H:%M").to_string()).collect()
1202    }
1203
1204    fn ticked(alarm: [bool; 7]) -> Needs {
1205        Needs { sleep_on: true, sleep: Sleep { alarm, ..Sleep::default() }, ..Needs::default() }
1206    }
1207
1208    #[test]
1209    fn alarms_at_waking_on_the_days_ticked() {
1210        // Sunday 4 October 2026, 20:00 in Paris.
1211        let now: Zoned = "2026-10-04T20:00[Europe/Paris]".parse().unwrap();
1212        // None ticked (the default): no alarm.
1213        assert!(ticked([false; 7]).wakings(&now, &Days::default(), WAKINGS_AHEAD_DAYS).is_empty());
1214        // Monday to Friday mornings, eight days ahead (Monday 12 included), at 07:00.
1215        let needs = ticked([true, true, true, true, true, false, false]);
1216        assert_eq!(rung(&needs.wakings(&now, &Days::default(), WAKINGS_AHEAD_DAYS)), ["Mon 05 07:00", "Tue 06 07:00", "Wed 07 07:00", "Thu 08 07:00", "Fri 09 07:00", "Mon 12 07:00"]);
1217        // Ticked by the morning it rings on: Saturday's ends Friday's night.
1218        assert_eq!(rung(&ticked([false, false, false, false, false, true, false]).wakings(&now, &Days::default(), WAKINGS_AHEAD_DAYS)), ["Sat 10 07:00"]);
1219        // A bedtime after midnight (Saturday 01:00 to 09:00): still Saturday's morning.
1220        let late = Needs { sleep: Sleep { bed: "01:00".into(), wake: "09:00".into(), alarm: [false, false, false, false, false, true, false], ..Sleep::default() }, ..needs.clone() };
1221        assert_eq!(rung(&late.wakings(&now, &Days::default(), WAKINGS_AHEAD_DAYS)), ["Sat 10 09:00"]);
1222        // No night set: nothing rings, ticked or not.
1223        assert!(Needs { sleep_on: false, ..needs.clone() }.wakings(&now, &Days::default(), WAKINGS_AHEAD_DAYS).is_empty());
1224        // Each day's own night: Monday's waking at 08:30, Tuesday's night taken out, "No alarm"
1225        // on Wednesday's, Thursday's "Not that day" (no notice): that one still rings.
1226        let mut days = Days::default();
1227        days.change(day("2026-10-05"), "sleep", |b| b.wake = "08:30".into());
1228        days.change(day("2026-10-06"), "sleep", |b| b.off = true);
1229        days.change(day("2026-10-07"), "sleep", |b| b.no_alarm = true);
1230        days.change(day("2026-10-08"), "sleep", |b| b.quiet = true);
1231        assert_eq!(rung(&needs.wakings(&now, &days, WAKINGS_AHEAD_DAYS)), ["Mon 05 07:00", "Tue 06 08:30", "Fri 09 07:00", "Mon 12 07:00"]);
1232        // Skipped, and said so (the page offers it back); taken out, no alarm at all.
1233        assert!(needs.alarm_of(day("2026-10-07"), &zone(), &days).is_some_and(|a| a.skipped));
1234        assert!(needs.alarm_of(day("2026-10-06"), &zone(), &days).is_none());
1235        // Its night starts at winding down, the evening before.
1236        let monday = needs.alarm_of(day("2026-10-04"), &zone(), &Days::default()).unwrap();
1237        assert_eq!((hm(monday.night_start), hm(monday.at)), ("04 22:00".to_string(), "05 07:00".to_string()));
1238        // Ringing at 07:00: the next one is Tuesday's.
1239        let seven: Zoned = "2026-10-05T07:00[Europe/Paris]".parse().unwrap();
1240        assert_eq!(rung(&needs.wakings(&seven, &Days::default(), 1)), ["Tue 06 07:00"]);
1241        // Kept with the night, none written when none is ticked; "no_alarm" by its night's date.
1242        assert!(toml::to_string(&needs).unwrap().contains("alarm = [true, true, true, true, true, false, false]"));
1243        assert!(!toml::to_string(&Needs::default()).unwrap().contains("alarm"));
1244        let read: Needs = toml::from_str("sleep_on = true\n[sleep]\nbed = \"23:00\"\nwake = \"07:00\"\n").unwrap();
1245        assert_eq!(read.sleep.alarm, [false; 7]);
1246        let by_hand: Days = toml::from_str("[2026-10-07.sleep]\nno_alarm = true\n").unwrap();
1247        assert!(by_hand.get(day("2026-10-07"), "sleep").is_some_and(|b| b.no_alarm && !b.off && !b.quiet));
1248    }
1249
1250    #[test]
1251    fn alarms_through_the_changes_of_hour() {
1252        let needs = ticked([true; 7]);
1253        let utc = |alarms: &[Alarm]| alarms.iter().map(|a| jiff::Timestamp::from_second(a.at).unwrap().to_string()).collect::<Vec<_>>();
1254        // Autumn, Sunday 25 October 2026: 03:00 summer time becomes 02:00. The night is an hour
1255        // longer, the alarm at 07:00 on the clock all the same: 06:00 UTC, 25 hours after Saturday's.
1256        let friday: Zoned = "2026-10-23T12:00[Europe/Paris]".parse().unwrap();
1257        let alarms = needs.wakings(&friday, &Days::default(), 4);
1258        assert_eq!(rung(&alarms), ["Sat 24 07:00", "Sun 25 07:00", "Mon 26 07:00", "Tue 27 07:00"]);
1259        assert_eq!(utc(&alarms), ["2026-10-24T05:00:00Z", "2026-10-25T06:00:00Z", "2026-10-26T06:00:00Z", "2026-10-27T06:00:00Z"]);
1260        assert_eq!(alarms[1].at - alarms[0].at, 25 * 3600);
1261        // Spring, Sunday 29 March 2026: 02:00 winter time becomes 03:00; 23 hours from Saturday's.
1262        let friday: Zoned = "2026-03-27T12:00[Europe/Paris]".parse().unwrap();
1263        let alarms = needs.wakings(&friday, &Days::default(), 3);
1264        assert_eq!(rung(&alarms), ["Sat 28 07:00", "Sun 29 07:00", "Mon 30 07:00"]);
1265        assert_eq!(utc(&alarms), ["2026-03-28T06:00:00Z", "2026-03-29T05:00:00Z", "2026-03-30T05:00:00Z"]);
1266        assert_eq!(alarms[1].at - alarms[0].at, 23 * 3600);
1267        // A waking the change of hour skips (02:30 that Sunday) rings just after it, 03:30 summer time.
1268        let early = Needs { sleep: Sleep { bed: "22:00".into(), wake: "02:30".into(), ..needs.sleep.clone() }, ..needs.clone() };
1269        let skipped = early.alarm_of(day("2026-03-28"), &zone(), &Days::default()).unwrap();
1270        assert_eq!(jiff::Timestamp::from_second(skipped.at).unwrap().to_zoned(zone()).strftime("%d %H:%M %:z").to_string(), "29 03:30 +02:00");
1271        // One it gives twice (02:30 on 25 October): the first.
1272        let twice = early.alarm_of(day("2026-10-24"), &zone(), &Days::default()).unwrap();
1273        assert_eq!(jiff::Timestamp::from_second(twice.at).unwrap().to_string(), "2026-10-25T00:30:00Z");
1274        // A day's own waking that night (08:30 on the 25th), on the clock too.
1275        let mut days = Days::default();
1276        days.change(day("2026-10-24"), "sleep", |b| b.wake = "08:30".into());
1277        let saturday: Zoned = "2026-10-24T12:00[Europe/Paris]".parse().unwrap();
1278        assert_eq!(utc(&needs.wakings(&saturday, &days, 2)), ["2026-10-25T07:30:00Z", "2026-10-26T06:00:00Z"]);
1279    }
1280
1281    #[test]
1282    fn no_alarm_asked_from_the_page() {
1283        let needs = ticked([true; 7]);
1284        let tuesday = day("2026-10-06");
1285        let asked = |action: &str, key: &str| DayEdit { action: action.into(), key: key.into(), ..DayEdit::default() };
1286        let mut days = Days::default();
1287        // Tuesday evening: no alarm at the end of tonight's night, then back as usual.
1288        let evening: Zoned = "2026-10-06T21:00[Europe/Paris]".parse().unwrap();
1289        needs.change_day(&mut days, tuesday, &asked("no-alarm", "sleep"), &evening, &[]).unwrap();
1290        assert_eq!(days.get(tuesday, "sleep").map(|b| b.no_alarm), Some(true));
1291        assert!(needs.wakings(&evening, &days, 1).is_empty());
1292        needs.change_day(&mut days, tuesday, &asked("alarm", "sleep"), &evening, &[]).unwrap();
1293        assert!(days.0.is_empty(), "nothing left: {days:?}");
1294        // Only a night has an alarm.
1295        assert_eq!(needs.change_day(&mut days, tuesday, &asked("no-alarm", "meal:0"), &evening, &[]), Err(DayProblem::Unknown));
1296        // After midnight the night going on is yesterday's: its alarm can still be taken out, not its times.
1297        let night: Zoned = "2026-10-07T01:30[Europe/Paris]".parse().unwrap();
1298        needs.change_day(&mut days, tuesday, &asked("no-alarm", "sleep"), &night, &[]).unwrap();
1299        assert_eq!(rung(&needs.wakings(&night, &days, 2)), ["Thu 08 07:00"]);
1300        assert_eq!(needs.change_day(&mut days, tuesday, &asked("later", "sleep"), &night, &[]), Err(DayProblem::Past));
1301        // Once over, it stays as it was.
1302        let noon: Zoned = "2026-10-07T12:00[Europe/Paris]".parse().unwrap();
1303        assert_eq!(needs.change_day(&mut days, tuesday, &asked("alarm", "sleep"), &noon, &[]), Err(DayProblem::Past));
1304        // "Back to usual" on a night brings its alarm back too.
1305        let wednesday = day("2026-10-07");
1306        needs.change_day(&mut days, wednesday, &asked("no-alarm", "sleep"), &noon, &[]).unwrap();
1307        needs.change_day(&mut days, wednesday, &asked("usual", "sleep"), &noon, &[]).unwrap();
1308        assert!(days.get(wednesday, "sleep").is_none());
1309    }
1310
1311    #[test]
1312    fn the_alarm_ends_the_sleep() {
1313        // While you sleep Sioul holds its notifications; at the alarm's minute the night is over,
1314        // so nothing held for sleep holds it (and it is rung by the phone, never asked "may I").
1315        let needs = ticked([true; 7]);
1316        let before: Zoned = "2026-10-07T06:59[Europe/Paris]".parse().unwrap();
1317        let alarm = needs.wakings(&before, &Days::default(), 1)[0];
1318        let ringing = jiff::Timestamp::from_second(alarm.at).unwrap().to_zoned(zone());
1319        let mode = |now: &Zoned| crate::quiet::mode(&[], &[], &crate::quiet::Overrides::default(), &crate::quiet::Blocks::of(&needs, &Days::default(), &[], now), now);
1320        assert!(mode(&before).sleeps());
1321        assert!(!mode(&ringing).sleeps());
1322        let usual = crate::attention::Attention::usual();
1323        assert_eq!(usual.level(crate::attention::Row::Own(crate::attention::Kind::Move), &crate::attention::Now::of(&mode(&ringing))), crate::attention::Level::Now);
1324    }
1325
1326    #[test]
1327    fn a_day_changed_by_dragging() {
1328        // What the Health page's timeline sends when a block or one of its edges is dropped
1329        // (HealthTimeline.qml): minutes of its column, on the clock, as "Move to…" and
1330        // "Change its times…" send them; a night's morning part is in the next day's column.
1331        let needs = Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() };
1332        let now: jiff::Zoned = "2026-10-06T10:00[Europe/Paris]".parse().unwrap();
1333        let (today, tomorrow) = (day("2026-10-06"), day("2026-10-07"));
1334        let clock = |minute: i64| format!("{:02}:{:02}", minute.rem_euclid(1440) / 60, minute.rem_euclid(60));
1335        let mut days = Days::default();
1336        let mut drop = |edit: DayEdit| needs.change_day(&mut days, today, &edit, &now, &[]);
1337        let moved = |from: i64| DayEdit { key: "meal:1".into(), action: "move".into(), from: clock(from), ..DayEdit::default() };
1338        // Lunch, kept 12:10–13:00, dragged 35 minutes later: kept 12:45–13:35, eating from 13:05.
1339        drop(moved(12 * 60 + 45)).unwrap();
1340        // Its bottom edge to 14:00: it lasts 55 minutes.
1341        drop(DayEdit { key: "meal:1".into(), action: "times".into(), from: clock(12 * 60 + 45), to: clock(14 * 60), ..DayEdit::default() }).unwrap();
1342        // The nap's bottom edge, 14:35 to 14:50: the quarter of an hour to come back stays, it lasts 35.
1343        drop(DayEdit { key: "nap:0".into(), action: "times".into(), from: clock(14 * 60), to: clock(14 * 60 + 50), ..DayEdit::default() }).unwrap();
1344        // The night's top edge, half an hour down: bed at 23:30, winding down from 22:30, waking as it was.
1345        drop(DayEdit { key: "sleep".into(), action: "times".into(), from: clock(22 * 60 + 30), bed: clock(23 * 60 + 30), to: clock(7 * 60), ..DayEdit::default() }).unwrap();
1346        // Its bottom edge, in tomorrow's column: up at 07:45.
1347        drop(DayEdit { key: "sleep".into(), action: "times".into(), from: clock(22 * 60 + 30), bed: clock(23 * 60 + 30), to: clock(7 * 60 + 45), ..DayEdit::default() }).unwrap();
1348        // The whole night dragged an hour later from tomorrow's column, where it starts at -90.
1349        drop(DayEdit { key: "sleep".into(), action: "move".into(), from: clock(-90 + 60), ..DayEdit::default() }).unwrap();
1350        assert_eq!(rows(&needs.blocks_of(today, &zone(), &days, &none)), ["meal:0 06 07:50–06 08:20", "meal:1 06 12:45–06 14:00", "nap:0 06 14:00–06 14:50", "meal:2 06 19:00–06 20:00", "sleep 06 23:30–07 08:45"]);
1351        let lunch = days.get(today, "meal:1").unwrap();
1352        assert_eq!((lunch.at.as_str(), lunch.minutes), ("13:05", Some(55)));
1353        let night = days.get(today, "sleep").unwrap();
1354        assert_eq!((night.at.as_str(), night.wake.as_str(), night.before), ("00:30", "08:45", None));
1355        // Tomorrow's own blocks as usual; only the night ending its morning changed.
1356        assert!(days.0.get(&tomorrow).is_none());
1357        assert_eq!(rows(&needs.kept_with(tomorrow, &zone(), &days, &none)).first().map(String::as_str), Some("sleep 06 23:30–07 08:45 quiet"));
1358    }
1359}