1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Block {
48 #[serde(default, skip_serializing_if = "String::is_empty")]
50 pub name: String,
51 pub at: String,
53 pub minutes: u32,
55 #[serde(default)]
57 pub before: u32,
58 #[serde(default)]
60 pub after: u32,
61 #[serde(default = "every_day")]
63 pub days: [bool; 7],
64 #[serde(default = "yes")]
66 pub on: bool,
67 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct Sleep {
81 pub bed: String,
83 pub wake: String,
85 #[serde(default = "an_hour")]
88 pub wind_down: u32,
89 #[serde(default = "yes")]
91 pub notices: bool,
92 #[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#[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 #[serde(default = "quarter")]
135 pub heads_up: u32,
136 #[serde(default = "quarter")]
138 pub later: u32,
139}
140
141impl Needs {
142 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, ¤t.meals_on),
153 meals: crate::settings::rebased(&shown.meals, &self.meals, ¤t.meals),
154 naps_on: field(&self.naps_on, &shown.naps_on, ¤t.naps_on),
155 naps: crate::settings::rebased(&shown.naps, &self.naps, ¤t.naps),
156 sleep_on: field(&self.sleep_on, &shown.sleep_on, ¤t.sleep_on),
157 sleep: field(&self.sleep, &shown.sleep, ¤t.sleep),
158 heads_up: field(&self.heads_up, &shown.heads_up, ¤t.heads_up),
159 later: field(&self.later, &shown.later, ¤t.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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
172pub struct Kept {
173 pub key: String,
175 pub kind: &'static str,
177 pub index: usize,
179 pub name: String,
181 pub start: i64,
183 pub at: i64,
185 pub end: i64,
187 pub notices: bool,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub struct Alarm {
193 pub at: i64,
195 pub night_start: i64,
197 pub skipped: bool,
199}
200
201pub const WAKINGS_AHEAD_DAYS: i64 = 8;
203
204#[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 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
214 pub shifts: BTreeMap<String, i64>,
215 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
217 pub skipped: BTreeSet<String>,
218 #[serde(default)]
220 pub sent: BTreeSet<String>,
221}
222
223#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
229pub struct DayBlock {
230 #[serde(default, skip_serializing_if = "String::is_empty")]
232 pub kind: String,
233 #[serde(default, skip_serializing_if = "String::is_empty")]
235 pub name: String,
236 #[serde(default, skip_serializing_if = "String::is_empty")]
238 pub at: String,
239 #[serde(default, skip_serializing_if = "String::is_empty")]
241 pub wake: String,
242 #[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 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
252 pub quiet: bool,
253 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
255 pub off: bool,
256 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
260 pub no_alarm: bool,
261}
262
263pub fn is_added(key: &str) -> bool {
265 key.starts_with("added-")
266}
267
268const KEPT_DAYS: i64 = 14;
270
271const 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#[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 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 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 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 pub fn get(&self, date: Date, key: &str) -> Option<&DayBlock> {
339 self.0.get(&date)?.get(key)
340 }
341
342 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 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 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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
397#[serde(default)]
398pub struct DayEdit {
399 pub key: String,
401 pub action: String,
404 pub from: String,
406 pub to: String,
407 pub bed: String,
409 pub before: Option<u32>,
411 pub after: Option<u32>,
412 pub minutes: i64,
414 pub kind: String,
416 pub name: String,
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
421pub enum DayProblem {
422 Past,
424 Times,
426 TooShort,
428 OtherDay,
430 Unknown,
432}
433
434fn 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
439fn 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 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 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
483fn 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
489fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 "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 pub fn long_gaps(&self) -> Vec<(String, String)> {
883 if !self.meals_on {
884 return Vec::new();
885 }
886 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 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 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 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 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 ("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 ("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 assert!(needs.kept_on(monday, &zone(), &none).iter().filter(|k| k.kind == "sleep").map(|k| k.notices).eq([false, true]));
951 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 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 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 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 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 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 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 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 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 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 assert_eq!(rows(&needs.kept_on(wednesday, &zone(), &none))[2], "meal:1 07 12:10–07 13:00");
1037 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 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 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 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 assert_eq!(change(day("2026-10-05"), asked("off", "meal:1")), Err(DayProblem::Past));
1070 change(wednesday, asked("later", "meal:1")).unwrap();
1072 change(wednesday, asked("later", "meal:1")).unwrap();
1073 change(wednesday, DayEdit { from: "11:30".into(), ..asked("move", "meal:1") }).unwrap();
1075 assert_eq!(change(wednesday, DayEdit { from: "23:50".into(), ..asked("move", "meal:2") }), Err(DayProblem::OtherDay));
1077 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 change(wednesday, DayEdit { from: "23:00".into(), bed: "00:30".into(), to: "08:30".into(), ..asked("times", "sleep") }).unwrap();
1084 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 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 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 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 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 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 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 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 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 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 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 let now: Zoned = "2026-10-04T20:00[Europe/Paris]".parse().unwrap();
1212 assert!(ticked([false; 7]).wakings(&now, &Days::default(), WAKINGS_AHEAD_DAYS).is_empty());
1214 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 assert_eq!(rung(&ticked([false, false, false, false, false, true, false]).wakings(&now, &Days::default(), WAKINGS_AHEAD_DAYS)), ["Sat 10 07:00"]);
1219 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 assert!(Needs { sleep_on: false, ..needs.clone() }.wakings(&now, &Days::default(), WAKINGS_AHEAD_DAYS).is_empty());
1224 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(needs.change_day(&mut days, tuesday, &asked("no-alarm", "meal:0"), &evening, &[]), Err(DayProblem::Unknown));
1296 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 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 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 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 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 drop(moved(12 * 60 + 45)).unwrap();
1340 drop(DayEdit { key: "meal:1".into(), action: "times".into(), from: clock(12 * 60 + 45), to: clock(14 * 60), ..DayEdit::default() }).unwrap();
1342 drop(DayEdit { key: "nap:0".into(), action: "times".into(), from: clock(14 * 60), to: clock(14 * 60 + 50), ..DayEdit::default() }).unwrap();
1344 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 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 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 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}