1use crate::backend::{QtThread, Shared, json, say, tell, tr};
13use crate::work;
14use cxx_qt_lib::QString;
15use jiff::civil::Date;
16use jiff::tz::TimeZone;
17use jiff::{Span, Timestamp, Zoned};
18use serde::{Deserialize, Serialize};
19use sioul_core::dose_reminders::{DoseReminders, Elsewhere, Others, Tried};
20use sioul_core::doses::{Answer, Answered, DoseRecords, Given, State};
21use sioul_core::health::{ChatLimit, Doubt, ErrandKind, Health, HealthState, Medicine, Movement, Named, Peer, Prescription, Problem, Schedule, Take, TakeProblem};
22use sioul_core::i18n::Translator;
23use sioul_core::needs::{DayEdit, DayProblem, Days, Kept, Needs};
24use sioul_core::tasks::TaskEdit;
25use std::sync::Arc;
26
27fn load() -> Health {
28 Health::load(&Health::default_path())
29}
30
31fn change_health<R>(change: impl FnOnce(&mut Health) -> Result<R, String>) -> Result<R, String> {
35 let out = Health::change(&Health::default_path(), change)?;
36 crate::alarms::schedule();
38 Ok(out)
39}
40
41fn listed(items: &[String]) -> String {
43 match items {
44 [] => String::new(),
45 [one] => one.clone(),
46 [rest @ .., last] => format!("{} {} {last}", rest.join(", "), tr().text("word-and", None)),
47 }
48}
49
50fn hhmm(text: &str) -> String {
52 let parsed = text.trim().split_once(':').and_then(|(h, m)| Some((h.trim().parse::<u8>().ok()?, m.trim().parse::<u8>().ok()?)));
53 match parsed {
54 Some((h, m)) if h < 24 && m < 60 => format!("{h:02}:{m:02}"),
55 _ => text.trim().to_string(),
56 }
57}
58
59fn words(schedule: &Schedule) -> String {
62 match schedule {
63 Schedule::Day { times, .. } => {
64 let mut times: Vec<String> = times.iter().map(|t| hhmm(t)).collect();
65 times.sort();
66 times.dedup();
67 times.join(" · ")
68 }
69 Schedule::Days { days, time, from } => {
70 let mut args = sioul_core::i18n::args();
71 args.set("days", *days);
72 args.set("time", time.clone());
73 args.set("from", tr().day(*from));
74 tr().text("health-every-days", Some(&args))
75 }
76 Schedule::Hours { hours, from } => {
77 let now = Zoned::now();
79 let next = schedule.doses(&now, &now.checked_add(Span::new().hours(i64::from(*hours))).unwrap_or_else(|_| now.clone())).into_iter().next();
80 let next = next.map_or(*from, |z| z.timestamp().as_second());
81 let next = Timestamp::from_second(next).map(|t| tr().date(&t.to_zoned(jiff::tz::TimeZone::system()), false)).unwrap_or_default();
82 let mut args = sioul_core::i18n::args();
83 args.set("hours", *hours);
84 args.set("next", next);
85 tr().text("health-every-hours", Some(&args))
86 }
87 }
88}
89
90#[derive(Serialize)]
91struct DoseRow {
92 key: String,
93 time: String,
94 name: String,
95 dose: String,
96 taken: String,
98 past: bool,
99 late: bool,
102 doubt: String,
106 doubt_off: Vec<OffButton>,
108 choose: bool,
110 question: String,
115}
116
117#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
119struct OffButton {
120 id: String,
121 label: String,
122}
123
124#[derive(Serialize)]
126struct MedicineRow {
127 #[serde(flatten)]
128 medicine: Medicine,
129 when: String,
133 amount: String,
135 precise: String,
137 line: String,
139 usual: String,
141 takes: Vec<Take>,
144 ends: String,
146 quiet: bool,
148}
149
150fn when_of(medicine: &Medicine) -> String {
153 match &medicine.schedule {
154 Schedule::Day { amounts, .. } if !amounts.is_empty() => medicine.takes_line(),
155 schedule => words(schedule),
156 }
157}
158
159fn medicine_row(m: &Medicine, today: Date) -> MedicineRow {
161 let usual = m.usual();
162 let each = matches!(&m.schedule, Schedule::Day { amounts, .. } if !amounts.is_empty());
163 let when = when_of(m);
164 let amount = if each { String::new() } else { m.dose.clone() };
165 MedicineRow {
166 precise: m.precise(),
167 line: [m.precise(), when.clone(), amount.clone()].into_iter().filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" · "),
168 when,
169 amount,
170 takes: m.takes().into_iter().map(|t| Take { amount: if t.amount == usual { String::new() } else { t.amount }, time: t.time }).collect(),
171 usual,
172 ends: m.until.map(|day| say("health-until", &[("day", tr().day_in(day, today))])).unwrap_or_default(),
173 quiet: m.paused || m.until.is_some_and(|day| day < today),
174 medicine: m.clone(),
175 }
176}
177
178#[derive(Serialize)]
180struct PrescriptionRow {
181 #[serde(flatten)]
182 prescription: Prescription,
183 next: Vec<String>,
186 covers: String,
190 lines: Vec<String>,
193 fetched_today: bool,
195 medicines: Vec<MedicineRow>,
197}
198
199fn medicine_rows(health: &Health, today: Date) -> Vec<MedicineRow> {
201 health.medicines.iter().map(|m| medicine_row(m, today)).collect()
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206enum Coming {
207 Pharmacy,
209 RenewBy,
211 ValidUntil,
213}
214
215fn coming(errands: &[sioul_core::health::Errand], p: &Prescription, today: Date) -> Vec<(Coming, Date)> {
218 let month_ago = today.checked_sub(Span::new().days(30)).unwrap_or(today);
219 let mut next: Vec<(Coming, Date)> = errands.iter().filter(|e| e.prescription == p.id && e.kind == ErrandKind::Refill && e.day >= month_ago).map(|e| (Coming::Pharmacy, e.day)).collect();
220 if let Some(until) = p.until {
221 next.push((if until < today { Coming::ValidUntil } else { Coming::RenewBy }, until));
222 }
223 next.sort_by_key(|(_, day)| *day);
224 next
225}
226
227fn tied<'a>(health: &'a Health, p: &'a Prescription) -> impl Iterator<Item = &'a Medicine> {
229 health.medicines.iter().filter(|m| m.prescription.as_deref() == Some(p.id.as_str()))
230}
231
232fn covered(health: &Health, p: &Prescription) -> Vec<String> {
235 let named: Vec<String> = tied(health, p).map(|m| m.name.clone()).collect();
236 let title = p.title.to_lowercase();
237 if named.iter().all(|name| title.contains(&name.to_lowercase())) { Vec::new() } else { named }
238}
239
240fn prescription_rows(health: &Health, today: Date) -> Vec<PrescriptionRow> {
242 let errands = health.errands();
243 health
244 .prescriptions
245 .iter()
246 .map(|p| {
247 let precise = tied(health, p).any(|m| !m.generic.trim().is_empty() || !m.strength.trim().is_empty());
248 let lines = if precise { tied(health, p).map(|m| medicine_row(m, today).line).collect() } else { Vec::new() };
249 let covers = if precise { Vec::new() } else { covered(health, p) };
250 PrescriptionRow {
251 lines,
252 next: coming(&errands, p, today)
253 .into_iter()
254 .map(|(what, day)| {
255 let day = [("day", tr().day_in(day, today))];
256 match what {
257 Coming::Pharmacy => say("health-next-refill", &day),
258 Coming::RenewBy => say("health-renew-by", &day),
259 Coming::ValidUntil => say("health-valid-until", &day),
260 }
261 })
262 .collect(),
263 covers: if covers.is_empty() { String::new() } else { say("health-covers", &[("names", listed(&covers))]) },
264 fetched_today: p.last_refill == Some(today),
265 medicines: tied(health, p).map(|m| medicine_row(m, today)).collect(),
266 prescription: p.clone(),
267 }
268 })
269 .collect()
270}
271
272#[derive(Serialize)]
275struct PageView {
276 week: WeekView,
277 missed: Vec<DoseRow>,
280 shared_note: String,
283 reminded_there: String,
285 later: u32,
287 any: bool,
289 medicines: Vec<MedicineRow>,
291 prescriptions: Vec<PrescriptionRow>,
292}
293
294#[derive(Serialize)]
297struct SettingsView {
298 movement: Movement,
299 chats: ChatLimit,
300 errands_list: String,
302 lists: Vec<serde_json::Value>,
303}
304
305#[derive(Serialize)]
308struct WeekView {
309 monday: String,
310 today: String,
311 from_minute: i64,
312 to_minute: i64,
313 days: Vec<DayView>,
314}
315
316#[derive(Serialize)]
318struct DayView {
319 date: String,
321 title: String,
323 weekday: String,
325 number: i8,
326 today: bool,
327 past: bool,
329 items: Vec<DayItem>,
332 segments: Vec<Segment>,
334 context: Vec<ContextItem>,
336 lines: Vec<String>,
338}
339
340#[derive(Serialize)]
342struct DayItem {
343 key: String,
345 kind: &'static str,
347 name: String,
348 dose: String,
350 from: String,
353 to: String,
354 at: String,
356 detail: String,
358 note: String,
361 changed: bool,
363 added: bool,
364 quiet: bool,
365 off: bool,
366 past: bool,
368 minutes: u32,
370 before: u32,
371 after: u32,
372 taken: String,
375 late: bool,
376 doubt: String,
377 doubt_off: Vec<OffButton>,
379 choose: bool,
380 alarm: String,
383 alarm_skipped: bool,
384 alarm_open: bool,
387}
388
389#[derive(Serialize)]
392struct Segment {
393 date: String,
395 key: String,
396 kind: &'static str,
398 name: String,
399 from_minute: i64,
400 to_minute: i64,
401 start_minute: i64,
404 end_minute: i64,
405 at_minute: i64,
407 until_minute: i64,
408 quiet: bool,
409 past: bool,
410 state: &'static str,
412}
413
414#[derive(Serialize)]
416struct ContextItem {
417 title: String,
418 kind: &'static str,
420 from_minute: i64,
421 to_minute: i64,
422}
423
424fn errands_list(health: &Health) -> Option<String> {
427 let lists: Vec<sioul_core::vdir::Collection> = sioul_core::tasks::lists().into_iter().filter(|c| !c.read_only).collect();
428 let id = |c: &sioul_core::vdir::Collection| format!("{}/{}", c.account, c.id);
429 let usual = crate::backend::load_config().tasks.list.clone().unwrap_or_default();
430 [health.errands_list.clone(), usual]
431 .into_iter()
432 .find(|wanted| !wanted.is_empty() && lists.iter().any(|c| id(c) == *wanted))
433 .or_else(|| lists.iter().find(|c| c.account != sioul_core::vdir::LOCAL).map(id))
434 .or_else(|| lists.first().map(id))
435}
436
437static SHOWN: std::sync::Mutex<Option<Date>> = std::sync::Mutex::new(None);
439
440pub(crate) fn show_week(day: &str) {
442 if let Ok(mut shown) = SHOWN.lock() {
443 *shown = day.trim().parse().ok();
444 }
445}
446
447fn clock(at: i64, zone: &TimeZone) -> String {
449 Timestamp::from_second(at).map(|t| t.to_zoned(zone.clone()).strftime("%H:%M").to_string()).unwrap_or_default()
450}
451
452fn midnight(date: Date, zone: &TimeZone) -> i64 {
454 date.to_zoned(zone.clone()).map_or(0, |z| z.timestamp().as_second())
455}
456
457fn capitalized(text: &str) -> String {
459 let mut chars = text.chars();
460 chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
461}
462
463pub(crate) fn page() -> String {
465 let health = load();
466 let state = record();
467 let knowledge = know();
468 let now = Zoned::now();
469 let shown = SHOWN.lock().ok().and_then(|s| *s).unwrap_or(now.date());
470 let records = records();
471 let week = week_view(&health, &state, &records, &knowledge, shown, &now);
472 let needs = &health.needs;
473 json(&PageView {
474 any: needs.meals_on || needs.naps_on || needs.sleep_on || !health.medicines.is_empty() || week.days.iter().any(|d| !d.items.is_empty()),
475 week,
476 missed: missed_rows(&health, &state, &told().unwrap_or_default(), &knowledge, &now, tr()),
477 shared_note: if health.medicines.is_empty() { String::new() } else { shared_note(&knowledge) },
478 reminded_there: [
479 REMINDED_THERE.lock().map(|r| r.clone()).unwrap_or_default(),
480 if health.medicines.is_empty() || crate::alarms::notifications_allowed() { String::new() } else { tr().text("dose-notifications-off", None) },
481 if health.medicines.is_empty() || crate::alarms::exact() { String::new() } else { tr().text("dose-alarms-inexact", None) },
482 ]
483 .into_iter()
484 .filter(|t| !t.is_empty())
485 .collect::<Vec<_>>()
486 .join(" "),
487 later: needs.later,
488 medicines: medicine_rows(&health, now.date()),
489 prescriptions: prescription_rows(&health, now.date()),
490 })
491}
492
493pub(crate) fn settings_view() -> String {
495 let health = load();
496 let config = crate::backend::load_config();
497 json(&SettingsView {
498 movement: health.movement.clone(),
499 chats: health.chats.clone(),
500 errands_list: errands_list(&health).unwrap_or_default(),
501 lists: sioul_core::tasks::lists().into_iter().filter(|c| !c.read_only).map(|c| serde_json::json!({ "id": format!("{}/{}", c.account, c.id), "name": c.label(&config, tr()), "local": c.account == sioul_core::vdir::LOCAL })).collect(),
502 })
503}
504
505fn week_events(monday: Date, zone: &TimeZone) -> Arc<Vec<sioul_core::agenda::Occurrence>> {
508 type Cached = Option<(i64, Date, Arc<Vec<sioul_core::agenda::Occurrence>>)>;
509 static CACHE: std::sync::Mutex<Cached> = std::sync::Mutex::new(None);
510 let stamp = Timestamp::now().as_second();
511 let mut cache = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
512 if let Some((at, day, events)) = cache.as_ref()
513 && *day == monday
514 && (0..300).contains(&(stamp - at))
515 {
516 return Arc::clone(events);
517 }
518 let from = midnight(monday, zone);
519 let to = monday.checked_add(Span::new().days(7)).map_or(from + 7 * 86_400, |d| midnight(d, zone));
520 let events = Arc::new(sioul_core::agenda::occurrences(from, to));
521 *cache = Some((stamp, monday, Arc::clone(&events)));
522 events
523}
524
525fn night_of(kept: &Kept, zone: &TimeZone) -> Option<Date> {
528 let bed = Timestamp::from_second(kept.at).ok()?.to_zoned(zone.clone());
529 if bed.hour() < 12 { bed.date().yesterday().ok() } else { Some(bed.date()) }
530}
531
532fn week_view(health: &Health, state: &HealthState, records: &DoseRecords, knowledge: &Knowledge, day: Date, now: &Zoned) -> WeekView {
535 let zone = now.time_zone().clone();
536 let monday = day.checked_sub(Span::new().days(i64::from(day.weekday().to_monday_zero_offset()))).unwrap_or(day);
537 let days = days();
538 let events = week_events(monday, &zone);
539 let held = sioul_core::plan::event_spans(&events, 0);
541 let steps = crate::work::day_steps();
542 let days: Vec<DayView> = (0..7).filter_map(|n| monday.checked_add(Span::new().days(n)).ok()).map(|date| day_view(health, state, records, knowledge, &days, &Around { events: &events, held: &held, steps: &steps }, date, now)).collect();
543 let (mut first, mut last) = (i64::MAX, i64::MIN);
546 for day in &days {
547 for s in &day.segments {
548 match s.kind {
549 "sleep" if s.from_minute <= 0 => first = first.min(s.to_minute),
550 "sleep" => last = last.max(s.at_minute.min(24 * 60)),
551 _ => {
552 first = first.min(s.from_minute);
553 last = last.max(s.to_minute);
554 }
555 }
556 }
557 for c in &day.context {
558 first = first.min(c.from_minute);
559 last = last.max(c.to_minute);
560 }
561 }
562 let first = if first == i64::MAX { 7 * 60 } else { first };
563 let last = if last == i64::MIN { 22 * 60 } else { last };
564 let from = (first - 30).clamp(0, 23 * 60) / 60 * 60;
565 let to = ((last + 30 + 59) / 60 * 60).clamp(from + 6 * 60, 24 * 60);
566 WeekView { monday: monday.to_string(), today: now.date().to_string(), from_minute: from.min(to - 60), to_minute: to, days }
567}
568
569struct Around<'a> {
571 events: &'a [sioul_core::agenda::Occurrence],
572 held: &'a [(i64, i64)],
573 steps: &'a [(i64, i64, String)],
574}
575
576fn day_view(health: &Health, state: &HealthState, records: &DoseRecords, knowledge: &Knowledge, days: &Days, around: &Around, date: Date, now: &Zoned) -> DayView {
578 let zone = now.time_zone().clone();
579 let today = now.date();
580 let stamp = now.timestamp().as_second();
581 let start = midnight(date, &zone);
582 let next = date.tomorrow().map_or(start + 86_400, |d| midnight(d, &zone));
583 let minute = |at: i64| -> i64 {
585 let Ok(t) = Timestamp::from_second(at) else { return 0 };
586 let z = t.to_zoned(zone.clone());
587 let after = date.until(z.date()).map_or(0, |s| i64::from(s.get_days()));
588 after * 24 * 60 + i64::from(z.hour()) * 60 + i64::from(z.minute())
589 };
590 let needs = &health.needs;
591 let pushed = needs.past_events_on(date, &zone, days, around.held);
592 let shift = |key: &str| pushed.get(key).copied().unwrap_or(0);
593 let past_day = date < today;
594 let mut items: Vec<(i64, DayItem)> = Vec::new();
595 let mut segments: Vec<Segment> = Vec::new();
596 let block_item = |k: &Kept, off: bool| -> DayItem {
597 let change = days.get(date, &k.key);
598 let added = sioul_core::needs::is_added(&k.key);
599 let (minutes, before, after) = needs.lengths(date, &k.key, days).unwrap_or((0, 0, 0));
600 let detail = match k.kind {
601 "meal" if before > 0 => say("need-meal-detail", &[("at", clock(k.at, &zone))]),
602 "nap" if after > 0 => say("need-nap-detail", &[("minutes", after.to_string())]),
603 "sleep" => say("need-night-detail", &[("bed", clock(k.at, &zone))]),
604 _ => String::new(),
605 };
606 let (alarm, alarm_skipped) = if k.kind == "sleep" && !off { crate::wake::of_night(needs, date, &zone, days) } else { (String::new(), false) };
608 let alarm_open = !alarm.is_empty() && needs.alarm_of(date, &zone, days).is_some_and(|a| a.at > stamp);
609 let detail = if alarm.is_empty() || alarm_skipped { detail } else { format!("{detail} · {}", say("wake-at", &[("time", alarm.clone())])) };
610 let changed = !added && change.is_some_and(|c| !c.at.is_empty() || !c.wake.is_empty() || c.minutes.is_some() || c.before.is_some() || c.after.is_some());
611 let quiet = change.is_some_and(|c| c.quiet);
612 let note = if off {
613 tr().text("need-off", None)
614 } else if added {
615 tr().text("need-added", None)
616 } else if changed {
617 tr().text("need-changed", None)
618 } else if shift(&k.key) > 0 {
619 tr().text("need-pushed", None)
620 } else {
621 String::new()
622 };
623 let note = if quiet && !off { [note, tr().text("need-quiet", None)].into_iter().filter(|n| !n.is_empty()).collect::<Vec<_>>().join(" · ") } else { note };
624 let note = if alarm_skipped { [note, tr().text("wake-none", None)].into_iter().filter(|n| !n.is_empty()).collect::<Vec<_>>().join(" · ") } else { note };
625 DayItem {
626 key: k.key.clone(),
627 kind: k.kind,
628 name: if k.kind == "sleep" { tr().text("needs-sleep", None) } else { block_name(k.kind, &k.key, &k.name) },
629 dose: String::new(),
630 from: clock(k.start, &zone),
631 to: clock(k.end, &zone),
632 at: clock(k.at, &zone),
633 detail,
634 note,
635 changed,
636 added,
637 quiet,
638 off,
639 past: past_day || k.end <= stamp,
640 minutes,
641 before,
642 after,
643 taken: String::new(),
644 late: false,
645 doubt: String::new(),
646 doubt_off: Vec::new(),
647 choose: false,
648 alarm,
649 alarm_skipped,
650 alarm_open,
651 }
652 };
653 for k in needs.blocks_of(date, &zone, days, &shift) {
654 let item = block_item(&k, false);
655 let until = if k.kind == "sleep" { k.end } else { k.at + i64::from(item.minutes) * 60 };
657 if minute(k.start) < 24 * 60 {
658 segments.push(Segment { date: date.to_string(), key: k.key.clone(), kind: k.kind, name: item.name.clone(), from_minute: minute(k.start).max(0), to_minute: minute(k.end).min(24 * 60), start_minute: minute(k.start), end_minute: minute(k.end), at_minute: minute(k.at), until_minute: minute(until), quiet: item.quiet, past: item.past, state: "" });
659 }
660 items.push((minute(k.start), item));
661 }
662 let usual = needs.blocks_of(date, &zone, &Days::default(), &|_| 0);
664 for k in usual.iter().filter(|k| days.get(date, &k.key).is_some_and(|c| c.off)) {
665 items.push((minute(k.start), block_item(k, true)));
666 }
667 for k in needs.kept_with(date, &zone, days, &shift).iter().filter(|k| k.kind == "sleep") {
669 let Some(night) = night_of(k, &zone).filter(|night| *night != date) else { continue };
670 segments.push(Segment {
671 date: night.to_string(),
672 key: k.key.clone(),
673 kind: "sleep",
674 name: tr().text("needs-sleep", None),
675 from_minute: minute(k.start).max(0),
676 to_minute: minute(k.end).min(24 * 60),
677 start_minute: minute(k.start),
678 end_minute: minute(k.end),
679 at_minute: minute(k.at),
680 until_minute: minute(k.end),
681 quiet: days.get(night, "sleep").is_some_and(|c| c.quiet),
682 past: night < today || k.end <= stamp,
683 state: "",
684 });
685 }
686 let (from_z, to_z) = (Timestamp::from_second(start).map(|t| t.to_zoned(zone.clone())), Timestamp::from_second(next).map(|t| t.to_zoned(zone.clone())));
689 if let (Ok(from_z), Ok(to_z)) = (from_z, to_z) {
690 let mut doses: Vec<(String, i64, String, String)> = health.doses(&from_z, &to_z).into_iter().map(|d| (d.key, d.at.timestamp().as_second(), d.name, d.dose)).collect();
691 if date == today {
694 for key in state.taken.keys() {
695 let Some((id, due)) = key.rsplit_once('@') else { continue };
696 let (Some(medicine), Ok(due)) = (health.medicines.iter().find(|m| m.id == id), due.parse::<i64>()) else { continue };
697 if due >= start && due < next && !doses.iter().any(|d| &d.0 == key) {
698 doses.push((key.clone(), due, medicine.short_name(), health.amount_of(key, &zone).unwrap_or_default()));
699 }
700 }
701 }
702 for (key, due, name, dose) in doses {
703 let answered = if date == today { sioul_core::doses::answered(&key, state, records) } else { Answered::Open };
707 let (taken, marked) = match &answered {
708 Answered::Taken(given) => (clock(given.at, &zone), true),
709 _ => (String::new(), false),
710 };
711 let (doubt, doubt_off) = match &answered {
712 Answered::Differ(given) => (differ_sentence(given, &this_device(), tr()), Vec::new()),
713 _ if date != today || marked || due > stamp => (String::new(), Vec::new()),
714 _ => doubt_now_with(knowledge, due, stamp, tr()),
715 };
716 let item = DayItem {
717 taken,
718 choose: matches!(answered, Answered::Differ(_)),
719 doubt_off,
720 late: date == today && !marked && stamp - due > GRACE_MINUTES * 60,
721 key: key.clone(),
722 kind: "dose",
723 name: name.clone(),
724 dose,
725 from: clock(due, &zone),
726 to: String::new(),
727 at: clock(due, &zone),
728 detail: String::new(),
729 note: String::new(),
730 changed: false,
731 added: false,
732 quiet: false,
733 off: false,
734 past: past_day || due <= stamp,
735 minutes: 0,
736 before: 0,
737 after: 0,
738 doubt,
739 alarm: String::new(),
740 alarm_skipped: false,
741 alarm_open: false,
742 };
743 let state = if date != today { "" } else if marked { "taken" } else if item.doubt.is_empty() { "due" } else { "check" };
744 segments.push(Segment { date: date.to_string(), key, kind: "dose", name, from_minute: minute(due), to_minute: minute(due), start_minute: minute(due), end_minute: minute(due), at_minute: minute(due), until_minute: minute(due), quiet: false, past: item.past, state });
745 items.push((minute(due), item));
746 }
747 }
748 items.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| (a.1.kind == "dose").cmp(&(b.1.kind == "dose"))));
749 let mut context = Vec::new();
751 let mut lines = Vec::new();
752 for event in around.events.iter().filter(|e| !e.cancelled && e.end > start && e.start < next) {
753 if event.all_day {
754 lines.push(say("day-all-day", &[("what", event.summary.clone())]));
755 } else {
756 context.push(ContextItem { title: event.summary.clone(), kind: "event", from_minute: minute(event.start).max(0), to_minute: minute(event.end.max(event.start + 15 * 60)).min(24 * 60) });
757 }
758 }
759 if date == today {
760 for (from, to, title) in around.steps.iter().filter(|(from, to, _)| *to > start && *from < next) {
761 context.push(ContextItem { title: title.clone(), kind: "task", from_minute: minute(*from).max(0), to_minute: minute(*to).min(24 * 60) });
762 }
763 }
764 context.sort_by_key(|c| c.from_minute);
765 for errand in health.errands().into_iter().filter(|e| e.day == date) {
766 let what = say(if errand.kind == ErrandKind::Refill { "health-errand-refill" } else { "health-errand-renew" }, &[("title", errand.title.clone())]);
767 lines.push(say("health-errand-day", &[("what", what)]));
768 }
769 let named = tr().day(date);
770 let title = if date == today {
771 format!("{}, {named}", tr().text("agenda-today", None))
772 } else if today.tomorrow().is_ok_and(|t| t == date) {
773 format!("{}, {named}", tr().text("agenda-tomorrow", None))
774 } else {
775 capitalized(&named)
776 };
777 DayView {
778 date: date.to_string(),
779 title,
780 weekday: tr().weekday_short(date),
781 number: date.day(),
782 today: date == today,
783 past: past_day,
784 items: items.into_iter().map(|(_, item)| item).collect(),
785 segments,
786 context,
787 lines,
788 }
789}
790
791fn missed_rows(health: &Health, state: &HealthState, told: &DoseReminders, knowledge: &Knowledge, now: &Zoned, words: &Translator) -> Vec<DoseRow> {
798 let maybe = |key: &str| told.maybe_anywhere(key).is_some();
799 let mut rows: Vec<DoseRow> = state
800 .unanswered(health, now, MISSED_HOURS, GRACE_MINUTES)
801 .into_iter()
802 .map(|d| {
803 let (doubt, doubt_off) = doubt_now_with(knowledge, d.at.timestamp().as_second(), now.timestamp().as_second(), words);
804 DoseRow {
805 taken: String::new(),
806 past: true,
807 late: true,
808 doubt,
809 doubt_off,
810 choose: false,
811 question: if state.reminder_unshown(&d.key) && maybe(&d.key) {
812 words.text("health-maybe-question", None)
813 } else if state.reminder_unshown(&d.key) {
814 words.text("health-unshown-question", None)
815 } else {
816 words.text("health-missed-question", None)
817 },
818 time: if d.at.date() == now.date() { d.at.strftime("%H:%M").to_string() } else { format!("{} {}", words.weekday_short(d.at.date()), d.at.strftime("%H:%M")) },
819 key: d.key,
820 name: d.name,
821 dose: d.dose,
822 }
823 })
824 .collect();
825 rows.sort_by_key(|row| (state.reminder_unshown(&row.key), maybe(&row.key)));
826 rows
827}
828
829fn missed_title(missed: &[sioul_core::health::Dose], state: &HealthState, told: &DoseReminders, words: &Translator) -> String {
834 let maybe = missed.iter().filter(|d| state.reminder_unshown(&d.key) && told.maybe_anywhere(&d.key).is_some()).count();
835 let unshown = missed.iter().filter(|d| state.reminder_unshown(&d.key)).count() - maybe;
836 match (unshown, maybe) {
837 (0, 0) => words.text("health-missed", None),
838 (n, 0) if n == missed.len() => words.text("health-unshown", None),
839 (0, n) if n == missed.len() => words.text("health-maybe", None),
840 _ => words.text("health-missed-some", None),
841 }
842}
843
844fn note_minute(record: &mut HealthState, reminded: &[String], unshown: &[String], maybe: &[String], made: &[(String, String)], stamp: i64) {
850 for key in reminded {
851 record.reminded.insert(key.clone(), stamp);
852 }
853 for key in unshown.iter().chain(maybe) {
854 record.unshown.entry(key.clone()).or_insert(stamp);
855 }
856 for (key, uid) in made {
857 record.errands.insert(key.clone(), uid.clone());
858 }
859}
860
861#[derive(Default, Serialize)]
871struct PorchDoses {
872 due: Vec<DoseRow>,
873 closed: Vec<DoseRow>,
874}
875
876#[allow(clippy::too_many_arguments)]
879fn porch_doses(health: &Health, state: &HealthState, records: &DoseRecords, told: &DoseReminders, me: &str, knowledge: &Knowledge, now: &Zoned, silent: bool, heard: bool, words: &Translator) -> PorchDoses {
880 if silent || health.medicines.is_empty() {
881 return PorchDoses::default();
882 }
883 let stamp = now.timestamp().as_second();
884 let row = |d: sioul_core::health::Dose, doubt: String, doubt_off: Vec<OffButton>, choose: bool| {
885 let at = d.at.timestamp().as_second();
886 DoseRow { taken: String::new(), past: true, late: stamp - at > GRACE_MINUTES * 60, doubt, doubt_off, choose, question: String::new(), time: d.at.strftime("%H:%M").to_string(), key: d.key, name: d.name, dose: d.dose }
887 };
888 let mut due: Vec<DoseRow> = state
890 .due_today(health, now, MISSED_HOURS, GRACE_MINUTES)
891 .into_iter()
892 .filter(|d| sioul_core::doses::answered(&d.key, state, records) == Answered::Open)
893 .map(|d| {
894 let (doubt, doubt_off) = doubt_now_with(knowledge, d.at.timestamp().as_second(), stamp, words);
895 row(d, doubt, doubt_off, false)
896 })
897 .collect();
898 let midnight = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
900 let back = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
901 let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
902 for d in health.doses(if back > midnight { &back } else { &midnight }, &end) {
903 if let Answered::Differ(given) = sioul_core::doses::answered(&d.key, state, records) {
904 let sentence = differ_sentence(&given, me, words);
905 due.push(row(d, sentence, Vec::new(), true));
906 }
907 }
908 due.sort_by_key(|row| due_of(&row.key).unwrap_or(0));
909 PorchDoses { due, closed: if heard { missed_rows(health, state, told, knowledge, now, words) } else { Vec::new() } }
910}
911
912pub(crate) fn missed() -> String {
914 let health = load();
915 if health.medicines.is_empty() {
916 return json(&PorchDoses::default());
917 }
918 let heard = !crate::share::on() || crate::share::last_exchange().is_some();
919 json(&porch_doses(&health, &record(), &records(), &told().unwrap_or_default(), &this_device(), &know(), &Zoned::now(), crate::hours::doses_silent(), heard, tr()))
920}
921
922#[derive(Deserialize)]
924struct MedicineEdit {
925 #[serde(default)]
927 name: String,
928 #[serde(default)]
930 generic: String,
931 #[serde(default)]
932 strength: String,
933 #[serde(default)]
935 since: String,
936 #[serde(default)]
938 dose: String,
939 every: String,
941 #[serde(default)]
943 takes: Vec<Take>,
944 #[serde(default)]
946 times: Vec<String>,
947 #[serde(default)]
948 days: u32,
949 #[serde(default)]
950 hours: u32,
951 #[serde(default)]
953 from: String,
954 #[serde(default)]
955 time: String,
956 #[serde(default)]
957 until: String,
958 #[serde(default)]
959 prescription: String,
960 #[serde(default)]
961 paused: bool,
962}
963
964fn answer(result: Result<String, String>) -> String {
965 match result {
966 Ok(id) => serde_json::json!({ "id": id }).to_string(),
967 Err(error) => serde_json::json!({ "error": error }).to_string(),
968 }
969}
970
971fn take_problem(problem: &TakeProblem, name: Option<&str>, words: &Translator) -> String {
974 let id = match problem {
975 TakeProblem::None => "health-no-take",
976 TakeProblem::Unreadable(_) => "health-take-unreadable",
977 TakeProblem::Twice(_) => "health-take-twice",
978 };
979 let mut pairs = vec![];
980 if let TakeProblem::Unreadable(time) | TakeProblem::Twice(time) = problem {
981 pairs.push(("time", time.clone()));
982 }
983 match name {
984 Some(name) => {
985 pairs.push(("name", name.to_string()));
986 said(words, &format!("{id}-of"), &pairs)
987 }
988 None => said(words, id, &pairs),
989 }
990}
991
992fn apply_medicine(health: &mut Health, id: &str, edit: MedicineEdit, now: &Zoned, words: &Translator) -> Result<String, String> {
995 let name = if edit.name.trim().is_empty() { edit.generic.trim() } else { edit.name.trim() }.to_string();
997 if name.is_empty() {
998 return Err(words.text("health-no-name", None));
999 }
1000 let day = |text: &str| text.trim().parse::<Date>().ok();
1001 let id = if id.is_empty() { health.new_id(&name) } else { id.to_string() };
1002 let mut medicine = Medicine {
1003 id: id.clone(),
1004 name,
1005 generic: edit.generic.trim().to_string(),
1006 strength: edit.strength.trim().to_string(),
1007 since: day(&edit.since),
1008 dose: edit.dose.trim().to_string(),
1009 schedule: Schedule::Day { times: Vec::new(), amounts: Default::default() },
1010 prescription: Some(edit.prescription).filter(|p| !p.is_empty()),
1011 until: day(&edit.until),
1012 paused: edit.paused,
1013 };
1014 match edit.every.as_str() {
1015 "days" => medicine.schedule = Schedule::Days { days: edit.days.max(1), time: edit.time.trim().to_string(), from: day(&edit.from).unwrap_or(now.date()) },
1016 "hours" => {
1017 let from = edit.from.trim().parse::<jiff::civil::DateTime>().ok().and_then(|d| d.to_zoned(now.time_zone().clone()).ok()).map_or(now.timestamp().as_second(), |z| z.timestamp().as_second());
1018 medicine.schedule = Schedule::Hours { hours: edit.hours.max(1), from };
1019 }
1020 _ => {
1021 let takes: Vec<Take> = if edit.takes.is_empty() { edit.times.iter().filter(|t| !t.trim().is_empty()).map(|t| Take { time: t.clone(), amount: String::new() }).collect() } else { edit.takes };
1022 medicine.set_takes(&edit.dose, &takes).map_err(|problem| take_problem(&problem, None, words))?;
1023 }
1024 }
1025 match health.medicines.iter_mut().find(|m| m.id == id) {
1026 Some(slot) => *slot = medicine,
1027 None => health.medicines.push(medicine),
1028 }
1029 Ok(id)
1030}
1031
1032pub(crate) fn save_medicine(id: &str, edit: &str) -> String {
1034 let result = serde_json::from_str::<MedicineEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
1035 change_health(|health| apply_medicine(health, id, edit, &Zoned::now(), tr()))
1036 });
1037 answer(result)
1038}
1039
1040#[derive(Deserialize)]
1042struct PrescriptionEdit {
1043 title: String,
1044 #[serde(default)]
1045 prescriber: String,
1046 #[serde(default)]
1047 until: String,
1048 #[serde(default)]
1049 refill_days: u32,
1050 #[serde(default)]
1051 last_refill: String,
1052 #[serde(default)]
1053 note: String,
1054 #[serde(default)]
1056 medicines: Option<Vec<PrescribedEdit>>,
1057}
1058
1059#[derive(Deserialize)]
1061struct PrescribedEdit {
1062 #[serde(default)]
1064 id: String,
1065 #[serde(default)]
1066 name: String,
1067 #[serde(default)]
1069 generic: String,
1070 #[serde(default)]
1071 strength: String,
1072 #[serde(default)]
1074 dose: String,
1075 #[serde(default)]
1078 takes: Vec<Take>,
1079 #[serde(default)]
1081 removed: bool,
1082}
1083
1084fn apply_prescription(health: &mut Health, id: &str, edit: PrescriptionEdit, words: &Translator) -> Result<String, String> {
1089 if edit.title.trim().is_empty() {
1090 return Err(words.text("health-no-name", None));
1091 }
1092 let id = if id.is_empty() { health.new_id(&edit.title) } else { id.to_string() };
1093 let mut made: Vec<Medicine> = Vec::new();
1094 let mut gone: Vec<String> = Vec::new();
1095 for row in edit.medicines.iter().flatten() {
1096 if row.removed {
1097 gone.extend(Some(row.id.clone()).filter(|id| !id.is_empty()));
1098 continue;
1099 }
1100 let name = if row.name.trim().is_empty() { row.generic.trim() } else { row.name.trim() };
1102 if row.id.is_empty() && name.is_empty() && row.dose.trim().is_empty() && row.strength.trim().is_empty() && row.takes.iter().all(|t| t.amount.trim().is_empty()) {
1104 continue;
1105 }
1106 if name.is_empty() {
1107 return Err(words.text("health-medicine-no-name", None));
1108 }
1109 let mut medicine = health.medicines.iter().find(|m| !row.id.is_empty() && m.id == row.id).cloned().unwrap_or_else(|| Medicine {
1110 id: row.id.clone(),
1111 name: String::new(),
1112 dose: String::new(),
1113 schedule: Schedule::Day { times: Vec::new(), amounts: Default::default() },
1114 prescription: None,
1115 until: None,
1116 paused: false,
1117 generic: String::new(),
1118 strength: String::new(),
1119 since: None,
1120 });
1121 medicine.name = name.to_string();
1122 medicine.generic = row.generic.trim().to_string();
1123 medicine.strength = row.strength.trim().to_string();
1124 medicine.prescription = Some(id.clone());
1125 if matches!(medicine.schedule, Schedule::Day { .. }) {
1126 medicine.set_takes(&row.dose, &row.takes).map_err(|problem| take_problem(&problem, Some(name), words))?;
1127 } else {
1128 medicine.dose = row.dose.trim().to_string();
1129 }
1130 made.push(medicine);
1131 }
1132 let prescription = Prescription {
1133 id: id.clone(),
1134 title: edit.title.trim().to_string(),
1135 prescriber: edit.prescriber.trim().to_string(),
1136 until: edit.until.trim().parse().ok(),
1137 refill_days: Some(edit.refill_days).filter(|d| *d > 0),
1138 last_refill: edit.last_refill.trim().parse().ok(),
1139 note: edit.note.trim().to_string(),
1140 };
1141 match health.prescriptions.iter_mut().find(|p| p.id == id) {
1142 Some(slot) => *slot = prescription,
1143 None => health.prescriptions.push(prescription),
1144 }
1145 health.medicines.retain(|m| !gone.contains(&m.id));
1146 for mut medicine in made {
1147 if medicine.id.is_empty() {
1148 medicine.id = health.new_id(&medicine.name);
1149 }
1150 match health.medicines.iter_mut().find(|m| m.id == medicine.id) {
1151 Some(slot) => *slot = medicine,
1152 None => health.medicines.push(medicine),
1153 }
1154 }
1155 Ok(id)
1156}
1157
1158pub(crate) fn save_prescription(id: &str, edit: &str) -> String {
1160 let result = serde_json::from_str::<PrescriptionEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
1161 change_health(|health| apply_prescription(health, id, edit, tr()))
1162 });
1163 answer(result)
1164}
1165
1166#[derive(Serialize, Debug, PartialEq)]
1169struct ForProfessional {
1170 title: String,
1172 about: Vec<String>,
1174 medicines: Vec<Shown>,
1175 empty: String,
1177}
1178
1179#[derive(Serialize, Debug, PartialEq)]
1181struct Shown {
1182 molecule: String,
1185 brand: String,
1187 takes: Vec<String>,
1190 since: String,
1192 prescriber: String,
1195 notes: Vec<String>,
1197}
1198
1199fn how_long(since: Date, today: Date, words: &Translator) -> String {
1201 let days = since.until(today).map_or(0, |s| i64::from(s.get_days())).max(0);
1202 let (id, n) = if days == 0 {
1203 return String::new();
1204 } else if days < 14 {
1205 ("health-pro-days", days)
1206 } else if days < 61 {
1207 ("health-pro-weeks", days / 7)
1208 } else if days < 730 {
1209 ("health-pro-months", days * 12 / 365)
1210 } else {
1211 ("health-pro-years", days / 365)
1212 };
1213 words.text(id, Some(&words.counted(usize::try_from(n).unwrap_or(0))))
1214}
1215
1216fn for_professional_of(health: &Health, prescription: &str, today: Date, words: &Translator) -> ForProfessional {
1220 let p = health.prescriptions.iter().find(|p| p.id == prescription);
1221 let medicines: Vec<&Medicine> = match p {
1222 Some(p) => tied(health, p).collect(),
1223 None => health.medicines.iter().filter(|m| !m.paused && m.until.is_none_or(|day| day >= today)).collect(),
1224 };
1225 let shown = medicines
1226 .into_iter()
1227 .map(|m| {
1228 let (generic, strength) = (m.generic.trim(), m.strength.trim());
1229 let first = if generic.is_empty() { m.name.trim() } else { generic };
1230 let molecule = [first, strength].into_iter().filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ");
1231 let brand = if generic.is_empty() || m.name.trim().to_lowercase() == generic.to_lowercase() { String::new() } else { m.name.trim().to_string() };
1232 let takes = match &m.schedule {
1233 Schedule::Day { .. } => m.takes().into_iter().map(|t| if t.amount.is_empty() { t.time } else { format!("{} — {}", t.time, t.amount) }).collect(),
1234 schedule => vec![[words_of(schedule, words), m.dose.trim().to_string()].into_iter().filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" — ")],
1235 };
1236 let since = m
1237 .since
1238 .map(|day| match how_long(day, today, words) {
1239 long if long.is_empty() => said(words, "health-pro-since-day", &[("day", words.day_in(day, today))]),
1240 long => said(words, "health-pro-since", &[("day", words.day_in(day, today)), ("long", long)]),
1241 })
1242 .unwrap_or_default();
1243 let prescriber = match (p, m.prescription.as_deref().and_then(|id| health.prescriptions.iter().find(|p| p.id == id))) {
1244 (None, Some(of)) if !of.prescriber.trim().is_empty() => said(words, "health-pro-prescribed-by", &[("name", of.prescriber.trim().to_string())]),
1245 _ => String::new(),
1246 };
1247 let mut notes: Vec<String> = m.until.map(|day| said(words, "health-pro-until", &[("day", words.day_in(day, today))])).into_iter().collect();
1248 if m.paused {
1249 notes.push(words.text("health-pause", None));
1250 }
1251 Shown { molecule, brand, takes, since, prescriber, notes }
1252 })
1253 .collect::<Vec<_>>();
1254 let about = match p {
1255 Some(p) => [
1256 Some(p.prescriber.trim()).filter(|w| !w.is_empty()).map(|name| said(words, "health-pro-prescribed-by", &[("name", name.to_string())])),
1257 p.until.map(|day| said(words, "health-pro-valid-until", &[("day", words.day_in(day, today))])),
1258 ]
1259 .into_iter()
1260 .flatten()
1261 .collect(),
1262 None => vec![said(words, "health-pro-as-of", &[("day", format!("{} {}", words.day(today), today.year()))])],
1263 };
1264 ForProfessional {
1265 title: p.map_or_else(|| words.text("health-pro-current", None), |p| p.title.clone()),
1266 about,
1267 empty: if shown.is_empty() { words.text("health-pro-none", None) } else { String::new() },
1268 medicines: shown,
1269 }
1270}
1271
1272fn words_of(schedule: &Schedule, words: &Translator) -> String {
1274 let mut args = sioul_core::i18n::args();
1276 match schedule {
1277 Schedule::Days { days, time, from } => {
1278 args.set("days", *days);
1279 args.set("time", time.clone());
1280 args.set("from", words.day(*from));
1281 words.text("health-every-days", Some(&args))
1282 }
1283 Schedule::Hours { hours, .. } => {
1284 args.set("hours", *hours);
1285 words.text("health-pro-every-hours", Some(&args))
1286 }
1287 Schedule::Day { .. } => self::words(schedule),
1288 }
1289}
1290
1291pub(crate) fn for_professional(prescription: &str) -> String {
1294 json(&for_professional_of(&load(), prescription, Zoned::now().date(), tr()))
1295}
1296
1297pub(crate) fn remove(id: &str) -> String {
1299 change_health(|health| {
1300 health.medicines.retain(|m| m.id != id);
1301 health.prescriptions.retain(|p| p.id != id);
1302 for medicine in health.medicines.iter_mut().filter(|m| m.prescription.as_deref() == Some(id)) {
1303 medicine.prescription = None;
1304 }
1305 Ok(())
1306 })
1307 .err()
1308 .unwrap_or_default()
1309}
1310
1311pub(crate) fn refilled(id: &str) -> String {
1313 change_health(|health| {
1314 if let Some(p) = health.prescriptions.iter_mut().find(|p| p.id == id) {
1315 p.last_refill = Some(Zoned::now().date());
1316 }
1317 Ok(())
1318 })
1319 .err()
1320 .unwrap_or_default()
1321}
1322
1323const MISSED_HOURS: i64 = 12;
1326const GRACE_MINUTES: i64 = 30;
1327static MOVED: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
1329
1330fn movement_tick(qt: &QtThread, shared: &Arc<Shared>, health: &Health, now: &Zoned, here: bool) {
1338 use std::sync::atomic::Ordering;
1339 if cfg!(target_os = "android") {
1340 return;
1341 }
1342 let stamp = now.timestamp().as_second();
1343 let last = MOVED.load(Ordering::Relaxed);
1344 if last == 0 || !health.movement.enabled || sioul_core::timelog::running().is_some() || !crate::hours::comes(sioul_core::attention::Kind::Move) {
1347 MOVED.store(stamp, Ordering::Relaxed);
1348 return;
1349 }
1350 if !here || stamp - last < i64::from(health.movement.minutes.max(10)) * 60 {
1351 return;
1352 }
1353 MOVED.store(stamp, Ordering::Relaxed);
1354 let qt_open = qt.clone();
1355 let open: Box<dyn FnOnce() + Send> = Box::new(move || {
1356 let _ = qt_open.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("stopped"), QString::default(), QString::default()));
1357 });
1358 if let Err(e) = sioul_sync::notify::remind(&tr().text("health-move", None), &tr().text("health-move-body", None), Some((tr().text("stopped-note", None), open))) {
1359 tell(qt, shared, e);
1360 }
1361}
1362
1363static ASKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1365static SENT: std::sync::Mutex<std::collections::BTreeSet<String>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
1367static REMINDED_THERE: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
1369
1370fn shared_note(knowledge: &Knowledge) -> String {
1373 if !crate::share::on() {
1374 return tr().text("health-alone", None);
1375 }
1376 let now = Timestamp::now().as_second();
1377 let doubts = sioul_core::health::doubts_now(now - WAIT_FOR_NEWS, now, knowledge.record_lost, &knowledge.peers);
1381 let mut lines: Vec<String> = Vec::new();
1382 if !doubts.is_empty() {
1383 lines.push(say("dose-doubt-now", &[("why", why(&doubts))]));
1384 }
1385 for peer in knowledge.peers.iter().filter(|p| p.off) {
1387 lines.push(say("dose-off-note", &[("name", device_start(&named_of(peer), tr()))]));
1388 }
1389 lines.join(" ")
1390}
1391
1392pub(crate) fn show_health(qt: &QtThread, shared: &Arc<Shared>) {
1396 let qt = qt.clone();
1397 crate::backend::coalesced(shared, |s| &s.health_job, move |_| {
1398 let (view, missed, needs) = (page(), missed(), needs_today());
1399 if cfg!(target_os = "android") {
1400 eprintln!("sioul: health: {} medicines, {} prescriptions", load().medicines.len(), load().prescriptions.len());
1401 }
1402 let _ = qt.queue(move |mut sioul| {
1403 sioul.as_mut().set_health_view(QString::from(&view));
1404 sioul.as_mut().set_missed_view(QString::from(&missed));
1405 sioul.as_mut().set_needs_view(QString::from(&needs));
1406 });
1407 });
1408}
1409
1410const ALARMS_AHEAD_HOURS: i64 = 48;
1414
1415fn named(dose: &sioul_core::health::Dose) -> String {
1417 if dose.dose.is_empty() { dose.name.clone() } else { format!("{} · {}", dose.name, dose.dose) }
1418}
1419
1420pub(crate) fn alarms_coming() -> String {
1425 let health = load();
1426 let state = HealthState::read(&HealthState::default_path()).unwrap_or_default();
1427 let now = Zoned::now();
1428 let start = now.checked_sub(Span::new().minutes(GRACE_MINUTES)).unwrap_or_else(|_| now.clone());
1429 let end = now.checked_add(Span::new().hours(ALARMS_AHEAD_HOURS)).unwrap_or_else(|_| now.clone());
1430 let rows: Vec<serde_json::Value> = health
1431 .doses(&start, &end)
1432 .into_iter()
1433 .filter(|d| !state.taken.contains_key(&d.key) && !state.not_taken.contains_key(&d.key) && !state.reminded.contains_key(&d.key))
1434 .map(|d| serde_json::json!({ "key": d.key, "at": d.at.timestamp().as_millisecond(), "title": named(&d) }))
1435 .collect();
1436 serde_json::Value::Array(rows).to_string()
1437}
1438
1439pub(crate) fn marked_lately() -> Vec<String> {
1442 let Ok(state) = HealthState::read(&HealthState::default_path()) else { return Vec::new() };
1443 let since = Timestamp::now().as_second() - 26 * 3600;
1444 state.taken.keys().chain(state.not_taken.keys()).filter(|key| due_of(key).is_some_and(|due| due >= since)).cloned().collect()
1445}
1446
1447const SHOWN_WATCH: i64 = 3 * 3600;
1451const SHOWN_EVERY: i64 = 15 * 60;
1452
1453pub(crate) fn alarm_decide(key: &str) -> String {
1464 let answer = |show: bool, title: &str, body: &str, again: i64| serde_json::json!({ "show": show, "title": title, "body": body, "again_at": again * 1000, "taken": tr().text("health-taken", None) }).to_string();
1465 let _ = crate::share::exchange_here(true);
1466 let missed = crate::share::news_missed();
1469 let health = load();
1470 let now = Zoned::now();
1471 let stamp = now.timestamp().as_second();
1472 let _held = state_held();
1473 let state = record();
1474 let start = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
1475 let end = now.checked_add(Span::new().hours(ALARMS_AHEAD_HOURS)).unwrap_or_else(|_| now.clone());
1476 let Some(dose) = health.doses(&start, &end).into_iter().find(|d| d.key == key) else {
1478 crate::alarms::remove_reminder(key);
1479 return answer(false, "", "", 0);
1480 };
1481 let due = dose.at.timestamp().as_second();
1482 if state.taken.contains_key(key) || state.not_taken.contains_key(key) {
1484 crate::alarms::remove_reminder(key);
1485 return answer(false, "", "", 0);
1486 }
1487 if state.reminded.contains_key(key) || SENT.lock().is_ok_and(|sent| sent.contains(key)) {
1490 let again = if stamp < due + SHOWN_WATCH { stamp + SHOWN_EVERY } else { 0 };
1491 return answer(false, "", "", again);
1492 }
1493 if due > stamp + 60 {
1495 return answer(false, "", "", due);
1496 }
1497 if crate::hours::doses_silent()
1499 && let Some(waking) = crate::hours::waking()
1500 {
1501 return answer(false, "", "", waking);
1502 }
1503 let since = news_from(due, crate::hours::woken());
1506 let waiting = stamp < since + WAIT_FOR_NEWS;
1507 let keeper = crate::share::looked("health", sioul_sync::lease::Rule::FollowsYou);
1508 if !keeper.mine && waiting {
1509 return answer(false, "", "", since + WAIT_FOR_NEWS);
1510 }
1511 let knowledge = know();
1512 let told_now = told();
1517 let me = this_device();
1518 let pulled = missed.is_none() && PULLED.load(std::sync::atomic::Ordering::Relaxed);
1520 let first = match sioul_core::dose_reminders::others(key, since, stamp, &me, true, told_now.as_ref(), &knowledge.peers, pulled) {
1521 Others::Reminded => {
1522 let again = if stamp < due + SHOWN_WATCH { stamp + SHOWN_EVERY } else { 0 };
1523 return answer(false, "", "", again);
1524 }
1525 Others::First => None,
1526 Others::Doubt(elsewhere) => {
1527 if keeper.mine && elsewhere.iter().all(|e| matches!(e, Elsewhere::Unknown { .. })) && stamp < since + FIRST_WORD {
1531 return answer(false, "", "", since + FIRST_WORD);
1532 }
1533 Some(elsewhere_said(&elsewhere, since, tr()))
1534 }
1535 };
1536 let doubt = doubt_unread_with(&knowledge, due, since, stamp, missed.as_ref(), first.as_deref());
1537 if first.is_none() && !doubt.is_empty() && waiting {
1538 let known = sioul_core::health::known_from_at(due, since, stamp + 1, since + WAIT_FOR_NEWS, knowledge.record_lost, &knowledge.peers);
1539 return answer(false, "", "", alarm_again(since, stamp, known));
1540 }
1541 let named = named(&dose);
1542 let (title, body) = if doubt.is_empty() { (named, dose.at.strftime("%H:%M").to_string()) } else { (say("dose-check-title", &[("dose", named)]), format!("{}. {doubt}", dose.at.strftime("%H:%M"))) };
1543 if let Ok(mut sent) = SENT.lock() {
1544 sent.insert(key.to_string());
1545 }
1546 crate::devices::receiver(move || {
1550 let _ = change(|record| {
1551 record.reminded.insert(key.to_string(), stamp);
1552 });
1553 tell_mine(key, |mine| {
1555 mine.maybe = stamp;
1556 mine.shown = stamp;
1557 });
1558 open_canaries(&[key.to_string()], stamp);
1559 drop(_held);
1560 let _ = crate::share::exchange_here(false);
1561 });
1562 answer(true, &title, &body, stamp + SHOWN_EVERY)
1564}
1565
1566pub(crate) fn alarm_taken(key: &str) -> String {
1569 if is_late(key) {
1570 return serde_json::json!({ "done": false, "open": true, "line": tr().text("dose-alarm-late", None) }).to_string();
1571 }
1572 crate::devices::receiver(|| {
1575 let problem = set_taken(key, true);
1576 if !problem.is_empty() {
1577 return serde_json::json!({ "done": false, "open": true, "line": problem }).to_string();
1578 }
1579 let _ = crate::share::exchange_here(false);
1580 let line = say("health-taken-at", &[("time", Zoned::now().strftime("%H:%M").to_string())]);
1581 serde_json::json!({ "done": true, "open": false, "line": line }).to_string()
1582 })
1583}
1584
1585pub(crate) fn dose_open_now() -> bool {
1597 let health = load();
1598 if health.medicines.is_empty() {
1599 return false;
1600 }
1601 let read = HealthState::read(&HealthState::default_path()).ok().zip(DoseRecords::read(&DoseRecords::default_path()).ok());
1602 dose_open(&health, read.as_ref().map(|(state, records)| (state, records)), &Zoned::now())
1603}
1604
1605fn dose_open(health: &Health, records: Option<(&HealthState, &DoseRecords)>, now: &Zoned) -> bool {
1610 let midnight = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
1611 let back = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
1612 let start = if back > midnight { back } else { midnight };
1613 let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1614 let mut come = health.doses(&start, &end).into_iter();
1615 match records {
1616 Some((state, records)) => come.any(|d| sioul_core::doses::answered(&d.key, state, records) == Answered::Open),
1617 None => come.next().is_some(),
1618 }
1619}
1620
1621pub(crate) fn card_doses(now: &Zoned) -> Vec<crate::homecard::Dose> {
1633 let health = load();
1634 if health.medicines.is_empty() {
1635 return Vec::new();
1636 }
1637 let read = HealthState::read(&HealthState::default_path());
1638 let readable = read.is_ok();
1639 doses_for_card(&health, &read.unwrap_or_default(), &records(), readable, &know(), now)
1640}
1641
1642fn doses_for_card(health: &Health, state: &HealthState, records: &DoseRecords, readable: bool, knowledge: &Knowledge, now: &Zoned) -> Vec<crate::homecard::Dose> {
1646 let stamp = now.timestamp().as_second();
1647 let midnight_after = |at: &Zoned| at.date().tomorrow().ok().and_then(|d| d.to_zoned(at.time_zone().clone()).ok());
1648 let Some(tonight) = midnight_after(now) else { return Vec::new() };
1649 let later = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1650 let coming = health.doses(&later, &tonight).into_iter().filter(|d| !state.taken.contains_key(&d.key) && !state.not_taken.contains_key(&d.key));
1651 let mut out: Vec<crate::homecard::Dose> = state
1653 .due_today(health, now, MISSED_HOURS, GRACE_MINUTES)
1654 .into_iter()
1655 .chain(coming)
1656 .filter(|d| sioul_core::doses::answered(&d.key, state, records) == Answered::Open)
1657 .flat_map(|d| {
1658 let at = d.at.timestamp().as_second();
1659 let end = midnight_after(&d.at).map_or(at + 86_400, |m| m.timestamp().as_second());
1660 let until = (at + MISSED_HOURS * 3600).min(end);
1661 let start = stamp.max(at);
1662 let pieces = match readable.then(|| sioul_core::health::known_from(at, start, until, knowledge.record_lost, &knowledge.peers)).flatten() {
1665 None => vec![(at, until, 0)],
1666 Some(known) if known <= start => vec![(at, until, card_known_until(knowledge, at, start, until))],
1667 Some(known) => vec![(at, known, 0), (known, until, card_known_until(knowledge, at, known, until))],
1668 };
1669 let (name, time) = (named(&d), d.at.strftime("%H:%M").to_string());
1670 pieces.into_iter().map(move |(from, until, known_until)| crate::homecard::Dose { name: name.clone(), time: time.clone(), at: from, until, known_until })
1671 })
1672 .collect();
1673 let midnight = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
1675 let back = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
1676 for d in health.doses(if back > midnight { &back } else { &midnight }, &later) {
1677 if matches!(sioul_core::doses::answered(&d.key, state, records), Answered::Differ(_)) {
1678 let at = d.at.timestamp().as_second();
1679 let end = midnight_after(&d.at).map_or(at + 86_400, |m| m.timestamp().as_second());
1680 out.push(crate::homecard::Dose { name: named(&d), time: d.at.strftime("%H:%M").to_string(), at, until: (at + MISSED_HOURS * 3600).min(end), known_until: 0 });
1681 }
1682 }
1683 out.sort_by_key(|d| d.at);
1684 out
1685}
1686
1687fn card_known_until(knowledge: &Knowledge, due: i64, from: i64, until: i64) -> i64 {
1692 sioul_core::health::known_until(due, from, until, knowledge.record_lost, &knowledge.peers)
1693}
1694
1695const WAIT_FOR_NEWS: i64 = sioul_core::health::ALARM_AFTER;
1702
1703const LATE_UNTIL: i64 = WAIT_FOR_NEWS - 3 * 60;
1711
1712const FIRST_WORD: i64 = 2 * 60;
1716
1717fn alarm_again(since: i64, stamp: i64, known: Option<i64>) -> i64 {
1726 match known {
1727 Some(at) if at > stamp + 1 => at.max(stamp + 60).min(since + WAIT_FOR_NEWS),
1728 _ => since + WAIT_FOR_NEWS,
1729 }
1730}
1731
1732fn news_from(due: i64, woken: Option<(i64, i64)>) -> i64 {
1738 woken.filter(|(from, _)| *from <= due).map_or(due, |(_, woke)| woke.max(due))
1739}
1740
1741#[derive(Debug, Default, Serialize, Deserialize)]
1745struct RecordDoubt {
1746 since: i64,
1747 #[serde(default)]
1749 aside: String,
1750}
1751
1752fn record_doubt_path() -> std::path::PathBuf {
1753 sioul_core::config::state_dir().join("health-doubt.toml")
1754}
1755
1756fn record_doubt() -> Option<i64> {
1757 let doubt: RecordDoubt = toml::from_str(&std::fs::read_to_string(record_doubt_path()).ok()?).ok()?;
1758 Some(doubt.since)
1759}
1760
1761fn write_record_doubt(since: i64, aside: &str) {
1762 let doubt = RecordDoubt { since, aside: aside.to_string() };
1763 if let Ok(text) = toml::to_string(&doubt) {
1764 let _ = std::fs::write(record_doubt_path(), text);
1765 }
1766}
1767
1768fn record() -> HealthState {
1771 let path = HealthState::default_path();
1772 match HealthState::read(&path) {
1773 Ok(state) => state,
1774 Err(_) => {
1775 repair();
1776 HealthState::read(&path).unwrap_or_default()
1777 }
1778 }
1779}
1780
1781fn repair() {
1788 let now = Timestamp::now().as_second();
1789 let path = HealthState::default_path();
1790 let set_aside = || HealthState::set_aside(&path, now);
1791 let aside = if crate::share::on() {
1792 match crate::share::rebuild_health_record(set_aside) {
1793 Ok(aside) => aside,
1794 Err(_) => {
1795 write_record_doubt(now, "");
1796 return;
1797 }
1798 }
1799 } else {
1800 set_aside()
1801 };
1802 write_record_doubt(now, &aside.ok().flatten().map(|p| p.display().to_string()).unwrap_or_default());
1803}
1804
1805fn change(change: impl Fn(&mut HealthState)) -> String {
1808 let path = HealthState::default_path();
1809 let now = Timestamp::now().as_second();
1810 let changed = std::sync::atomic::AtomicBool::new(false);
1813 let changing = |state: &mut HealthState| {
1814 let before = state.clone();
1815 change(state);
1816 if *state != before {
1817 changed.store(true, std::sync::atomic::Ordering::Relaxed);
1818 }
1819 };
1820 let problem = match HealthState::update(&path, now, &changing) {
1821 Ok(()) => {
1822 crate::alarms::schedule();
1824 String::new()
1825 }
1826 Err(Problem::Unsound(_)) => {
1827 repair();
1828 match HealthState::update(&path, now, &changing) {
1829 Ok(()) => String::new(),
1830 Err(_) => tr().text("dose-record-broken", None),
1831 }
1832 }
1833 Err(Problem::Write(e)) => e,
1834 };
1835 if changed.load(std::sync::atomic::Ordering::Relaxed) {
1836 crate::devices::recorded();
1837 }
1838 problem
1839}
1840
1841fn records() -> DoseRecords {
1846 let path = DoseRecords::default_path();
1847 match DoseRecords::read(&path) {
1848 Ok(records) => records,
1849 Err(_) => {
1850 repair_records();
1851 DoseRecords::read(&path).unwrap_or_default()
1852 }
1853 }
1854}
1855
1856fn repair_records() {
1860 let now = Timestamp::now().as_second();
1861 let path = DoseRecords::default_path();
1862 let set_aside = || DoseRecords::set_aside(&path, now);
1863 let aside = if crate::share::on() {
1864 match crate::share::rebuild_dose_records(set_aside) {
1865 Ok(aside) => aside,
1866 Err(_) => {
1867 write_record_doubt(now, "");
1868 return;
1869 }
1870 }
1871 } else {
1872 set_aside()
1873 };
1874 write_record_doubt(now, &aside.ok().flatten().map(|p| p.display().to_string()).unwrap_or_default());
1875}
1876
1877fn change_records(change: impl Fn(&mut DoseRecords)) -> String {
1880 let path = DoseRecords::default_path();
1881 let now = Timestamp::now().as_second();
1882 let changed = std::sync::atomic::AtomicBool::new(false);
1884 let changing = |records: &mut DoseRecords| {
1885 let before = records.clone();
1886 change(records);
1887 if *records != before {
1888 changed.store(true, std::sync::atomic::Ordering::Relaxed);
1889 }
1890 };
1891 let problem = match DoseRecords::update(&path, now, &changing) {
1892 Ok(()) => String::new(),
1893 Err(Problem::Unsound(_)) => {
1894 repair_records();
1895 match DoseRecords::update(&path, now, &changing) {
1896 Ok(()) => String::new(),
1897 Err(_) => tr().text("dose-record-broken", None),
1898 }
1899 }
1900 Err(Problem::Write(e)) => e,
1901 };
1902 if changed.load(std::sync::atomic::Ordering::Relaxed) {
1903 crate::devices::recorded();
1904 }
1905 problem
1906}
1907
1908fn record_answer(key: &str, state: &str, at: i64, settle: bool) -> String {
1912 let me = this_device();
1913 if me.is_empty() {
1914 return String::new();
1915 }
1916 let answer = Answer { state: state.to_string(), at, noted: Timestamp::now().as_second(), name: crate::devices::name(), kind: crate::devices::kind().to_string() };
1917 change_records(|records| if settle { records.choose(key, &me, answer.clone()) } else { records.answer(key, &me, answer.clone()) })
1918}
1919
1920fn open_due(health: &Health, state: &HealthState, now: &Zoned) {
1924 let start = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
1925 let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1926 let keys: Vec<String> = health.doses(&start, &end).into_iter().map(|d| d.key).filter(|key| !state.taken.contains_key(key) && !state.not_taken.contains_key(key)).collect();
1927 open_canaries(&keys, now.timestamp().as_second());
1928}
1929
1930fn open_canaries(keys: &[String], now: i64) {
1932 let me = this_device();
1933 if keys.is_empty() || me.is_empty() {
1934 return;
1935 }
1936 let Ok(records) = DoseRecords::read(&DoseRecords::default_path()) else { return };
1938 if keys.iter().all(|key| records.dose.get(key).is_some_and(|r| !r.opened.is_empty() || !r.answer.is_empty())) {
1939 return;
1940 }
1941 let _ = change_records(|records| {
1942 for key in keys {
1943 records.open(key, &me, now);
1944 }
1945 });
1946}
1947
1948fn this_device() -> String {
1950 crate::share::vault().map(|(id, _)| id).unwrap_or_default()
1951}
1952
1953#[derive(Debug, Default, Serialize, Deserialize)]
1956struct Peers {
1957 #[serde(default)]
1958 peers: std::collections::BTreeMap<String, PeerSeen>,
1959 #[serde(default)]
1962 looked: i64,
1963}
1964
1965#[derive(Debug, Default, Clone, Serialize, Deserialize)]
1966struct PeerSeen {
1967 peer: Peer,
1968 #[serde(default)]
1970 renewed: i64,
1971 #[serde(default)]
1973 looked: i64,
1974 #[serde(default)]
1976 delays: Vec<i64>,
1977 #[serde(default)]
1981 entry: Option<sioul_sync::devices::Entry>,
1982 #[serde(default, skip_serializing_if = "Option::is_none")]
1988 acked_now: Option<std::collections::BTreeMap<String, i64>>,
1989 #[serde(default)]
1992 aheads: Vec<i64>,
1993 #[serde(default)]
1995 off: Option<Mark>,
1996 #[serde(default)]
1998 forgotten: Option<Mark>,
1999 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2002 wakes: Vec<i64>,
2003 #[serde(default, skip_serializing_if = "is_zero")]
2006 since: i64,
2007}
2008
2009fn is_zero(n: &i64) -> bool {
2010 *n == 0
2011}
2012
2013const FUTURE: i64 = 10 * 60;
2020
2021fn latest_time(entry: &sioul_sync::devices::Entry) -> i64 {
2023 entry.started.max(entry.closed).max(entry.imported).max(entry.exported)
2024}
2025
2026fn from_the_future(entry: &sioul_sync::devices::Entry, now: i64) -> bool {
2028 latest_time(entry) > now + FUTURE
2029}
2030
2031const WAKES: usize = 10;
2035
2036const UNSURE_WAKE: i64 = 60 * 60;
2042
2043fn note_wake(seen: &mut PeerSeen, delay: i64, news: Option<bool>) {
2049 let delay = match news {
2050 Some(false) => return,
2051 Some(true) => delay,
2052 None => delay.min(UNSURE_WAKE),
2053 };
2054 seen.wakes.push(delay.max(0));
2055 let keep = seen.wakes.len().saturating_sub(WAKES);
2056 seen.wakes.drain(..keep);
2057}
2058
2059#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
2061struct Mark {
2062 #[serde(default)]
2063 started: i64,
2064 #[serde(default)]
2065 exported: i64,
2066 #[serde(default)]
2067 renewed: i64,
2068 #[serde(default)]
2070 at: i64,
2071}
2072
2073fn peers_path() -> std::path::PathBuf {
2074 sioul_core::config::state_dir().join("share").join("peers.json")
2075}
2076
2077pub(crate) struct Knowledge {
2080 record_lost: Option<i64>,
2081 peers: Vec<Peer>,
2082}
2083
2084static KNOWING: std::sync::Mutex<()> = std::sync::Mutex::new(());
2086
2087static PULLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2092
2093fn know() -> Knowledge {
2097 let now = Timestamp::now().as_second();
2098 let record_lost = record_doubt();
2099 let Some(others) = crate::share::others_on_health() else {
2100 PULLED.store(false, std::sync::atomic::Ordering::Relaxed);
2101 return Knowledge { record_lost, peers: Vec::new() };
2102 };
2103 PULLED.store(others.news == Some(true), std::sync::atomic::Ordering::Relaxed);
2104 let _held = KNOWING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2105 let own = sioul_sync::devices::own(&sioul_sync::devices::own_path(&sioul_core::config::state_dir()));
2107 let kept = sioul_core::filelock::with_lock(&peers_path(), || {
2109 let mut kept = load_peers();
2110 learn_with(&mut kept, &others.claims, &others.entries, &others.heard, others.news, now);
2111 let replaced = replacements(&kept, &others.entries, &own);
2112 apply_replaced(&mut kept, &replaced);
2113 save_peers(&kept);
2114 kept
2115 });
2116 let mut peers: Vec<Peer> = kept.peers.values().map(|s| s.peer.clone()).collect();
2121 for peer in &mut peers {
2123 peer.acknowledged = own.acked.get(&peer.id).copied().unwrap_or(0);
2124 }
2125 let silent = sioul_core::health::SILENT_DAYS * 86_400;
2129 for other in others.others.iter().filter(|o| !kept.peers.contains_key(&o.id) && now - o.heard < silent) {
2130 peers.push(Peer { id: other.id.clone(), name: tr().text("share-other-device", None), heard: other.heard, ..Peer::default() });
2131 }
2132 let unread: Vec<String> = others.unread.iter().filter(|id| !kept.peers.contains_key(*id) && !peers.iter().any(|p| p.id == **id)).cloned().collect();
2133 for id in unread {
2134 peers.push(Peer { id, name: tr().text("share-other-device", None), heard: now, ..Peer::default() });
2135 }
2136 Knowledge { record_lost, peers }
2137}
2138
2139fn replacements(kept: &Peers, entries: &[sioul_sync::devices::Entry], own: &sioul_sync::devices::Entry) -> Vec<sioul_sync::devices::Replaced> {
2144 let newest = kept.peers.values().filter_map(|seen| seen.entry.as_ref());
2145 entries.iter().chain(newest).chain(std::iter::once(own)).flat_map(|e| e.replaces.iter().cloned()).collect()
2146}
2147
2148fn apply_replaced(kept: &mut Peers, replaced: &[sioul_sync::devices::Replaced]) {
2156 for r in replaced {
2157 let Some(seen) = kept.peers.get_mut(&r.id) else { continue };
2158 let latest = seen.entry.as_ref().map_or(0, latest_time).max(seen.renewed).max(seen.peer.heard).saturating_mul(1000);
2159 if r.holds(seen.entry.as_ref().map(|e| e.started), latest) {
2160 seen.peer.off = true;
2161 seen.peer.forgotten = true;
2162 }
2163 }
2164}
2165
2166pub(crate) fn replaced_now(id: &str) -> sioul_sync::devices::Replaced {
2170 let _ = know();
2171 let kept = load_peers();
2172 let seen = kept.peers.get(id);
2173 let entry = seen.and_then(|s| s.entry.as_ref());
2174 let latest = entry.map_or(0, latest_time).max(seen.map_or(0, |s| s.renewed.max(s.peer.heard))).saturating_mul(1000);
2175 sioul_sync::devices::Replaced { id: id.to_string(), started: entry.map_or(0, |e| e.started), heard: latest.max(crate::share::heard_of(id)) }
2176}
2177
2178fn newest_entries() -> Vec<sioul_sync::devices::Entry> {
2186 newest_of(&load_peers())
2187}
2188
2189fn newest_of(kept: &Peers) -> Vec<sioul_sync::devices::Entry> {
2190 kept.peers
2191 .values()
2192 .filter(|seen| seen.peer.said.is_some() && !seen.peer.set_aside)
2193 .filter_map(|seen| {
2194 let now = seen.acked_now.as_ref()?;
2195 let mut entry = seen.entry.clone()?;
2196 entry.acked.retain(|computer, opening| now.get(computer) == Some(opening));
2197 Some(entry)
2198 })
2199 .collect()
2200}
2201
2202fn load_peers() -> Peers {
2203 std::fs::read_to_string(peers_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
2204}
2205
2206fn save_peers(kept: &Peers) -> String {
2211 let written = serde_json::to_string(kept).map_err(|e| e.to_string()).and_then(|text| sioul_sync::share::write_atomically(&peers_path(), text.as_bytes()));
2212 written.err().unwrap_or_default()
2213}
2214
2215#[cfg(test)]
2218fn learn(kept: &mut Peers, claims: &[sioul_sync::lease::Claim], entries: &[sioul_sync::devices::Entry], heard: &sioul_sync::share::Heard, now: i64) {
2219 learn_with(kept, claims, entries, heard, None, now);
2220}
2221
2222fn learn_with(kept: &mut Peers, claims: &[sioul_sync::lease::Claim], entries: &[sioul_sync::devices::Entry], heard: &sioul_sync::share::Heard, news: Option<bool>, now: i64) {
2225 let watching = kept.looked > 0 && now - kept.looked <= 2 * 60;
2229 kept.looked = now;
2230 for claim in claims {
2231 let seen = kept.peers.entry(claim.computer.clone()).or_default();
2232 seen.peer.id = claim.computer.clone();
2233 seen.peer.name = claim.name.clone();
2234 if claim.since != seen.since {
2236 if seen.since > 0 && watching && !claim.closed {
2237 note_wake(seen, now - claim.since, news);
2238 }
2239 seen.since = claim.since;
2240 }
2241 if claim.renewed > seen.renewed {
2242 if seen.renewed > 0 && now - seen.looked <= 2 * 60 {
2245 seen.delays.push((now - claim.renewed).max(0));
2246 let keep = seen.delays.len().saturating_sub(60);
2247 seen.delays.drain(..keep);
2248 }
2249 seen.renewed = claim.renewed;
2250 }
2251 seen.looked = now;
2252 seen.peer.delay = seen.delays.iter().copied().max();
2253 seen.peer.heard = seen.peer.heard.max(claim.renewed);
2254 if !claim.closed {
2256 seen.peer.claimed = seen.peer.claimed.max(claim.renewed);
2257 }
2258 if let Some(wrote) = claim.wrote
2263 && heard.complete(&claim.computer, wrote)
2264 && claim.renewed <= now + 60
2265 && claim.renewed.min(now) >= seen.peer.known_until
2266 {
2267 seen.peer.known_until = claim.renewed.min(now);
2268 seen.peer.closed = claim.closed;
2269 }
2270 seen.peer.broken = heard.broken.get(&claim.computer).copied();
2271 }
2272 for seen in kept.peers.values_mut() {
2276 seen.peer.said = None;
2277 seen.peer.complete = false;
2278 seen.peer.set_aside = false;
2279 seen.acked_now = None;
2280 }
2281 for entry in entries {
2282 let seen = kept.peers.entry(entry.id.clone()).or_default();
2283 seen.peer.id = entry.id.clone();
2284 if !entry.name.is_empty() {
2285 seen.peer.name = entry.name.clone();
2286 }
2287 let came = seen.peer.seen;
2295 let ahead_when_kept = |at: i64| at > now + FUTURE && at > came + FUTURE;
2296 if seen.entry.as_ref().is_some_and(|last| ahead_when_kept(latest_time(last))) {
2297 seen.entry = None;
2298 }
2299 if ahead_when_kept(seen.peer.latest_opening) {
2300 seen.peer.latest_opening = 0;
2301 }
2302 let aside = from_the_future(entry, now);
2305 seen.peer.set_aside = aside;
2306 seen.acked_now = (!aside).then(|| entry.acked.clone());
2307 let newer = !from_the_future(entry, now) && seen.entry.as_ref().is_none_or(|last| sioul_sync::devices::written(entry) > sioul_sync::devices::written(last));
2312 if newer {
2313 seen.peer.seen = now;
2316 if seen.entry.as_ref().is_none_or(|last| last.exported != entry.exported) {
2317 seen.aheads.push(entry.exported - now);
2318 let keep = seen.aheads.len().saturating_sub(60);
2319 seen.aheads.drain(..keep);
2320 }
2321 if watching && seen.entry.as_ref().is_some_and(|last| entry.started > last.started) {
2324 let ahead = seen.aheads.iter().copied().max().unwrap_or(0).max(0);
2325 note_wake(seen, now - entry.started + ahead, news);
2326 }
2327 seen.entry = Some(entry.clone());
2328 let spoke = |mark: &Mark| entry.started != mark.started || entry.exported > mark.exported;
2332 if seen.off.as_ref().is_some_and(spoke) {
2333 seen.off = None;
2334 }
2335 if seen.forgotten.as_ref().is_some_and(spoke) {
2336 seen.forgotten = None;
2337 }
2338 }
2339 seen.peer.ahead = seen.aheads.iter().copied().max().unwrap_or(0).max(0);
2340 let Some(entry) = seen.entry.clone() else { continue };
2342 let entry = &entry;
2343 let alive_since = claims.iter().any(|c| c.computer == entry.id && !c.closed && c.renewed > entry.closed + 60);
2350 if !entry.working && alive_since && !aside {
2351 continue;
2352 }
2353 seen.peer.said = Some(entry.said());
2354 seen.peer.latest_opening = seen.peer.latest_opening.max(entry.started);
2356 seen.peer.complete = entry.wrote.is_none_or(|wrote| heard.complete(&entry.id, wrote));
2358 seen.peer.heard = seen.peer.heard.max(entry.exported);
2359 seen.peer.broken = heard.broken.get(&entry.id).copied();
2360 }
2361 for seen in kept.peers.values_mut() {
2364 seen.peer.wake = seen.wakes.iter().copied().max();
2365 let renewed = seen.renewed;
2366 let newer = |mark: &Mark| renewed > mark.renewed;
2367 if seen.off.as_ref().is_some_and(newer) {
2368 seen.off = None;
2369 }
2370 if seen.forgotten.as_ref().is_some_and(newer) {
2371 seen.forgotten = None;
2372 }
2373 seen.peer.off = seen.off.is_some() || seen.forgotten.is_some();
2374 seen.peer.forgotten = seen.forgotten.is_some();
2375 }
2376}
2377
2378pub(crate) fn device_off(id: &str, off: bool) -> String {
2383 mark_device(id, |seen, mark| seen.off = off.then_some(mark))
2384}
2385
2386pub(crate) fn quiet_since(id: &str) -> Option<i64> {
2391 load_peers().peers.get(id).map(|seen| seen.peer.seen.max(seen.peer.heard)).filter(|since| *since > 0)
2392}
2393
2394pub(crate) fn forgotten_here(id: &str) -> bool {
2397 load_peers().peers.get(id).is_some_and(|seen| seen.forgotten.is_some() || seen.peer.forgotten)
2398}
2399
2400pub(crate) fn device_forget(id: &str) -> String {
2402 crate::share::forget_device(id);
2404 mark_device(id, |seen, mark| seen.forgotten = Some(mark))
2405}
2406
2407pub(crate) fn device_forget_doses(id: &str) -> String {
2415 mark_device(id, |seen, mark| seen.forgotten = Some(mark))
2416}
2417
2418fn mark_device(id: &str, set: impl FnOnce(&mut PeerSeen, Mark)) -> String {
2419 let id = id.trim();
2420 if id.is_empty() {
2421 return String::new();
2422 }
2423 let _ = know();
2425 let _held = KNOWING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2426 sioul_core::filelock::with_lock(&peers_path(), || {
2427 let mut kept = load_peers();
2428 let seen = kept.peers.entry(id.to_string()).or_default();
2429 seen.peer.id = id.to_string();
2430 let mark = Mark { started: seen.entry.as_ref().map_or(0, |e| e.started), exported: seen.entry.as_ref().map_or(0, |e| e.exported), renewed: seen.renewed, at: Timestamp::now().as_second() };
2431 set(seen, mark);
2432 save_peers(&kept)
2433 })
2434}
2435
2436pub(crate) const FORGET_NOW_AFTER: i64 = 86_400;
2440
2441#[derive(Serialize, Debug)]
2443pub(crate) struct DeviceRow {
2444 id: String,
2445 name: String,
2446 state: String,
2448 notes: Vec<String>,
2450 off: bool,
2452 forget: bool,
2454 forget_now: bool,
2458 build: String,
2461}
2462
2463pub(crate) fn device_rows(holders: &[(String, i64, bool)]) -> Vec<DeviceRow> {
2469 let knowledge = know();
2470 let kept = load_peers();
2471 let mut rows = device_rows_of(&knowledge.peers, &kept, Timestamp::now().as_second(), tr());
2472 holder_rows(&mut rows, holders, tr());
2473 rows
2474}
2475
2476fn holder_rows(rows: &mut Vec<DeviceRow>, holders: &[(String, i64, bool)], words: &Translator) {
2485 let zone = jiff::tz::TimeZone::system();
2486 let when = |at: i64| Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2487 for (id, heard, holds) in holders {
2488 if id.is_empty() || rows.iter().any(|row| row.id == *id) {
2489 continue;
2490 }
2491 let state = match (holds, *heard > 0) {
2492 (true, true) => said(words, "share-device-holds", &[("when", when(*heard))]),
2493 (true, false) => words.text("share-device-holds-unknown", None),
2494 (false, true) => said(words, "share-device-older", &[("when", when(*heard))]),
2495 (false, false) => words.text("share-device-unread", None),
2496 };
2497 rows.push(DeviceRow { id: id.clone(), name: capitalized(&words.text("share-other-device", None)), state, notes: Vec::new(), off: false, forget: true, forget_now: false, build: String::new() });
2498 }
2499 rows.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
2500}
2501
2502fn device_rows_of(peers: &[Peer], kept: &Peers, now: i64, words: &Translator) -> Vec<DeviceRow> {
2503 let zone = jiff::tz::TimeZone::system();
2504 let when = |at: i64| Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2505 let mut rows: Vec<DeviceRow> = peers
2506 .iter()
2507 .filter(|peer| !peer.id.is_empty() && !peer.forgotten && kept.peers.get(&peer.id).is_none_or(|seen| seen.forgotten.is_none()))
2509 .map(|peer| {
2510 let seen = kept.peers.get(&peer.id);
2511 let entry = seen.and_then(|s| s.entry.as_ref()).filter(|_| peer.said.is_some());
2512 let named = named_of(peer);
2513 let name = match entry {
2514 Some(e) if e.kind == sioul_sync::devices::PHONE && !e.name.is_empty() && e.name != sioul_sync::devices::PHONE => format!("{} ({})", capitalized(&words.text("device-the-phone", None)), e.name),
2515 _ => device_start(&named, words),
2516 };
2517 let off = peer.off;
2518 let counted = sioul_core::health::counts(&Peer { off: false, ..peer.clone() }, now);
2519 let future = peer.set_aside;
2521 let state = if off {
2522 words.text("share-device-off", None)
2523 } else if future && entry.is_none() {
2524 words.text("share-device-future", None)
2525 } else if !counted {
2526 said(words, "share-device-silent", &[("when", when(peer.heard))])
2527 } else {
2528 match entry {
2529 Some(e) if e.working && now - peer.seen > sioul_core::health::QUIET => said(words, "share-device-quiet", &[("when", when(e.exported))]),
2530 Some(e) if e.working => said(words, "share-device-in-use", &[("when", when(e.exported))]),
2531 Some(e) => said(words, "share-device-closed", &[("when", when(e.closed)), ("shared", when(e.exported))]),
2532 None if seen.is_some_and(|s| s.renewed > 0) => said(words, "share-device-older", &[("when", when(peer.heard))]),
2533 None => words.text("share-device-unread", None),
2534 }
2535 };
2536 let mut notes = Vec::new();
2537 if entry.is_some_and(|e| !e.doses) {
2538 notes.push(words.text("share-device-apart", None));
2539 }
2540 if future && (off || entry.is_some()) {
2541 notes.push(words.text("share-device-future", None));
2542 } else if peer.ahead >= 60 {
2543 let mut args = sioul_core::i18n::args();
2544 args.set("minutes", peer.ahead / 60);
2545 notes.push(words.text("share-device-ahead", Some(&args)));
2546 }
2547 let build = match entry.map(|e| (e.build(), sioul_core::build::older(&e.version, sioul_core::build::VERSION))) {
2549 Some((build, _)) if build.is_empty() => String::new(),
2550 Some((build, true)) => said(words, "share-device-build-older", &[("build", build), ("here", sioul_core::build::DESCRIBED.to_string())]),
2551 Some((build, false)) => said(words, "share-device-build", &[("build", build)]),
2552 None => String::new(),
2553 };
2554 let forget = !counted || off;
2556 let phone = peer.said.as_ref().is_some_and(|s| s.phone) || entry.is_some_and(|e| e.kind == sioul_sync::devices::PHONE);
2559 let forget_now = !forget && phone && crate::devices::kind() == sioul_sync::devices::COMPUTER && now - peer.seen.max(peer.heard) >= FORGET_NOW_AFTER;
2560 DeviceRow { id: peer.id.clone(), name, state, notes, off, forget, forget_now, build }
2561 })
2562 .collect();
2563 rows.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
2564 rows
2565}
2566
2567fn why(doubts: &[Doubt]) -> String {
2569 why_in(doubts, tr())
2570}
2571
2572fn why_in(doubts: &[Doubt], words: &Translator) -> String {
2574 joined(why_parts(doubts, words), words)
2575}
2576
2577fn why_parts(doubts: &[Doubt], words: &Translator) -> Vec<String> {
2579 let zone = jiff::tz::TimeZone::system();
2580 let when = |at: i64| Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2581 let named = |device: &sioul_core::health::Named| device_word(device, words);
2582 let parts: Vec<String> = doubts
2583 .iter()
2584 .map(|doubt| match doubt {
2585 Doubt::Record { since } => said(words, "dose-doubt-record", &[("when", when(*since))]),
2586 Doubt::Unheard { device, until: 0, .. } => said(words, "dose-doubt-never", &[("name", named(device))]),
2587 Doubt::Unheard { device, until, closed: true } => said(words, "dose-doubt-closed", &[("name", named(device)), ("when", when(*until))]),
2588 Doubt::Unheard { device, until, closed: false } => said(words, "dose-doubt-open", &[("name", named(device)), ("when", when(*until))]),
2589 Doubt::Broken { device } => said(words, "dose-doubt-broken", &[("name", named(device))]),
2590 Doubt::Working { device, shared, quiet: false } => said(words, "dose-doubt-working", &[("name", named(device)), ("when", when(*shared))]),
2591 Doubt::Working { device, shared, quiet: true } => said(words, "dose-doubt-quiet", &[("name", named(device)), ("when", when(*shared))]),
2592 Doubt::Coming { device, shared } => said(words, "dose-doubt-coming", &[("name", named(device)), ("when", when(*shared))]),
2593 Doubt::Apart { device } => said(words, "dose-doubt-apart", &[("name", named(device))]),
2594 Doubt::Ahead { device } => said(words, "dose-doubt-ahead", &[("name", named(device))]),
2595 })
2596 .collect();
2597 parts
2598}
2599
2600fn joined(parts: Vec<String>, words: &Translator) -> String {
2603 let mut once: Vec<String> = Vec::new();
2605 for part in parts {
2606 if !once.contains(&part) {
2607 once.push(part);
2608 }
2609 }
2610 once.into_iter().reduce(|first, then| said(words, "dose-doubt-and", &[("first", first), ("then", then)])).unwrap_or_default()
2611}
2612
2613fn elsewhere_said(elsewhere: &[Elsewhere], since: i64, words: &Translator) -> String {
2617 let zone = jiff::tz::TimeZone::system();
2618 let when = |at: i64| Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2619 let parts = elsewhere
2620 .iter()
2621 .map(|e| match e {
2622 Elsewhere::Sent { device, at } => said(words, "dose-doubt-maybe", &[("name", device_word(device, words)), ("when", when(*at))]),
2623 Elsewhere::Unshown { device, at } => said(words, "dose-doubt-maybe-late", &[("name", device_word(device, words)), ("when", when(*at))]),
2624 Elsewhere::Unknown { device } => said(words, "dose-doubt-unknown", &[("name", device_word(device, words)), ("when", when(since))]),
2625 Elsewhere::Closed { device, at } => said(words, "dose-doubt-closed", &[("name", device_word(device, words)), ("when", when(*at))]),
2626 Elsewhere::Alarm { device } => said(words, "dose-doubt-alarm", &[("name", device_word(device, words))]),
2627 Elsewhere::Ahead { device } => said(words, "dose-doubt-ahead", &[("name", device_word(device, words))]),
2629 Elsewhere::Unread => words.text("dose-doubt-reminders", None),
2630 })
2631 .collect();
2632 joined(parts, words)
2633}
2634
2635fn when_said(at: &Zoned, words: &Translator) -> String {
2637 if at.date() == Zoned::now().with_time_zone(at.time_zone().clone()).date() { said(words, "when-at", &[("time", at.strftime("%H:%M").to_string())]) } else { words.when(at) }
2638}
2639
2640fn device_word(device: &sioul_core::health::Named, words: &Translator) -> String {
2643 if device.phone {
2644 words.text("device-the-phone", None)
2645 } else if device.name.trim().is_empty() || device.name == "localhost" || device.name == "?" {
2646 words.text("share-other-device", None)
2647 } else {
2648 device.name.clone()
2649 }
2650}
2651
2652fn said(words: &Translator, id: &str, pairs: &[(&str, String)]) -> String {
2654 let mut args = sioul_core::i18n::args();
2655 for (key, value) in pairs {
2656 args.set(key.to_string(), value.clone());
2657 }
2658 words.text(id, Some(&args))
2659}
2660
2661fn doubt_of(knowledge: &Knowledge, due: i64, waking: i64, now: i64) -> String {
2664 doubt_unread(knowledge, due, waking, now, None)
2665}
2666
2667fn doubt_unread_with(knowledge: &Knowledge, due: i64, waking: i64, now: i64, missed: Option<&(i64, std::collections::BTreeSet<String>)>, first: Option<&str>) -> String {
2673 let doubts = match missed {
2674 Some((since, heard)) => sioul_core::health::doubts_unread_at(due, waking, now, knowledge.record_lost, &knowledge.peers, *since, heard),
2675 None => sioul_core::health::doubts_at(due, waking, now, knowledge.record_lost, &knowledge.peers),
2676 };
2677 let parts: Vec<String> = why_parts(&doubts, tr()).into_iter().filter(|part| first.is_none_or(|first| !first.contains(part.as_str()))).collect();
2679 let reasons = (!parts.is_empty()).then(|| joined(parts, tr()));
2680 let why = match (first, reasons) {
2681 (None, None) => return String::new(),
2682 (Some(first), None) => first.to_string(),
2683 (None, Some(reasons)) => reasons,
2684 (Some(first), Some(reasons)) => joined(vec![first.to_string(), reasons], tr()),
2685 };
2686 say("dose-doubt", &[("why", why)])
2687}
2688
2689fn moment_said(at: i64) -> String {
2691 Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(TimeZone::system()), tr())).unwrap_or_default()
2692}
2693
2694fn phone_alarms(knowledge: &Knowledge) -> bool {
2699 knowledge.peers.iter().any(|p| p.said.as_ref().is_none_or(|said| said.phone && !said.left))
2700}
2701
2702fn must_acknowledge(knowledge: &Knowledge) -> Vec<&Peer> {
2710 knowledge.peers.iter().filter(|p| !p.forgotten && p.said.as_ref().is_none_or(|said| said.phone && !said.left)).collect()
2711}
2712
2713fn acknowledgements(computer: &str, entries: &[sioul_sync::devices::Entry]) -> std::collections::BTreeMap<String, i64> {
2716 entries.iter().filter(|e| e.kind == sioul_sync::devices::PHONE).filter_map(|e| e.acked.get(computer).map(|opening| (e.id.clone(), *opening))).collect()
2717}
2718
2719pub(crate) fn acknowledged_openings() -> std::collections::BTreeMap<String, i64> {
2724 if crate::devices::kind() != sioul_sync::devices::PHONE {
2725 return std::collections::BTreeMap::new();
2726 }
2727 let now = Timestamp::now().as_second();
2728 know()
2729 .peers
2730 .iter()
2731 .filter(|p| sioul_core::health::counts(p, now))
2732 .filter_map(|p| p.said.as_ref().filter(|s| !s.phone && s.started > 0).map(|s| (p.id.clone(), s.started.max(p.latest_opening))))
2734 .collect()
2735}
2736
2737static ACKED: std::sync::Mutex<Option<(i64, i64)>> = std::sync::Mutex::new(None);
2741
2742const WORD_FRESH: i64 = 2 * sioul_sync::devices::RESTATED;
2750
2751fn acknowledged_since(knowledge: &Knowledge, start: i64, acks: &std::collections::BTreeMap<String, i64>, now: i64) -> Option<i64> {
2753 let all = all_acknowledged(knowledge, start, acks, now);
2754 let mut acked = ACKED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2755 *acked = acked_after(*acked, all, start, now);
2756 acked.map(|(_, at)| at)
2757}
2758
2759fn all_acknowledged(knowledge: &Knowledge, start: i64, acks: &std::collections::BTreeMap<String, i64>, now: i64) -> bool {
2765 start > 0 && must_acknowledge(knowledge).iter().all(|p| acks.get(&p.id) == Some(&start) && (0..=WORD_FRESH).contains(&(now - p.seen)))
2766}
2767
2768fn acked_after(before: Option<(i64, i64)>, all: bool, start: i64, now: i64) -> Option<(i64, i64)> {
2771 match before {
2772 Some((opening, at)) if all && opening == start => Some((opening, at)),
2773 _ if all => Some((start, now)),
2774 _ => None,
2775 }
2776}
2777
2778fn word_lead(knowledge: &Knowledge) -> i64 {
2783 sioul_core::health::SKEW + must_acknowledge(knowledge).iter().map(|p| p.ahead.max(0)).max().unwrap_or(0)
2784}
2785
2786fn first_here(alone: bool, told: bool, kept_since: i64, acked: Option<i64>, since: i64, lead: i64) -> bool {
2795 alone || (told && kept_since <= since && acked.is_some_and(|at| at <= since - lead))
2796}
2797
2798fn doubt_unread(knowledge: &Knowledge, due: i64, waking: i64, now: i64, missed: Option<&(i64, std::collections::BTreeSet<String>)>) -> String {
2803 let doubts = match missed {
2804 Some((since, heard)) => sioul_core::health::doubts_unread_at(due, waking, now, knowledge.record_lost, &knowledge.peers, *since, heard),
2805 None => sioul_core::health::doubts_at(due, waking, now, knowledge.record_lost, &knowledge.peers),
2806 };
2807 if doubts.is_empty() { String::new() } else { say("dose-doubt", &[("why", why(&doubts))]) }
2808}
2809
2810fn doubt_now_with(knowledge: &Knowledge, due: i64, now: i64, words: &Translator) -> (String, Vec<OffButton>) {
2817 let doubts = sioul_core::health::doubts_now(due, now, knowledge.record_lost, &knowledge.peers);
2818 if doubts.is_empty() {
2819 return (String::new(), Vec::new());
2820 }
2821 let named: Vec<&Named> = doubts
2822 .iter()
2823 .filter_map(|doubt| match doubt {
2824 Doubt::Working { device, .. } | Doubt::Coming { device, .. } | Doubt::Unheard { device, .. } => Some(device),
2825 _ => None,
2826 })
2827 .filter(|device| !device.id.is_empty())
2828 .collect();
2829 let off = match named.as_slice() {
2830 [one] => vec![OffButton { id: one.id.clone(), label: words.text("device-off", None) }],
2831 many => many.iter().map(|device| OffButton { id: device.id.clone(), label: said(words, "device-off-named", &[("name", device_start(device, words))]) }).collect(),
2832 };
2833 (said(words, "dose-doubt", &[("why", why_in(&doubts, words))]), off)
2834}
2835
2836fn device_start(device: &Named, words: &Translator) -> String {
2838 let word = device_word(device, words);
2839 if word == device.name { word } else { capitalized(&word) }
2840}
2841
2842fn named_by(knowledge: &Knowledge, id: &str) -> Named {
2846 let phones = knowledge.peers.iter().filter(|p| p.said.as_ref().is_some_and(|s| s.phone)).count();
2847 knowledge.peers.iter().find(|p| p.id == id).map_or_else(|| Named { id: id.to_string(), name: String::new(), phone: false }, |p| Named { id: p.id.clone(), name: p.name.clone(), phone: p.said.as_ref().is_some_and(|s| s.phone) && phones == 1 })
2848}
2849
2850fn named_of(peer: &Peer) -> Named {
2852 Named { id: peer.id.clone(), name: peer.name.clone(), phone: peer.said.as_ref().is_some_and(|s| s.phone) }
2853}
2854
2855fn differ_sentence(given: &[Given], me: &str, words: &Translator) -> String {
2859 let zone = jiff::tz::TimeZone::system();
2860 let parts: Vec<String> = given
2861 .iter()
2862 .map(|g| {
2863 let when = Timestamp::from_second(g.at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2864 let name = device_word(&Named { id: g.device.clone(), name: g.name.clone(), phone: g.kind == sioul_sync::devices::PHONE }, words);
2865 let here = !me.is_empty() && g.device == me;
2866 let id = match (&g.state, g.device.is_empty(), here) {
2867 (State::Taken, true, _) => "dose-answer-taken",
2868 (State::Taken, _, true) => "dose-answer-taken-here",
2869 (State::Taken, _, false) => "dose-answer-taken-on",
2870 (State::Skipped, true, _) => "dose-answer-skipped",
2871 (State::Skipped, _, true) => "dose-answer-skipped-here",
2872 (State::Skipped, _, false) => "dose-answer-skipped-on",
2873 (State::Other(_), _, _) => "dose-answer-other-on",
2874 };
2875 said(words, id, &[("name", name), ("when", when)])
2876 })
2877 .collect();
2878 said(words, "dose-answers", &[("answers", parts.join(", "))])
2879}
2880
2881static STATE: std::sync::Mutex<()> = std::sync::Mutex::new(());
2885
2886fn state_held() -> std::sync::MutexGuard<'static, ()> {
2887 STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
2888}
2889
2890static TICKING: std::sync::Mutex<()> = std::sync::Mutex::new(());
2892
2893pub(crate) fn not_taken(key: &str) -> String {
2895 let now = Timestamp::now().as_second();
2896 let problem = change(|state| {
2897 state.not_taken.insert(key.to_string(), now);
2898 });
2899 if !problem.is_empty() {
2900 return problem;
2901 }
2902 record_answer(key, sioul_core::doses::SKIPPED, now, false)
2904}
2905
2906pub(crate) fn choose(key: &str, taken: bool) -> String {
2912 let _held = state_held();
2913 let now = Timestamp::now().as_second();
2914 let first_taken = match sioul_core::doses::answered(key, &record(), &records()) {
2915 Answered::Taken(given) => Some(given.at),
2916 Answered::Differ(given) => given.iter().find(|g| g.state == State::Taken).map(|g| g.at),
2917 _ => None,
2918 };
2919 let at = if taken { first_taken.unwrap_or(now) } else { now };
2920 let moved = std::sync::Mutex::new(None);
2921 let problem = change(|state| {
2922 if taken {
2923 state.taken.insert(key.to_string(), at);
2924 state.not_taken.remove(key);
2925 } else {
2926 state.taken.remove(key);
2927 state.not_taken.insert(key.to_string(), now);
2928 if let Ok(mut moved) = moved.lock() {
2929 *moved = state.moved.remove(key);
2930 }
2931 }
2932 });
2933 if let Some([before, after]) = moved.into_inner().ok().flatten() {
2934 let _ = change_health(|health| Ok(health.taken_back(key, before, after)));
2935 }
2936 if !problem.is_empty() {
2937 return problem;
2938 }
2939 record_answer(key, if taken { sioul_core::doses::TAKEN } else { sioul_core::doses::SKIPPED }, at, true)
2940}
2941
2942fn due_of(key: &str) -> Option<i64> {
2944 key.rsplit_once('@').and_then(|(_, at)| at.parse().ok())
2945}
2946
2947pub(crate) fn is_late(key: &str) -> bool {
2949 due_of(key).is_some_and(|due| Timestamp::now().as_second() - due > GRACE_MINUTES * 60)
2950}
2951
2952pub(crate) fn taken_late(key: &str, time: &str) -> String {
2956 let now = Zoned::now();
2957 let Some(clock) = time.trim().split_once(':').and_then(|(h, m)| jiff::civil::Time::new(h.trim().parse().ok()?, m.trim().parse().ok()?, 0, 0).ok()) else {
2958 return tr().text("dose-time-wrong", None);
2959 };
2960 let mut at = now.date().to_datetime(clock).to_zoned(now.time_zone().clone()).map(|z| z.timestamp().as_second()).unwrap_or(now.timestamp().as_second());
2961 if at > now.timestamp().as_second() {
2962 at -= 86_400;
2963 }
2964 let _held = state_held();
2965 let moved = match change_health(|health| Ok(health.taken_at(key, at))) {
2967 Ok(moved) => moved,
2968 Err(e) => return e,
2969 };
2970 let problem = change(|state| {
2971 state.taken.insert(key.to_string(), at);
2972 state.not_taken.remove(key);
2973 if let Some((before, after)) = moved {
2974 state.moved.insert(key.to_string(), [before, after]);
2975 }
2976 });
2977 if problem.is_empty() {
2979 let _ = record_answer(key, sioul_core::doses::TAKEN, at, false);
2980 }
2981 if !problem.is_empty()
2983 && let Some((before, after)) = moved
2984 {
2985 let _ = change_health(|health| Ok(health.taken_back(key, before, after)));
2986 }
2987 problem
2988}
2989
2990pub(crate) fn dose_info(key: &str) -> String {
2992 let health = load();
2993 let now = Zoned::now();
2994 let (Some((id, _)), Some(due)) = (key.rsplit_once('@'), due_of(key)) else { return "null".into() };
2995 let Some(medicine) = health.medicines.iter().find(|m| m.id == id) else { return "null".into() };
2996 let due = Timestamp::from_second(due).map(|t| t.to_zoned(now.time_zone().clone())).unwrap_or_else(|_| now.clone());
2997 let hours = match medicine.schedule {
2998 Schedule::Hours { hours, .. } => hours,
2999 _ => 0,
3000 };
3001 serde_json::json!({
3002 "name": medicine.short_name(),
3003 "dose": health.amount_of(key, now.time_zone()).unwrap_or_else(|| medicine.dose.clone()),
3004 "due": if due.date() == now.date() { due.strftime("%H:%M").to_string() } else { format!("{} {}", tr().weekday_short(due.date()), due.strftime("%H:%M")) },
3005 "now": now.strftime("%H:%M").to_string(),
3006 "hours": hours,
3008 })
3009 .to_string()
3010}
3011
3012pub(crate) fn set_taken(key: &str, taken: bool) -> String {
3016 let _held = state_held();
3017 let now = Timestamp::now().as_second();
3018 let shift = if taken {
3019 match change_health(|health| Ok(health.taken_at(key, now))) {
3020 Ok(shift) => shift,
3021 Err(e) => return e,
3022 }
3023 } else {
3024 None
3025 };
3026 let moved = std::sync::Mutex::new(None);
3027 let problem = change(|state| {
3028 if taken {
3029 state.taken.insert(key.to_string(), now);
3030 if let Some((before, after)) = shift {
3031 state.moved.insert(key.to_string(), [before, after]);
3032 }
3033 } else {
3034 state.taken.remove(key);
3035 if let Ok(mut moved) = moved.lock() {
3036 *moved = state.moved.remove(key);
3037 }
3038 }
3039 });
3040 if let Some([before, after]) = moved.into_inner().ok().flatten() {
3041 let _ = change_health(|health| Ok(health.taken_back(key, before, after)));
3042 }
3043 if problem.is_empty() {
3046 let _ = if taken { record_answer(key, sioul_core::doses::TAKEN, now, false) } else { change_records(|records| records.take_back(key)) };
3047 }
3048 problem
3049}
3050
3051pub(crate) fn set_setting(key: &str, value: &str) -> String {
3054 let number = || value.trim().parse::<u32>().unwrap_or(0);
3055 if !matches!(key, "movement.enabled" | "movement.minutes" | "chats.enabled" | "chats.minutes" | "chats.locked_minutes" | "errands_list") {
3056 return say("setting-unknown-key", &[("key", key.to_string())]);
3057 }
3058 change_health(|health| {
3059 match key {
3060 "movement.enabled" => health.movement.enabled = value == "true",
3061 "movement.minutes" => health.movement.minutes = number().clamp(10, 240),
3062 "chats.enabled" => health.chats.enabled = value == "true",
3063 "chats.minutes" => health.chats.minutes = number(),
3064 "chats.locked_minutes" => health.chats.locked_minutes = number(),
3065 _ => health.errands_list = value.trim().to_string(),
3066 }
3067 Ok(())
3068 })
3069 .err()
3070 .unwrap_or_default()
3071}
3072
3073pub(crate) fn movement_minutes() -> i32 {
3075 let movement = load().movement;
3076 if movement.enabled { i32::try_from(movement.minutes).unwrap_or(45) } else { 0 }
3077}
3078
3079pub(crate) fn chat_minute() -> bool {
3081 let limit = load().chats;
3082 let _held = state_held();
3083 let now = Zoned::now();
3084 let covered = std::sync::atomic::AtomicBool::new(false);
3085 let _ = change(|state| covered.store(state.chat_minute(&limit, &now), std::sync::atomic::Ordering::Relaxed));
3086 covered.into_inner()
3087}
3088
3089pub(crate) fn chats_covered() -> bool {
3091 record().chats_covered(&Zoned::now())
3092}
3093
3094pub(crate) fn usual_name(kind: &str, index: usize) -> String {
3099 match (kind, index) {
3100 ("meal", 0..=2) => tr().text(&format!("need-meal-{index}"), None),
3101 ("meal", n) => say("need-meal-n", &[("n", (n + 1).to_string())]),
3102 ("nap", _) => tr().text("need-nap", None),
3103 _ => tr().text("need-sleep", None),
3104 }
3105}
3106
3107pub(crate) fn block_name(kind: &str, key: &str, name: &str) -> String {
3110 if !name.trim().is_empty() {
3111 return name.to_string();
3112 }
3113 if sioul_core::needs::is_added(key) {
3114 return tr().text(if kind == "nap" { "need-added-nap" } else { "need-added-meal" }, None);
3115 }
3116 usual_name(kind, key.rsplit_once(':').and_then(|(_, i)| i.parse().ok()).unwrap_or(0))
3117}
3118
3119fn name_of(kept: &Kept) -> String {
3120 block_name(kept.kind, &kept.key, &kept.name)
3121}
3122
3123pub(crate) fn days() -> Days {
3127 let path = Days::default_path();
3128 let now = Zoned::now();
3129 let today_path = sioul_core::needs::Today::default_path();
3130 let mut today = sioul_core::needs::Today::load(&today_path, now.date());
3131 if today.shifts.is_empty() && today.skipped.is_empty() {
3132 return Days::load(&path);
3133 }
3134 let zone = now.time_zone().clone();
3135 let blocks = load().needs.blocks_of(now.date(), &zone, &Days::default(), &|_| 0);
3136 let moved = Days::change_file(&path, now.date(), |days| {
3138 for k in &blocks {
3139 let shift = today.shifts.get(&k.key).copied().unwrap_or(0) * 60;
3140 let skipped = today.skipped.contains(&k.key);
3141 days.change(now.date(), &k.key, |b| {
3142 if shift != 0 && b.at.is_empty() {
3143 b.at = clock(k.at + shift, &zone);
3144 if k.kind == "sleep" {
3145 b.wake = clock(k.end + shift, &zone);
3146 }
3147 }
3148 b.quiet |= skipped;
3149 });
3150 }
3151 });
3152 if moved.is_ok() {
3153 today.shifts.clear();
3154 today.skipped.clear();
3155 let _ = today.save(&today_path);
3156 }
3157 Days::load(&path)
3158}
3159
3160fn held_today(now: &Zoned) -> Vec<(i64, i64)> {
3163 static CACHE: std::sync::Mutex<Option<(i64, Date, Vec<(i64, i64)>)>> = std::sync::Mutex::new(None);
3164 let stamp = now.timestamp().as_second();
3165 let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
3166 if let Some((at, day, held)) = cache.as_ref()
3167 && *day == now.date()
3168 && (0..300).contains(&(stamp - at))
3169 {
3170 return held.clone();
3171 }
3172 let midnight = now.date().to_zoned(now.time_zone().clone()).map_or(stamp, |z| z.timestamp().as_second());
3173 let held = sioul_core::plan::event_spans(&sioul_core::agenda::occurrences(midnight, midnight + 26 * 3600), 0);
3174 *cache = Some((stamp, now.date(), held.clone()));
3175 held
3176}
3177
3178fn held_on(date: Date, now: &Zoned) -> Vec<(i64, i64)> {
3180 if date == now.date() {
3181 return held_today(now);
3182 }
3183 let start = midnight(date, now.time_zone());
3184 sioul_core::plan::event_spans(&sioul_core::agenda::occurrences(start, start + 26 * 3600), 0)
3185}
3186
3187fn blocks_now(needs: &Needs, days: &Days, date: Date, now: &Zoned) -> Vec<Kept> {
3189 let pushed = needs.past_events_on(date, now.time_zone(), days, &held_on(date, now));
3190 needs.blocks_of(date, now.time_zone(), days, &|key: &str| pushed.get(key).copied().unwrap_or(0))
3191}
3192
3193fn needs_now(health: &Health, now: &Zoned) -> Vec<(Kept, bool)> {
3201 let needs = &health.needs;
3202 let days = days();
3203 if !(needs.meals_on || needs.naps_on || needs.sleep_on) && !days.0.contains_key(&now.date()) {
3204 return Vec::new();
3205 }
3206 let path = sioul_core::needs::Today::default_path();
3207 let mut today = sioul_core::needs::Today::load(&path, now.date());
3208 let stamp = now.timestamp().as_second();
3209 let pushed = needs.past_events_on(now.date(), now.time_zone(), &days, &held_today(now));
3210 let kept = needs.kept_with(now.date(), now.time_zone(), &days, &|key: &str| pushed.get(key).copied().unwrap_or(0));
3211 let due: Vec<(Kept, bool)> = today.due(&kept, needs.heads_up, stamp).into_iter().map(|(block, heads_up)| (block.clone(), heads_up)).collect();
3212 if due.is_empty() {
3213 return Vec::new();
3214 }
3215 let _ = today.save(&path);
3216 let meeting = sioul_core::agenda::occurrences(stamp - 86_400, stamp + 60).iter().any(|e| !e.cancelled && !e.all_day && e.start <= stamp && e.end > stamp);
3218 if meeting {
3219 return Vec::new();
3220 }
3221 let attention = crate::hours::attention();
3222 let moment = crate::hours::attention_now();
3223 let row = sioul_core::attention::Row::Own(sioul_core::attention::Kind::Needs);
3224 let may = attention.level(row, &moment) == sioul_core::attention::Level::Now;
3225 let mut awake = moment.clone();
3228 awake.times.retain(|c| *c != sioul_core::attention::Column::Sleep);
3229 let starting = attention.level(row, &awake) == sioul_core::attention::Level::Now;
3230 due.into_iter()
3231 .filter(|(block, heads_up)| {
3232 let own_start = !heads_up && (block.kind == "sleep" || block.kind == "nap") && block.start <= stamp;
3233 may || (own_start && starting)
3234 })
3235 .collect()
3236}
3237
3238fn night_offered(block: &Kept, now: &Zoned) -> Vec<crate::reviews::Offered> {
3243 let options = crate::reviews::Offered { label: tr().text("need-open", None), kind: "need", key: block.key.clone() };
3244 match sioul_core::reviews::night_day(block.at, now.time_zone()).filter(|_| crate::reviews::night_open(block.at, now)) {
3245 Some(day) => vec![crate::reviews::Offered { label: tr().text("review-close-night", None), kind: "review", key: format!("night:{day}") }, options],
3246 None => vec![options],
3247 }
3248}
3249
3250fn needs_tick(qt: &QtThread, shared: &Arc<Shared>, health: &Health, now: &Zoned) {
3256 if cfg!(target_os = "android") {
3257 return;
3258 }
3259 let hm = |at: i64| clock(at, now.time_zone());
3260 for (block, heads_up) in needs_now(health, now) {
3261 let name = name_of(&block);
3262 let (key, qt_open) = (block.key.clone(), qt.clone());
3265 let open: Box<dyn FnOnce() + Send> = Box::new(move || {
3266 let _ = qt_open.queue(move |mut sioul| sioul.as_mut().reminder_opened(QString::from("need"), QString::default(), QString::from(&key)));
3267 });
3268 let action = Some((tr().text("need-open", None), open));
3269 let shown = if heads_up {
3270 sioul_sync::notify::remind(&tr().text("need-heads-up", None), &say("need-at", &[("name", name), ("time", hm(block.start))]), action)
3271 } else if block.kind == "sleep" {
3272 crate::reviews::show_notice(qt, &name, &hm(block.start), night_offered(&block, now))
3276 } else {
3277 sioul_sync::notify::remind(&name, &hm(block.start), action)
3278 };
3279 if let Err(e) = shown {
3280 tell(qt, shared, e);
3281 }
3282 }
3283}
3284
3285pub(crate) fn coming_night(now: &Zoned) -> Vec<(String, i64)> {
3293 let stamp = now.timestamp().as_second();
3294 let today = sioul_core::needs::Today::load(&sioul_core::needs::Today::default_path(), now.date());
3295 crate::hours::blocks(now)
3296 .kept
3297 .iter()
3298 .filter(|block| block.kind == "sleep" && block.notices && stamp - block.start < 15 * 60)
3299 .filter_map(|block| {
3300 let date = Timestamp::from_second(block.start).ok()?.to_zoned(now.time_zone().clone()).date();
3301 let given = date == now.date() && today.sent.contains(&format!("{}:start", block.key));
3302 (!given).then(|| (format!("day:night:{date}"), block.start))
3303 })
3304 .collect()
3305}
3306
3307pub(crate) fn decide_night(now: &Zoned) -> serde_json::Value {
3316 let stamp = now.timestamp().as_second();
3317 let starts: Vec<i64> = crate::hours::blocks(now).kept.iter().filter(|block| block.kind == "sleep").map(|block| block.start).collect();
3318 if let Some(start) = starts.iter().copied().filter(|start| *start > stamp).min()
3319 && start - stamp < 3600
3320 {
3321 return serde_json::json!({ "again_at": start * 1000 });
3322 }
3323 if !starts.iter().any(|start| (0..15 * 60).contains(&(stamp - start))) {
3325 return serde_json::json!({});
3326 }
3327 let keeper = crate::share::looked("health", sioul_sync::lease::Rule::FollowsYou);
3328 if !keeper.mine {
3329 return serde_json::json!({});
3330 }
3331 if !keeper.settled {
3332 return serde_json::json!({ "again_at": (stamp + 60) * 1000 });
3333 }
3334 let Some(block) = needs_now(&load(), now).into_iter().find(|(block, heads_up)| !heads_up && block.kind == "sleep").map(|(block, _)| block) else {
3335 return serde_json::json!({});
3336 };
3337 let date = Timestamp::from_second(block.start).map(|t| t.to_zoned(now.time_zone().clone()).date()).unwrap_or(now.date());
3338 let offered = night_offered(&block, now);
3339 crate::eventalarms::day_note(&format!("day:night:{date}"), &name_of(&block), &clock(block.start, now.time_zone()), &offered, block.end.min(stamp + crate::reviews::PHONE_NOTICE_KEPT));
3340 serde_json::json!({ "shown": true })
3341}
3342
3343pub(crate) fn change_need(edit: &str) -> String {
3349 #[derive(Deserialize)]
3350 struct Asked {
3351 #[serde(default)]
3352 date: String,
3353 #[serde(flatten)]
3354 edit: DayEdit,
3355 }
3356 match serde_json::from_str::<Asked>(edit) {
3357 Ok(asked) => change_day(&asked.date, &asked.edit),
3358 Err(e) => e.to_string(),
3359 }
3360}
3361
3362fn change_day(date: &str, edit: &DayEdit) -> String {
3363 let now = Zoned::now();
3364 let date = if date.trim().is_empty() { Some(now.date()) } else { date.trim().parse::<Date>().ok() };
3365 let Some(date) = date else { return tr().text("need-times-wrong", None) };
3366 let _ = days();
3368 let needs = load().needs;
3369 let held = held_on(date, &now);
3370 let changed = match Days::change_file(&Days::default_path(), now.date(), |days| needs.change_day(days, date, edit, &now, &held)) {
3371 Ok(changed) => changed,
3372 Err(e) => return e,
3373 };
3374 match changed {
3375 Ok(()) => {
3376 crate::wake::schedule();
3378 String::new()
3379 }
3380 Err(DayProblem::Past) => tr().text("need-past", None),
3381 Err(DayProblem::Times) => tr().text("need-times-wrong", None),
3382 Err(DayProblem::TooShort) => tr().text("need-too-short", None),
3383 Err(DayProblem::OtherDay) => tr().text("need-other-day", None),
3384 Err(DayProblem::Unknown) => say("setting-unknown-key", &[("key", edit.action.clone())]),
3385 }
3386}
3387
3388pub(crate) fn drag_need(qt: &QtThread, shared: &Arc<Shared>, edit: &str) -> String {
3394 #[derive(Deserialize)]
3395 struct Asked {
3396 #[serde(default)]
3397 date: String,
3398 #[serde(flatten)]
3399 edit: DayEdit,
3400 }
3401 let asked = match serde_json::from_str::<Asked>(edit) {
3402 Ok(asked) => asked,
3403 Err(e) => return e.to_string(),
3404 };
3405 let now = Zoned::now();
3406 let date = if asked.date.trim().is_empty() { Some(now.date()) } else { asked.date.trim().parse::<Date>().ok() };
3407 let Some(date) = date else { return tr().text("need-times-wrong", None) };
3408 let key = asked.edit.key.clone();
3409 let was = days().get(date, &key).cloned();
3410 let problem = change_day(&asked.date, &asked.edit);
3411 if !problem.is_empty() {
3412 return problem;
3413 }
3414 let after = days();
3415 if after.get(date, &key) == was.as_ref() {
3416 return String::new();
3417 }
3418 let zone = now.time_zone().clone();
3420 let line = blocks_now(&load().needs, &after, date, &now)
3421 .iter()
3422 .find(|k| k.key == key)
3423 .map(|k| say("drag-done", &[("what", name_of(k)), ("time", format!("{}–{}", clock(k.start, &zone), clock(k.end, &zone)))]))
3424 .unwrap_or_else(|| tr().text("drag-done-plain", None));
3425 crate::mail::offer_back(qt, shared, line, move |qt, shared| {
3426 let _ = days();
3428 let problem = Days::change_file(&Days::default_path(), Zoned::now().date(), |days| match &was {
3429 Some(block) => days.change(date, &key, |b| *b = block.clone()),
3430 None => days.forget(date, &key),
3431 })
3432 .err()
3433 .unwrap_or_default();
3434 crate::wake::schedule();
3436 crate::work::show_work(qt, shared);
3437 show_health(qt, shared);
3438 problem
3439 });
3440 String::new()
3441}
3442
3443pub(crate) fn move_today(key: &str, minutes: i64, time: &str) -> String {
3447 let action = if time.is_empty() { "later" } else { "move" };
3448 change_day("", &DayEdit { key: key.to_string(), action: action.to_string(), minutes, from: time.to_string(), ..DayEdit::default() })
3449}
3450
3451pub(crate) fn needs_today() -> String {
3454 let needs = load().needs;
3455 let now = Zoned::now();
3456 let days = days();
3457 let zone = now.time_zone().clone();
3458 let stamp = now.timestamp().as_second();
3459 let rows: Vec<serde_json::Value> = blocks_now(&needs, &days, now.date(), &now)
3460 .iter()
3461 .map(|k| serde_json::json!({ "key": k.key, "kind": k.kind, "name": name_of(k), "from": clock(k.start, &zone), "to": clock(k.end, &zone), "skipped": days.get(now.date(), &k.key).is_some_and(|c| c.quiet), "past": k.end <= stamp }))
3462 .collect();
3463 json(&rows)
3464}
3465
3466pub(crate) fn later_minutes() -> u32 {
3468 load().needs.later
3469}
3470
3471pub(crate) fn skip_today(key: &str, skip: bool) -> String {
3473 change_day("", &DayEdit { key: key.to_string(), action: if skip { "quiet" } else { "loud" }.to_string(), ..DayEdit::default() })
3474}
3475
3476pub(crate) fn needs_page() -> String {
3479 let needs = load().needs;
3480 serde_json::json!({
3481 "needs": needs,
3482 "usual": {
3483 "meals": (0..needs.meals.len().max(3) + 1).map(|i| usual_name("meal", i)).collect::<Vec<_>>(),
3484 "nap": usual_name("nap", 0),
3485 "sleep": usual_name("sleep", 0),
3486 },
3487 "gaps": needs.long_gaps().into_iter().map(|(from, to)| say("need-gap", &[("from", from), ("to", to)])).collect::<Vec<_>>(),
3488 "wake": crate::wake::status(&needs),
3490 })
3491 .to_string()
3492}
3493
3494fn removed_at(old: &[sioul_core::needs::Block], new: &[sioul_core::needs::Block]) -> Option<usize> {
3496 if new.len() + 1 != old.len() {
3497 return None;
3498 }
3499 let at = (0..old.len()).find(|&i| i == new.len() || old[i] != new[i])?;
3500 (old[at + 1..] == new[at..]).then_some(at)
3501}
3502
3503pub(crate) fn save_needs(edit: &str, shown: &str) -> String {
3507 let needs: Needs = match serde_json::from_str(edit) {
3508 Ok(needs) => needs,
3509 Err(e) => return e.to_string(),
3510 };
3511 let shown: Option<Needs> = serde_json::from_str(shown).ok();
3513 let removed = change_health(|health| {
3514 let needs = shown.as_ref().map_or_else(|| needs.clone(), |shown| needs.rebased(shown, &health.needs));
3515 let removed = (removed_at(&health.needs.meals, &needs.meals), removed_at(&health.needs.naps, &needs.naps));
3516 health.needs = needs;
3517 Ok(removed)
3518 });
3519 let (meal, nap) = match removed {
3520 Ok(removed) => removed,
3521 Err(e) => return e,
3522 };
3523 if meal.is_none() && nap.is_none() {
3524 return String::new();
3525 }
3526 Days::change_file(&Days::default_path(), Zoned::now().date(), |days| {
3528 if let Some(at) = meal {
3529 days.removed_usual("meal", at);
3530 }
3531 if let Some(at) = nap {
3532 days.removed_usual("nap", at);
3533 }
3534 })
3535 .err()
3536 .unwrap_or_default()
3537}
3538
3539fn told() -> Option<DoseReminders> {
3546 let path = DoseReminders::default_path();
3547 match DoseReminders::read(&path) {
3548 Ok(told) => Some(told),
3549 Err(_) => {
3550 repair_told();
3551 DoseReminders::read(&path).ok()
3552 }
3553 }
3554}
3555
3556fn repair_told() {
3561 let now = Timestamp::now().as_second();
3562 let path = DoseReminders::default_path();
3563 let set_aside = || sioul_core::health::HealthState::set_aside(&path, now);
3564 let aside = if crate::share::on() {
3565 match crate::share::rebuild_dose_reminders(set_aside) {
3566 Ok(aside) => aside,
3567 Err(_) => {
3568 write_record_doubt(now, "");
3569 return;
3570 }
3571 }
3572 } else {
3573 set_aside()
3574 };
3575 write_record_doubt(now, &aside.ok().flatten().map(|p| p.display().to_string()).unwrap_or_default());
3576}
3577
3578fn tell_mine(key: &str, change: impl FnOnce(&mut Tried)) -> bool {
3581 let me = this_device();
3582 let now = Timestamp::now().as_second();
3583 let written = DoseReminders::update(&DoseReminders::default_path(), now, &me, |all| change(all.entry(key, &me))).is_ok();
3584 if written {
3585 crate::devices::recorded();
3586 }
3587 written
3588}
3589
3590fn promise(key: &str, until: i64) -> bool {
3593 tell_mine(key, |mine| mine.quiet = mine.quiet.max(until))
3594}
3595
3596#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3599enum Plan {
3600 Not,
3602 First,
3606 Quiet(i64),
3608 Late(Option<sioul_sync::notify::Late>),
3613}
3614
3615fn first_wait(alarms: bool) -> i64 {
3619 if alarms { LATE_UNTIL } else { WAIT_FOR_NEWS }
3620}
3621
3622fn plan(own: Option<Tried>, read: bool, first_ok: bool, since: i64, stamp: i64, alarms: bool) -> Plan {
3631 use sioul_sync::notify::Late;
3632 let first = first_ok && stamp < since + first_wait(alarms);
3633 let quiet = alarms && stamp < since + sioul_core::dose_reminders::QUIET_AFTER;
3634 if !read {
3637 return match own {
3638 None if first => Plan::First,
3639 _ if quiet => Plan::Quiet(since + sioul_core::dose_reminders::QUIET_AFTER),
3640 _ => Plan::Late(None),
3641 };
3642 }
3643 match own {
3644 Some(own) if own.shown > 0 || own.quiet >= stamp => Plan::Not,
3645 Some(own) if own.maybe > 0 || own.unshown > 0 => {
3646 if quiet && stamp >= since + LATE_UNTIL {
3647 Plan::Quiet(since + sioul_core::dose_reminders::QUIET_AFTER)
3648 } else if own.maybe > 0 {
3649 Plan::Late(Some(Late::Unconfirmed))
3650 } else {
3651 Plan::Late(Some(Late::Retry))
3652 }
3653 }
3654 Some(_) if quiet => Plan::Quiet(since + sioul_core::dose_reminders::QUIET_AFTER),
3656 Some(_) => Plan::Late(None),
3657 None if first => Plan::First,
3658 None if quiet => Plan::Quiet(since + sioul_core::dose_reminders::QUIET_AFTER),
3659 None => Plan::Late(None),
3660 }
3661}
3662
3663fn kept_here() -> bool {
3665 crate::share::looked("health", sioul_sync::lease::Rule::FollowsYou).mine
3666}
3667
3668fn let_go(key: &str) {
3673 if let Ok(mut sent) = SENT.lock() {
3674 sent.remove(key);
3675 }
3676}
3677
3678fn still_to_remind(key: &str) -> bool {
3682 let _held = state_held();
3683 let state = record();
3684 late_reminder_wanted(&state, &records(), key) && !told().is_some_and(|told| told.shown(key))
3685}
3686
3687fn late_words(key: &str, since: i64, why: Option<sioul_sync::notify::Late>) -> Option<(String, String)> {
3692 if !still_to_remind(key) {
3693 return None;
3694 }
3695 let now = Zoned::now();
3696 let stamp = now.timestamp().as_second();
3697 if stamp >= since + GRACE_MINUTES * 60 {
3698 return None;
3699 }
3700 let start = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
3701 let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
3702 let dose = load().doses(&start, &end).into_iter().find(|d| d.key == key)?;
3703 Some(late_said(&dose, &know(), stamp, since, why))
3704}
3705
3706fn late_said(dose: &sioul_core::health::Dose, knowledge: &Knowledge, now: i64, since: i64, why: Option<sioul_sync::notify::Late>) -> (String, String) {
3712 use sioul_sync::notify::Late;
3713 let due = dose.at.timestamp().as_second();
3714 let late = match why {
3715 Some(Late::Retry) => say("dose-doubt-late", &[("when", moment_said(due))]),
3716 Some(Late::Unconfirmed) => say("dose-doubt-unconfirmed", &[("when", moment_said(due))]),
3717 None => say("dose-doubt-other", &[("when", moment_said(since))]),
3718 };
3719 let doubt = doubt_unread_with(knowledge, due, since, now, None, Some(&late));
3720 (say("dose-check-title", &[("dose", named(dose))]), format!("{}. {doubt}", dose.at.strftime("%H:%M")))
3721}
3722
3723struct DoseNotes {
3728 key: String,
3729 since: i64,
3731 before: Option<i64>,
3733}
3734
3735impl sioul_sync::notify::DoseRecord for DoseNotes {
3736 fn going(&mut self, going: sioul_sync::notify::Going) -> sioul_sync::notify::Say {
3737 use sioul_sync::notify::{Going, Say};
3738 let words = match going {
3741 Going::First => Some(None),
3742 Going::Late(_) if !kept_here() => {
3744 let_go(&self.key);
3745 return Say::Nothing;
3746 }
3747 Going::Late(why) => late_words(&self.key, self.since, Some(why)).map(Some),
3748 };
3749 let Some(words) = words else {
3750 promise(&self.key, sioul_core::dose_reminders::NEVER);
3751 return Say::Nothing;
3752 };
3753 let now = Timestamp::now().as_second();
3754 let mut before = 0;
3755 let written = tell_mine(&self.key, |mine| {
3756 before = mine.maybe;
3757 mine.maybe = now;
3758 });
3759 self.before = written.then_some(before);
3763 match words {
3764 None => Say::Own,
3765 Some((title, body)) => Say::Words(title, body),
3766 }
3767 }
3768
3769 fn refused(&mut self) {
3770 if let Some(before) = self.before.take() {
3771 tell_mine(&self.key, |mine| mine.maybe = before);
3772 }
3773 }
3774
3775 fn shown(&mut self) {
3776 let now = Timestamp::now().as_second();
3777 tell_mine(&self.key, |mine| {
3778 if mine.shown == 0 {
3779 mine.shown = now;
3780 }
3781 });
3782 let _held = state_held();
3783 let _ = change(|record| late_reminded(record, &self.key, now));
3784 }
3785
3786 fn unshown(&mut self) {
3787 let now = Timestamp::now().as_second();
3788 tell_mine(&self.key, |mine| {
3789 if mine.unshown == 0 {
3790 mine.unshown = now;
3791 }
3792 });
3793 let _held = state_held();
3794 let _ = change(|record| {
3795 if !record.reminded.contains_key(&self.key) {
3796 record.unshown.entry(self.key.clone()).or_insert(now);
3797 }
3798 });
3799 }
3800
3801 fn pause(&mut self, until: i64) -> bool {
3802 if !kept_here() {
3803 let_go(&self.key);
3804 false
3805 } else if still_to_remind(&self.key) {
3806 promise(&self.key, until);
3808 true
3809 } else {
3810 promise(&self.key, sioul_core::dose_reminders::NEVER);
3811 false
3812 }
3813 }
3814
3815 fn over(&mut self) {
3816 promise(&self.key, sioul_core::dose_reminders::NEVER);
3817 }
3818
3819 fn settled(&mut self) -> bool {
3820 let _held = state_held();
3821 let state = record();
3822 state.taken.contains_key(&self.key) || state.not_taken.contains_key(&self.key) || sioul_core::doses::answered(&self.key, &state, &records()) != Answered::Open
3823 }
3824}
3825
3826fn late_reminder_wanted(state: &HealthState, records: &DoseRecords, key: &str) -> bool {
3831 !state.taken.contains_key(key) && !state.not_taken.contains_key(key) && !state.reminded.contains_key(key) && sioul_core::doses::answered(key, state, records) == Answered::Open
3832}
3833
3834fn late_reminded(record: &mut HealthState, key: &str, now: i64) {
3838 if !record.reminded.contains_key(key) && !record.taken.contains_key(key) && !record.not_taken.contains_key(key) {
3839 record.reminded.insert(key.to_string(), now);
3840 }
3841}
3842
3843const WAKE_GAP: i64 = 3 * 60;
3851
3852static LAST_TICK: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
3854
3855fn woke_from_gap(last: i64, now: i64) -> bool {
3858 last > 0 && now - last >= WAKE_GAP
3859}
3860
3861pub(crate) fn tick(qt: &QtThread, shared: &Arc<Shared>) {
3862 let now_tick = Timestamp::now().as_second();
3864 if !cfg!(target_os = "android") && woke_from_gap(LAST_TICK.swap(now_tick, std::sync::atomic::Ordering::SeqCst), now_tick) {
3865 crate::devices::resumed();
3866 }
3867 let Some(_ticking) = crate::backend::one_at_a_time(&TICKING) else { return };
3868 let health = load();
3869 if health.medicines.is_empty() && health.prescriptions.is_empty() {
3870 let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
3872 let (keeper, _) = crate::share::keeper("health", sioul_sync::lease::Rule::FollowsYou, active, false);
3873 movement_tick(qt, shared, &health, &Zoned::now(), keeper.mine);
3874 if keeper.mine && keeper.settled {
3875 needs_tick(qt, shared, &health, &Zoned::now());
3876 }
3877 crate::wake::schedule();
3879 return;
3880 }
3881 let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
3885 let (keeper, claim_failed) = crate::share::keeper("health", sioul_sync::lease::Rule::FollowsYou, active, false);
3886 let _held = state_held();
3887 let state = record();
3888 let health = if health.clone().mend_shifts(&state) && change_health(|h| Ok(h.mend_shifts(&state))).is_ok() { load() } else { health };
3892 let knowledge = know();
3893 let now = Zoned::now();
3894 let stamp = now.timestamp().as_second();
3895 if !cfg!(target_os = "android") && woke_from_gap(now_tick, stamp) {
3897 crate::devices::resumed();
3898 }
3899 open_due(&health, &state, &now);
3902 if let Ok(mut there) = REMINDED_THERE.lock() {
3903 *there = if keeper.mine { String::new() } else { say("health-reminded-there", &[("computer", keeper.name.clone())]) };
3904 }
3905 movement_tick(qt, shared, &health, &now, keeper.mine);
3906 if keeper.mine && keeper.settled {
3907 needs_tick(qt, shared, &health, &now);
3908 }
3909 let mut reminded: Vec<String> = Vec::new();
3910 let mut unshown: Vec<String> = Vec::new();
3911 let mut maybe: Vec<String> = Vec::new();
3912 let alarms = phone_alarms(&knowledge);
3916 let woken = crate::hours::woken();
3918 let told_now = told();
3920 let answers = records();
3921 let me = this_device();
3922 let configured = crate::share::configured();
3926 let alone = !configured || (crate::share::on() && knowledge.peers.is_empty());
3927 let alarms = alarms || (configured && !crate::share::on());
3929 let start = crate::devices::published_start();
3930 let told_all = crate::share::on() && !claim_failed && start > 0;
3931 let acked = told_all.then(|| acknowledged_since(&knowledge, start, &acknowledgements(&me, &newest_entries()), stamp)).flatten();
3933 let lead = word_lead(&knowledge);
3934 let gone: Vec<String> = knowledge.peers.iter().filter(|p| p.said.as_ref().is_some_and(|s| s.left)).map(|p| p.id.clone()).collect();
3937 if told_now.as_ref().is_some_and(|told| told.holds_any(&gone)) {
3938 let _ = DoseReminders::update(&DoseReminders::default_path(), stamp, &me, |all| all.forget(&gone));
3939 }
3940 let mut sending: Vec<(String, sioul_sync::notify::Sending)> = Vec::new();
3942 crate::alarms::schedule();
3944 if !cfg!(target_os = "android") && !crate::hours::doses_silent() {
3947 let window = crate::hours::woke_from().map_or(GRACE_MINUTES, |since| GRACE_MINUTES.max((stamp - since) / 60 + 1));
3949 for dose in state.to_remind(&health, &now, window) {
3950 if SENT.lock().is_ok_and(|sent| sent.contains(&dose.key)) {
3951 continue;
3952 }
3953 let due = dose.at.timestamp().as_second();
3954 let since = news_from(due, woken);
3955 if told_now.as_ref().is_some_and(|told| told.shown(&dose.key)) || sioul_core::doses::answered(&dose.key, &state, &answers) != Answered::Open {
3957 continue;
3958 }
3959 let own = told_now.as_ref().and_then(|told| told.of(&dose.key, &me));
3960 let quiet_until = since + sioul_core::dose_reminders::QUIET_AFTER;
3961 if !(keeper.mine && keeper.settled) {
3965 if alarms && told_now.is_some() && stamp < quiet_until && own.is_none_or(|own| own.maybe == 0 && own.quiet < quiet_until) {
3966 promise(&dose.key, quiet_until);
3967 }
3968 continue;
3969 }
3970 let first_ok = first_here(alone, told_all, keeper.since, acked, since, lead);
3971 let (title, body) = match plan(own, told_now.is_some(), first_ok, since, stamp, alarms) {
3972 Plan::Not => continue,
3973 Plan::Quiet(until) => {
3974 promise(&dose.key, until);
3975 continue;
3976 }
3977 Plan::First => {
3978 let other = told_now.as_ref().and_then(|told| told.maybe_by(&dose.key, &me)).map(|(device, at)| say("dose-doubt-maybe", &[("name", device_word(&named_by(&knowledge, &device), tr())), ("when", moment_said(at))]));
3984 let doubt = doubt_unread_with(&knowledge, due, since, stamp, None, other.as_deref());
3985 if !doubt.is_empty() && other.is_none() && stamp + 60 < since + first_wait(alarms) {
3986 continue;
3987 }
3988 let named = named(&dose);
3989 if doubt.is_empty() { (named, dose.at.strftime("%H:%M").to_string()) } else { (say("dose-check-title", &[("dose", named)]), format!("{}. {doubt}", dose.at.strftime("%H:%M"))) }
3990 }
3991 Plan::Late(None) => {
3992 let elsewhere = match sioul_core::dose_reminders::others(&dose.key, since, stamp, &me, false, told_now.as_ref(), &knowledge.peers, PULLED.load(std::sync::atomic::Ordering::Relaxed)) {
3994 Others::Doubt(elsewhere) => elsewhere,
3995 Others::First | Others::Reminded => Vec::new(),
3996 };
3997 let untold = (configured && !told_all).then(|| say("dose-doubt-untold", &[]));
3998 let reasons: Vec<String> = untold.into_iter().chain((!elsewhere.is_empty()).then(|| elsewhere_said(&elsewhere, since, tr()))).collect();
3999 let first = if reasons.is_empty() { say("dose-doubt-other", &[("when", moment_said(since))]) } else { joined(reasons, tr()) };
4001 let doubt = doubt_unread_with(&knowledge, due, since, stamp, None, Some(&first));
4002 (say("dose-check-title", &[("dose", named(&dose))]), format!("{}. {doubt}", dose.at.strftime("%H:%M")))
4003 }
4004 Plan::Late(why) => late_said(&dose, &knowledge, stamp, since, why),
4005 };
4006 if let Ok(mut sent) = SENT.lock() {
4007 sent.insert(dose.key.clone());
4008 }
4009 let key = dose.key.clone();
4010 let (qt_taken, shared_taken) = (qt.clone(), Arc::clone(shared));
4011 let taken: Box<dyn FnOnce() + Send> = Box::new(move || {
4012 if is_late(&key) {
4014 let _ = qt_taken.queue(move |mut sioul| sioul.as_mut().reminder_opened(QString::from("dose"), QString::default(), QString::from(&key)));
4015 return;
4016 }
4017 let problem = set_taken(&key, true);
4018 if !problem.is_empty() {
4019 tell(&qt_taken, &shared_taken, problem);
4020 }
4021 crate::share::exchange(&qt_taken, &shared_taken);
4023 });
4024 let instant = |at: i64| std::time::Instant::now() + std::time::Duration::from_secs(u64::try_from(at - stamp).unwrap_or(0));
4029 let quiet = (alarms && stamp < quiet_until).then(|| (instant(since + LATE_UNTIL), instant(quiet_until), quiet_until));
4030 let again = sioul_sync::notify::Again { end: instant(since + GRACE_MINUTES * 60), quiet, record: Box::new(DoseNotes { key: dose.key.clone(), since, before: None }) };
4031 sending.push((dose.key.clone(), sioul_sync::notify::remind_dose(&title, &body, (tr().text("health-taken", None), taken), again)));
4032 }
4033 }
4034 let heard = !crate::share::on() || crate::share::last_exchange().is_some();
4039 if heard && keeper.mine && !cfg!(target_os = "android") && !crate::hours::doses_silent() && !ASKED.swap(true, std::sync::atomic::Ordering::Relaxed) {
4042 let missed = state.unanswered(&health, &now, MISSED_HOURS, GRACE_MINUTES);
4043 if !missed.is_empty() {
4044 let names: Vec<String> = missed.iter().map(|d| format!("{} {}", d.name, d.at.strftime("%H:%M"))).collect();
4045 let qt_open = qt.clone();
4046 let open: Box<dyn FnOnce() + Send> = Box::new(move || {
4047 let _ = qt_open.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("porch"), QString::default(), QString::default()));
4048 });
4049 let doubts: Vec<String> = missed.iter().map(|d| doubt_of(&knowledge, d.at.timestamp().as_second(), d.at.timestamp().as_second(), stamp)).filter(|d| !d.is_empty()).collect();
4050 let mut body = say("health-missed-body", &[("doses", names.join(", "))]);
4051 if let Some(doubt) = doubts.first() {
4052 body = format!("{body} {doubt}");
4053 }
4054 if let Err(e) = sioul_sync::notify::remind(&missed_title(&missed, &state, &told_now.clone().unwrap_or_default(), tr()), &body, Some((tr().text("health-missed-open", None), open))) {
4055 tell(qt, shared, e);
4056 }
4057 }
4058 }
4059 let target = errands_list(&health);
4062 let mut made: Vec<(String, String)> = Vec::new();
4063 for errand in health.errands().into_iter().filter(|e| e.day <= now.date().checked_add(Span::new().days(7)).unwrap_or(now.date())) {
4064 if state.errands.contains_key(&errand.key) {
4065 continue;
4066 }
4067 let title = say(if errand.kind == ErrandKind::Refill { "health-errand-refill" } else { "health-errand-renew" }, &[("title", errand.title.clone())]);
4068 let edit = TaskEdit { title, start: errand.day.to_string(), categories: vec![tr().text("health-category", None)], estimate: 30, ..TaskEdit::default() };
4069 let task = match &target {
4070 Some(list) => work::create_task(qt, shared, &edit, list),
4071 None => work::local_task(qt, shared, &tr().text("health-list", None), &edit),
4072 };
4073 if let Ok(uid) = task {
4074 made.push((errand.key, uid));
4075 }
4076 }
4077 if let Some(list) = target.as_ref().filter(|l| !l.starts_with(&format!("{}/", sioul_core::vdir::LOCAL))) {
4078 let loaded = work::loaded(shared);
4079 for uid in state.errands.values().chain(made.iter().map(|(_, uid)| uid)) {
4080 let here = loaded.tasks.iter().find(|t| &t.uid == uid).is_some_and(|t| t.status.is_open() && t.list_id.starts_with(&format!("{}/", sioul_core::vdir::LOCAL)));
4081 if here {
4082 let _ = work::move_task(qt, shared, uid, list, true);
4083 }
4084 }
4085 }
4086 drop(_held);
4096 let deadline = std::time::Instant::now() + sioul_sync::notify::FIRST_TRY;
4097 for (key, sent) in sending {
4098 match sent.outcome(deadline.saturating_duration_since(std::time::Instant::now())) {
4099 sioul_sync::notify::Outcome::Shown => reminded.push(key),
4100 sioul_sync::notify::Outcome::Unshown(e) => {
4101 unshown.push(key);
4102 tell(qt, shared, e);
4103 }
4104 sioul_sync::notify::Outcome::Maybe(e) => {
4105 maybe.push(key);
4106 tell(qt, shared, e);
4107 }
4108 sioul_sync::notify::Outcome::Gone => {}
4109 }
4110 }
4111 let _held = state_held();
4112 let problem = change(|record| note_minute(record, &reminded, &unshown, &maybe, &made, stamp));
4114 if !problem.is_empty() && (!reminded.is_empty() || !unshown.is_empty() || !maybe.is_empty() || !made.is_empty()) {
4115 tell(qt, shared, problem);
4116 }
4117}
4118
4119#[cfg(test)]
4120mod tests {
4121 use super::*;
4122 use sioul_sync::lease::Claim;
4123
4124 fn claim(renewed: i64, wrote: Option<(u32, u64)>, closed: bool) -> Claim {
4125 Claim { computer: "laptop-id".into(), name: "laptop".into(), since: 0, renewed, until: renewed + 300, active: renewed, taken: 0, wrote, closed, pad: String::new() }
4126 }
4127
4128 fn heard_up_to(n: u64) -> sioul_sync::share::Heard {
4129 sioul_sync::share::Heard { read: [("laptop-id".to_string(), (1, n))].into(), broken: Default::default() }
4130 }
4131
4132 #[test]
4133 fn what_the_others_claims_teach() {
4134 let mut kept = Peers::default();
4135 learn(&mut kept, &[claim(1_000, Some((1, 5)), false)], &[], &heard_up_to(5), 9_000);
4137 let peer = &kept.peers["laptop-id"].peer;
4138 assert_eq!((peer.known_until, peer.delay, peer.name.as_str()), (1_000, None, "laptop"));
4139 learn(&mut kept, &[claim(9_030, Some((1, 6)), false)], &[], &heard_up_to(6), 9_060);
4141 learn(&mut kept, &[claim(9_090, Some((1, 6)), false)], &[], &heard_up_to(6), 9_120);
4142 assert_eq!(kept.peers["laptop-id"].peer.delay, Some(30));
4143 learn(&mut kept, &[claim(9_150, Some((1, 9)), false)], &[], &heard_up_to(6), 9_180);
4145 assert_eq!(kept.peers["laptop-id"].peer.known_until, 9_090);
4146 learn(&mut kept, &[claim(9_200, Some((1, 9)), true)], &[], &heard_up_to(9), 9_240);
4148 let peer = &kept.peers["laptop-id"].peer;
4149 assert!(peer.closed && peer.known_until == 9_200);
4150 assert!(sioul_core::health::doubts(9_600, 9_700, None, std::slice::from_ref(peer)).is_empty());
4152 let mut old = Peers::default();
4154 learn(&mut old, &[claim(9_000, None, false)], &[], &heard_up_to(99), 9_030);
4155 assert_eq!(old.peers["laptop-id"].peer.known_until, 0);
4156 assert_eq!(sioul_core::health::doubts(9_010, 9_030, None, &[old.peers["laptop-id"].peer.clone()]).len(), 1);
4157 }
4158
4159 #[test]
4163 fn a_claim_from_a_clock_ahead_is_never_known_past_now() {
4164 let mut kept = Peers::default();
4165 learn(&mut kept, &[claim(9_600, Some((1, 5)), false)], &[], &heard_up_to(5), 9_000);
4166 assert_eq!(kept.peers["laptop-id"].peer.known_until, 0, "ten minutes ahead: not known");
4167 assert_eq!(sioul_core::health::doubts(9_000, 9_000, None, &[kept.peers["laptop-id"].peer.clone()]).len(), 1);
4168 learn(&mut kept, &[claim(9_630, Some((1, 5)), false)], &[], &heard_up_to(5), 9_600);
4169 assert_eq!(kept.peers["laptop-id"].peer.known_until, 9_600, "half a minute ahead: until now");
4170 }
4171
4172 #[test]
4177 fn medicines_and_prescriptions_on_the_page() {
4178 let day = |text: &str| text.parse::<Date>().unwrap();
4179 let today = day("2026-10-06");
4180 assert_eq!((hhmm("8:00"), hhmm(" 18:30 "), hhmm("noon"), hhmm("25:00")), ("08:00".into(), "18:30".into(), "noon".into(), "25:00".into()));
4181 assert_eq!(words(&Schedule::Day { times: vec!["20:00".into(), "8:00".into(), "08:00".into()], amounts: Default::default() }), "08:00 · 20:00");
4182 let medicine = |id: &str, prescription: Option<&str>| Medicine {
4183 id: id.into(),
4184 name: id.into(),
4185 dose: String::new(),
4186 schedule: Schedule::Day { times: vec!["07:30".into()], amounts: Default::default() },
4187 prescription: prescription.map(Into::into),
4188 until: None,
4189 paused: false,
4190 generic: String::new(),
4191 strength: String::new(),
4192 since: None,
4193 };
4194 let health = Health {
4195 prescriptions: vec![
4196 Prescription { id: "thyroid".into(), title: "Thyroid".into(), until: Some(day("2027-02-04")), refill_days: Some(28), last_refill: Some(today), ..Default::default() },
4198 Prescription { id: "levo".into(), title: "Levothyroxine 75 µg".into(), until: Some(day("2026-10-01")), ..Default::default() },
4200 Prescription { id: "old".into(), title: "Old".into(), refill_days: Some(30), last_refill: Some(day("2026-06-01")), ..Default::default() },
4202 ],
4203 medicines: vec![medicine("Levothyroxine", Some("levo")), medicine("Magnesium", Some("thyroid")), medicine("Iron", Some("thyroid")), medicine("Zinc", None)],
4204 ..Default::default()
4205 };
4206 let errands = health.errands();
4207 assert_eq!(coming(&errands, &health.prescriptions[0], today), [(Coming::Pharmacy, day("2026-11-01")), (Coming::RenewBy, day("2027-02-04"))]);
4209 assert_eq!(coming(&errands, &health.prescriptions[1], today), [(Coming::ValidUntil, day("2026-10-01"))]);
4210 assert!(coming(&errands, &health.prescriptions[2], today).is_empty());
4211 assert_eq!(covered(&health, &health.prescriptions[0]), ["Magnesium", "Iron"]);
4212 assert!(covered(&health, &health.prescriptions[1]).is_empty() && covered(&health, &health.prescriptions[2]).is_empty());
4213 }
4214
4215 fn take(time: &str, amount: &str) -> Take {
4216 Take { time: time.into(), amount: amount.into() }
4217 }
4218
4219 fn prescription_edit(medicines: serde_json::Value) -> PrescriptionEdit {
4220 serde_json::from_value(serde_json::json!({ "title": "Thyroid and iron", "prescriber": "Dr Martin", "refill_days": 30, "medicines": medicines })).unwrap()
4221 }
4222
4223 #[test]
4226 fn a_prescriptions_medicines_and_their_takes_saved_from_its_form() {
4227 let english = Translator::new("en");
4228 let now: Zoned = "2026-10-08T09:00[Europe/Paris]".parse().unwrap();
4229 let mut health = Health::default();
4230 let levo: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Levothyroxine", "dose": "75 µg", "every": "day", "takes": [{ "time": "07:30", "amount": "" }] })).unwrap();
4232 let levo = apply_medicine(&mut health, "", levo, &now, &english).unwrap();
4233 let rows = serde_json::json!([
4235 { "id": levo, "name": "Levothyroxine", "dose": "75 µg", "takes": [{ "time": "07:30", "amount": "" }] },
4236 { "id": "", "name": "Iron", "dose": "1 tablet", "takes": [{ "time": "08:00", "amount": "" }, { "time": "13:00", "amount": "" }, { "time": "20:00", "amount": "2 tablets" }] },
4237 { "id": "", "name": "Magnesium", "dose": "300 mg", "takes": [{ "time": "21:30", "amount": "" }, { "time": "12:30", "amount": "150 mg" }] },
4238 { "id": "", "name": "", "dose": "", "takes": [{ "time": "08:00", "amount": "" }] },
4239 ]);
4240 let id = apply_prescription(&mut health, "", prescription_edit(rows), &english).unwrap();
4241 assert_eq!(health.prescriptions.len(), 1);
4242 assert_eq!(health.medicines.iter().map(|m| (m.name.as_str(), m.prescription.as_deref())).collect::<Vec<_>>(), [("Levothyroxine", Some(id.as_str())), ("Iron", Some(id.as_str())), ("Magnesium", Some(id.as_str()))]);
4243 let iron = health.medicines.iter().find(|m| m.name == "Iron").unwrap().clone();
4244 assert_eq!(iron.takes(), [take("08:00", "1 tablet"), take("13:00", "1 tablet"), take("20:00", "2 tablets")]);
4245 let today = now.date();
4247 assert_eq!(tied(&health, &health.prescriptions[0]).map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Levothyroxine", "Iron", "Magnesium"]);
4248 let row = medicine_row(&iron, today);
4249 assert_eq!((row.when.as_str(), row.amount.as_str(), row.usual.as_str()), ("08:00 · 1 tablet, 13:00 · 1 tablet, 20:00 · 2 tablets", "", "1 tablet"));
4250 assert_eq!(row.takes, [take("08:00", ""), take("13:00", ""), take("20:00", "2 tablets")], "the form says a take's own amount only where it differs");
4251 let levo_row = medicine_row(&health.medicines[0], today);
4252 assert_eq!((levo_row.when.as_str(), levo_row.amount.as_str()), ("07:30", "75 µg"));
4253 let start = now.date().to_zoned(now.time_zone().clone()).unwrap();
4254 let end = start.checked_add(Span::new().days(1)).unwrap();
4255 let doses: Vec<(String, String, String)> = health.doses(&start, &end).into_iter().map(|d| (d.at.strftime("%H:%M").to_string(), d.name, d.dose)).collect();
4256 let expected = [("07:30", "Levothyroxine", "75 µg"), ("08:00", "Iron", "1 tablet"), ("12:30", "Magnesium", "150 mg"), ("13:00", "Iron", "1 tablet"), ("20:00", "Iron", "2 tablets"), ("21:30", "Magnesium", "300 mg")];
4257 assert_eq!(doses, expected.map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string())));
4258 let before = health.clone();
4260 let twice = serde_json::json!([{ "id": iron.id, "name": "Iron", "dose": "1 tablet", "takes": [{ "time": "08:00", "amount": "" }, { "time": "8:00", "amount": "2 tablets" }] }]);
4261 assert_eq!(apply_prescription(&mut health, &id, prescription_edit(twice), &english), Err("Iron: two takes at 08:00. Keep one, with its amount.".into()));
4262 let unnamed = serde_json::json!([{ "id": "", "name": " ", "dose": "5 mg", "takes": [{ "time": "08:00", "amount": "" }] }]);
4263 assert!(apply_prescription(&mut health, &id, prescription_edit(unnamed), &english).is_err());
4264 let none = serde_json::json!([{ "id": iron.id, "name": "Iron", "dose": "1 tablet", "takes": [] }]);
4265 assert_eq!(apply_prescription(&mut health, &id, prescription_edit(none), &english), Err("Iron: add a take, the time it is taken each day.".into()));
4266 assert_eq!(health, before);
4267 let out = serde_json::json!([{ "id": levo, "name": "Levothyroxine", "dose": "75 µg", "takes": [{ "time": "07:30", "amount": "" }] }, { "id": iron.id, "removed": true }]);
4270 apply_prescription(&mut health, &id, prescription_edit(out), &english).unwrap();
4271 assert_eq!(health.medicines.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Levothyroxine", "Magnesium"]);
4272 assert!(health.doses(&start, &end).iter().all(|d| d.name != "Iron"));
4273 let kept = health.clone();
4274 let plain: PrescriptionEdit = serde_json::from_value(serde_json::json!({ "title": "Thyroid and iron", "refill_days": 30 })).unwrap();
4275 apply_prescription(&mut health, &id, plain, &english).unwrap();
4276 assert_eq!(health.medicines, kept.medicines);
4277 let hours: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Antibiotic", "dose": "500 mg", "every": "hours", "hours": 8, "from": "2026-10-08T08:00", "prescription": id })).unwrap();
4279 let antibiotic = apply_medicine(&mut health, "", hours, &now, &english).unwrap();
4280 let schedule = health.medicines.iter().find(|m| m.id == antibiotic).unwrap().schedule.clone();
4281 let rename = serde_json::json!([{ "id": antibiotic, "name": "Amoxicillin", "dose": "1 g", "takes": [] }]);
4282 apply_prescription(&mut health, &id, prescription_edit(rename), &english).unwrap();
4283 let changed = health.medicines.iter().find(|m| m.id == antibiotic).unwrap();
4284 assert_eq!((changed.name.as_str(), changed.dose.as_str(), &changed.schedule), ("Amoxicillin", "1 g", &schedule));
4285 let typed: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Zinc", "every": "day", "times": ["12:00", " 18:00"] })).unwrap();
4287 let zinc = apply_medicine(&mut health, "", typed, &now, &english).unwrap();
4288 assert_eq!(health.medicines.iter().find(|m| m.id == zinc).unwrap().takes(), [take("12:00", ""), take("18:00", "")]);
4289 let wrong: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Zinc", "every": "day", "takes": [{ "time": "noon", "amount": "" }] })).unwrap();
4290 assert_eq!(apply_medicine(&mut health, &zinc, wrong, &now, &english), Err("“noon” is not a time of day.".into()));
4291 }
4292
4293 #[test]
4296 fn a_takes_own_amount_is_said_wherever_its_dose_is() {
4297 let dir = std::env::temp_dir().join(format!("sioul-takes-{}", std::process::id()));
4298 let _ = std::fs::remove_dir_all(&dir);
4299 std::fs::create_dir_all(&dir).unwrap();
4300 let path = dir.join("health.toml");
4301 let mut iron = Medicine { id: "iron".into(), name: "Iron".into(), dose: String::new(), schedule: Schedule::Day { times: Vec::new(), amounts: Default::default() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None };
4302 iron.set_takes("1 tablet", &[take("08:00", ""), take("20:00", "2 tablets")]).unwrap();
4303 Health { medicines: vec![iron], ..Health::default() }.save(&path).unwrap();
4304 let health = Health::load(&path);
4305 let english = Translator::new("en");
4306 let at = |text: &str| text.parse::<Zoned>().unwrap();
4307 let alone = Knowledge { record_lost: None, peers: Vec::new() };
4308 let state = HealthState::default();
4309 let evening = at("2026-10-08T20:01[Europe/Paris]");
4311 let porch = serde_json::to_value(porch_doses(&health, &state, &DoseRecords::default(), &DoseReminders::default(), "desk-id", &alone, &evening, false, true, &english)).unwrap();
4312 let due: Vec<(&str, &str)> = porch["due"].as_array().unwrap().iter().map(|d| (d["time"].as_str().unwrap(), d["dose"].as_str().unwrap())).collect();
4313 assert_eq!(due, [("20:00", "2 tablets")]);
4314 let card = doses_for_card(&health, &state, &DoseRecords::default(), true, &alone, &at("2026-10-08T07:00[Europe/Paris]"));
4316 assert_eq!(card.iter().map(|d| (d.time.as_str(), d.name.as_str())).collect::<Vec<_>>(), [("08:00", "Iron · 1 tablet"), ("20:00", "Iron · 2 tablets")]);
4317 let doses = health.doses(&at("2026-10-08T00:00[Europe/Paris]"), &at("2026-10-09T00:00[Europe/Paris]"));
4319 assert_eq!(doses.iter().map(named).collect::<Vec<_>>(), ["Iron · 1 tablet", "Iron · 2 tablets"]);
4320 assert_eq!(health.amount_of(&doses[1].key, evening.time_zone()).as_deref(), Some("2 tablets"));
4322 let _ = std::fs::remove_dir_all(&dir);
4323 }
4324
4325 #[test]
4330 fn a_doctor_or_pharmacist_reads_the_generic_name_first() {
4331 let english = Translator::new("en");
4332 let now: Zoned = "2026-10-08T09:00[Europe/Paris]".parse().unwrap();
4333 let today = now.date();
4334 let mut health = Health::default();
4335 let rows = serde_json::json!([
4336 { "id": "", "name": "Thyrolan", "generic": "levothyroxine", "strength": "75 µg", "dose": "1 tablet", "takes": [{ "time": "07:30", "amount": "" }] },
4337 { "id": "", "name": " ", "generic": "metformin", "strength": "500 mg per tablet", "dose": "1 tablet", "takes": [{ "time": "08:00", "amount": "" }, { "time": "20:00", "amount": "2 tablets" }] },
4338 ]);
4339 let edit: PrescriptionEdit = serde_json::from_value(serde_json::json!({ "title": "Thyroid and sugar", "prescriber": "Dr Martin", "until": "2027-02-04", "medicines": rows })).unwrap();
4340 let id = apply_prescription(&mut health, "", edit, &english).unwrap();
4341 let fields: Vec<(&str, &str, &str)> = health.medicines.iter().map(|m| (m.name.as_str(), m.generic.as_str(), m.strength.as_str())).collect();
4343 assert_eq!(fields, [("Thyrolan", "levothyroxine", "75 µg"), ("metformin", "metformin", "500 mg per tablet")]);
4344 let antibiotic: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Amoxi", "generic": "amoxicillin", "strength": "500 mg", "dose": "1 capsule", "every": "hours", "hours": 8, "from": "2026-10-08T08:00", "since": "2026-10-01" })).unwrap();
4346 apply_medicine(&mut health, "", antibiotic, &now, &english).unwrap();
4347 let mut paused = health.medicines[0].clone();
4349 (paused.id, paused.name, paused.paused, paused.prescription) = ("zinc".into(), "Zinc".into(), true, None);
4350 let mut over = health.medicines[0].clone();
4351 (over.id, over.name, over.prescription, over.until) = ("iron".into(), "Iron".into(), None, Some("2026-10-01".parse().unwrap()));
4352 health.medicines.extend([paused, over]);
4353 let again: Health = toml::from_str(&toml::to_string(&health).unwrap()).unwrap();
4355 assert_eq!(again, health);
4356 let nameless: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "", "generic": " ", "every": "day", "takes": [{ "time": "08:00" }] })).unwrap();
4358 assert_eq!(apply_medicine(&mut health.clone(), "", nameless, &now, &english), Err("A name is needed.".into()));
4359 let levo = medicine_row(&health.medicines[0], today);
4361 assert_eq!((levo.precise.as_str(), levo.line.as_str()), ("Thyrolan — levothyroxine 75 µg", "Thyrolan — levothyroxine 75 µg · 07:30 · 1 tablet"));
4362 assert_eq!(medicine_row(&health.medicines[1], today).precise, "metformin 500 mg per tablet");
4363 let start = now.date().to_zoned(now.time_zone().clone()).unwrap();
4364 let end = start.checked_add(Span::new().days(1)).unwrap();
4365 assert_eq!(health.doses(&start, &end).iter().find(|d| d.at.hour() == 7).map(|d| d.name.as_str()), Some("Thyrolan"));
4366 let view = for_professional_of(&health, &id, today, &english);
4368 assert_eq!(view.title, "Thyroid and sugar");
4369 assert_eq!(view.about, ["Prescribed by Dr Martin", "Valid until Thursday 4 February 2027"]);
4370 let levo = &view.medicines[0];
4371 assert_eq!((levo.molecule.as_str(), levo.brand.as_str(), levo.takes.clone(), levo.prescriber.as_str()), ("levothyroxine 75 µg", "Thyrolan", vec!["07:30 — 1 tablet".to_string()], ""));
4372 let metformin = &view.medicines[1];
4373 assert_eq!((metformin.molecule.as_str(), metformin.brand.as_str()), ("metformin 500 mg per tablet", ""), "its name is its generic name: said once");
4374 assert_eq!(metformin.takes, ["08:00 — 1 tablet", "20:00 — 2 tablets"]);
4375 assert_eq!(view.medicines.len(), 2, "the antibiotic is not on it");
4376 let all = for_professional_of(&health, "", today, &english);
4378 assert_eq!((all.title.as_str(), all.about.clone()), ("Current medicines", vec!["As of Thursday 8 October 2026".to_string()]));
4379 assert_eq!(all.medicines.iter().map(|m| m.molecule.as_str()).collect::<Vec<_>>(), ["levothyroxine 75 µg", "metformin 500 mg per tablet", "amoxicillin 500 mg"]);
4380 assert_eq!(all.medicines[0].prescriber, "Prescribed by Dr Martin");
4381 let amoxicillin = &all.medicines[2];
4382 assert_eq!((amoxicillin.takes.clone(), amoxicillin.since.as_str(), amoxicillin.brand.as_str()), (vec!["every 8 hours — 1 capsule".to_string()], "Taken since Thursday 1 October, 7 days", "Amoxi"));
4383 health.medicines[1].paused = true;
4385 assert_eq!(for_professional_of(&health, &id, today, &english).medicines[1].notes, ["Paused for now"]);
4386 let french = for_professional_of(&health, "", today, &Translator::new("fr"));
4388 assert_eq!(french.title, "Médicaments en cours");
4389 assert_eq!(french.medicines[1].since, "Pris depuis le jeudi 1er octobre, 7 jours");
4390 assert_eq!(french.medicines[0].molecule, "levothyroxine 75 µg");
4391 assert_eq!(for_professional_of(&Health::default(), "", today, &english).empty, "No medicine to show.");
4393 assert_eq!((how_long("2024-01-01".parse().unwrap(), today, &english), how_long("2026-06-01".parse().unwrap(), today, &english), how_long(today, today, &english)), ("2 years".into(), "4 months".into(), String::new()));
4394 }
4395
4396 #[test]
4402 fn the_porch_shows_todays_doses_until_marked() {
4403 let dir = std::env::temp_dir().join(format!("sioul-porch-doses-{}", std::process::id()));
4404 let _ = std::fs::remove_dir_all(&dir);
4405 std::fs::create_dir_all(&dir).unwrap();
4406 let path = dir.join("health.toml");
4407 let file = "[[medicine]]\nid = \"levo\"\nname = \"Levothyroxine\"\ndose = \"75 µg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"07:30\"]\n\n\
4408 [[medicine]]\nid = \"iron\"\nname = \"Iron\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"12:00\", \"21:00\"]\n\n\
4409 [[medicine]]\nid = \"zinc\"\nname = \"Zinc\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"09:00\"]\n";
4410 std::fs::write(&path, file).unwrap();
4411 let health = Health::load(&path);
4412 assert_eq!(health.medicines.len(), 3);
4413 let english = Translator::new("en");
4414 let at = |text: &str| text.parse::<Zoned>().unwrap();
4415 let key = |id: &str, when: &str| format!("{id}@{}", at(when).timestamp().as_second());
4416 let alone = Knowledge { record_lost: None, peers: Vec::new() };
4417 let shown = |state: &HealthState, knowledge: &Knowledge, now: &Zoned, silent: bool, heard: bool| serde_json::to_value(porch_doses(&health, state, &DoseRecords::default(), &DoseReminders::default(), "desk-id", knowledge, now, silent, heard, &english)).unwrap();
4418 let mut state = HealthState::default();
4419 state.reminded.insert(key("levo", "2026-10-06T07:30[Europe/Paris]"), at("2026-10-06T07:31[Europe/Paris]").timestamp().as_second());
4421 let noon = at("2026-10-06T12:00:40[Europe/Paris]");
4422 let view = shown(&state, &alone, &noon, false, true);
4423 let due = view["due"].as_array().unwrap();
4424 assert_eq!(due.len(), 2, "{view}");
4425 assert_eq!((due[0]["time"].as_str(), due[0]["name"].as_str(), due[0]["dose"].as_str(), due[0]["late"].as_bool()), (Some("07:30"), Some("Levothyroxine"), Some("75 µg"), Some(true)));
4427 assert_eq!((due[1]["time"].as_str(), due[1]["late"].as_bool(), due[1]["doubt"].as_str()), (Some("12:00"), Some(false), Some("")));
4429 let closed = view["closed"].as_array().unwrap();
4431 assert_eq!(closed.len(), 1);
4432 assert_eq!((closed[0]["key"].as_str(), closed[0]["time"].as_str()), (Some(key("zinc", "2026-10-06T09:00[Europe/Paris]").as_str()), Some("09:00")));
4433 let early = shown(&state, &alone, &noon, false, false);
4435 assert_eq!((early["due"].as_array().unwrap().len(), early["closed"].as_array().unwrap().len()), (2, 0));
4436 let asleep = shown(&state, &alone, &noon, true, true);
4438 assert!(asleep["due"].as_array().unwrap().is_empty() && asleep["closed"].as_array().unwrap().is_empty());
4439 let heard = at("2026-10-06T07:35[Europe/Paris]").timestamp().as_second();
4441 let unsure = Knowledge { record_lost: None, peers: vec![Peer { name: "laptop".into(), known_until: heard, closed: false, delay: Some(30), broken: None, heard, ..Peer::default() }] };
4442 let doubted = shown(&state, &unsure, &noon, false, true);
4443 for row in doubted["due"].as_array().unwrap().iter().chain(doubted["closed"].as_array().unwrap()) {
4444 let doubt = row["doubt"].as_str().unwrap();
4445 assert!(doubt.starts_with("Sioul can't tell whether it was taken") && doubt.contains("laptop") && doubt.ends_with("Check before taking it."), "{doubt}");
4446 assert!(!doubt.to_lowercase().contains("not taken"), "{doubt}");
4447 }
4448 state.taken.insert(key("iron", "2026-10-06T12:00[Europe/Paris]"), noon.timestamp().as_second());
4450 state.not_taken.insert(key("levo", "2026-10-06T07:30[Europe/Paris]"), noon.timestamp().as_second());
4451 assert!(shown(&state, &alone, &noon, false, true)["due"].as_array().unwrap().is_empty());
4452 let night = shown(&state, &alone, &at("2026-10-07T00:30[Europe/Paris]"), false, true);
4454 assert!(night["due"].as_array().unwrap().is_empty());
4455 assert_eq!(night["closed"][0]["time"].as_str(), Some("Tue 21:00"));
4456 let _ = std::fs::remove_dir_all(&dir);
4457 }
4458
4459 #[test]
4466 fn a_reminder_not_shown_is_asked_about_under_its_own_question() {
4467 let dir = std::env::temp_dir().join(format!("sioul-unshown-doses-{}", std::process::id()));
4468 let _ = std::fs::remove_dir_all(&dir);
4469 std::fs::create_dir_all(&dir).unwrap();
4470 let path = dir.join("health.toml");
4471 let file = "[[medicine]]\nid = \"demo-a\"\nname = \"Demo A\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\"]\n\n\
4472 [[medicine]]\nid = \"demo-b\"\nname = \"Demo B\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"07:00\"]\n";
4473 std::fs::write(&path, file).unwrap();
4474 let health = Health::load(&path);
4475 let (english, french) = (Translator::new("en"), Translator::new("fr"));
4476 let at = |text: &str| text.parse::<Zoned>().unwrap();
4477 let key = |id: &str, when: &str| format!("{id}@{}", at(when).timestamp().as_second());
4478 let (a, b) = (key("demo-a", "2026-10-06T08:00[Europe/Paris]"), key("demo-b", "2026-10-06T07:00[Europe/Paris]"));
4479 let alone = Knowledge { record_lost: None, peers: Vec::new() };
4480 let noon = at("2026-10-06T12:00[Europe/Paris]");
4481 let mut state = HealthState::default();
4483 note_minute(&mut state, &[], std::slice::from_ref(&a), &[], &[], at("2026-10-06T08:00:30[Europe/Paris]").timestamp().as_second());
4484 assert!(state.reminded.is_empty() && state.reminder_unshown(&a));
4485 let view = serde_json::to_value(porch_doses(&health, &state, &DoseRecords::default(), &DoseReminders::default(), "desk-id", &alone, &noon, false, true, &english)).unwrap();
4487 let closed = view["closed"].as_array().unwrap();
4488 assert_eq!(closed.len(), 2, "{view}");
4489 assert_eq!((closed[0]["key"].as_str(), closed[0]["question"].as_str()), (Some(b.as_str()), Some("Due while Sioul was closed, and marked nowhere Sioul can see: did you take them?")));
4490 assert_eq!(closed[1]["key"].as_str(), Some(a.as_str()));
4492 let question = closed[1]["question"].as_str().unwrap();
4493 assert!(question.starts_with("Due while Sioul ran, but their reminder could not be shown") && !question.contains("closed"), "{question}");
4494 assert!(view["due"].as_array().unwrap().iter().all(|row| row["question"] == ""), "today's doses carry no question");
4495 let rows = missed_rows(&health, &state, &DoseReminders::default(), &alone, &noon, &french);
4497 assert_eq!(rows.iter().map(|r| r.key.as_str()).collect::<Vec<_>>(), [b.as_str(), a.as_str()]);
4498 assert!(rows[1].question.starts_with("Prévus pendant que Sioul tournait, mais leur rappel n’a pas pu s’afficher"), "{}", rows[1].question);
4499 let heard = at("2026-10-06T06:30[Europe/Paris]").timestamp().as_second();
4501 let unsure = Knowledge { record_lost: None, peers: vec![Peer { name: "laptop".into(), known_until: heard, closed: false, delay: Some(30), broken: None, heard, ..Peer::default() }] };
4502 for row in missed_rows(&health, &state, &DoseReminders::default(), &unsure, &noon, &english) {
4503 assert!(row.doubt.starts_with("Sioul can't tell whether it was taken") && !row.doubt.to_lowercase().contains("not taken"), "{}", row.doubt);
4504 }
4505 let missed = state.unanswered(&health, &noon, MISSED_HOURS, GRACE_MINUTES);
4507 assert_eq!(missed_title(&missed, &state, &DoseReminders::default(), &english), "Doses due earlier");
4508 assert_eq!(missed_title(&missed[1..], &state, &DoseReminders::default(), &english), "A reminder could not be shown");
4509 assert_eq!(missed_title(&missed[..1], &state, &DoseReminders::default(), &english), "While Sioul was closed");
4510 assert_eq!(missed_title(&missed[1..], &state, &DoseReminders::default(), &french), "Un rappel n’a pas pu s’afficher");
4511 note_minute(&mut state, std::slice::from_ref(&a), &[], &[], &[], at("2026-10-06T08:05[Europe/Paris]").timestamp().as_second());
4513 let rows = missed_rows(&health, &state, &DoseReminders::default(), &alone, &noon, &english);
4514 assert_eq!(rows.iter().map(|r| r.key.as_str()).collect::<Vec<_>>(), [b.as_str()]);
4515 let _ = std::fs::remove_dir_all(&dir);
4516 }
4517
4518 #[test]
4529 fn a_dose_reminder_counts_once_shown_and_late_only_if_still_to_remind() {
4530 let key = "mag@1791360000".to_string();
4531 let (state, none) = (HealthState::default(), DoseRecords::default());
4532 assert!(late_reminder_wanted(&state, &none, &key), "nothing said of it: shown late");
4533 let mut taken = HealthState::default();
4534 taken.taken.insert(key.clone(), 1_791_360_100);
4535 assert!(!late_reminder_wanted(&taken, &none, &key), "taken meanwhile");
4536 let mut skipped = HealthState::default();
4537 skipped.not_taken.insert(key.clone(), 1_791_360_100);
4538 assert!(!late_reminder_wanted(&skipped, &none, &key), "said not taken");
4539 let mut phone = HealthState::default();
4540 phone.unshown.insert(key.clone(), 1_791_360_005);
4541 phone.reminded.insert(key.clone(), 1_791_360_600);
4542 assert!(!late_reminder_wanted(&phone, &none, &key), "the phone's alarm reminded it meanwhile: its turn kept");
4543 let mut records = DoseRecords::default();
4544 records.answer(&key, "phone", Answer { state: sioul_core::doses::TAKEN.into(), at: 1_791_360_300, noted: 1_791_360_300, name: "phone".into(), kind: "phone".into() });
4545 assert!(!late_reminder_wanted(&state, &records, &key), "answered on the phone, its mark not here yet");
4546 let mut first = HealthState::default();
4548 first.unshown.insert(key.clone(), 1_791_360_005);
4549 assert!(first.reminder_unshown(&key));
4550 late_reminded(&mut first, &key, 1_791_360_040);
4551 assert_eq!(first.reminded.get(&key), Some(&1_791_360_040));
4552 assert!(!first.reminder_unshown(&key), "a reminder now: not asked as one that could not be shown");
4553 assert!(!late_reminder_wanted(&first, &none, &key), "recorded: no longer to remind");
4556 let mut earlier = phone.clone();
4557 late_reminded(&mut earlier, &key, 1_791_360_700);
4558 assert_eq!(earlier.reminded.get(&key), Some(&1_791_360_600), "the phone's reminder kept");
4559 let mut marked = taken.clone();
4560 late_reminded(&mut marked, &key, 1_791_360_700);
4561 assert!(marked.reminded.is_empty(), "nothing recorded over a mark");
4562 let mut minute = HealthState::default();
4566 note_minute(&mut minute, &[], &[], std::slice::from_ref(&key), &[], 1_791_360_002);
4567 assert!(minute.unshown.contains_key(&key) && minute.reminder_unshown(&key) && !minute.reminded.contains_key(&key));
4568 late_reminded(&mut minute, &key, 1_791_360_004);
4569 assert!(!minute.reminder_unshown(&key), "answered late: shown, upgraded");
4570 let source = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/health.rs")).unwrap();
4572 let tick = source.split("pub(crate) fn tick(qt: &QtThread, shared: &Arc<Shared>) {").nth(1).and_then(|rest| rest.split("\n}\n").next()).unwrap();
4573 assert!(tick.contains("sioul_sync::notify::remind_dose(") && !tick.contains("sioul_sync::notify::remind(&title, &body"), "a dose's reminder says what became of it");
4574 let waited = tick.split("drop(_held);").nth(1).unwrap();
4575 assert!(waited.contains("sent.outcome(deadline.saturating_duration_since("));
4576 for (outcome, list) in [("Outcome::Shown => reminded.push(key)", ""), ("Outcome::Unshown(e) => {\n unshown.push(key);", ""), ("Outcome::Maybe(e) => {\n maybe.push(key);", "")] {
4577 assert!(waited.contains(outcome), "{outcome}{list}");
4578 }
4579 assert!(waited.find("let _held = state_held();").unwrap() < waited.find("note_minute(record, &reminded, &unshown, &maybe").unwrap(), "recorded under the record's lock again");
4580 }
4581
4582 #[test]
4591 fn a_phone_reminds_plainly_only_on_the_others_word() {
4592 use sioul_core::dose_reminders::{QUIET_AFTER, others};
4593 use sioul_core::health::Said;
4594 let due: i64 = 1_791_360_000;
4595 let key = format!("levo@{due}");
4596 let at_alarm = due + WAIT_FOR_NEWS;
4597 let (english, french) = (Translator::new("en"), Translator::new("fr"));
4598 let desk = |exported: i64, working: bool| Peer {
4599 name: "desk".into(),
4600 id: "desk-id".into(),
4601 said: Some(Said { started: due - 10_800, closed: if working { 0 } else { exported }, working, exported, imported: exported, doses: true, left: false, phone: false }),
4602 complete: true,
4603 seen: exported + 5,
4604 heard: exported,
4605 ..Peer::default()
4606 };
4607 let in_use = [desk(at_alarm - 30, true)];
4608 let with = |tried: Tried| {
4609 let mut told = DoseReminders::default();
4610 *told.entry(&key, "desk-id") = tried;
4611 told
4612 };
4613 let said_as = |told: &DoseReminders, peers: &[Peer], words: &Translator| match others(&key, due, at_alarm, "phone-id", true, Some(told), peers, true) {
4614 Others::Doubt(e) => Some(elsewhere_said(&e, due, words)),
4615 Others::First => None,
4616 Others::Reminded => Some("reminded".into()),
4617 };
4618 let sent = with(Tried { maybe: due + 2, ..Tried::default() });
4620 let when = |at: i64, words: &Translator| when_said(&Timestamp::from_second(at).unwrap().to_zoned(TimeZone::system()), words);
4621 assert_eq!(said_as(&sent, &in_use, &english), Some(format!("desk may have shown its reminder {}", when(due + 2, &english))));
4622 assert_eq!(said_as(&sent, &in_use, &french), Some(format!("desk a peut-être affiché son rappel {}", when(due + 2, &french))));
4623 assert_eq!(said_as(&with(Tried { maybe: due + 2, shown: due + 3, ..Tried::default() }), &in_use, &english).as_deref(), Some("reminded"));
4625 let tried = with(Tried { unshown: due + 5, ..Tried::default() });
4627 assert!(said_as(&tried, &in_use, &english).is_some_and(|w| w.contains("could not show its reminder") && w.contains("may have shown it since")));
4628 let promised = with(Tried { unshown: due + 5, quiet: due + QUIET_AFTER, ..Tried::default() });
4630 assert_eq!(said_as(&promised, &in_use, &english), None);
4631 assert!(said_as(&DoseReminders::default(), &in_use, &english).is_some_and(|w| w.starts_with("desk was in use and may have reminded you")));
4633 assert_eq!(said_as(&DoseReminders::default(), &[Peer { acknowledged: due - 10_800, ..desk(due - 600, false) }], &english), None, "its opening, the one this phone acknowledged");
4635 let tablet = Peer { name: "tablet".into(), id: "tablet-id".into(), said: Some(Said { phone: true, ..desk(due - 600, false).said.unwrap() }), ..desk(due - 600, false) };
4637 assert_eq!(said_as(&promised, &[in_use[0].clone(), tablet.clone()], &english).as_deref(), Some("tablet may have reminded you too, by its own alarm"));
4638 assert_eq!(said_as(&promised, &[in_use[0].clone(), tablet], &french).as_deref(), Some("tablet vous l’a peut-être rappelée aussi, par sa propre alarme"));
4639 let two = elsewhere_said(&[Elsewhere::Unknown { device: Named { id: "a".into(), name: "desk".into(), phone: false } }, Elsewhere::Alarm { device: Named { id: "b".into(), name: "tablet".into(), phone: false } }], due, &french);
4641 assert_eq!(two, format!("desk était utilisé et vous l’a peut-être rappelée {}\u{202f}; tablet vous l’a peut-être rappelée aussi, par sa propre alarme", when(due, &french)));
4642 assert_eq!(joined(vec!["a".into(), "b".into()], &english), "a; b");
4643 assert_eq!(joined(vec!["a".into(), "b".into()], &french), "a\u{202f}; b");
4644 assert!(matches!(others(&key, due, at_alarm, "phone-id", true, None, &in_use, true), Others::Doubt(ref e) if e == &[Elsewhere::Unread]));
4646 }
4647
4648 #[test]
4655 fn a_computer_reminds_first_only_when_it_is_and_keeps_its_word() {
4656 use sioul_core::dose_reminders::{NEVER, QUIET_AFTER};
4657 use sioul_sync::notify::Late;
4658 let since: i64 = 1_791_360_000;
4659 let unshown = Some(Tried { unshown: since + 5, ..Tried::default() });
4660 assert_eq!(plan(None, true, true, since, since + 30, true), Plan::First);
4662 assert_eq!(plan(None, true, true, since, since + LATE_UNTIL - 1, true), Plan::First);
4663 assert_eq!(plan(None, true, true, since, since + WAIT_FOR_NEWS - 1, false), Plan::First, "no phone: its window is the wait for news");
4664 assert_eq!(plan(None, true, true, since, since + LATE_UNTIL, true), Plan::Quiet(since + QUIET_AFTER));
4666 assert_eq!(plan(None, true, false, since, since + 60, true), Plan::Quiet(since + QUIET_AFTER));
4668 assert_eq!(plan(None, true, false, since, since + 60, false), Plan::Late(None));
4669 assert_eq!(plan(unshown, true, true, since, since + 120, true), Plan::Late(Some(Late::Retry)));
4672 assert_eq!(plan(unshown, true, true, since, since + LATE_UNTIL + 10, true), Plan::Quiet(since + QUIET_AFTER));
4673 let paused = Some(Tried { unshown: since + 5, quiet: since + QUIET_AFTER, ..Tried::default() });
4674 assert_eq!(plan(paused, true, true, since, since + QUIET_AFTER - 1, true), Plan::Not, "its promise kept");
4675 assert_eq!(plan(paused, true, true, since, since + QUIET_AFTER + 60, true), Plan::Late(Some(Late::Retry)));
4676 assert_eq!(plan(unshown, true, true, since, since + 8 * 60, true), Plan::Quiet(since + QUIET_AFTER));
4678 assert_eq!(plan(Some(Tried { maybe: since + 2, ..Tried::default() }), true, true, since, since + 120, true), Plan::Late(Some(Late::Unconfirmed)));
4680 assert_eq!(plan(Some(Tried { maybe: since + 2, shown: since + 3, ..Tried::default() }), true, true, since, since + 120, true), Plan::Not);
4681 assert_eq!(plan(Some(Tried { unshown: since + 5, quiet: NEVER, ..Tried::default() }), true, true, since, since + 1_200, true), Plan::Not);
4682 assert_eq!(plan(None, false, true, since, since + 30, true), Plan::First);
4684 assert_eq!(plan(None, false, false, since, since + 120, true), Plan::Quiet(since + QUIET_AFTER), "the file not read, not its first: quiet around the phone's alarm");
4685 assert_eq!(plan(None, false, true, since, since + QUIET_AFTER + 60, true), Plan::Late(None), "then the doubt first, its late notice going all the same (L1)");
4686 let dose = sioul_core::health::Dose { key: format!("levo@{since}"), medicine: "levo".into(), name: "Levothyroxine".into(), dose: "75 µg".into(), at: Timestamp::from_second(since).unwrap().to_zoned(TimeZone::system()) };
4688 let alone = Knowledge { record_lost: None, peers: Vec::new() };
4689 let (title, retry) = late_said(&dose, &alone, since + 120, since, Some(Late::Retry));
4690 assert_eq!(title, say("dose-check-title", &[("dose", "Levothyroxine · 75 µg".to_string())]));
4691 assert!(retry.contains(&say("dose-doubt-late", &[("when", moment_said(since))])), "{retry}");
4692 let (_, unconfirmed) = late_said(&dose, &alone, since + 120, since, Some(Late::Unconfirmed));
4693 assert!(unconfirmed.contains(&say("dose-doubt-unconfirmed", &[("when", moment_said(since))])) && !unconfirmed.contains(&say("dose-doubt-late", &[("when", moment_said(since))])), "{unconfirmed}");
4694 let (_, other) = late_said(&dose, &alone, since + 120, since, None);
4695 assert!(other.contains(&say("dose-doubt-other", &[("when", moment_said(since))])), "{other}");
4696 assert_eq!(said(&Translator::new("en"), "dose-doubt-unconfirmed", &[("when", "at 08:00".to_string())]), "its reminder, due at 08:00, was not confirmed in time, and it may have been taken since without being marked");
4698 }
4699
4700 #[test]
4710 fn a_desk_opened_just_before_a_dose_is_never_a_second_first() {
4711 use sioul_core::dose_reminders::{QUIET_AFTER, others};
4712 use sioul_core::health::Said;
4713 let due: i64 = 1_791_360_000; let key = format!("levo@{due}");
4715 let english = Translator::new("en");
4716 let closed_desk = |claimed: i64| Peer {
4718 name: "desk".into(),
4719 id: "desk-id".into(),
4720 said: Some(Said { started: due - 36_000, closed: due - 32_400, working: false, exported: due - 32_400, imported: due - 32_400, doses: true, left: false, phone: false }),
4721 complete: true,
4722 seen: due - 32_400,
4723 heard: due - 32_400,
4724 claimed,
4725 acknowledged: due - 36_000,
4727 ..Peer::default()
4728 };
4729 let phone = Peer {
4730 name: "phone".into(),
4731 id: "phone-id".into(),
4732 said: Some(Said { started: due - 7_200, closed: due - 3_600, working: false, exported: due - 3_600, imported: due - 3_600, doses: true, left: false, phone: true }),
4733 complete: true,
4734 seen: due - 3_600,
4735 heard: due - 3_600,
4736 ..Peer::default()
4737 };
4738 let desk_knows = Knowledge { record_lost: None, peers: vec![phone.clone()] };
4739 let nothing = DoseReminders::default();
4740 let opened = due - 30; let mut acks = std::collections::BTreeMap::new();
4742 acks.insert("phone-id".to_string(), due - 36_000);
4744 let lead = word_lead(&desk_knows);
4745 assert!(!all_acknowledged(&desk_knows, opened, &acks, due));
4746 let acked = acked_after(None, all_acknowledged(&desk_knows, opened, &acks, due), opened, due + 5);
4747 let first = first_here(false, true, opened, acked.map(|(_, at)| at), due, lead);
4748 assert!(!first, "the phone has not said it read this opening: not the first");
4749 assert_eq!(plan(None, true, first, due, due + 5, true), Plan::Quiet(due + QUIET_AFTER), "R1: quiet around the phone's alarm, promised");
4750 assert_eq!(others(&key, due, due + 5, "phone-id", true, Some(¬hing), &[closed_desk(0)], true), Others::First);
4752 assert_eq!(plan(Some(Tried { quiet: due + QUIET_AFTER, ..Tried::default() }), true, first, due, due + QUIET_AFTER + 60, true), Plan::Late(None));
4754 let from_desk = match others(&key, due, due + QUIET_AFTER + 60, "desk-id", false, Some(¬hing), std::slice::from_ref(&phone), false) {
4755 Others::Doubt(e) => elsewhere_said(&e, due, &english),
4756 other => panic!("{other:?}"),
4757 };
4758 assert_eq!(from_desk, "the phone may have reminded you too, by its own alarm");
4759 acks.insert("phone-id".to_string(), opened);
4761 let late_ack = acked_after(None, all_acknowledged(&desk_knows, opened, &acks, due), opened, due + 245);
4762 assert!(!first_here(false, true, opened, late_ack.map(|(_, at)| at), due, lead), "said at 08:04: too late for this dose");
4763 let early = acked_after(None, all_acknowledged(&desk_knows, opened, &acks, due), opened, due - lead - 10);
4764 assert!(first_here(false, true, opened, early.map(|(_, at)| at), due, lead), "said before 08:00 (and the clocks' lead): the desk is the first; the phone then knows it open");
4765 let just = acked_after(None, true, opened, due - 10);
4766 assert!(!first_here(false, true, opened, just.map(|(_, at)| at), due, lead), "said ten seconds before the dose: a phone ahead may have decided already (L1)");
4767 assert_eq!(acked_after(early, true, opened, due + 600), early, "kept for the same opening");
4768 assert_eq!(acked_after(early, true, opened + 60, due + 600), Some((opened + 60, due + 600)), "a new opening, said anew");
4769 match others(&key, due, due + 600, "phone-id", true, Some(¬hing), &[closed_desk(opened)], true) {
4771 Others::Doubt(e) => assert_eq!(elsewhere_said(&e, due, &english), format!("desk closed {}, and its news can be slow to come", when_said(&Timestamp::from_second(due - 32_400).unwrap().to_zoned(TimeZone::system()), &english))),
4772 other => panic!("{other:?}"),
4773 }
4774 assert!(matches!(others(&key, due, due + 600, "phone-id", true, Some(¬hing), &[closed_desk(0)], false), Others::Doubt(_)));
4776 assert!(!first_here(false, false, due - 3_600, Some(due - 3_600), due, lead));
4778 let silent_phone = Peer { seen: due - 30 * 86_400, said: Some(Said { exported: due - 30 * 86_400, ..phone.said.clone().unwrap() }), ..phone.clone() };
4780 assert!(phone_alarms(&Knowledge { record_lost: None, peers: vec![silent_phone.clone()] }) && must_acknowledge(&Knowledge { record_lost: None, peers: vec![silent_phone] }).len() == 1);
4781 assert_eq!(plan(Some(Tried { unshown: due + 5, ..Tried::default() }), true, true, due, due + 180, true), Plan::Late(Some(sioul_sync::notify::Late::Retry)));
4783 assert!(first_here(true, true, due + 2, None, due, lead));
4785 assert_eq!(plan(None, true, true, due, due + 2, false), Plan::First);
4786 }
4787
4788 #[test]
4795 fn every_phone_not_gone_must_say_it_read_this_opening() {
4796 use sioul_core::dose_reminders::{QUIET_AFTER, others};
4797 use sioul_core::health::Said;
4798 let due: i64 = 1_791_360_000; let key = format!("levo@{due}");
4800 let english = Translator::new("en");
4801 let phone = |seen: i64, off: bool, forgotten: bool| Peer {
4802 name: "phone".into(),
4803 id: "phone-id".into(),
4804 said: Some(Said { started: seen - 600, closed: seen, working: false, exported: seen, imported: seen, doses: true, left: false, phone: true }),
4805 complete: true,
4806 seen,
4807 heard: seen,
4808 off: off || forgotten,
4809 forgotten,
4810 ..Peer::default()
4811 };
4812 let opened = due - 3 * 86_400; let acks = |opening: i64| [("phone-id".to_string(), opening)].into_iter().collect::<std::collections::BTreeMap<String, i64>>();
4814 let off_there = phone(due - 3_600, false, false);
4817 let knows = Knowledge { record_lost: None, peers: vec![off_there.clone()] };
4818 assert!(!all_acknowledged(&knows, opened, &std::collections::BTreeMap::new(), due), "no word: not the first");
4819 for phone in [phone(due - 8 * 86_400, false, false), phone(due - 3_600, true, false)] {
4821 let fresh = due - phone.seen <= WORD_FRESH;
4822 let knows = Knowledge { record_lost: None, peers: vec![phone] };
4823 assert_eq!(must_acknowledge(&knows).len(), 1);
4824 assert!(!all_acknowledged(&knows, opened, &std::collections::BTreeMap::new(), due));
4825 assert_eq!(all_acknowledged(&knows, opened, &acks(opened), due), fresh, "its word on this very opening, while it is heard here (N2)");
4826 }
4827 assert!(must_acknowledge(&Knowledge { record_lost: None, peers: vec![phone(due - 3_600, false, true)] }).is_empty());
4829 let left = Peer { said: Some(Said { left: true, ..phone(due - 3_600, false, false).said.unwrap() }), ..phone(due - 3_600, false, false) };
4830 assert!(must_acknowledge(&Knowledge { record_lost: None, peers: vec![left] }).is_empty());
4831 let older = Peer { id: "old-id".into(), name: "FP5".into(), heard: due - 600, ..Peer::default() };
4833 assert!(!all_acknowledged(&Knowledge { record_lost: None, peers: vec![older.clone()] }, opened, &acks(opened), due));
4834 let knows = Knowledge { record_lost: None, peers: vec![phone(due - 3_600, false, false)] };
4836 assert!(!all_acknowledged(&knows, opened, &acks(opened + 3_000), due));
4837 assert!(!all_acknowledged(&knows, opened, &acks(i64::MAX), due));
4838 assert!(woke_from_gap(due - 600, due - 30) && !woke_from_gap(due - 90, due - 30) && !woke_from_gap(0, due), "three minutes without a tick: a new opening");
4840 assert_eq!(plan(None, true, false, due, due + 180, true), Plan::Quiet(due + QUIET_AFTER));
4842 assert_eq!(plan(Some(Tried { quiet: due + QUIET_AFTER, ..Tried::default() }), true, false, due, due + QUIET_AFTER + 60, true), Plan::Late(None));
4843 let laptop = Peer {
4845 name: "laptop".into(),
4846 id: "laptop-id".into(),
4847 said: Some(Said { started: due - 36_000, closed: due - 32_400, working: false, exported: due - 32_400, imported: due - 32_400, doses: true, left: false, phone: false }),
4848 complete: true,
4849 seen: due - 32_400,
4850 heard: due - 32_400,
4851 acknowledged: due - 36_000,
4852 ..Peer::default()
4853 };
4854 match others(&key, due, due + 180, "desk-id", false, Some(&DoseReminders::default()), std::slice::from_ref(&laptop), true) {
4855 Others::Doubt(e) => assert!(elsewhere_said(&e, due, &english).starts_with("laptop closed "), "{e:?}"),
4856 other => panic!("{other:?}"),
4857 }
4858 match others(&key, due, due + 600, "phone-id", true, Some(&DoseReminders::default()), std::slice::from_ref(&older), true) {
4860 Others::Doubt(e) => assert_eq!(elsewhere_said(&e, due, &english), "FP5 may have reminded you too, by its own alarm"),
4861 other => panic!("{other:?}"),
4862 }
4863 }
4864
4865 #[test]
4874 fn a_phones_word_counts_while_it_is_heard_and_never_goes_back() {
4875 use sioul_core::dose_reminders::{QUIET_AFTER, others};
4876 use sioul_core::health::Said;
4877 let due: i64 = 1_791_360_000; let key = format!("levo@{due}");
4879 let english = Translator::new("en");
4880 let opened = due - 82_800; let phone = |seen: i64| Peer {
4882 name: "phone".into(),
4883 id: "phone-id".into(),
4884 said: Some(Said { started: seen - 600, closed: seen, working: false, exported: seen, imported: seen, doses: true, left: false, phone: true }),
4885 complete: true,
4886 seen,
4887 heard: seen,
4888 ..Peer::default()
4889 };
4890 let word = [("phone-id".to_string(), opened)].into_iter().collect::<std::collections::BTreeMap<String, i64>>();
4891 assert!(all_acknowledged(&Knowledge { record_lost: None, peers: vec![phone(due - 3_600)] }, opened, &word, due));
4893 let n1 = Knowledge { record_lost: None, peers: vec![phone(due - 7_800)] };
4895 assert!(!all_acknowledged(&n1, opened, &word, due));
4896 let acked = acked_after(Some((opened, opened + 180)), all_acknowledged(&n1, opened, &word, due), opened, due);
4897 assert_eq!(acked, None, "the old time forgotten with the word");
4898 let first = first_here(false, true, opened, acked.map(|(_, at)| at), due, word_lead(&n1));
4899 assert!(!first);
4900 assert_eq!(plan(None, true, first, due, due + 5, true), Plan::Quiet(due + QUIET_AFTER));
4901 assert!(!all_acknowledged(&Knowledge { record_lost: None, peers: vec![phone(due - 8 * 86_400)] }, opened, &word, due));
4903 let mut kept = Peers::default();
4905 let desk_entry = |started: i64, closed: i64, working: bool| sioul_sync::devices::Entry { id: "desk-id".into(), name: "desk".into(), kind: sioul_sync::devices::COMPUTER.into(), started, closed, working, exported: started + 60, doses: true, ..sioul_sync::devices::Entry::default() };
4906 learn(&mut kept, &[], &[desk_entry(due - 1_800, 0, true)], &sioul_sync::share::Heard::default(), due - 1_680);
4907 learn(&mut kept, &[], &[desk_entry(due - 32_400 - 3_600, due - 32_400, false)], &sioul_sync::share::Heard::default(), due + 5);
4908 let desk = kept.peers.get("desk-id").unwrap().peer.clone();
4909 assert_eq!(desk.latest_opening, due - 1_800, "never lowered by the older copy");
4910 let said = desk.said.clone().unwrap();
4911 assert_eq!(said.started.max(desk.latest_opening), due - 1_800, "the phone's word stays on 07:30's opening");
4912 assert!(said.working && said.started == due - 1_800, "what it says comes from its newest copy (the ninth review's W1)");
4913 let desk = Peer { acknowledged: due - 1_800, complete: true, ..desk };
4914 match others(&key, due, due + 5, "phone-id", true, Some(&DoseReminders::default()), std::slice::from_ref(&desk), true) {
4915 Others::Doubt(e) => assert!(elsewhere_said(&e, due, &english).starts_with("desk was in use and may have reminded you"), "{e:?}"),
4916 other => panic!("the older copy taken as its word: {other:?}"),
4917 }
4918 assert_eq!(joined(vec!["desk closed".into(), "desk closed".into(), "the phone may have reminded you".into()], &english), "desk closed; the phone may have reminded you");
4920 }
4921
4922 #[test]
4931 fn only_a_newer_copy_of_an_entry_is_news() {
4932 let t0: i64 = 1_791_349_200; let opening = t0 - 72_000;
4934 let copy = |exported: i64, acked: bool| sioul_sync::devices::Entry {
4935 id: "phone-id".into(),
4936 kind: sioul_sync::devices::PHONE.into(),
4937 started: t0 - 3_600,
4938 closed: t0 - 3_000,
4939 exported,
4940 imported: exported,
4941 doses: true,
4942 acked: if acked { [("desk-id".to_string(), opening)].into_iter().collect() } else { std::collections::BTreeMap::new() },
4943 ..sioul_sync::devices::Entry::default()
4944 };
4945 let none = sioul_sync::share::Heard::default();
4946 let mut kept = Peers::default();
4947 learn(&mut kept, &[], &[copy(t0 + 3_000, true)], &none, t0 + 3_060);
4949 for h in 1..=6 {
4951 learn(&mut kept, &[], &[copy(if h % 2 == 1 { t0 - 600 } else { t0 + 3_000 }, true)], &none, t0 + 3_060 + h * 3_600);
4952 }
4953 let knows = Knowledge { record_lost: None, peers: kept.peers.values().map(|s| s.peer.clone()).collect() };
4954 let acks = [("phone-id".to_string(), opening)].into_iter().collect();
4955 let at = t0 + 3_060 + 6 * 3_600 + 60;
4956 assert!(!all_acknowledged(&knows, opening, &acks, at), "an older copy put back is no news of the phone");
4957 assert_eq!(kept.peers["phone-id"].peer.seen, t0 + 3_060, "seen when its newest copy came, not since");
4958 learn(&mut kept, &[], &[copy(t0 + 3_600 * 7, false)], &none, t0 + 3_600 * 7 + 60);
4960 learn(&mut kept, &[], &[copy(t0 + 3_000, true)], &none, t0 + 3_600 * 7 + 120);
4961 assert!(acknowledgements("desk-id", &newest_of(&kept)).is_empty(), "the word from the newest copy read, never an older one");
4962 learn(&mut kept, &[], &[copy(t0 + 3_600 * 8, true)], &none, t0 + 3_600 * 8 + 60);
4964 assert_eq!(acknowledgements("desk-id", &newest_of(&kept)).get("phone-id"), Some(&opening));
4965 learn(&mut kept, &[], &[], &none, t0 + 3_600 * 8 + 120);
4966 assert!(acknowledgements("desk-id", &newest_of(&kept)).is_empty(), "a word no longer read says nothing");
4967 let fresh = Knowledge { record_lost: None, peers: vec![Peer { seen: at + 3_600, ..knows.peers[0].clone() }] };
4969 assert!(!all_acknowledged(&fresh, opening, &acks, at));
4970 let desk = |started: i64| sioul_sync::devices::Entry { id: "desk-id".into(), name: "desk".into(), kind: sioul_sync::devices::COMPUTER.into(), started, closed: started + 600, exported: started + 590, imported: started + 590, doses: true, ..sioul_sync::devices::Entry::default() };
4972 let year = 400 * 86_400;
4973 let mut kept = Peers::default();
4974 learn(&mut kept, &[], &[desk(t0 + year)], &none, t0 + 3_660);
4975 assert!(kept.peers["desk-id"].entry.is_none() && kept.peers["desk-id"].peer.latest_opening == 0, "nothing kept from a clock a year ahead");
4976 let english = Translator::new("en");
4978 let rows = |kept: &Peers, now: i64| device_rows_of(&kept.peers.values().map(|s| s.peer.clone()).collect::<Vec<_>>(), kept, now, &english);
4979 assert_eq!(rows(&kept, t0 + 3_660)[0].state, "Its clock runs more than ten minutes ahead of this one's: what it says is set aside here until its clock is set right.");
4980 learn(&mut kept, &[], &[desk(t0 + 4_800)], &none, t0 + 5_460);
4981 assert_eq!(kept.peers["desk-id"].peer.latest_opening, t0 + 4_800, "set right: its opening counts");
4982 let row = &rows(&kept, t0 + 5_460)[0];
4983 assert!(row.state.starts_with("Closed ") && row.notes.is_empty(), "{row:?}");
4984 kept.peers.get_mut("desk-id").unwrap().peer.latest_opening = t0 + year;
4986 learn(&mut kept, &[], &[desk(t0 + 4_800)], &none, t0 + 5_520);
4987 assert_eq!(kept.peers["desk-id"].peer.latest_opening, t0 + 4_800);
4988 }
4989
4990 #[test]
5002 fn a_copy_that_is_no_news_takes_a_word_away_and_never_gives_one() {
5003 use sioul_core::dose_reminders::QUIET_AFTER;
5004 let due: i64 = 1_791_360_000; let opening = due - 82_800; let none = sioul_sync::share::Heard::default();
5007 let copy = |exported: i64, word: bool| sioul_sync::devices::Entry {
5008 id: "phone-id".into(),
5009 kind: sioul_sync::devices::PHONE.into(),
5010 started: due - 10_800,
5011 closed: due - 10_700,
5012 exported,
5013 imported: exported,
5014 doses: true,
5015 acked: if word { [("desk-id".to_string(), opening)].into_iter().collect() } else { std::collections::BTreeMap::new() },
5016 ..sioul_sync::devices::Entry::default()
5017 };
5018 let word_here = |kept: &Peers| acknowledgements("desk-id", &newest_of(kept));
5020 let mut kept = Peers::default();
5022 learn(&mut kept, &[], &[copy(due - 5_400, true)], &none, due - 5_340);
5023 assert_eq!(word_here(&kept).get("phone-id"), Some(&opening));
5024 for (real, word) in [(due - 4_740, true), (due - 3_300, false), (due - 1_800, false), (due - 60, false)] {
5026 learn(&mut kept, &[], &[copy(real + 900, word)], &none, real + 30);
5027 }
5028 let seen = &kept.peers["phone-id"];
5029 assert!(seen.peer.set_aside && seen.entry.as_ref().is_some_and(|e| e.acked.get("desk-id") == Some(&opening)), "the 06:30 copy kept, its word in it");
5030 assert!(word_here(&kept).is_empty(), "a phone whose copy read now is set aside gives no word");
5031 let knows = Knowledge { record_lost: None, peers: kept.peers.values().map(|s| s.peer.clone()).collect() };
5032 let at = due - 30;
5033 assert!(!all_acknowledged(&knows, opening, &word_here(&kept), at));
5034 let acked = acked_after(Some((opening, opening + 180)), all_acknowledged(&knows, opening, &word_here(&kept), at), opening, at);
5035 let first = first_here(false, true, opening, acked.map(|(_, at)| at), due, word_lead(&knows));
5036 assert!(!first);
5037 assert_eq!(plan(None, true, first, due, due + 10, true), Plan::Quiet(due + QUIET_AFTER), "quiet, then the doubt: the phone's reminder the only plain one");
5038 let english = Translator::new("en");
5039 assert_eq!(why_in(&sioul_core::health::doubts_now(due, due + QUIET_AFTER, None, &knows.peers), &english), "the phone has its clock more than ten minutes ahead of this one's, so what it says is set aside here");
5040 let mut kept = Peers::default();
5042 learn(&mut kept, &[], &[copy(due - 3_600, true)], &none, due - 3_590);
5043 learn(&mut kept, &[], &[copy(due - 3_600, false)], &none, due - 3_580);
5044 assert_eq!(kept.peers["phone-id"].peer.seen, due - 3_590, "no news");
5045 assert!(word_here(&kept).is_empty(), "the word taken back at once");
5046 let mut kept = Peers::default();
5049 learn(&mut kept, &[], &[copy(due - 3_600, true)], &none, due - 3_590);
5050 learn(&mut kept, &[], &[copy(due - 3_480 - 1_800, true)], &none, due - 3_470);
5051 assert_eq!(word_here(&kept).get("phone-id"), Some(&opening), "said by both: it stands");
5052 learn(&mut kept, &[], &[copy(due - 3_300 - 1_800, false)], &none, due - 3_290);
5053 assert_eq!(kept.peers["phone-id"].peer.seen, due - 3_590, "no news");
5054 assert!(word_here(&kept).is_empty(), "taken away at 07:05, not at 07:31");
5055 let mut kept = Peers::default();
5057 learn(&mut kept, &[], &[copy(due - 3_600, false)], &none, due - 3_590);
5058 learn(&mut kept, &[], &[copy(due - 7_200, true)], &none, due - 3_500);
5059 assert!(word_here(&kept).is_empty());
5060 }
5061
5062 #[test]
5068 fn a_device_said_off_stays_so_until_it_says_anything_newer() {
5069 let t: i64 = 1_791_349_200;
5070 let desk = |started: i64, exported: i64| sioul_sync::devices::Entry { id: "desk-id".into(), name: "desk".into(), kind: sioul_sync::devices::COMPUTER.into(), started, closed: exported + 10, exported, imported: exported, doses: true, ..sioul_sync::devices::Entry::default() };
5071 let none = sioul_sync::share::Heard::default();
5072 let mut kept = Peers::default();
5073 learn(&mut kept, &[], &[desk(t, t + 600)], &none, t + 660);
5074 let mark = Mark { started: t, exported: t + 600, renewed: 0, at: t + 700 };
5075 let seen = kept.peers.get_mut("desk-id").unwrap();
5076 (seen.off, seen.forgotten) = (Some(mark.clone()), Some(mark));
5077 learn(&mut kept, &[], &[desk(t, t + 600)], &none, t - 2_900);
5079 let seen = &kept.peers["desk-id"];
5080 assert!(seen.peer.set_aside && seen.entry.is_some() && seen.peer.off && seen.peer.forgotten);
5081 learn(&mut kept, &[], &[desk(t, t + 600)], &none, t + 800);
5083 let seen = &kept.peers["desk-id"];
5084 assert!(!seen.peer.set_aside && seen.peer.off && seen.peer.forgotten);
5085 learn(&mut kept, &[], &[desk(t + 900, t + 960)], &none, t + 1_000);
5087 let seen = &kept.peers["desk-id"];
5088 assert!(!seen.peer.off && !seen.peer.forgotten, "it spoke again");
5089 let mut kept = Peers::default();
5091 learn(&mut kept, &[], &[desk(t, t + 600)], &none, t + 660);
5092 kept.peers.get_mut("desk-id").unwrap().entry = Some(desk(t + 86_400, t + 86_400));
5093 learn(&mut kept, &[], &[desk(t + 700, t + 760)], &none, t + 800);
5094 assert_eq!(kept.peers["desk-id"].entry.as_ref().map(|e| e.started), Some(t + 700));
5095 }
5096
5097 #[test]
5110 fn a_replaced_or_forgotten_phone_never_makes_two_plain_reminders_nor_none() {
5111 use sioul_core::dose_reminders::{DoseReminders, QUIET_AFTER, Tried, others};
5112 use sioul_sync::devices::{Entry, PHONE, Replaced};
5113 let due: i64 = 1_791_360_000; let key = format!("levo@{due}");
5115 let opened = due - 3_600; let none = sioul_sync::share::Heard::default();
5117 let phone = |id: &str, started: i64, exported: i64, acked: Option<i64>| Entry {
5118 id: id.into(),
5119 name: format!("{id} model"),
5120 kind: PHONE.into(),
5121 started,
5122 closed: exported + 5,
5123 working: false,
5124 imported: exported,
5125 exported,
5126 doses: true,
5127 acked: acked.map(|opening| [("desk-id".to_string(), opening)].into_iter().collect()).unwrap_or_default(),
5128 ..Entry::default()
5129 };
5130 let knows = |kept: &Peers| Knowledge { record_lost: None, peers: kept.peers.values().map(|s| s.peer.clone()).collect() };
5131 let learned = |kept: &mut Peers, entries: &[Entry], now: i64| {
5132 learn(kept, &[], entries, &none, now);
5133 let replaced = replacements(kept, entries, &Entry::default());
5134 apply_replaced(kept, &replaced);
5135 };
5136 let must = |kept: &Peers| must_acknowledge(&knows(kept)).iter().map(|p| p.id.clone()).collect::<Vec<_>>();
5137 let old = phone("old-id", due - 3 * 86_400 - 600, due - 3 * 86_400, Some(due - 4 * 86_400));
5139 let mut kept = Peers::default();
5140 learned(&mut kept, std::slice::from_ref(&old), due - 3 * 86_400 + 60);
5141 assert_eq!(must(&kept), ["old-id"], "silent, its word is asked all the same");
5142 assert!(!all_acknowledged(&knows(&kept), opened, &acknowledgements("desk-id", std::slice::from_ref(&old)), due - 600), "never given: the desk is never the first");
5143 let replaces = Replaced { id: "old-id".into(), started: old.started, heard: (old.closed.max(old.exported)) * 1000 };
5145 let new = Entry { replaces: vec![replaces.clone()], ..phone("new-id", due - 1_800, due - 1_700, None) };
5146 learned(&mut kept, &[old.clone(), new.clone()], due - 1_690);
5147 let seen_old = kept.peers["old-id"].peer.clone();
5148 assert!(seen_old.off && seen_old.forgotten, "replaced: as forgotten here");
5149 assert_eq!(must(&kept), ["new-id"], "the old phone owes no word any more");
5150 let older = phone("old-id", due - 5 * 86_400, due - 5 * 86_400 + 60, None);
5152 learned(&mut kept, &[older, new.clone()], due - 1_680);
5153 assert!(kept.peers["old-id"].peer.forgotten, "an older copy is no news");
5154 let acks = acknowledgements("desk-id", &[old.clone(), new.clone()]);
5157 assert!(!all_acknowledged(&knows(&kept), opened, &acks, due - 1_680));
5158 assert_eq!(plan(None, true, false, due, due + 180, true), Plan::Quiet(due + QUIET_AFTER));
5159 assert_eq!(plan(Some(Tried { quiet: due + QUIET_AFTER, ..Tried::default() }), true, false, due, due + QUIET_AFTER + 60, true), Plan::Late(None));
5160 let new = Entry { replaces: vec![replaces.clone()], ..phone("new-id", due - 1_800, due - 1_200, Some(opened)) };
5162 learned(&mut kept, &[old.clone(), new.clone()], due - 1_190);
5163 let acks = acknowledgements("desk-id", &[old.clone(), new.clone()]);
5164 let knowledge = knows(&kept);
5165 assert!(all_acknowledged(&knowledge, opened, &acks, due - 1_190));
5166 assert!(first_here(false, true, opened, Some(due - 1_190), due, word_lead(&knowledge)));
5167 assert_eq!(plan(None, true, true, due, due + 30, true), Plan::First);
5168 let desk = Peer {
5170 name: "desk".into(),
5171 id: "desk-id".into(),
5172 said: Some(sioul_core::health::Said { started: opened, closed: 0, working: true, exported: due + 570, imported: due + 570, doses: true, left: false, phone: false }),
5173 complete: true,
5174 seen: due + 575,
5175 heard: due + 570,
5176 ..Peer::default()
5177 };
5178 let gone = Peer { off: true, forgotten: true, ..seen_old.clone() };
5179 let mut shown = DoseReminders::default();
5180 *shown.entry(&key, "desk-id") = Tried { maybe: due + 2, shown: due + 3, ..Tried::default() };
5181 assert!(matches!(others(&key, due, due + WAIT_FOR_NEWS, "new-id", true, Some(&shown), &[desk.clone(), gone.clone()], true), Others::Reminded), "the desk reminded it: the phone stays silent");
5182 let mut quiet = DoseReminders::default();
5183 *quiet.entry(&key, "desk-id") = Tried { quiet: due + QUIET_AFTER, ..Tried::default() };
5184 assert!(matches!(others(&key, due, due + WAIT_FOR_NEWS, "new-id", true, Some(&quiet), &[desk.clone(), gone.clone()], true), Others::First), "the desk promised none: the phone's alarm is the first");
5185 let counted = Peer { off: false, forgotten: false, ..gone.clone() };
5186 assert!(matches!(others(&key, due, due + WAIT_FOR_NEWS, "new-id", true, Some(&quiet), &[desk, counted], true), Others::Doubt(_)), "counted, the old phone's alarm could ring: the doubt");
5187 let back = phone("old-id", due - 900, due - 890, Some(due - 4 * 86_400));
5190 learned(&mut kept, &[back.clone(), new.clone()], due - 880);
5191 let seen_back = kept.peers["old-id"].peer.clone();
5192 assert!(!seen_back.off && !seen_back.forgotten, "it spoke again: counted");
5193 assert_eq!(must(&kept).len(), 2);
5194 assert!(!all_acknowledged(&knows(&kept), opened, &acknowledgements("desk-id", &[back.clone(), new.clone()]), due - 880), "never plain beside it");
5195 let back = phone("old-id", due - 900, due - 860, Some(opened));
5197 learned(&mut kept, &[back.clone(), new.clone()], due - 850);
5198 assert!(all_acknowledged(&knows(&kept), opened, &acknowledgements("desk-id", &[back, new]), due - 850));
5199 let mut kept = Peers::default();
5201 let silent = phone("old-id", due - 86_400 - 600, due - 86_400, Some(due - 2 * 86_400));
5202 learned(&mut kept, std::slice::from_ref(&silent), due - 86_400 + 60);
5203 let seen = kept.peers.get_mut("old-id").unwrap();
5204 seen.forgotten = Some(Mark { started: silent.started, exported: silent.exported, renewed: 0, at: due - 600 });
5205 learned(&mut kept, std::slice::from_ref(&silent), due - 590);
5206 assert!(must(&kept).is_empty() && kept.peers["old-id"].peer.forgotten);
5207 let rows = device_rows_of(&knows(&kept).peers, &kept, due - 590, &Translator::new("en"));
5208 assert!(rows.iter().all(|r| r.id != "old-id"), "forgotten: not listed");
5209 learned(&mut kept, &[phone("old-id", due - 300, due - 290, None)], due - 280);
5210 assert_eq!(must(&kept), ["old-id"], "it spoke again: its word owed again");
5211 assert!(replaces.holds(Some(old.started), old.exported * 1000) && !replaces.holds(Some(due - 900), old.exported * 1000) && !replaces.holds(Some(old.started), (old.exported + 60) * 1000));
5213 }
5214
5215 #[test]
5220 fn a_phone_silent_a_day_is_offered_to_be_forgotten_now() {
5221 use sioul_core::health::Said;
5222 let now: i64 = 1_791_360_000;
5223 let peer = |id: &str, phone: bool, seen: i64| Peer {
5224 id: id.into(),
5225 name: id.into(),
5226 said: Some(Said { started: seen - 60, closed: seen, working: false, exported: seen, imported: seen, doses: true, left: false, phone }),
5227 complete: true,
5228 seen,
5229 heard: seen,
5230 ..Peer::default()
5231 };
5232 let peers = [peer("silent-phone", true, now - FORGET_NOW_AFTER - 60), peer("fresh-phone", true, now - 3_600), peer("silent-desk", false, now - FORGET_NOW_AFTER - 60), peer("week-phone", true, now - 8 * 86_400)];
5233 let rows = device_rows_of(&peers, &Peers::default(), now, &Translator::new("en"));
5234 let row = |id: &str| rows.iter().find(|r| r.id == id).unwrap();
5235 assert!(row("silent-phone").forget_now && !row("silent-phone").forget);
5236 assert!(!row("fresh-phone").forget_now);
5237 assert!(!row("silent-desk").forget_now, "a computer is not offered it");
5238 assert!(row("week-phone").forget && !row("week-phone").forget_now, "silent a week: Forget this device, as before");
5239 }
5240
5241 #[test]
5247 fn a_computer_whose_clock_runs_ahead_is_said_so() {
5248 use sioul_core::dose_reminders::{DoseReminders, others};
5249 let due: i64 = 1_791_360_000; let key = format!("levo@{due}");
5251 let desk = |started: i64, closed: i64, working: bool, exported: i64| sioul_sync::devices::Entry { id: "desk-id".into(), name: "desk".into(), kind: sioul_sync::devices::COMPUTER.into(), started, closed, working, exported, imported: exported, doses: true, ..sioul_sync::devices::Entry::default() };
5252 let none = sioul_sync::share::Heard::default();
5253 let mut kept = Peers::default();
5254 learn(&mut kept, &[], &[desk(due - 36_000, due - 32_400, false, due - 32_410)], &none, due - 32_000);
5256 let claim = sioul_sync::lease::Claim { computer: "desk-id".into(), name: "desk".into(), since: due, renewed: due + 3_540, until: due + 3_840, active: due + 3_540, taken: 0, wrote: None, closed: false, pad: String::new() };
5258 learn(&mut kept, std::slice::from_ref(&claim), &[desk(due, 0, true, due + 3_540)], &none, due - 40);
5259 let peer = kept.peers["desk-id"].peer.clone();
5260 assert!(peer.set_aside && peer.said.as_ref().is_some_and(|s| s.started == due - 36_000), "set aside, last night's kept: never an older Sioul");
5261 let english = Translator::new("en");
5262 let french = Translator::new("fr");
5263 let words = "desk has its clock more than ten minutes ahead of this one's, so what it says is set aside here";
5264 let Others::Doubt(e) = others(&key, due, due + 600, "phone-id", true, Some(&DoseReminders::default()), std::slice::from_ref(&peer), true) else { panic!("never plain") };
5265 assert_eq!(elsewhere_said(&e, due, &english), words);
5266 assert_eq!(elsewhere_said(&e, due, &french), "desk a son horloge en avance de plus de dix minutes sur celle de cet appareil, et ce qu’il dit est mis de côté ici");
5267 let doubts = sioul_core::health::doubts_now(due, due + 600, None, std::slice::from_ref(&peer));
5268 assert_eq!(why_in(&doubts, &english), words);
5269 assert!(!why_in(&doubts, &french).contains("plus ancien"));
5270 let knows = Knowledge { record_lost: None, peers: vec![peer.clone()] };
5272 let first = elsewhere_said(&e, due, tr());
5273 assert_eq!(doubt_unread_with(&knows, due, due, due + 600, None, Some(&first)), say("dose-doubt", &[("why", first.clone())]));
5274 assert_eq!(peer.said.as_ref().map(|s| s.started.max(peer.latest_opening)), Some(due - 36_000));
5276 }
5277
5278 #[test]
5288 fn a_word_the_lagging_copy_still_names_is_withdrawn() {
5289 use sioul_core::dose_reminders::QUIET_AFTER;
5290 let due: i64 = 1_791_360_000; let opening = due - 82_800; let none = sioul_sync::share::Heard::default();
5293 let copy = |dated: i64, word: bool| sioul_sync::devices::Entry {
5294 id: "phone-id".into(),
5295 kind: sioul_sync::devices::PHONE.into(),
5296 started: due - 10_800,
5297 closed: due - 10_700,
5298 exported: dated,
5299 imported: dated,
5300 doses: true,
5301 acked: if word { [("desk-id".to_string(), opening)].into_iter().collect() } else { std::collections::BTreeMap::new() },
5302 ..sioul_sync::devices::Entry::default()
5303 };
5304 let folder = copy(due - 5_400, true);
5305 let mut kept = Peers::default();
5306 learn(&mut kept, &[], &[sioul_sync::devices::chosen(folder.clone(), folder.clone())], &none, due - 5_340);
5307 let mut word_until = 0;
5308 for real in (due - 4_800..due).step_by(600) {
5310 let fetched = copy(real - 10_800, real < due - 3_300);
5311 let read = sioul_sync::devices::chosen(folder.clone(), fetched);
5312 assert_eq!(read.exported, due - 5_400, "the folder's copy, dated later, is the one read");
5313 learn(&mut kept, &[], &[read], &none, real + 60);
5314 if acknowledgements("desk-id", &newest_of(&kept)).get("phone-id") == Some(&opening) {
5315 word_until = real + 60;
5316 }
5317 }
5318 assert_eq!(word_until, due - 3_540, "the word stood until 07:01, gone from 07:11");
5319 let knows = Knowledge { record_lost: None, peers: kept.peers.values().map(|s| s.peer.clone()).collect() };
5320 let at = due - 30;
5321 let acks = acknowledgements("desk-id", &newest_of(&kept));
5322 let acked = acked_after(Some((opening, opening + 180)), all_acknowledged(&knows, opening, &acks, at), opening, at);
5323 let first = first_here(false, true, opening, acked.map(|(_, at)| at), due, word_lead(&knows));
5324 assert!(!first);
5325 assert_eq!(plan(None, true, first, due, due + 10, true), Plan::Quiet(due + QUIET_AFTER), "no plain reminder at 08:00");
5326 }
5327
5328 #[test]
5336 fn a_new_word_stands_while_one_channel_lags() {
5337 let due: i64 = 1_791_360_000; let (before, opening) = (due - 82_800, due - 3_600); let none = sioul_sync::share::Heard::default();
5340 let copy = |dated: i64, word: Option<i64>| sioul_sync::devices::Entry {
5341 id: "phone-id".into(),
5342 kind: sioul_sync::devices::PHONE.into(),
5343 started: due - 10_800,
5344 closed: due - 10_700,
5345 exported: dated,
5346 imported: dated,
5347 doses: true,
5348 acked: word.map(|at| ("desk-id".to_string(), at)).into_iter().collect(),
5349 ..sioul_sync::devices::Entry::default()
5350 };
5351 let run = |folder: &sioul_sync::devices::Entry| {
5352 let mut kept = Peers::default();
5353 let mut acked = None;
5354 for real in (due - 3_480..due).step_by(600) {
5355 learn(&mut kept, &[], &[sioul_sync::devices::chosen(folder.clone(), copy(real, Some(opening)))], &none, real + 60);
5356 let knows = Knowledge { record_lost: None, peers: kept.peers.values().map(|s| s.peer.clone()).collect() };
5357 acked = acked_after(acked, all_acknowledged(&knows, opening, &acknowledgements("desk-id", &newest_of(&kept)), real + 60), opening, real + 60);
5358 }
5359 let knows = Knowledge { record_lost: None, peers: kept.peers.values().map(|s| s.peer.clone()).collect() };
5360 (acked, first_here(false, true, opening, acked.map(|(_, at)| at), due, word_lead(&knows)))
5361 };
5362 let (acked, first) = run(©(due - 5_400, Some(before)));
5363 assert_eq!(acked, Some((opening, due - 3_420)), "the word from 07:03");
5364 assert!(first);
5365 assert_eq!(plan(None, true, first, due, due + 10, true), Plan::First, "plain at 08:00, as before");
5366 let (acked, first) = run(©(due - 5_400, None));
5368 assert!(acked.is_none() && !first);
5369 }
5370
5371 #[test]
5377 fn at_waking_the_first_reminder_counts_from_waking() {
5378 use sioul_core::health::Said;
5379 let (bed, woke) = (1_800_000_000, 1_800_028_800);
5380 let due = bed + 6 * 3600;
5381 let since = news_from(due, Some((bed, woke)));
5382 assert_eq!(since, woke);
5383 let phone = Peer {
5384 name: "phone".into(),
5385 id: "phone-id".into(),
5386 said: Some(Said { started: bed - 7_200, closed: bed - 1_800, working: false, exported: bed - 1_800, imported: bed - 1_800, doses: true, left: false, phone: true }),
5387 complete: true,
5388 seen: bed - 1_800,
5389 heard: bed - 1_800,
5390 wake: Some(30),
5391 ..Peer::default()
5392 };
5393 let knowledge = Knowledge { record_lost: None, peers: vec![phone] };
5394 assert!(doubt_of(&knowledge, due, due, woke + 30).is_empty(), "counted from the dose's time, the phone would be known by its alarm long rung");
5395 assert!(!doubt_of(&knowledge, due, since, woke + 30).is_empty(), "counted from waking: the phone may have been picked up to mark it");
5396 assert!(doubt_of(&knowledge, due, since, woke + 30 + sioul_core::health::SKEW).is_empty(), "known once the news of an opening had the time to come");
5397 assert_eq!(plan(None, true, true, since, woke + 30, true), Plan::First, "its first, at waking");
5398 }
5399
5400 #[test]
5404 fn a_reminder_that_may_have_shown_is_asked_about_under_its_own_question() {
5405 let health = Health {
5406 medicines: vec![
5407 Medicine { id: "demo-a".into(), name: "Demo A".into(), dose: String::new(), schedule: Schedule::Day { times: vec!["08:00".into()], amounts: std::collections::BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None },
5408 Medicine { id: "demo-b".into(), name: "Demo B".into(), dose: String::new(), schedule: Schedule::Day { times: vec!["07:00".into()], amounts: std::collections::BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None },
5409 ],
5410 ..Health::default()
5411 };
5412 let (english, french) = (Translator::new("en"), Translator::new("fr"));
5413 let at = |text: &str| text.parse::<Zoned>().unwrap();
5414 let key = |id: &str, when: &str| format!("{id}@{}", at(when).timestamp().as_second());
5415 let (a, b) = (key("demo-a", "2026-10-06T08:00[Europe/Paris]"), key("demo-b", "2026-10-06T07:00[Europe/Paris]"));
5416 let alone = Knowledge { record_lost: None, peers: Vec::new() };
5417 let noon = at("2026-10-06T12:00[Europe/Paris]");
5418 let mut state = HealthState::default();
5419 let sent = at("2026-10-06T08:00:02[Europe/Paris]").timestamp().as_second();
5420 note_minute(&mut state, &[], &[], std::slice::from_ref(&a), &[], sent);
5421 let mut told = DoseReminders::default();
5423 told.entry(&a, "desk-id").maybe = sent;
5424 let rows = missed_rows(&health, &state, &told, &alone, &noon, &english);
5425 assert_eq!(rows.iter().map(|r| r.key.as_str()).collect::<Vec<_>>(), [b.as_str(), a.as_str()]);
5426 assert!(rows[1].question.starts_with("Due while Sioul ran, but their reminder may not have been shown"), "{}", rows[1].question);
5427 assert!(missed_rows(&health, &state, &told, &alone, &noon, &french)[1].question.starts_with("Prévus pendant que Sioul tournait, mais leur rappel ne s’est peut-être pas affiché"));
5428 let missed = state.unanswered(&health, &noon, MISSED_HOURS, GRACE_MINUTES);
5429 assert_eq!(missed_title(&missed[1..], &state, &told, &english), "A reminder may not have shown");
5430 assert_eq!(missed_title(&missed[1..], &state, &told, &french), "Un rappel ne s’est peut-être pas affiché");
5431 assert_eq!(missed_title(&missed, &state, &told, &english), "Doses due earlier");
5432 assert_eq!(missed_title(&missed[1..], &state, &DoseReminders::default(), &english), "A reminder could not be shown");
5434 }
5435
5436 #[test]
5444 fn a_dose_is_open_from_its_time_until_marked_or_its_day_ends() {
5445 let dir = std::env::temp_dir().join(format!("sioul-dose-open-{}", std::process::id()));
5446 let _ = std::fs::remove_dir_all(&dir);
5447 std::fs::create_dir_all(&dir).unwrap();
5448 let path = dir.join("health.toml");
5449 std::fs::write(&path, "[[medicine]]\nid = \"mag\"\nname = \"Magnesium\"\ndose = \"300 mg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"22:00\"]\n").unwrap();
5450 let health = Health::load(&path);
5451 let at = |text: &str| text.parse::<Zoned>().unwrap();
5452 let s = |text: &str| at(text).timestamp().as_second();
5453 let (state, none) = (HealthState::default(), DoseRecords::default());
5454 let read = Some((&state, &none));
5455 assert!(!dose_open(&health, read, &at("2026-10-06T21:59[Europe/Paris]")), "not its time yet");
5456 assert!(dose_open(&health, read, &at("2026-10-06T22:00[Europe/Paris]")), "its time: open");
5457 assert!(dose_open(&health, read, &at("2026-10-06T23:30[Europe/Paris]")), "not marked, the night begun");
5458 assert!(!dose_open(&health, read, &at("2026-10-07T00:30[Europe/Paris]")), "its day over: no longer asked about");
5459 let key = format!("mag@{}", s("2026-10-06T22:00[Europe/Paris]"));
5461 let mut taken = HealthState::default();
5462 taken.taken.insert(key.clone(), s("2026-10-06T22:05[Europe/Paris]"));
5463 assert!(!dose_open(&health, Some((&taken, &none)), &at("2026-10-06T22:10[Europe/Paris]")));
5464 let mut records = DoseRecords::default();
5465 let marked = s("2026-10-06T22:07[Europe/Paris]");
5466 records.answer(&key, "desk", Answer { state: sioul_core::doses::TAKEN.into(), at: marked, noted: marked, name: "desk".into(), kind: "computer".into() });
5467 assert!(!dose_open(&health, Some((&state, &records)), &at("2026-10-06T22:10[Europe/Paris]")), "marked at the desk");
5468 assert!(!dose_open(&Health::default(), read, &at("2026-10-06T22:10[Europe/Paris]")), "no medicine");
5469 assert!(!dose_open(&health, None, &at("2026-10-06T20:00[Europe/Paris]")), "unread, its time two hours on: not open");
5471 assert!(!dose_open(&health, None, &at("2026-10-06T21:59[Europe/Paris]")), "unread, not its time yet");
5472 assert!(dose_open(&health, None, &at("2026-10-06T22:00[Europe/Paris]")), "unread, its time: open (the safe side)");
5473 assert!(dose_open(&health, None, &at("2026-10-06T23:59[Europe/Paris]")), "unread, the night begun");
5474 assert!(!dose_open(&health, None, &at("2026-10-07T00:30[Europe/Paris]")), "unread, its day over: closed");
5475 assert!(!dose_open(&health, None, &at("2026-10-07T12:00[Europe/Paris]")), "unread, the next day before its time");
5476 assert!(!dose_open(&Health::default(), None, &at("2026-10-06T22:10[Europe/Paris]")), "unread, no medicine");
5477 let _ = std::fs::remove_dir_all(&dir);
5478 }
5479
5480 #[test]
5485 fn the_home_screen_card_says_doses_as_known() {
5486 let dir = std::env::temp_dir().join(format!("sioul-card-doses-{}", std::process::id()));
5487 let _ = std::fs::remove_dir_all(&dir);
5488 std::fs::create_dir_all(&dir).unwrap();
5489 let path = dir.join("health.toml");
5490 std::fs::write(&path, "[[medicine]]\nid = \"mag\"\nname = \"Magnesium\"\ndose = \"300 mg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"12:30\", \"20:00\"]\n").unwrap();
5491 let health = Health::load(&path);
5492 let at = |text: &str| text.parse::<Zoned>().unwrap();
5493 let s = |text: &str| at(text).timestamp().as_second();
5494 let now = at("2026-10-06T13:00[Europe/Paris]");
5495 let alone = Knowledge { record_lost: None, peers: Vec::new() };
5496 let mut state = HealthState::default();
5497 let doses = doses_for_card(&health, &state, &DoseRecords::default(), true, &alone, &now);
5498 assert_eq!(doses.iter().map(|d| (d.name.as_str(), d.time.as_str())).collect::<Vec<_>>(), [("Magnesium · 300 mg", "12:30"), ("Magnesium · 300 mg", "20:00")]);
5500 let midnight = s("2026-10-07T00:00[Europe/Paris]");
5502 assert_eq!((doses[0].at, doses[0].until, doses[0].known_until), (s("2026-10-06T12:30[Europe/Paris]"), midnight, midnight));
5503 state.taken.insert(format!("mag@{}", s("2026-10-06T12:30[Europe/Paris]")), s("2026-10-06T12:35[Europe/Paris]"));
5505 assert_eq!(doses_for_card(&health, &state, &DoseRecords::default(), true, &alone, &now).len(), 1);
5506 let heard = s("2026-10-06T12:58[Europe/Paris]");
5508 let laptop = Knowledge { record_lost: None, peers: vec![Peer { name: "laptop".into(), known_until: heard, closed: false, delay: Some(30), broken: None, heard, ..Peer::default() }] };
5509 let fresh = doses_for_card(&health, &HealthState::default(), &DoseRecords::default(), true, &laptop, &now);
5510 assert_eq!(fresh[0].known_until, heard + 5 * 60 + 1);
5511 assert_eq!(fresh[1].known_until, 0);
5513 let noon = s("2026-10-06T12:00[Europe/Paris]");
5515 let old = Knowledge { record_lost: None, peers: vec![Peer { name: "laptop".into(), known_until: noon, closed: false, delay: Some(30), broken: None, heard: noon, ..Peer::default() }] };
5516 assert_eq!(doses_for_card(&health, &HealthState::default(), &DoseRecords::default(), true, &old, &now)[0].known_until, 0);
5517 let unread = doses_for_card(&health, &HealthState::default(), &DoseRecords::default(), false, &alone, &now);
5519 assert_eq!(unread.len(), 2);
5520 assert!(unread.iter().all(|d| d.known_until == 0));
5521 let mut differ = HealthState::default();
5524 let key = format!("mag@{}", s("2026-10-06T12:30[Europe/Paris]"));
5525 differ.taken.insert(key.clone(), s("2026-10-06T12:35[Europe/Paris]"));
5526 differ.not_taken.insert(key, s("2026-10-06T12:40[Europe/Paris]"));
5527 let doses = doses_for_card(&health, &differ, &DoseRecords::default(), true, &alone, &now);
5528 assert_eq!(doses.iter().map(|d| (d.time.as_str(), d.known_until)).collect::<Vec<_>>(), [("12:30", 0), ("20:00", midnight)]);
5529 let key = format!("mag@{}", s("2026-10-06T12:30[Europe/Paris]"));
5532 let given = |state: &str, at: &str, name: &str| Answer { state: state.into(), at: s(at), noted: s(at), name: name.into(), kind: name.into() };
5533 let mut records = DoseRecords::default();
5534 records.answer(&key, "phone-id", given(sioul_core::doses::TAKEN, "2026-10-06T12:35[Europe/Paris]", "phone"));
5535 let doses = doses_for_card(&health, &HealthState::default(), &records, true, &alone, &now);
5536 assert_eq!(doses.iter().map(|d| d.time.as_str()).collect::<Vec<_>>(), ["20:00"]);
5537 records.answer(&key, "desk-id", given(sioul_core::doses::SKIPPED, "2026-10-06T12:40[Europe/Paris]", "computer"));
5538 let doses = doses_for_card(&health, &HealthState::default(), &records, true, &alone, &now);
5539 assert_eq!(doses.iter().map(|d| (d.time.as_str(), d.known_until)).collect::<Vec<_>>(), [("12:30", 0), ("20:00", midnight)]);
5540 let _ = std::fs::remove_dir_all(&dir);
5541 }
5542
5543 #[test]
5548 fn the_card_says_check_while_a_closed_device_may_have_opened() {
5549 let dir = std::env::temp_dir().join(format!("sioul-card-wake-{}", std::process::id()));
5550 let _ = std::fs::remove_dir_all(&dir);
5551 std::fs::create_dir_all(&dir).unwrap();
5552 let path = dir.join("health.toml");
5553 std::fs::write(&path, "[[medicine]]\nid = \"mag\"\nname = \"Magnesium\"\ndose = \"300 mg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"12:30\", \"20:00\"]\n").unwrap();
5554 let health = Health::load(&path);
5555 let s = |text: &str| text.parse::<Zoned>().unwrap().timestamp().as_second();
5556 let now = "2026-10-06T13:00[Europe/Paris]".parse::<Zoned>().unwrap();
5557 let said = sioul_core::health::Said { started: s("2026-10-06T08:00[Europe/Paris]"), closed: s("2026-10-06T12:00[Europe/Paris]"), working: false, exported: s("2026-10-06T12:00[Europe/Paris]"), doses: true, ..Default::default() };
5558 let desk = Peer { id: "desk-id".into(), name: "desk".into(), said: Some(said), complete: true, seen: s("2026-10-06T12:01[Europe/Paris]"), heard: s("2026-10-06T12:00[Europe/Paris]"), wake: Some(120), ..Peer::default() };
5559 let knowledge = Knowledge { record_lost: None, peers: vec![desk] };
5560 let doses = doses_for_card(&health, &HealthState::default(), &DoseRecords::default(), true, &knowledge, &now);
5561 let (eight, midnight) = (s("2026-10-06T20:00[Europe/Paris]"), s("2026-10-07T00:00[Europe/Paris]"));
5562 let pieces: Vec<(&str, i64, i64, i64)> = doses.iter().map(|d| (d.time.as_str(), d.at, d.until, d.known_until)).collect();
5563 let woke = eight + 120 + sioul_core::health::SKEW;
5564 assert_eq!(pieces, [("12:30", s("2026-10-06T12:30[Europe/Paris]"), midnight, midnight), ("20:00", eight, woke, 0), ("20:00", woke, midnight, midnight)]);
5565 let _ = std::fs::remove_dir_all(&dir);
5566 }
5567
5568 fn heard_of(id: &str, n: u64) -> sioul_sync::share::Heard {
5569 sioul_sync::share::Heard { read: [(id.to_string(), (1, n))].into(), broken: Default::default() }
5570 }
5571
5572 #[test]
5581 fn an_alarm_that_read_no_news_says_a_closed_device_in_doubt() {
5582 let english = Translator::new("en");
5583 let due = 1_800_000_000;
5584 let said = sioul_core::health::Said { started: due - 7_200, closed: due - 3_600, working: false, exported: due - 3_605, imported: due - 3_605, doses: true, ..Default::default() };
5585 let laptop = Peer { id: "laptop-id".into(), name: "laptop".into(), said: Some(said), complete: true, seen: due - 3_500, heard: due - 3_605, ..Peer::default() };
5586 let peers = vec![laptop];
5587 assert!(sioul_core::health::doubts(due, due + 60, None, &peers).is_empty(), "news read: known");
5588 let doubts = sioul_core::health::doubts_unread(due, due + 60, None, &peers, due + 30, &Default::default());
5589 let words = why_in(&doubts, &english);
5590 assert!(words.starts_with("laptop closed") && words.contains("its news can be slow to come") && !words.to_lowercase().contains("not taken"), "{words}");
5591 }
5592
5593 #[test]
5599 fn how_late_a_devices_opening_comes_is_timed_while_looking() {
5600 use sioul_sync::devices::Entry;
5601 let phone = |started: i64, working: bool, closed: i64| Entry { id: "phone-id".into(), name: "FP3".into(), kind: "phone".into(), started, closed, working, exported: started.max(closed), wrote: Some((1, 5)), doses: true, ..Entry::default() };
5602 let mut kept = Peers::default();
5603 learn(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), 1_020);
5605 for now in [1_100, 1_200, 1_300] {
5606 learn(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), now);
5607 }
5608 assert_eq!(kept.peers["phone-id"].peer.wake, None);
5609 learn(&mut kept, &[], &[phone(1_150, true, 1_000)], &heard_of("phone-id", 5), 1_390);
5611 assert_eq!(kept.peers["phone-id"].peer.wake, Some(240));
5612 learn(&mut kept, &[], &[phone(1_150, false, 1_500)], &heard_of("phone-id", 5), 1_510);
5614 learn(&mut kept, &[], &[phone(1_600, true, 1_500)], &heard_of("phone-id", 5), 2_200);
5615 assert_eq!(kept.peers["phone-id"].peer.wake, Some(240));
5616 learn(&mut kept, &[], &[phone(1_600, false, 2_250)], &heard_of("phone-id", 5), 2_260);
5618 learn(&mut kept, &[], &[phone(2_300, true, 2_250)], &heard_of("phone-id", 5), 2_330);
5619 assert_eq!(kept.peers["phone-id"].peer.wake, Some(240));
5620 learn(&mut kept, &[], &[phone(2_300, false, 2_900)], &heard_of("phone-id", 5), 2_910);
5622 let peers = vec![kept.peers["phone-id"].peer.clone()];
5623 let skew = sioul_core::health::SKEW;
5624 assert_eq!(sioul_core::health::doubts_now(3_000, 3_239 + skew, None, &peers).len(), 1);
5625 assert!(sioul_core::health::doubts_now(3_000, 3_240 + skew, None, &peers).is_empty());
5626 }
5627
5628 #[test]
5633 fn an_alarm_in_doubt_asks_again_when_the_doubt_may_end() {
5634 let due = 1_800_000_000;
5635 assert_eq!(alarm_again(due, due + 5, Some(due + 300)), due + 300, "a closed device's opening could have come by then");
5636 assert_eq!(alarm_again(due, due + 5, Some(due + 30)), due + 65, "a minute apart at least");
5637 assert_eq!(alarm_again(due, due + 5, Some(due + 6)), due + WAIT_FOR_NEWS, "known by what was read: only the news missed");
5638 assert_eq!(alarm_again(due, due + 5, None), due + WAIT_FOR_NEWS);
5639 assert_eq!(alarm_again(due, due + 5, Some(due + 2 * WAIT_FOR_NEWS)), due + WAIT_FOR_NEWS);
5640 }
5641
5642 #[test]
5652 fn a_phone_held_by_sleep_waits_for_news_from_waking() {
5653 use sioul_core::health::Said;
5654 let (bed, woke) = (1_800_000_000, 1_800_028_800);
5655 let due = bed + 5 * 3600;
5656 assert_eq!(news_from(due, Some((bed, woke))), woke, "due asleep: from waking");
5657 assert_eq!(news_from(woke + 600, Some((bed, woke))), woke + 600, "due after waking: from its time");
5658 assert_eq!(news_from(bed - 600, Some((bed, woke))), bed - 600, "due before the sleep: from its time");
5659 assert_eq!(news_from(due, None), due, "no sleep held it");
5660 let since = news_from(due, Some((bed, woke)));
5661 assert_eq!(alarm_again(since, woke + 5, None), woke + WAIT_FOR_NEWS, "asked again at the end of the wait from waking");
5662 let unshown = Some(Tried { unshown: woke + 2, ..Tried::default() });
5665 assert_eq!(plan(unshown, true, true, since, woke + LATE_UNTIL + 30, true), Plan::Quiet(woke + sioul_core::dose_reminders::QUIET_AFTER));
5666 let key = format!("levo@{due}");
5667 let mut told = DoseReminders::default();
5668 *told.entry(&key, "desk-id") = Tried { unshown: woke + 2, quiet: woke + sioul_core::dose_reminders::QUIET_AFTER, ..Tried::default() };
5669 let desk = Peer {
5670 name: "desk".into(),
5671 id: "desk-id".into(),
5672 said: Some(Said { started: bed - 3_600, closed: 0, working: true, exported: woke + 540, imported: woke + 540, doses: true, left: false, phone: false }),
5673 complete: true,
5674 seen: woke + 540,
5675 heard: woke + 540,
5676 ..Peer::default()
5677 };
5678 let at_alarm = woke + WAIT_FOR_NEWS;
5679 assert_eq!(sioul_core::dose_reminders::others(&key, since, at_alarm, "phone-id", true, Some(&told), std::slice::from_ref(&desk), true), Others::First);
5680 assert!(matches!(sioul_core::dose_reminders::others(&key, due, at_alarm, "phone-id", true, Some(&told), std::slice::from_ref(&desk), true), Others::First), "the promise, not the dose's time, says it");
5681 let unpromised = { let mut told = told.clone(); told.entry(&key, "desk-id").quiet = 0; told };
5682 assert!(matches!(sioul_core::dose_reminders::others(&key, since, at_alarm, "phone-id", true, Some(&unpromised), std::slice::from_ref(&desk), true), Others::Doubt(_)));
5683 }
5684
5685 #[test]
5691 fn an_opening_is_not_timed_by_this_devices_own_outage() {
5692 use sioul_sync::devices::Entry;
5693 let phone = |started: i64, working: bool, closed: i64| Entry { id: "phone-id".into(), name: "FP3".into(), kind: "phone".into(), started, closed, working, exported: started.max(closed), wrote: Some((1, 5)), doses: true, ..Entry::default() };
5694 let opened_late = |news: Option<bool>| {
5695 let mut kept = Peers::default();
5696 learn_with(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), news, 1_020);
5697 learn_with(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), news, 1_080);
5698 for now in (1_140..19_100).step_by(60) {
5700 learn_with(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), news, now);
5701 }
5702 learn_with(&mut kept, &[], &[phone(1_100, true, 1_000)], &heard_of("phone-id", 5), news, 19_100);
5703 kept.peers["phone-id"].peer.wake
5704 };
5705 assert_eq!(opened_late(Some(false)), None, "its own pull stale: its outage, not the phone's");
5706 assert_eq!(opened_late(Some(true)), Some(18_000), "its own pull went through: the phone's news was late");
5707 assert_eq!(opened_late(None), Some(UNSURE_WAKE), "a sync app alone: an hour at most");
5708 }
5709
5710 #[test]
5711 fn what_the_devices_say_of_themselves_teaches() {
5712 use sioul_sync::devices::Entry;
5713 let phone = |exported: i64, working: bool, closed: i64| Entry { id: "phone-id".into(), name: "FP3".into(), kind: "phone".into(), started: 1_000, closed, working, exported, wrote: Some((1, 5)), doses: true, ..Entry::default() };
5714 let mut kept = Peers::default();
5715 learn(&mut kept, &[], &[phone(1_100, true, 0)], &heard_of("phone-id", 5), 1_120);
5716 let peer = &kept.peers["phone-id"].peer;
5717 assert_eq!((peer.seen, peer.complete, peer.said.as_ref().map(|s| (s.working, s.phone))), (1_120, true, Some((true, true))));
5718 learn(&mut kept, &[], &[phone(1_100, true, 0)], &heard_of("phone-id", 5), 1_180);
5720 assert_eq!(kept.peers["phone-id"].peer.seen, 1_120);
5721 learn(&mut kept, &[], &[phone(1_400, true, 0)], &heard_of("phone-id", 5), 1_220);
5723 assert_eq!((kept.peers["phone-id"].peer.seen, kept.peers["phone-id"].peer.ahead), (1_220, 180));
5724 learn(&mut kept, &[], &[Entry { wrote: Some((1, 9)), ..phone(1_460, true, 0) }], &heard_of("phone-id", 5), 1_280);
5726 assert!(!kept.peers["phone-id"].peer.complete);
5727 learn(&mut kept, &[], &[], &heard_of("phone-id", 9), 1_340);
5729 assert_eq!(kept.peers["phone-id"].peer.said, None);
5730 learn(&mut kept, &[], &[phone(1_460, true, 0)], &heard_of("phone-id", 9), 1_400);
5732 kept.peers.get_mut("phone-id").unwrap().off = Some(Mark { started: 1_000, exported: 1_460, renewed: 0, at: 1_400 });
5733 learn(&mut kept, &[], &[phone(1_460, true, 0)], &heard_of("phone-id", 9), 1_460);
5734 assert!(kept.peers["phone-id"].peer.off, "nothing newer: still off");
5735 learn(&mut kept, &[], &[phone(1_520, false, 1_525)], &heard_of("phone-id", 9), 1_530);
5736 assert!(!kept.peers["phone-id"].peer.off, "it spoke again");
5737 let open = Claim { computer: "phone-id".into(), name: "localhost".into(), since: 1_600, renewed: 1_700, until: 2_000, active: 1_700, taken: 0, wrote: Some((1, 9)), closed: false, pad: String::new() };
5740 learn(&mut kept, &[open], &[phone(1_520, false, 1_525)], &heard_of("phone-id", 9), 1_710);
5741 assert_eq!(kept.peers["phone-id"].peer.said, None);
5742 }
5743
5744 #[test]
5750 fn the_porch_says_answers_that_differ_and_which_device_may_know() {
5751 use sioul_core::health::Said;
5752 let dir = std::env::temp_dir().join(format!("sioul-porch-registry-{}", std::process::id()));
5753 let _ = std::fs::remove_dir_all(&dir);
5754 std::fs::create_dir_all(&dir).unwrap();
5755 let path = dir.join("health.toml");
5756 std::fs::write(&path, "[[medicine]]\nid = \"levo\"\nname = \"Levothyroxine\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\", \"12:00\"]\n").unwrap();
5757 let health = Health::load(&path);
5758 let english = Translator::new("en");
5759 let s = |text: &str| text.parse::<Zoned>().unwrap().timestamp().as_second();
5760 let eight = format!("levo@{}", s("2026-10-06T08:00[Europe/Paris]"));
5761 let noon = "2026-10-06T12:00:40[Europe/Paris]".parse::<Zoned>().unwrap();
5762 let mut records = DoseRecords::default();
5764 records.answer(&eight, "phone-id", Answer { state: "taken".into(), at: s("2026-10-06T08:02[Europe/Paris]"), noted: s("2026-10-06T08:02[Europe/Paris]"), name: "FP3".into(), kind: "phone".into() });
5765 records.answer(&eight, "desk-id", Answer { state: "skipped".into(), at: s("2026-10-06T08:10[Europe/Paris]"), noted: s("2026-10-06T08:10[Europe/Paris]"), name: "desk".into(), kind: "computer".into() });
5766 let mut state = HealthState::default();
5767 state.taken.insert(eight.clone(), s("2026-10-06T08:02[Europe/Paris]"));
5768 state.not_taken.insert(eight.clone(), s("2026-10-06T08:10[Europe/Paris]"));
5769 let shown = |knowledge: &Knowledge| serde_json::to_value(porch_doses(&health, &state, &records, &DoseReminders::default(), "desk-id", knowledge, &noon, false, true, &english)).unwrap();
5770 let alone = Knowledge { record_lost: None, peers: Vec::new() };
5771 let view = shown(&alone);
5772 let due = view["due"].as_array().unwrap();
5773 assert_eq!(due.len(), 2, "{view}");
5774 let differ = due[0]["doubt"].as_str().unwrap();
5775 assert!(differ.starts_with("Marked taken on the phone ") && differ.contains(", skipped here ") && differ.ends_with("Check which is right before taking it."), "{differ}");
5776 assert_eq!((due[0]["key"].as_str(), due[0]["choose"].as_bool()), (Some(eight.as_str()), Some(true)));
5777 assert_eq!((due[1]["doubt"].as_str(), due[1]["choose"].as_bool()), (Some(""), Some(false)));
5778 let phone = Peer { id: "phone-id".into(), name: "FP3".into(), said: Some(Said { started: s("2026-10-06T07:00[Europe/Paris]"), working: true, exported: s("2026-10-06T11:45[Europe/Paris]"), doses: true, phone: true, ..Said::default() }), complete: true, seen: s("2026-10-06T11:45:30[Europe/Paris]"), heard: s("2026-10-06T11:45[Europe/Paris]"), ..Peer::default() };
5780 let view = shown(&Knowledge { record_lost: None, peers: vec![phone.clone()] });
5781 let row = &view["due"][1];
5782 let doubt = row["doubt"].as_str().unwrap();
5783 assert!(doubt.starts_with("Sioul can't tell whether it was taken: the phone was in use and last shared ") && doubt.ends_with(". Check before taking it."), "{doubt}");
5784 assert!(!doubt.to_lowercase().contains("not taken"), "{doubt}");
5785 assert_eq!(row["doubt_off"], serde_json::json!([{ "id": "phone-id", "label": "This device is off" }]));
5786 let closed = Peer { said: Some(Said { started: s("2026-10-06T07:00[Europe/Paris]"), closed: s("2026-10-06T11:50[Europe/Paris]"), exported: s("2026-10-06T11:49[Europe/Paris]"), doses: true, phone: true, ..Said::default() }), ..phone.clone() };
5788 let view = shown(&Knowledge { record_lost: None, peers: vec![closed] });
5789 assert_eq!((view["due"][1]["doubt"].as_str(), view["due"][1]["doubt_off"].as_array().map(Vec::len)), (Some(""), Some(0)));
5790 let off = Peer { off: true, ..phone };
5792 let view = shown(&Knowledge { record_lost: None, peers: vec![off] });
5793 assert_eq!((view["due"][1]["doubt"].as_str(), view["due"].as_array().map(Vec::len)), (Some(""), Some(2)));
5794 let view = serde_json::to_value(porch_doses(&health, &HealthState::default(), &records, &DoseReminders::default(), "desk-id", &alone, &noon, false, true, &english)).unwrap();
5796 let keys: Vec<&str> = view["due"].as_array().unwrap().iter().map(|row| row["key"].as_str().unwrap_or("")).collect();
5797 assert_eq!(keys.iter().filter(|key| **key == eight).count(), 1, "{view}");
5798 assert_eq!(view["due"][0]["choose"].as_bool(), Some(true), "{view}");
5799 let _ = std::fs::remove_dir_all(&dir);
5800 }
5801
5802 #[test]
5805 fn your_devices_listed_in_words() {
5806 use sioul_core::health::Said;
5807 use sioul_sync::devices::Entry;
5808 let english = Translator::new("en");
5809 let now = 1_800_000_000;
5810 let entry = |id: &str, kind: &str, name: &str, working: bool, exported: i64, doses: bool| Entry { id: id.into(), name: name.into(), kind: kind.into(), started: now - 7_200, closed: if working { 0 } else { exported + 5 }, working, exported, doses, ..Entry::default() };
5811 let mut kept = Peers::default();
5812 let mut add = |entry: Entry, seen: i64, ahead: i64| {
5813 let peer = Peer { id: entry.id.clone(), name: entry.name.clone(), said: Some(entry.said()), complete: true, seen, heard: entry.exported, ahead, ..Peer::default() };
5814 kept.peers.insert(entry.id.clone(), PeerSeen { peer, entry: Some(entry), ..PeerSeen::default() });
5815 };
5816 add(entry("a-phone", "phone", "FP3", false, now - 600, true), now - 590, 240);
5817 add(entry("b-desk", "computer", "desk", true, now - 30, true), now - 25, 0);
5818 add(entry("c-laptop", "computer", "laptop", true, now - 4 * 3_600, false), now - 4 * 3_600, 0);
5819 add(entry("d-old", "computer", "old", true, now - 9 * 86_400, true), now - 9 * 86_400, 0);
5820 kept.peers.insert("e-older".into(), PeerSeen { peer: Peer { id: "e-older".into(), name: "tower".into(), heard: now - 120, ..Peer::default() }, renewed: now - 120, ..PeerSeen::default() });
5821 kept.peers.get_mut("b-desk").unwrap().peer.off = true;
5822 let peers: Vec<Peer> = kept.peers.values().map(|s| s.peer.clone()).collect();
5823 let rows = device_rows_of(&peers, &kept, now, &english);
5824 let row = |id: &str| rows.iter().find(|r| r.id == id).unwrap();
5825 assert_eq!(row("a-phone").name, "The phone (FP3)");
5826 assert!(row("a-phone").state.starts_with("Closed ") && row("a-phone").notes == ["Its clock is 4 minutes ahead of this one's, at least."], "{:?}", row("a-phone"));
5827 assert!(row("b-desk").off && row("b-desk").state == "Counted as off, as you said, until it shares again.");
5828 assert!(row("c-laptop").state.starts_with("In use when it last shared") && row("c-laptop").notes == ["It does not share its doses."], "{:?}", row("c-laptop"));
5829 assert!(row("d-old").forget && row("d-old").state.starts_with("Silent since "), "{:?}", row("d-old"));
5830 assert!(row("e-older").state.starts_with("An older Sioul: last heard "), "{:?}", row("e-older"));
5831 assert!(rows.iter().all(|r| !r.state.contains("not taken")));
5832 let _ = Said::default();
5833 let mut rows = rows;
5836 holder_rows(&mut rows, &[("e-older".into(), now - 120, true), ("f-gone".into(), now - 90 * 86_400, true), ("g-unknown".into(), 0, true), ("h-after".into(), now - 3_600, false)], &english);
5837 assert_eq!(rows.iter().filter(|r| r.id == "e-older").count(), 1);
5838 let row = |id: &str| rows.iter().find(|r| r.id == id).unwrap();
5839 assert!(!row("e-older").forget, "counted for the doses: forgotten once silent a week only");
5840 assert!(row("f-gone").forget && row("f-gone").name == "Another device of yours" && row("f-gone").state.starts_with("Known here by what it shared, last "), "{:?}", row("f-gone"));
5841 assert!(row("h-after").forget && row("h-after").state.starts_with("An older Sioul: last heard "), "{:?}", row("h-after"));
5842 assert!(row("g-unknown").forget && row("g-unknown").state == "Known here by what it shared: until it is updated there, or forgotten here, your devices keep sharing in the form an older Sioul reads.", "{:?}", row("g-unknown"));
5843 }
5844}