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::doses::{Answer, Answered, DoseRecords, Given, State};
20use sioul_core::health::{ChatLimit, Doubt, ErrandKind, Health, HealthState, Medicine, Movement, Named, Peer, Prescription, Problem, Schedule, Take, TakeProblem};
21use sioul_core::i18n::Translator;
22use sioul_core::needs::{DayEdit, DayProblem, Days, Kept, Needs};
23use sioul_core::tasks::TaskEdit;
24use std::sync::Arc;
25
26fn load() -> Health {
27 Health::load(&Health::default_path())
28}
29
30fn change_health<R>(change: impl FnOnce(&mut Health) -> Result<R, String>) -> Result<R, String> {
34 let out = Health::change(&Health::default_path(), change)?;
35 crate::alarms::schedule();
37 Ok(out)
38}
39
40fn listed(items: &[String]) -> String {
42 match items {
43 [] => String::new(),
44 [one] => one.clone(),
45 [rest @ .., last] => format!("{} {} {last}", rest.join(", "), tr().text("word-and", None)),
46 }
47}
48
49fn hhmm(text: &str) -> String {
51 let parsed = text.trim().split_once(':').and_then(|(h, m)| Some((h.trim().parse::<u8>().ok()?, m.trim().parse::<u8>().ok()?)));
52 match parsed {
53 Some((h, m)) if h < 24 && m < 60 => format!("{h:02}:{m:02}"),
54 _ => text.trim().to_string(),
55 }
56}
57
58fn words(schedule: &Schedule) -> String {
61 match schedule {
62 Schedule::Day { times, .. } => {
63 let mut times: Vec<String> = times.iter().map(|t| hhmm(t)).collect();
64 times.sort();
65 times.dedup();
66 times.join(" · ")
67 }
68 Schedule::Days { days, time, from } => {
69 let mut args = sioul_core::i18n::args();
70 args.set("days", *days);
71 args.set("time", time.clone());
72 args.set("from", tr().day(*from));
73 tr().text("health-every-days", Some(&args))
74 }
75 Schedule::Hours { hours, from } => {
76 let now = Zoned::now();
78 let next = schedule.doses(&now, &now.checked_add(Span::new().hours(i64::from(*hours))).unwrap_or_else(|_| now.clone())).into_iter().next();
79 let next = next.map_or(*from, |z| z.timestamp().as_second());
80 let next = Timestamp::from_second(next).map(|t| tr().date(&t.to_zoned(jiff::tz::TimeZone::system()), false)).unwrap_or_default();
81 let mut args = sioul_core::i18n::args();
82 args.set("hours", *hours);
83 args.set("next", next);
84 tr().text("health-every-hours", Some(&args))
85 }
86 }
87}
88
89#[derive(Serialize)]
90struct DoseRow {
91 key: String,
92 time: String,
93 name: String,
94 dose: String,
95 taken: String,
97 past: bool,
98 late: bool,
101 doubt: String,
105 doubt_off: Vec<OffButton>,
107 choose: bool,
109 question: String,
114}
115
116#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
118struct OffButton {
119 id: String,
120 label: String,
121}
122
123#[derive(Serialize)]
125struct MedicineRow {
126 #[serde(flatten)]
127 medicine: Medicine,
128 when: String,
132 amount: String,
134 precise: String,
136 line: String,
138 usual: String,
140 takes: Vec<Take>,
143 ends: String,
145 quiet: bool,
147}
148
149fn when_of(medicine: &Medicine) -> String {
152 match &medicine.schedule {
153 Schedule::Day { amounts, .. } if !amounts.is_empty() => medicine.takes_line(),
154 schedule => words(schedule),
155 }
156}
157
158fn medicine_row(m: &Medicine, today: Date) -> MedicineRow {
160 let usual = m.usual();
161 let each = matches!(&m.schedule, Schedule::Day { amounts, .. } if !amounts.is_empty());
162 let when = when_of(m);
163 let amount = if each { String::new() } else { m.dose.clone() };
164 MedicineRow {
165 precise: m.precise(),
166 line: [m.precise(), when.clone(), amount.clone()].into_iter().filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" · "),
167 when,
168 amount,
169 takes: m.takes().into_iter().map(|t| Take { amount: if t.amount == usual { String::new() } else { t.amount }, time: t.time }).collect(),
170 usual,
171 ends: m.until.map(|day| say("health-until", &[("day", tr().day_in(day, today))])).unwrap_or_default(),
172 quiet: m.paused || m.until.is_some_and(|day| day < today),
173 medicine: m.clone(),
174 }
175}
176
177#[derive(Serialize)]
179struct PrescriptionRow {
180 #[serde(flatten)]
181 prescription: Prescription,
182 next: Vec<String>,
185 covers: String,
189 lines: Vec<String>,
192 fetched_today: bool,
194 medicines: Vec<MedicineRow>,
196}
197
198fn medicine_rows(health: &Health, today: Date) -> Vec<MedicineRow> {
200 health.medicines.iter().map(|m| medicine_row(m, today)).collect()
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205enum Coming {
206 Pharmacy,
208 RenewBy,
210 ValidUntil,
212}
213
214fn coming(errands: &[sioul_core::health::Errand], p: &Prescription, today: Date) -> Vec<(Coming, Date)> {
217 let month_ago = today.checked_sub(Span::new().days(30)).unwrap_or(today);
218 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();
219 if let Some(until) = p.until {
220 next.push((if until < today { Coming::ValidUntil } else { Coming::RenewBy }, until));
221 }
222 next.sort_by_key(|(_, day)| *day);
223 next
224}
225
226fn tied<'a>(health: &'a Health, p: &'a Prescription) -> impl Iterator<Item = &'a Medicine> {
228 health.medicines.iter().filter(|m| m.prescription.as_deref() == Some(p.id.as_str()))
229}
230
231fn covered(health: &Health, p: &Prescription) -> Vec<String> {
234 let named: Vec<String> = tied(health, p).map(|m| m.name.clone()).collect();
235 let title = p.title.to_lowercase();
236 if named.iter().all(|name| title.contains(&name.to_lowercase())) { Vec::new() } else { named }
237}
238
239fn prescription_rows(health: &Health, today: Date) -> Vec<PrescriptionRow> {
241 let errands = health.errands();
242 health
243 .prescriptions
244 .iter()
245 .map(|p| {
246 let precise = tied(health, p).any(|m| !m.generic.trim().is_empty() || !m.strength.trim().is_empty());
247 let lines = if precise { tied(health, p).map(|m| medicine_row(m, today).line).collect() } else { Vec::new() };
248 let covers = if precise { Vec::new() } else { covered(health, p) };
249 PrescriptionRow {
250 lines,
251 next: coming(&errands, p, today)
252 .into_iter()
253 .map(|(what, day)| {
254 let day = [("day", tr().day_in(day, today))];
255 match what {
256 Coming::Pharmacy => say("health-next-refill", &day),
257 Coming::RenewBy => say("health-renew-by", &day),
258 Coming::ValidUntil => say("health-valid-until", &day),
259 }
260 })
261 .collect(),
262 covers: if covers.is_empty() { String::new() } else { say("health-covers", &[("names", listed(&covers))]) },
263 fetched_today: p.last_refill == Some(today),
264 medicines: tied(health, p).map(|m| medicine_row(m, today)).collect(),
265 prescription: p.clone(),
266 }
267 })
268 .collect()
269}
270
271#[derive(Serialize)]
274struct PageView {
275 week: WeekView,
276 missed: Vec<DoseRow>,
279 shared_note: String,
282 reminded_there: String,
284 later: u32,
286 any: bool,
288 medicines: Vec<MedicineRow>,
290 prescriptions: Vec<PrescriptionRow>,
291}
292
293#[derive(Serialize)]
296struct SettingsView {
297 movement: Movement,
298 chats: ChatLimit,
299 errands_list: String,
301 lists: Vec<serde_json::Value>,
302}
303
304#[derive(Serialize)]
307struct WeekView {
308 monday: String,
309 today: String,
310 from_minute: i64,
311 to_minute: i64,
312 days: Vec<DayView>,
313}
314
315#[derive(Serialize)]
317struct DayView {
318 date: String,
320 title: String,
322 weekday: String,
324 number: i8,
325 today: bool,
326 past: bool,
328 items: Vec<DayItem>,
331 segments: Vec<Segment>,
333 context: Vec<ContextItem>,
335 lines: Vec<String>,
337}
338
339#[derive(Serialize)]
341struct DayItem {
342 key: String,
344 kind: &'static str,
346 name: String,
347 dose: String,
349 from: String,
352 to: String,
353 at: String,
355 detail: String,
357 note: String,
360 changed: bool,
362 added: bool,
363 quiet: bool,
364 off: bool,
365 past: bool,
367 minutes: u32,
369 before: u32,
370 after: u32,
371 taken: String,
374 late: bool,
375 doubt: String,
376 doubt_off: Vec<OffButton>,
378 choose: bool,
379 alarm: String,
382 alarm_skipped: bool,
383 alarm_open: bool,
386}
387
388#[derive(Serialize)]
391struct Segment {
392 date: String,
394 key: String,
395 kind: &'static str,
397 name: String,
398 from_minute: i64,
399 to_minute: i64,
400 start_minute: i64,
403 end_minute: i64,
404 at_minute: i64,
406 until_minute: i64,
407 quiet: bool,
408 past: bool,
409 state: &'static str,
411}
412
413#[derive(Serialize)]
415struct ContextItem {
416 title: String,
417 kind: &'static str,
419 from_minute: i64,
420 to_minute: i64,
421}
422
423fn errands_list(health: &Health) -> Option<String> {
426 let lists: Vec<sioul_core::vdir::Collection> = sioul_core::tasks::lists().into_iter().filter(|c| !c.read_only).collect();
427 let id = |c: &sioul_core::vdir::Collection| format!("{}/{}", c.account, c.id);
428 let usual = crate::backend::load_config().tasks.list.clone().unwrap_or_default();
429 [health.errands_list.clone(), usual]
430 .into_iter()
431 .find(|wanted| !wanted.is_empty() && lists.iter().any(|c| id(c) == *wanted))
432 .or_else(|| lists.iter().find(|c| c.account != sioul_core::vdir::LOCAL).map(id))
433 .or_else(|| lists.first().map(id))
434}
435
436static SHOWN: std::sync::Mutex<Option<Date>> = std::sync::Mutex::new(None);
438
439pub(crate) fn show_week(day: &str) {
441 if let Ok(mut shown) = SHOWN.lock() {
442 *shown = day.trim().parse().ok();
443 }
444}
445
446fn clock(at: i64, zone: &TimeZone) -> String {
448 Timestamp::from_second(at).map(|t| t.to_zoned(zone.clone()).strftime("%H:%M").to_string()).unwrap_or_default()
449}
450
451fn midnight(date: Date, zone: &TimeZone) -> i64 {
453 date.to_zoned(zone.clone()).map_or(0, |z| z.timestamp().as_second())
454}
455
456fn capitalized(text: &str) -> String {
458 let mut chars = text.chars();
459 chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
460}
461
462pub(crate) fn page() -> String {
464 let health = load();
465 let state = record();
466 let knowledge = know();
467 let now = Zoned::now();
468 let shown = SHOWN.lock().ok().and_then(|s| *s).unwrap_or(now.date());
469 let records = records();
470 let week = week_view(&health, &state, &records, &knowledge, shown, &now);
471 let needs = &health.needs;
472 json(&PageView {
473 any: needs.meals_on || needs.naps_on || needs.sleep_on || !health.medicines.is_empty() || week.days.iter().any(|d| !d.items.is_empty()),
474 week,
475 missed: missed_rows(&health, &state, &knowledge, &now, tr()),
476 shared_note: if health.medicines.is_empty() { String::new() } else { shared_note(&knowledge) },
477 reminded_there: [
478 REMINDED_THERE.lock().map(|r| r.clone()).unwrap_or_default(),
479 if health.medicines.is_empty() || crate::alarms::notifications_allowed() { String::new() } else { tr().text("dose-notifications-off", None) },
480 if health.medicines.is_empty() || crate::alarms::exact() { String::new() } else { tr().text("dose-alarms-inexact", None) },
481 ]
482 .into_iter()
483 .filter(|t| !t.is_empty())
484 .collect::<Vec<_>>()
485 .join(" "),
486 later: needs.later,
487 medicines: medicine_rows(&health, now.date()),
488 prescriptions: prescription_rows(&health, now.date()),
489 })
490}
491
492pub(crate) fn settings_view() -> String {
494 let health = load();
495 let config = crate::backend::load_config();
496 json(&SettingsView {
497 movement: health.movement.clone(),
498 chats: health.chats.clone(),
499 errands_list: errands_list(&health).unwrap_or_default(),
500 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(),
501 })
502}
503
504fn week_events(monday: Date, zone: &TimeZone) -> Arc<Vec<sioul_core::agenda::Occurrence>> {
507 type Cached = Option<(i64, Date, Arc<Vec<sioul_core::agenda::Occurrence>>)>;
508 static CACHE: std::sync::Mutex<Cached> = std::sync::Mutex::new(None);
509 let stamp = Timestamp::now().as_second();
510 let mut cache = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
511 if let Some((at, day, events)) = cache.as_ref()
512 && *day == monday
513 && (0..300).contains(&(stamp - at))
514 {
515 return Arc::clone(events);
516 }
517 let from = midnight(monday, zone);
518 let to = monday.checked_add(Span::new().days(7)).map_or(from + 7 * 86_400, |d| midnight(d, zone));
519 let events = Arc::new(sioul_core::agenda::occurrences(from, to));
520 *cache = Some((stamp, monday, Arc::clone(&events)));
521 events
522}
523
524fn night_of(kept: &Kept, zone: &TimeZone) -> Option<Date> {
527 let bed = Timestamp::from_second(kept.at).ok()?.to_zoned(zone.clone());
528 if bed.hour() < 12 { bed.date().yesterday().ok() } else { Some(bed.date()) }
529}
530
531fn week_view(health: &Health, state: &HealthState, records: &DoseRecords, knowledge: &Knowledge, day: Date, now: &Zoned) -> WeekView {
534 let zone = now.time_zone().clone();
535 let monday = day.checked_sub(Span::new().days(i64::from(day.weekday().to_monday_zero_offset()))).unwrap_or(day);
536 let days = days();
537 let events = week_events(monday, &zone);
538 let held = sioul_core::plan::event_spans(&events, 0);
540 let steps = crate::work::day_steps();
541 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();
542 let (mut first, mut last) = (i64::MAX, i64::MIN);
545 for day in &days {
546 for s in &day.segments {
547 match s.kind {
548 "sleep" if s.from_minute <= 0 => first = first.min(s.to_minute),
549 "sleep" => last = last.max(s.at_minute.min(24 * 60)),
550 _ => {
551 first = first.min(s.from_minute);
552 last = last.max(s.to_minute);
553 }
554 }
555 }
556 for c in &day.context {
557 first = first.min(c.from_minute);
558 last = last.max(c.to_minute);
559 }
560 }
561 let first = if first == i64::MAX { 7 * 60 } else { first };
562 let last = if last == i64::MIN { 22 * 60 } else { last };
563 let from = (first - 30).clamp(0, 23 * 60) / 60 * 60;
564 let to = ((last + 30 + 59) / 60 * 60).clamp(from + 6 * 60, 24 * 60);
565 WeekView { monday: monday.to_string(), today: now.date().to_string(), from_minute: from.min(to - 60), to_minute: to, days }
566}
567
568struct Around<'a> {
570 events: &'a [sioul_core::agenda::Occurrence],
571 held: &'a [(i64, i64)],
572 steps: &'a [(i64, i64, String)],
573}
574
575fn day_view(health: &Health, state: &HealthState, records: &DoseRecords, knowledge: &Knowledge, days: &Days, around: &Around, date: Date, now: &Zoned) -> DayView {
577 let zone = now.time_zone().clone();
578 let today = now.date();
579 let stamp = now.timestamp().as_second();
580 let start = midnight(date, &zone);
581 let next = date.tomorrow().map_or(start + 86_400, |d| midnight(d, &zone));
582 let minute = |at: i64| -> i64 {
584 let Ok(t) = Timestamp::from_second(at) else { return 0 };
585 let z = t.to_zoned(zone.clone());
586 let after = date.until(z.date()).map_or(0, |s| i64::from(s.get_days()));
587 after * 24 * 60 + i64::from(z.hour()) * 60 + i64::from(z.minute())
588 };
589 let needs = &health.needs;
590 let pushed = needs.past_events_on(date, &zone, days, around.held);
591 let shift = |key: &str| pushed.get(key).copied().unwrap_or(0);
592 let past_day = date < today;
593 let mut items: Vec<(i64, DayItem)> = Vec::new();
594 let mut segments: Vec<Segment> = Vec::new();
595 let block_item = |k: &Kept, off: bool| -> DayItem {
596 let change = days.get(date, &k.key);
597 let added = sioul_core::needs::is_added(&k.key);
598 let (minutes, before, after) = needs.lengths(date, &k.key, days).unwrap_or((0, 0, 0));
599 let detail = match k.kind {
600 "meal" if before > 0 => say("need-meal-detail", &[("at", clock(k.at, &zone))]),
601 "nap" if after > 0 => say("need-nap-detail", &[("minutes", after.to_string())]),
602 "sleep" => say("need-night-detail", &[("bed", clock(k.at, &zone))]),
603 _ => String::new(),
604 };
605 let (alarm, alarm_skipped) = if k.kind == "sleep" && !off { crate::wake::of_night(needs, date, &zone, days) } else { (String::new(), false) };
607 let alarm_open = !alarm.is_empty() && needs.alarm_of(date, &zone, days).is_some_and(|a| a.at > stamp);
608 let detail = if alarm.is_empty() || alarm_skipped { detail } else { format!("{detail} · {}", say("wake-at", &[("time", alarm.clone())])) };
609 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());
610 let quiet = change.is_some_and(|c| c.quiet);
611 let note = if off {
612 tr().text("need-off", None)
613 } else if added {
614 tr().text("need-added", None)
615 } else if changed {
616 tr().text("need-changed", None)
617 } else if shift(&k.key) > 0 {
618 tr().text("need-pushed", None)
619 } else {
620 String::new()
621 };
622 let note = if quiet && !off { [note, tr().text("need-quiet", None)].into_iter().filter(|n| !n.is_empty()).collect::<Vec<_>>().join(" · ") } else { note };
623 let note = if alarm_skipped { [note, tr().text("wake-none", None)].into_iter().filter(|n| !n.is_empty()).collect::<Vec<_>>().join(" · ") } else { note };
624 DayItem {
625 key: k.key.clone(),
626 kind: k.kind,
627 name: if k.kind == "sleep" { tr().text("needs-sleep", None) } else { block_name(k.kind, &k.key, &k.name) },
628 dose: String::new(),
629 from: clock(k.start, &zone),
630 to: clock(k.end, &zone),
631 at: clock(k.at, &zone),
632 detail,
633 note,
634 changed,
635 added,
636 quiet,
637 off,
638 past: past_day || k.end <= stamp,
639 minutes,
640 before,
641 after,
642 taken: String::new(),
643 late: false,
644 doubt: String::new(),
645 doubt_off: Vec::new(),
646 choose: false,
647 alarm,
648 alarm_skipped,
649 alarm_open,
650 }
651 };
652 for k in needs.blocks_of(date, &zone, days, &shift) {
653 let item = block_item(&k, false);
654 let until = if k.kind == "sleep" { k.end } else { k.at + i64::from(item.minutes) * 60 };
656 if minute(k.start) < 24 * 60 {
657 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: "" });
658 }
659 items.push((minute(k.start), item));
660 }
661 let usual = needs.blocks_of(date, &zone, &Days::default(), &|_| 0);
663 for k in usual.iter().filter(|k| days.get(date, &k.key).is_some_and(|c| c.off)) {
664 items.push((minute(k.start), block_item(k, true)));
665 }
666 for k in needs.kept_with(date, &zone, days, &shift).iter().filter(|k| k.kind == "sleep") {
668 let Some(night) = night_of(k, &zone).filter(|night| *night != date) else { continue };
669 segments.push(Segment {
670 date: night.to_string(),
671 key: k.key.clone(),
672 kind: "sleep",
673 name: tr().text("needs-sleep", None),
674 from_minute: minute(k.start).max(0),
675 to_minute: minute(k.end).min(24 * 60),
676 start_minute: minute(k.start),
677 end_minute: minute(k.end),
678 at_minute: minute(k.at),
679 until_minute: minute(k.end),
680 quiet: days.get(night, "sleep").is_some_and(|c| c.quiet),
681 past: night < today || k.end <= stamp,
682 state: "",
683 });
684 }
685 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())));
688 if let (Ok(from_z), Ok(to_z)) = (from_z, to_z) {
689 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();
690 if date == today {
693 for key in state.taken.keys() {
694 let Some((id, due)) = key.rsplit_once('@') else { continue };
695 let (Some(medicine), Ok(due)) = (health.medicines.iter().find(|m| m.id == id), due.parse::<i64>()) else { continue };
696 if due >= start && due < next && !doses.iter().any(|d| &d.0 == key) {
697 doses.push((key.clone(), due, medicine.short_name(), health.amount_of(key, &zone).unwrap_or_default()));
698 }
699 }
700 }
701 for (key, due, name, dose) in doses {
702 let answered = if date == today { sioul_core::doses::answered(&key, state, records) } else { Answered::Open };
706 let (taken, marked) = match &answered {
707 Answered::Taken(given) => (clock(given.at, &zone), true),
708 _ => (String::new(), false),
709 };
710 let (doubt, doubt_off) = match &answered {
711 Answered::Differ(given) => (differ_sentence(given, &this_device(), tr()), Vec::new()),
712 _ if date != today || marked || due > stamp => (String::new(), Vec::new()),
713 _ => doubt_now_with(knowledge, due, stamp, tr()),
714 };
715 let item = DayItem {
716 taken,
717 choose: matches!(answered, Answered::Differ(_)),
718 doubt_off,
719 late: date == today && !marked && stamp - due > GRACE_MINUTES * 60,
720 key: key.clone(),
721 kind: "dose",
722 name: name.clone(),
723 dose,
724 from: clock(due, &zone),
725 to: String::new(),
726 at: clock(due, &zone),
727 detail: String::new(),
728 note: String::new(),
729 changed: false,
730 added: false,
731 quiet: false,
732 off: false,
733 past: past_day || due <= stamp,
734 minutes: 0,
735 before: 0,
736 after: 0,
737 doubt,
738 alarm: String::new(),
739 alarm_skipped: false,
740 alarm_open: false,
741 };
742 let state = if date != today { "" } else if marked { "taken" } else if item.doubt.is_empty() { "due" } else { "check" };
743 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 });
744 items.push((minute(due), item));
745 }
746 }
747 items.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| (a.1.kind == "dose").cmp(&(b.1.kind == "dose"))));
748 let mut context = Vec::new();
750 let mut lines = Vec::new();
751 for event in around.events.iter().filter(|e| !e.cancelled && e.end > start && e.start < next) {
752 if event.all_day {
753 lines.push(say("day-all-day", &[("what", event.summary.clone())]));
754 } else {
755 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) });
756 }
757 }
758 if date == today {
759 for (from, to, title) in around.steps.iter().filter(|(from, to, _)| *to > start && *from < next) {
760 context.push(ContextItem { title: title.clone(), kind: "task", from_minute: minute(*from).max(0), to_minute: minute(*to).min(24 * 60) });
761 }
762 }
763 context.sort_by_key(|c| c.from_minute);
764 for errand in health.errands().into_iter().filter(|e| e.day == date) {
765 let what = say(if errand.kind == ErrandKind::Refill { "health-errand-refill" } else { "health-errand-renew" }, &[("title", errand.title.clone())]);
766 lines.push(say("health-errand-day", &[("what", what)]));
767 }
768 let named = tr().day(date);
769 let title = if date == today {
770 format!("{}, {named}", tr().text("agenda-today", None))
771 } else if today.tomorrow().is_ok_and(|t| t == date) {
772 format!("{}, {named}", tr().text("agenda-tomorrow", None))
773 } else {
774 capitalized(&named)
775 };
776 DayView {
777 date: date.to_string(),
778 title,
779 weekday: tr().weekday_short(date),
780 number: date.day(),
781 today: date == today,
782 past: past_day,
783 items: items.into_iter().map(|(_, item)| item).collect(),
784 segments,
785 context,
786 lines,
787 }
788}
789
790fn missed_rows(health: &Health, state: &HealthState, knowledge: &Knowledge, now: &Zoned, words: &Translator) -> Vec<DoseRow> {
796 let mut rows: Vec<DoseRow> = state
797 .unanswered(health, now, MISSED_HOURS, GRACE_MINUTES)
798 .into_iter()
799 .map(|d| {
800 let (doubt, doubt_off) = doubt_now_with(knowledge, d.at.timestamp().as_second(), now.timestamp().as_second(), words);
801 DoseRow {
802 taken: String::new(),
803 past: true,
804 late: true,
805 doubt,
806 doubt_off,
807 choose: false,
808 question: if state.reminder_unshown(&d.key) { words.text("health-unshown-question", None) } else { words.text("health-missed-question", None) },
809 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")) },
810 key: d.key,
811 name: d.name,
812 dose: d.dose,
813 }
814 })
815 .collect();
816 rows.sort_by_key(|row| state.reminder_unshown(&row.key));
817 rows
818}
819
820fn missed_title(missed: &[sioul_core::health::Dose], state: &HealthState, words: &Translator) -> String {
825 let unshown = missed.iter().filter(|d| state.reminder_unshown(&d.key)).count();
826 match unshown {
827 0 => words.text("health-missed", None),
828 n if n == missed.len() => words.text("health-unshown", None),
829 _ => words.text("health-missed-some", None),
830 }
831}
832
833fn note_minute(record: &mut HealthState, reminded: &[String], unshown: &[String], made: &[(String, String)], stamp: i64) {
837 for key in reminded {
838 record.reminded.insert(key.clone(), stamp);
839 }
840 for key in unshown {
841 record.unshown.insert(key.clone(), stamp);
842 }
843 for (key, uid) in made {
844 record.errands.insert(key.clone(), uid.clone());
845 }
846}
847
848#[derive(Default, Serialize)]
858struct PorchDoses {
859 due: Vec<DoseRow>,
860 closed: Vec<DoseRow>,
861}
862
863#[allow(clippy::too_many_arguments)]
866fn porch_doses(health: &Health, state: &HealthState, records: &DoseRecords, me: &str, knowledge: &Knowledge, now: &Zoned, silent: bool, heard: bool, words: &Translator) -> PorchDoses {
867 if silent || health.medicines.is_empty() {
868 return PorchDoses::default();
869 }
870 let stamp = now.timestamp().as_second();
871 let row = |d: sioul_core::health::Dose, doubt: String, doubt_off: Vec<OffButton>, choose: bool| {
872 let at = d.at.timestamp().as_second();
873 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 }
874 };
875 let mut due: Vec<DoseRow> = state
877 .due_today(health, now, MISSED_HOURS, GRACE_MINUTES)
878 .into_iter()
879 .filter(|d| sioul_core::doses::answered(&d.key, state, records) == Answered::Open)
880 .map(|d| {
881 let (doubt, doubt_off) = doubt_now_with(knowledge, d.at.timestamp().as_second(), stamp, words);
882 row(d, doubt, doubt_off, false)
883 })
884 .collect();
885 let midnight = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
887 let back = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
888 let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
889 for d in health.doses(if back > midnight { &back } else { &midnight }, &end) {
890 if let Answered::Differ(given) = sioul_core::doses::answered(&d.key, state, records) {
891 let sentence = differ_sentence(&given, me, words);
892 due.push(row(d, sentence, Vec::new(), true));
893 }
894 }
895 due.sort_by_key(|row| due_of(&row.key).unwrap_or(0));
896 PorchDoses { due, closed: if heard { missed_rows(health, state, knowledge, now, words) } else { Vec::new() } }
897}
898
899pub(crate) fn missed() -> String {
901 let health = load();
902 if health.medicines.is_empty() {
903 return json(&PorchDoses::default());
904 }
905 let heard = !crate::share::on() || crate::share::last_exchange().is_some();
906 json(&porch_doses(&health, &record(), &records(), &this_device(), &know(), &Zoned::now(), crate::hours::doses_silent(), heard, tr()))
907}
908
909#[derive(Deserialize)]
911struct MedicineEdit {
912 #[serde(default)]
914 name: String,
915 #[serde(default)]
917 generic: String,
918 #[serde(default)]
919 strength: String,
920 #[serde(default)]
922 since: String,
923 #[serde(default)]
925 dose: String,
926 every: String,
928 #[serde(default)]
930 takes: Vec<Take>,
931 #[serde(default)]
933 times: Vec<String>,
934 #[serde(default)]
935 days: u32,
936 #[serde(default)]
937 hours: u32,
938 #[serde(default)]
940 from: String,
941 #[serde(default)]
942 time: String,
943 #[serde(default)]
944 until: String,
945 #[serde(default)]
946 prescription: String,
947 #[serde(default)]
948 paused: bool,
949}
950
951fn answer(result: Result<String, String>) -> String {
952 match result {
953 Ok(id) => serde_json::json!({ "id": id }).to_string(),
954 Err(error) => serde_json::json!({ "error": error }).to_string(),
955 }
956}
957
958fn take_problem(problem: &TakeProblem, name: Option<&str>, words: &Translator) -> String {
961 let id = match problem {
962 TakeProblem::None => "health-no-take",
963 TakeProblem::Unreadable(_) => "health-take-unreadable",
964 TakeProblem::Twice(_) => "health-take-twice",
965 };
966 let mut pairs = vec![];
967 if let TakeProblem::Unreadable(time) | TakeProblem::Twice(time) = problem {
968 pairs.push(("time", time.clone()));
969 }
970 match name {
971 Some(name) => {
972 pairs.push(("name", name.to_string()));
973 said(words, &format!("{id}-of"), &pairs)
974 }
975 None => said(words, id, &pairs),
976 }
977}
978
979fn apply_medicine(health: &mut Health, id: &str, edit: MedicineEdit, now: &Zoned, words: &Translator) -> Result<String, String> {
982 let name = if edit.name.trim().is_empty() { edit.generic.trim() } else { edit.name.trim() }.to_string();
984 if name.is_empty() {
985 return Err(words.text("health-no-name", None));
986 }
987 let day = |text: &str| text.trim().parse::<Date>().ok();
988 let id = if id.is_empty() { health.new_id(&name) } else { id.to_string() };
989 let mut medicine = Medicine {
990 id: id.clone(),
991 name,
992 generic: edit.generic.trim().to_string(),
993 strength: edit.strength.trim().to_string(),
994 since: day(&edit.since),
995 dose: edit.dose.trim().to_string(),
996 schedule: Schedule::Day { times: Vec::new(), amounts: Default::default() },
997 prescription: Some(edit.prescription).filter(|p| !p.is_empty()),
998 until: day(&edit.until),
999 paused: edit.paused,
1000 };
1001 match edit.every.as_str() {
1002 "days" => medicine.schedule = Schedule::Days { days: edit.days.max(1), time: edit.time.trim().to_string(), from: day(&edit.from).unwrap_or(now.date()) },
1003 "hours" => {
1004 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());
1005 medicine.schedule = Schedule::Hours { hours: edit.hours.max(1), from };
1006 }
1007 _ => {
1008 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 };
1009 medicine.set_takes(&edit.dose, &takes).map_err(|problem| take_problem(&problem, None, words))?;
1010 }
1011 }
1012 match health.medicines.iter_mut().find(|m| m.id == id) {
1013 Some(slot) => *slot = medicine,
1014 None => health.medicines.push(medicine),
1015 }
1016 Ok(id)
1017}
1018
1019pub(crate) fn save_medicine(id: &str, edit: &str) -> String {
1021 let result = serde_json::from_str::<MedicineEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
1022 change_health(|health| apply_medicine(health, id, edit, &Zoned::now(), tr()))
1023 });
1024 answer(result)
1025}
1026
1027#[derive(Deserialize)]
1029struct PrescriptionEdit {
1030 title: String,
1031 #[serde(default)]
1032 prescriber: String,
1033 #[serde(default)]
1034 until: String,
1035 #[serde(default)]
1036 refill_days: u32,
1037 #[serde(default)]
1038 last_refill: String,
1039 #[serde(default)]
1040 note: String,
1041 #[serde(default)]
1043 medicines: Option<Vec<PrescribedEdit>>,
1044}
1045
1046#[derive(Deserialize)]
1048struct PrescribedEdit {
1049 #[serde(default)]
1051 id: String,
1052 #[serde(default)]
1053 name: String,
1054 #[serde(default)]
1056 generic: String,
1057 #[serde(default)]
1058 strength: String,
1059 #[serde(default)]
1061 dose: String,
1062 #[serde(default)]
1065 takes: Vec<Take>,
1066 #[serde(default)]
1068 removed: bool,
1069}
1070
1071fn apply_prescription(health: &mut Health, id: &str, edit: PrescriptionEdit, words: &Translator) -> Result<String, String> {
1076 if edit.title.trim().is_empty() {
1077 return Err(words.text("health-no-name", None));
1078 }
1079 let id = if id.is_empty() { health.new_id(&edit.title) } else { id.to_string() };
1080 let mut made: Vec<Medicine> = Vec::new();
1081 let mut gone: Vec<String> = Vec::new();
1082 for row in edit.medicines.iter().flatten() {
1083 if row.removed {
1084 gone.extend(Some(row.id.clone()).filter(|id| !id.is_empty()));
1085 continue;
1086 }
1087 let name = if row.name.trim().is_empty() { row.generic.trim() } else { row.name.trim() };
1089 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()) {
1091 continue;
1092 }
1093 if name.is_empty() {
1094 return Err(words.text("health-medicine-no-name", None));
1095 }
1096 let mut medicine = health.medicines.iter().find(|m| !row.id.is_empty() && m.id == row.id).cloned().unwrap_or_else(|| Medicine {
1097 id: row.id.clone(),
1098 name: String::new(),
1099 dose: String::new(),
1100 schedule: Schedule::Day { times: Vec::new(), amounts: Default::default() },
1101 prescription: None,
1102 until: None,
1103 paused: false,
1104 generic: String::new(),
1105 strength: String::new(),
1106 since: None,
1107 });
1108 medicine.name = name.to_string();
1109 medicine.generic = row.generic.trim().to_string();
1110 medicine.strength = row.strength.trim().to_string();
1111 medicine.prescription = Some(id.clone());
1112 if matches!(medicine.schedule, Schedule::Day { .. }) {
1113 medicine.set_takes(&row.dose, &row.takes).map_err(|problem| take_problem(&problem, Some(name), words))?;
1114 } else {
1115 medicine.dose = row.dose.trim().to_string();
1116 }
1117 made.push(medicine);
1118 }
1119 let prescription = Prescription {
1120 id: id.clone(),
1121 title: edit.title.trim().to_string(),
1122 prescriber: edit.prescriber.trim().to_string(),
1123 until: edit.until.trim().parse().ok(),
1124 refill_days: Some(edit.refill_days).filter(|d| *d > 0),
1125 last_refill: edit.last_refill.trim().parse().ok(),
1126 note: edit.note.trim().to_string(),
1127 };
1128 match health.prescriptions.iter_mut().find(|p| p.id == id) {
1129 Some(slot) => *slot = prescription,
1130 None => health.prescriptions.push(prescription),
1131 }
1132 health.medicines.retain(|m| !gone.contains(&m.id));
1133 for mut medicine in made {
1134 if medicine.id.is_empty() {
1135 medicine.id = health.new_id(&medicine.name);
1136 }
1137 match health.medicines.iter_mut().find(|m| m.id == medicine.id) {
1138 Some(slot) => *slot = medicine,
1139 None => health.medicines.push(medicine),
1140 }
1141 }
1142 Ok(id)
1143}
1144
1145pub(crate) fn save_prescription(id: &str, edit: &str) -> String {
1147 let result = serde_json::from_str::<PrescriptionEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
1148 change_health(|health| apply_prescription(health, id, edit, tr()))
1149 });
1150 answer(result)
1151}
1152
1153#[derive(Serialize, Debug, PartialEq)]
1156struct ForProfessional {
1157 title: String,
1159 about: Vec<String>,
1161 medicines: Vec<Shown>,
1162 empty: String,
1164}
1165
1166#[derive(Serialize, Debug, PartialEq)]
1168struct Shown {
1169 molecule: String,
1172 brand: String,
1174 takes: Vec<String>,
1177 since: String,
1179 prescriber: String,
1182 notes: Vec<String>,
1184}
1185
1186fn how_long(since: Date, today: Date, words: &Translator) -> String {
1188 let days = since.until(today).map_or(0, |s| i64::from(s.get_days())).max(0);
1189 let (id, n) = if days == 0 {
1190 return String::new();
1191 } else if days < 14 {
1192 ("health-pro-days", days)
1193 } else if days < 61 {
1194 ("health-pro-weeks", days / 7)
1195 } else if days < 730 {
1196 ("health-pro-months", days * 12 / 365)
1197 } else {
1198 ("health-pro-years", days / 365)
1199 };
1200 words.text(id, Some(&words.counted(usize::try_from(n).unwrap_or(0))))
1201}
1202
1203fn for_professional_of(health: &Health, prescription: &str, today: Date, words: &Translator) -> ForProfessional {
1207 let p = health.prescriptions.iter().find(|p| p.id == prescription);
1208 let medicines: Vec<&Medicine> = match p {
1209 Some(p) => tied(health, p).collect(),
1210 None => health.medicines.iter().filter(|m| !m.paused && m.until.is_none_or(|day| day >= today)).collect(),
1211 };
1212 let shown = medicines
1213 .into_iter()
1214 .map(|m| {
1215 let (generic, strength) = (m.generic.trim(), m.strength.trim());
1216 let first = if generic.is_empty() { m.name.trim() } else { generic };
1217 let molecule = [first, strength].into_iter().filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ");
1218 let brand = if generic.is_empty() || m.name.trim().to_lowercase() == generic.to_lowercase() { String::new() } else { m.name.trim().to_string() };
1219 let takes = match &m.schedule {
1220 Schedule::Day { .. } => m.takes().into_iter().map(|t| if t.amount.is_empty() { t.time } else { format!("{} — {}", t.time, t.amount) }).collect(),
1221 schedule => vec![[words_of(schedule, words), m.dose.trim().to_string()].into_iter().filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" — ")],
1222 };
1223 let since = m
1224 .since
1225 .map(|day| match how_long(day, today, words) {
1226 long if long.is_empty() => said(words, "health-pro-since-day", &[("day", words.day_in(day, today))]),
1227 long => said(words, "health-pro-since", &[("day", words.day_in(day, today)), ("long", long)]),
1228 })
1229 .unwrap_or_default();
1230 let prescriber = match (p, m.prescription.as_deref().and_then(|id| health.prescriptions.iter().find(|p| p.id == id))) {
1231 (None, Some(of)) if !of.prescriber.trim().is_empty() => said(words, "health-pro-prescribed-by", &[("name", of.prescriber.trim().to_string())]),
1232 _ => String::new(),
1233 };
1234 let mut notes: Vec<String> = m.until.map(|day| said(words, "health-pro-until", &[("day", words.day_in(day, today))])).into_iter().collect();
1235 if m.paused {
1236 notes.push(words.text("health-pause", None));
1237 }
1238 Shown { molecule, brand, takes, since, prescriber, notes }
1239 })
1240 .collect::<Vec<_>>();
1241 let about = match p {
1242 Some(p) => [
1243 Some(p.prescriber.trim()).filter(|w| !w.is_empty()).map(|name| said(words, "health-pro-prescribed-by", &[("name", name.to_string())])),
1244 p.until.map(|day| said(words, "health-pro-valid-until", &[("day", words.day_in(day, today))])),
1245 ]
1246 .into_iter()
1247 .flatten()
1248 .collect(),
1249 None => vec![said(words, "health-pro-as-of", &[("day", format!("{} {}", words.day(today), today.year()))])],
1250 };
1251 ForProfessional {
1252 title: p.map_or_else(|| words.text("health-pro-current", None), |p| p.title.clone()),
1253 about,
1254 empty: if shown.is_empty() { words.text("health-pro-none", None) } else { String::new() },
1255 medicines: shown,
1256 }
1257}
1258
1259fn words_of(schedule: &Schedule, words: &Translator) -> String {
1261 let mut args = sioul_core::i18n::args();
1263 match schedule {
1264 Schedule::Days { days, time, from } => {
1265 args.set("days", *days);
1266 args.set("time", time.clone());
1267 args.set("from", words.day(*from));
1268 words.text("health-every-days", Some(&args))
1269 }
1270 Schedule::Hours { hours, .. } => {
1271 args.set("hours", *hours);
1272 words.text("health-pro-every-hours", Some(&args))
1273 }
1274 Schedule::Day { .. } => self::words(schedule),
1275 }
1276}
1277
1278pub(crate) fn for_professional(prescription: &str) -> String {
1281 json(&for_professional_of(&load(), prescription, Zoned::now().date(), tr()))
1282}
1283
1284pub(crate) fn remove(id: &str) -> String {
1286 change_health(|health| {
1287 health.medicines.retain(|m| m.id != id);
1288 health.prescriptions.retain(|p| p.id != id);
1289 for medicine in health.medicines.iter_mut().filter(|m| m.prescription.as_deref() == Some(id)) {
1290 medicine.prescription = None;
1291 }
1292 Ok(())
1293 })
1294 .err()
1295 .unwrap_or_default()
1296}
1297
1298pub(crate) fn refilled(id: &str) -> String {
1300 change_health(|health| {
1301 if let Some(p) = health.prescriptions.iter_mut().find(|p| p.id == id) {
1302 p.last_refill = Some(Zoned::now().date());
1303 }
1304 Ok(())
1305 })
1306 .err()
1307 .unwrap_or_default()
1308}
1309
1310const MISSED_HOURS: i64 = 12;
1313const GRACE_MINUTES: i64 = 30;
1314static MOVED: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
1316
1317fn movement_tick(qt: &QtThread, shared: &Arc<Shared>, health: &Health, now: &Zoned, here: bool) {
1322 use std::sync::atomic::Ordering;
1323 let stamp = now.timestamp().as_second();
1324 let last = MOVED.load(Ordering::Relaxed);
1325 if last == 0 || !health.movement.enabled || sioul_core::timelog::running().is_some() || !crate::hours::comes(sioul_core::attention::Kind::Move) {
1328 MOVED.store(stamp, Ordering::Relaxed);
1329 return;
1330 }
1331 if !here || stamp - last < i64::from(health.movement.minutes.max(10)) * 60 {
1332 return;
1333 }
1334 MOVED.store(stamp, Ordering::Relaxed);
1335 let qt_open = qt.clone();
1336 let open: Box<dyn FnOnce() + Send> = Box::new(move || {
1337 let _ = qt_open.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("stopped"), QString::default(), QString::default()));
1338 });
1339 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))) {
1340 tell(qt, shared, e);
1341 }
1342}
1343
1344static ASKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1346static SENT: std::sync::Mutex<std::collections::BTreeSet<String>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
1348static REMINDED_THERE: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
1350
1351fn shared_note(knowledge: &Knowledge) -> String {
1354 if !crate::share::on() {
1355 return tr().text("health-alone", None);
1356 }
1357 let now = Timestamp::now().as_second();
1358 let doubts = sioul_core::health::doubts_now(now - WAIT_FOR_NEWS, now, knowledge.record_lost, &knowledge.peers);
1362 let mut lines: Vec<String> = Vec::new();
1363 if !doubts.is_empty() {
1364 lines.push(say("dose-doubt-now", &[("why", why(&doubts))]));
1365 }
1366 for peer in knowledge.peers.iter().filter(|p| p.off) {
1368 lines.push(say("dose-off-note", &[("name", device_start(&named_of(peer), tr()))]));
1369 }
1370 lines.join(" ")
1371}
1372
1373pub(crate) fn show_health(qt: &QtThread, shared: &Arc<Shared>) {
1377 let qt = qt.clone();
1378 crate::backend::coalesced(shared, |s| &s.health_job, move |_| {
1379 let (view, missed, needs) = (page(), missed(), needs_today());
1380 if cfg!(target_os = "android") {
1381 eprintln!("sioul: health: {} medicines, {} prescriptions", load().medicines.len(), load().prescriptions.len());
1382 }
1383 let _ = qt.queue(move |mut sioul| {
1384 sioul.as_mut().set_health_view(QString::from(&view));
1385 sioul.as_mut().set_missed_view(QString::from(&missed));
1386 sioul.as_mut().set_needs_view(QString::from(&needs));
1387 });
1388 });
1389}
1390
1391const ALARMS_AHEAD_HOURS: i64 = 48;
1395
1396fn named(dose: &sioul_core::health::Dose) -> String {
1398 if dose.dose.is_empty() { dose.name.clone() } else { format!("{} · {}", dose.name, dose.dose) }
1399}
1400
1401pub(crate) fn alarms_coming() -> String {
1406 let health = load();
1407 let state = HealthState::read(&HealthState::default_path()).unwrap_or_default();
1408 let now = Zoned::now();
1409 let start = now.checked_sub(Span::new().minutes(GRACE_MINUTES)).unwrap_or_else(|_| now.clone());
1410 let end = now.checked_add(Span::new().hours(ALARMS_AHEAD_HOURS)).unwrap_or_else(|_| now.clone());
1411 let rows: Vec<serde_json::Value> = health
1412 .doses(&start, &end)
1413 .into_iter()
1414 .filter(|d| !state.taken.contains_key(&d.key) && !state.not_taken.contains_key(&d.key) && !state.reminded.contains_key(&d.key))
1415 .map(|d| serde_json::json!({ "key": d.key, "at": d.at.timestamp().as_millisecond(), "title": named(&d) }))
1416 .collect();
1417 serde_json::Value::Array(rows).to_string()
1418}
1419
1420pub(crate) fn marked_lately() -> Vec<String> {
1423 let Ok(state) = HealthState::read(&HealthState::default_path()) else { return Vec::new() };
1424 let since = Timestamp::now().as_second() - 26 * 3600;
1425 state.taken.keys().chain(state.not_taken.keys()).filter(|key| due_of(key).is_some_and(|due| due >= since)).cloned().collect()
1426}
1427
1428const SHOWN_WATCH: i64 = 3 * 3600;
1432const SHOWN_EVERY: i64 = 15 * 60;
1433
1434pub(crate) fn alarm_decide(key: &str) -> String {
1445 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();
1446 let _ = crate::share::exchange_here(true);
1447 let missed = crate::share::news_missed();
1450 let health = load();
1451 let now = Zoned::now();
1452 let stamp = now.timestamp().as_second();
1453 let _held = state_held();
1454 let state = record();
1455 let start = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
1456 let end = now.checked_add(Span::new().hours(ALARMS_AHEAD_HOURS)).unwrap_or_else(|_| now.clone());
1457 let Some(dose) = health.doses(&start, &end).into_iter().find(|d| d.key == key) else {
1459 crate::alarms::remove_reminder(key);
1460 return answer(false, "", "", 0);
1461 };
1462 let due = dose.at.timestamp().as_second();
1463 if state.taken.contains_key(key) || state.not_taken.contains_key(key) {
1465 crate::alarms::remove_reminder(key);
1466 return answer(false, "", "", 0);
1467 }
1468 if state.reminded.contains_key(key) || SENT.lock().is_ok_and(|sent| sent.contains(key)) {
1471 let again = if stamp < due + SHOWN_WATCH { stamp + SHOWN_EVERY } else { 0 };
1472 return answer(false, "", "", again);
1473 }
1474 if due > stamp + 60 {
1476 return answer(false, "", "", due);
1477 }
1478 if crate::hours::doses_silent()
1480 && let Some(waking) = crate::hours::waking()
1481 {
1482 return answer(false, "", "", waking);
1483 }
1484 let waiting = stamp < due + WAIT_FOR_NEWS;
1485 let keeper = crate::share::looked("health", sioul_sync::lease::Rule::FollowsYou);
1486 if !keeper.mine && waiting {
1487 return answer(false, "", "", due + WAIT_FOR_NEWS);
1488 }
1489 let knowledge = know();
1490 let doubt = doubt_unread(&knowledge, due, stamp, missed.as_ref());
1491 if !doubt.is_empty() && waiting {
1492 let known = sioul_core::health::known_from(due, stamp + 1, due + WAIT_FOR_NEWS, knowledge.record_lost, &knowledge.peers);
1493 return answer(false, "", "", alarm_again(due, stamp, known));
1494 }
1495 let named = named(&dose);
1496 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"))) };
1497 if let Ok(mut sent) = SENT.lock() {
1498 sent.insert(key.to_string());
1499 }
1500 crate::devices::receiver(move || {
1504 let _ = change(|record| {
1505 record.reminded.insert(key.to_string(), stamp);
1506 });
1507 open_canaries(&[key.to_string()], stamp);
1508 drop(_held);
1509 let _ = crate::share::exchange_here(false);
1510 });
1511 answer(true, &title, &body, stamp + SHOWN_EVERY)
1513}
1514
1515pub(crate) fn alarm_taken(key: &str) -> String {
1518 if is_late(key) {
1519 return serde_json::json!({ "done": false, "open": true, "line": tr().text("dose-alarm-late", None) }).to_string();
1520 }
1521 crate::devices::receiver(|| {
1524 let problem = set_taken(key, true);
1525 if !problem.is_empty() {
1526 return serde_json::json!({ "done": false, "open": true, "line": problem }).to_string();
1527 }
1528 let _ = crate::share::exchange_here(false);
1529 let line = say("health-taken-at", &[("time", Zoned::now().strftime("%H:%M").to_string())]);
1530 serde_json::json!({ "done": true, "open": false, "line": line }).to_string()
1531 })
1532}
1533
1534pub(crate) fn card_doses(now: &Zoned) -> Vec<crate::homecard::Dose> {
1546 let health = load();
1547 if health.medicines.is_empty() {
1548 return Vec::new();
1549 }
1550 let read = HealthState::read(&HealthState::default_path());
1551 let readable = read.is_ok();
1552 doses_for_card(&health, &read.unwrap_or_default(), &records(), readable, &know(), now)
1553}
1554
1555fn doses_for_card(health: &Health, state: &HealthState, records: &DoseRecords, readable: bool, knowledge: &Knowledge, now: &Zoned) -> Vec<crate::homecard::Dose> {
1559 let stamp = now.timestamp().as_second();
1560 let midnight_after = |at: &Zoned| at.date().tomorrow().ok().and_then(|d| d.to_zoned(at.time_zone().clone()).ok());
1561 let Some(tonight) = midnight_after(now) else { return Vec::new() };
1562 let later = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1563 let coming = health.doses(&later, &tonight).into_iter().filter(|d| !state.taken.contains_key(&d.key) && !state.not_taken.contains_key(&d.key));
1564 let mut out: Vec<crate::homecard::Dose> = state
1566 .due_today(health, now, MISSED_HOURS, GRACE_MINUTES)
1567 .into_iter()
1568 .chain(coming)
1569 .filter(|d| sioul_core::doses::answered(&d.key, state, records) == Answered::Open)
1570 .flat_map(|d| {
1571 let at = d.at.timestamp().as_second();
1572 let end = midnight_after(&d.at).map_or(at + 86_400, |m| m.timestamp().as_second());
1573 let until = (at + MISSED_HOURS * 3600).min(end);
1574 let start = stamp.max(at);
1575 let pieces = match readable.then(|| sioul_core::health::known_from(at, start, until, knowledge.record_lost, &knowledge.peers)).flatten() {
1578 None => vec![(at, until, 0)],
1579 Some(known) if known <= start => vec![(at, until, card_known_until(knowledge, at, start, until))],
1580 Some(known) => vec![(at, known, 0), (known, until, card_known_until(knowledge, at, known, until))],
1581 };
1582 let (name, time) = (named(&d), d.at.strftime("%H:%M").to_string());
1583 pieces.into_iter().map(move |(from, until, known_until)| crate::homecard::Dose { name: name.clone(), time: time.clone(), at: from, until, known_until })
1584 })
1585 .collect();
1586 let midnight = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
1588 let back = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
1589 for d in health.doses(if back > midnight { &back } else { &midnight }, &later) {
1590 if matches!(sioul_core::doses::answered(&d.key, state, records), Answered::Differ(_)) {
1591 let at = d.at.timestamp().as_second();
1592 let end = midnight_after(&d.at).map_or(at + 86_400, |m| m.timestamp().as_second());
1593 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 });
1594 }
1595 }
1596 out.sort_by_key(|d| d.at);
1597 out
1598}
1599
1600fn card_known_until(knowledge: &Knowledge, due: i64, from: i64, until: i64) -> i64 {
1605 sioul_core::health::known_until(due, from, until, knowledge.record_lost, &knowledge.peers)
1606}
1607
1608const WAIT_FOR_NEWS: i64 = 10 * 60;
1613
1614fn alarm_again(due: i64, stamp: i64, known: Option<i64>) -> i64 {
1623 match known {
1624 Some(at) if at > stamp + 1 => at.max(stamp + 60).min(due + WAIT_FOR_NEWS),
1625 _ => due + WAIT_FOR_NEWS,
1626 }
1627}
1628
1629#[derive(Debug, Default, Serialize, Deserialize)]
1633struct RecordDoubt {
1634 since: i64,
1635 #[serde(default)]
1637 aside: String,
1638}
1639
1640fn record_doubt_path() -> std::path::PathBuf {
1641 sioul_core::config::state_dir().join("health-doubt.toml")
1642}
1643
1644fn record_doubt() -> Option<i64> {
1645 let doubt: RecordDoubt = toml::from_str(&std::fs::read_to_string(record_doubt_path()).ok()?).ok()?;
1646 Some(doubt.since)
1647}
1648
1649fn write_record_doubt(since: i64, aside: &str) {
1650 let doubt = RecordDoubt { since, aside: aside.to_string() };
1651 if let Ok(text) = toml::to_string(&doubt) {
1652 let _ = std::fs::write(record_doubt_path(), text);
1653 }
1654}
1655
1656fn record() -> HealthState {
1659 let path = HealthState::default_path();
1660 match HealthState::read(&path) {
1661 Ok(state) => state,
1662 Err(_) => {
1663 repair();
1664 HealthState::read(&path).unwrap_or_default()
1665 }
1666 }
1667}
1668
1669fn repair() {
1675 let now = Timestamp::now().as_second();
1676 if crate::share::on() && !crate::share::rebuild_health_record().is_empty() {
1677 write_record_doubt(now, "");
1678 return;
1679 }
1680 let aside = HealthState::set_aside(&HealthState::default_path(), now).ok().flatten().map(|p| p.display().to_string()).unwrap_or_default();
1681 write_record_doubt(now, &aside);
1682}
1683
1684fn change(change: impl Fn(&mut HealthState)) -> String {
1687 let path = HealthState::default_path();
1688 let now = Timestamp::now().as_second();
1689 let changed = std::sync::atomic::AtomicBool::new(false);
1692 let changing = |state: &mut HealthState| {
1693 let before = state.clone();
1694 change(state);
1695 if *state != before {
1696 changed.store(true, std::sync::atomic::Ordering::Relaxed);
1697 }
1698 };
1699 let problem = match HealthState::update(&path, now, &changing) {
1700 Ok(()) => {
1701 crate::alarms::schedule();
1703 String::new()
1704 }
1705 Err(Problem::Unsound(_)) => {
1706 repair();
1707 match HealthState::update(&path, now, &changing) {
1708 Ok(()) => String::new(),
1709 Err(_) => tr().text("dose-record-broken", None),
1710 }
1711 }
1712 Err(Problem::Write(e)) => e,
1713 };
1714 if changed.load(std::sync::atomic::Ordering::Relaxed) {
1715 crate::devices::recorded();
1716 }
1717 problem
1718}
1719
1720fn records() -> DoseRecords {
1725 let path = DoseRecords::default_path();
1726 match DoseRecords::read(&path) {
1727 Ok(records) => records,
1728 Err(_) => {
1729 repair_records();
1730 DoseRecords::read(&path).unwrap_or_default()
1731 }
1732 }
1733}
1734
1735fn repair_records() {
1739 let now = Timestamp::now().as_second();
1740 if crate::share::on() && !crate::share::rebuild_dose_records().is_empty() {
1741 write_record_doubt(now, "");
1742 return;
1743 }
1744 let aside = DoseRecords::set_aside(&DoseRecords::default_path(), now).ok().flatten().map(|p| p.display().to_string()).unwrap_or_default();
1745 write_record_doubt(now, &aside);
1746}
1747
1748fn change_records(change: impl Fn(&mut DoseRecords)) -> String {
1751 let path = DoseRecords::default_path();
1752 let now = Timestamp::now().as_second();
1753 let changed = std::sync::atomic::AtomicBool::new(false);
1755 let changing = |records: &mut DoseRecords| {
1756 let before = records.clone();
1757 change(records);
1758 if *records != before {
1759 changed.store(true, std::sync::atomic::Ordering::Relaxed);
1760 }
1761 };
1762 let problem = match DoseRecords::update(&path, now, &changing) {
1763 Ok(()) => String::new(),
1764 Err(Problem::Unsound(_)) => {
1765 repair_records();
1766 match DoseRecords::update(&path, now, &changing) {
1767 Ok(()) => String::new(),
1768 Err(_) => tr().text("dose-record-broken", None),
1769 }
1770 }
1771 Err(Problem::Write(e)) => e,
1772 };
1773 if changed.load(std::sync::atomic::Ordering::Relaxed) {
1774 crate::devices::recorded();
1775 }
1776 problem
1777}
1778
1779fn record_answer(key: &str, state: &str, at: i64, settle: bool) -> String {
1783 let me = this_device();
1784 if me.is_empty() {
1785 return String::new();
1786 }
1787 let answer = Answer { state: state.to_string(), at, noted: Timestamp::now().as_second(), name: crate::devices::name(), kind: crate::devices::kind().to_string() };
1788 change_records(|records| if settle { records.choose(key, &me, answer.clone()) } else { records.answer(key, &me, answer.clone()) })
1789}
1790
1791fn open_due(health: &Health, state: &HealthState, now: &Zoned) {
1795 let start = now.checked_sub(Span::new().hours(MISSED_HOURS)).unwrap_or_else(|_| now.clone());
1796 let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1797 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();
1798 open_canaries(&keys, now.timestamp().as_second());
1799}
1800
1801fn open_canaries(keys: &[String], now: i64) {
1803 let me = this_device();
1804 if keys.is_empty() || me.is_empty() {
1805 return;
1806 }
1807 let Ok(records) = DoseRecords::read(&DoseRecords::default_path()) else { return };
1809 if keys.iter().all(|key| records.dose.get(key).is_some_and(|r| !r.opened.is_empty() || !r.answer.is_empty())) {
1810 return;
1811 }
1812 let _ = change_records(|records| {
1813 for key in keys {
1814 records.open(key, &me, now);
1815 }
1816 });
1817}
1818
1819fn this_device() -> String {
1821 crate::share::vault().map(|(id, _)| id).unwrap_or_default()
1822}
1823
1824#[derive(Debug, Default, Serialize, Deserialize)]
1827struct Peers {
1828 #[serde(default)]
1829 peers: std::collections::BTreeMap<String, PeerSeen>,
1830 #[serde(default)]
1833 looked: i64,
1834}
1835
1836#[derive(Debug, Default, Clone, Serialize, Deserialize)]
1837struct PeerSeen {
1838 peer: Peer,
1839 #[serde(default)]
1841 renewed: i64,
1842 #[serde(default)]
1844 looked: i64,
1845 #[serde(default)]
1847 delays: Vec<i64>,
1848 #[serde(default)]
1850 entry: Option<sioul_sync::devices::Entry>,
1851 #[serde(default)]
1854 aheads: Vec<i64>,
1855 #[serde(default)]
1857 off: Option<Mark>,
1858 #[serde(default)]
1860 forgotten: Option<Mark>,
1861 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1864 wakes: Vec<i64>,
1865 #[serde(default, skip_serializing_if = "is_zero")]
1868 since: i64,
1869}
1870
1871fn is_zero(n: &i64) -> bool {
1872 *n == 0
1873}
1874
1875const WAKES: usize = 10;
1879
1880const UNSURE_WAKE: i64 = 60 * 60;
1886
1887fn note_wake(seen: &mut PeerSeen, delay: i64, news: Option<bool>) {
1893 let delay = match news {
1894 Some(false) => return,
1895 Some(true) => delay,
1896 None => delay.min(UNSURE_WAKE),
1897 };
1898 seen.wakes.push(delay.max(0));
1899 let keep = seen.wakes.len().saturating_sub(WAKES);
1900 seen.wakes.drain(..keep);
1901}
1902
1903#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
1905struct Mark {
1906 #[serde(default)]
1907 started: i64,
1908 #[serde(default)]
1909 exported: i64,
1910 #[serde(default)]
1911 renewed: i64,
1912 #[serde(default)]
1914 at: i64,
1915}
1916
1917fn peers_path() -> std::path::PathBuf {
1918 sioul_core::config::state_dir().join("share").join("peers.json")
1919}
1920
1921pub(crate) struct Knowledge {
1924 record_lost: Option<i64>,
1925 peers: Vec<Peer>,
1926}
1927
1928static KNOWING: std::sync::Mutex<()> = std::sync::Mutex::new(());
1930
1931fn know() -> Knowledge {
1935 let now = Timestamp::now().as_second();
1936 let record_lost = record_doubt();
1937 let Some(others) = crate::share::others_on_health() else { return Knowledge { record_lost, peers: Vec::new() } };
1938 let _held = KNOWING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1939 let kept = sioul_core::filelock::with_lock(&peers_path(), || {
1941 let mut kept = load_peers();
1942 learn_with(&mut kept, &others.claims, &others.entries, &others.heard, others.news, now);
1943 save_peers(&kept);
1944 kept
1945 });
1946 let mut peers: Vec<Peer> = kept.peers.values().map(|s| s.peer.clone()).collect();
1947 if peers.iter().filter(|p| p.said.as_ref().is_some_and(|s| s.phone)).count() > 1 {
1949 for said in peers.iter_mut().filter_map(|p| p.said.as_mut()) {
1950 said.phone = false;
1951 }
1952 }
1953 let silent = sioul_core::health::SILENT_DAYS * 86_400;
1957 for other in others.others.iter().filter(|o| !kept.peers.contains_key(&o.id) && now - o.heard < silent) {
1958 peers.push(Peer { id: other.id.clone(), name: tr().text("share-other-device", None), heard: other.heard, ..Peer::default() });
1959 }
1960 let unread: Vec<String> = others.unread.iter().filter(|id| !kept.peers.contains_key(*id) && !peers.iter().any(|p| p.id == **id)).cloned().collect();
1961 for id in unread {
1962 peers.push(Peer { id, name: tr().text("share-other-device", None), heard: now, ..Peer::default() });
1963 }
1964 Knowledge { record_lost, peers }
1965}
1966
1967fn load_peers() -> Peers {
1968 std::fs::read_to_string(peers_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
1969}
1970
1971fn save_peers(kept: &Peers) -> String {
1972 let written = serde_json::to_string(kept).map_err(|e| e.to_string()).and_then(|text| {
1973 std::fs::create_dir_all(sioul_core::config::state_dir().join("share")).map_err(|e| e.to_string())?;
1974 std::fs::write(peers_path(), text).map_err(|e| e.to_string())
1975 });
1976 written.err().unwrap_or_default()
1977}
1978
1979#[cfg(test)]
1982fn learn(kept: &mut Peers, claims: &[sioul_sync::lease::Claim], entries: &[sioul_sync::devices::Entry], heard: &sioul_sync::share::Heard, now: i64) {
1983 learn_with(kept, claims, entries, heard, None, now);
1984}
1985
1986fn 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) {
1989 let watching = kept.looked > 0 && now - kept.looked <= 2 * 60;
1993 kept.looked = now;
1994 for claim in claims {
1995 let seen = kept.peers.entry(claim.computer.clone()).or_default();
1996 seen.peer.id = claim.computer.clone();
1997 seen.peer.name = claim.name.clone();
1998 if claim.since != seen.since {
2000 if seen.since > 0 && watching && !claim.closed {
2001 note_wake(seen, now - claim.since, news);
2002 }
2003 seen.since = claim.since;
2004 }
2005 if claim.renewed > seen.renewed {
2006 if seen.renewed > 0 && now - seen.looked <= 2 * 60 {
2009 seen.delays.push((now - claim.renewed).max(0));
2010 let keep = seen.delays.len().saturating_sub(60);
2011 seen.delays.drain(..keep);
2012 }
2013 seen.renewed = claim.renewed;
2014 }
2015 seen.looked = now;
2016 seen.peer.delay = seen.delays.iter().copied().max();
2017 seen.peer.heard = seen.peer.heard.max(claim.renewed);
2018 if let Some(wrote) = claim.wrote
2023 && heard.complete(&claim.computer, wrote)
2024 && claim.renewed <= now + 60
2025 && claim.renewed.min(now) >= seen.peer.known_until
2026 {
2027 seen.peer.known_until = claim.renewed.min(now);
2028 seen.peer.closed = claim.closed;
2029 }
2030 seen.peer.broken = heard.broken.get(&claim.computer).copied();
2031 }
2032 for seen in kept.peers.values_mut() {
2036 seen.peer.said = None;
2037 seen.peer.complete = false;
2038 }
2039 for entry in entries {
2040 let seen = kept.peers.entry(entry.id.clone()).or_default();
2041 seen.peer.id = entry.id.clone();
2042 if !entry.name.is_empty() {
2043 seen.peer.name = entry.name.clone();
2044 }
2045 let state = |e: &sioul_sync::devices::Entry| (e.started, e.closed, e.working, e.exported, e.left);
2046 if seen.entry.as_ref().is_none_or(|last| state(last) != state(entry)) {
2047 seen.peer.seen = now;
2050 if seen.entry.as_ref().is_none_or(|last| last.exported != entry.exported) {
2051 seen.aheads.push(entry.exported - now);
2052 let keep = seen.aheads.len().saturating_sub(60);
2053 seen.aheads.drain(..keep);
2054 }
2055 if watching && seen.entry.as_ref().is_some_and(|last| entry.started > last.started) {
2058 let ahead = seen.aheads.iter().copied().max().unwrap_or(0).max(0);
2059 note_wake(seen, now - entry.started + ahead, news);
2060 }
2061 seen.entry = Some(entry.clone());
2062 }
2063 seen.peer.ahead = seen.aheads.iter().copied().max().unwrap_or(0).max(0);
2064 let alive_since = claims.iter().any(|c| c.computer == entry.id && !c.closed && c.renewed > entry.closed + 60);
2069 if !entry.working && alive_since {
2070 continue;
2071 }
2072 seen.peer.said = Some(entry.said());
2073 seen.peer.complete = entry.wrote.is_none_or(|wrote| heard.complete(&entry.id, wrote));
2075 seen.peer.heard = seen.peer.heard.max(entry.exported);
2076 seen.peer.broken = heard.broken.get(&entry.id).copied();
2077 }
2078 for seen in kept.peers.values_mut() {
2081 seen.peer.wake = seen.wakes.iter().copied().max();
2082 let (started, exported, renewed) = (seen.entry.as_ref().map_or(0, |e| e.started), seen.entry.as_ref().map_or(0, |e| e.exported), seen.renewed);
2083 let newer = |mark: &Mark| started != mark.started || exported > mark.exported || renewed > mark.renewed;
2084 if seen.off.as_ref().is_some_and(newer) {
2085 seen.off = None;
2086 }
2087 if seen.forgotten.as_ref().is_some_and(newer) {
2088 seen.forgotten = None;
2089 }
2090 seen.peer.off = seen.off.is_some() || seen.forgotten.is_some();
2091 }
2092}
2093
2094pub(crate) fn device_off(id: &str, off: bool) -> String {
2099 mark_device(id, |seen, mark| seen.off = off.then_some(mark))
2100}
2101
2102pub(crate) fn device_forget(id: &str) -> String {
2104 crate::share::forget_device(id);
2106 mark_device(id, |seen, mark| seen.forgotten = Some(mark))
2107}
2108
2109fn mark_device(id: &str, set: impl FnOnce(&mut PeerSeen, Mark)) -> String {
2110 let id = id.trim();
2111 if id.is_empty() {
2112 return String::new();
2113 }
2114 let _ = know();
2116 let _held = KNOWING.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2117 sioul_core::filelock::with_lock(&peers_path(), || {
2118 let mut kept = load_peers();
2119 let seen = kept.peers.entry(id.to_string()).or_default();
2120 seen.peer.id = id.to_string();
2121 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() };
2122 set(seen, mark);
2123 save_peers(&kept)
2124 })
2125}
2126
2127#[derive(Serialize, Debug)]
2129pub(crate) struct DeviceRow {
2130 id: String,
2131 name: String,
2132 state: String,
2134 notes: Vec<String>,
2136 off: bool,
2138 forget: bool,
2140 build: String,
2143}
2144
2145pub(crate) fn device_rows(holders: &[(String, i64, bool)]) -> Vec<DeviceRow> {
2151 let knowledge = know();
2152 let kept = load_peers();
2153 let mut rows = device_rows_of(&knowledge.peers, &kept, Timestamp::now().as_second(), tr());
2154 holder_rows(&mut rows, holders, tr());
2155 rows
2156}
2157
2158fn holder_rows(rows: &mut Vec<DeviceRow>, holders: &[(String, i64, bool)], words: &Translator) {
2167 let zone = jiff::tz::TimeZone::system();
2168 let when = |at: i64| Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2169 for (id, heard, holds) in holders {
2170 if id.is_empty() || rows.iter().any(|row| row.id == *id) {
2171 continue;
2172 }
2173 let state = match (holds, *heard > 0) {
2174 (true, true) => said(words, "share-device-holds", &[("when", when(*heard))]),
2175 (true, false) => words.text("share-device-holds-unknown", None),
2176 (false, true) => said(words, "share-device-older", &[("when", when(*heard))]),
2177 (false, false) => words.text("share-device-unread", None),
2178 };
2179 rows.push(DeviceRow { id: id.clone(), name: capitalized(&words.text("share-other-device", None)), state, notes: Vec::new(), off: false, forget: true, build: String::new() });
2180 }
2181 rows.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
2182}
2183
2184fn device_rows_of(peers: &[Peer], kept: &Peers, now: i64, words: &Translator) -> Vec<DeviceRow> {
2185 let zone = jiff::tz::TimeZone::system();
2186 let when = |at: i64| Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2187 let mut rows: Vec<DeviceRow> = peers
2188 .iter()
2189 .filter(|peer| !peer.id.is_empty() && kept.peers.get(&peer.id).is_none_or(|seen| seen.forgotten.is_none()))
2190 .map(|peer| {
2191 let seen = kept.peers.get(&peer.id);
2192 let entry = seen.and_then(|s| s.entry.as_ref()).filter(|_| peer.said.is_some());
2193 let named = named_of(peer);
2194 let name = match entry {
2195 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),
2196 _ => device_start(&named, words),
2197 };
2198 let off = peer.off;
2199 let counted = sioul_core::health::counts(&Peer { off: false, ..peer.clone() }, now);
2200 let state = if off {
2201 words.text("share-device-off", None)
2202 } else if !counted {
2203 said(words, "share-device-silent", &[("when", when(peer.heard))])
2204 } else {
2205 match entry {
2206 Some(e) if e.working && now - peer.seen > sioul_core::health::QUIET => said(words, "share-device-quiet", &[("when", when(e.exported))]),
2207 Some(e) if e.working => said(words, "share-device-in-use", &[("when", when(e.exported))]),
2208 Some(e) => said(words, "share-device-closed", &[("when", when(e.closed)), ("shared", when(e.exported))]),
2209 None if seen.is_some_and(|s| s.renewed > 0) => said(words, "share-device-older", &[("when", when(peer.heard))]),
2210 None => words.text("share-device-unread", None),
2211 }
2212 };
2213 let mut notes = Vec::new();
2214 if entry.is_some_and(|e| !e.doses) {
2215 notes.push(words.text("share-device-apart", None));
2216 }
2217 if peer.ahead >= 60 {
2218 let mut args = sioul_core::i18n::args();
2219 args.set("minutes", peer.ahead / 60);
2220 notes.push(words.text("share-device-ahead", Some(&args)));
2221 }
2222 let build = match entry.map(|e| (e.build(), sioul_core::build::older(&e.version, sioul_core::build::VERSION))) {
2224 Some((build, _)) if build.is_empty() => String::new(),
2225 Some((build, true)) => said(words, "share-device-build-older", &[("build", build), ("here", sioul_core::build::DESCRIBED.to_string())]),
2226 Some((build, false)) => said(words, "share-device-build", &[("build", build)]),
2227 None => String::new(),
2228 };
2229 DeviceRow { id: peer.id.clone(), name, state, notes, off, forget: !counted && !off, build }
2230 })
2231 .collect();
2232 rows.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
2233 rows
2234}
2235
2236fn why(doubts: &[Doubt]) -> String {
2238 why_in(doubts, tr())
2239}
2240
2241fn why_in(doubts: &[Doubt], words: &Translator) -> String {
2243 let zone = jiff::tz::TimeZone::system();
2244 let when = |at: i64| Timestamp::from_second(at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2245 let named = |device: &sioul_core::health::Named| device_word(device, words);
2246 let parts: Vec<String> = doubts
2247 .iter()
2248 .map(|doubt| match doubt {
2249 Doubt::Record { since } => said(words, "dose-doubt-record", &[("when", when(*since))]),
2250 Doubt::Unheard { device, until: 0, .. } => said(words, "dose-doubt-never", &[("name", named(device))]),
2251 Doubt::Unheard { device, until, closed: true } => said(words, "dose-doubt-closed", &[("name", named(device)), ("when", when(*until))]),
2252 Doubt::Unheard { device, until, closed: false } => said(words, "dose-doubt-open", &[("name", named(device)), ("when", when(*until))]),
2253 Doubt::Broken { device } => said(words, "dose-doubt-broken", &[("name", named(device))]),
2254 Doubt::Working { device, shared, quiet: false } => said(words, "dose-doubt-working", &[("name", named(device)), ("when", when(*shared))]),
2255 Doubt::Working { device, shared, quiet: true } => said(words, "dose-doubt-quiet", &[("name", named(device)), ("when", when(*shared))]),
2256 Doubt::Coming { device, shared } => said(words, "dose-doubt-coming", &[("name", named(device)), ("when", when(*shared))]),
2257 Doubt::Apart { device } => said(words, "dose-doubt-apart", &[("name", named(device))]),
2258 })
2259 .collect();
2260 parts.join("; ")
2261}
2262
2263fn when_said(at: &Zoned, words: &Translator) -> String {
2265 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) }
2266}
2267
2268fn device_word(device: &sioul_core::health::Named, words: &Translator) -> String {
2271 if device.phone {
2272 words.text("device-the-phone", None)
2273 } else if device.name.trim().is_empty() || device.name == "localhost" || device.name == "?" {
2274 words.text("share-other-device", None)
2275 } else {
2276 device.name.clone()
2277 }
2278}
2279
2280fn said(words: &Translator, id: &str, pairs: &[(&str, String)]) -> String {
2282 let mut args = sioul_core::i18n::args();
2283 for (key, value) in pairs {
2284 args.set(key.to_string(), value.clone());
2285 }
2286 words.text(id, Some(&args))
2287}
2288
2289fn doubt_of(knowledge: &Knowledge, due: i64, now: i64) -> String {
2291 doubt_unread(knowledge, due, now, None)
2292}
2293
2294fn doubt_unread(knowledge: &Knowledge, due: i64, now: i64, missed: Option<&(i64, std::collections::BTreeSet<String>)>) -> String {
2299 let doubts = match missed {
2300 Some((since, heard)) => sioul_core::health::doubts_unread(due, now, knowledge.record_lost, &knowledge.peers, *since, heard),
2301 None => sioul_core::health::doubts(due, now, knowledge.record_lost, &knowledge.peers),
2302 };
2303 if doubts.is_empty() { String::new() } else { say("dose-doubt", &[("why", why(&doubts))]) }
2304}
2305
2306fn doubt_now_with(knowledge: &Knowledge, due: i64, now: i64, words: &Translator) -> (String, Vec<OffButton>) {
2313 let doubts = sioul_core::health::doubts_now(due, now, knowledge.record_lost, &knowledge.peers);
2314 if doubts.is_empty() {
2315 return (String::new(), Vec::new());
2316 }
2317 let named: Vec<&Named> = doubts
2318 .iter()
2319 .filter_map(|doubt| match doubt {
2320 Doubt::Working { device, .. } | Doubt::Coming { device, .. } | Doubt::Unheard { device, .. } => Some(device),
2321 _ => None,
2322 })
2323 .filter(|device| !device.id.is_empty())
2324 .collect();
2325 let off = match named.as_slice() {
2326 [one] => vec![OffButton { id: one.id.clone(), label: words.text("device-off", None) }],
2327 many => many.iter().map(|device| OffButton { id: device.id.clone(), label: said(words, "device-off-named", &[("name", device_start(device, words))]) }).collect(),
2328 };
2329 (said(words, "dose-doubt", &[("why", why_in(&doubts, words))]), off)
2330}
2331
2332fn device_start(device: &Named, words: &Translator) -> String {
2334 let word = device_word(device, words);
2335 if word == device.name { word } else { capitalized(&word) }
2336}
2337
2338fn named_of(peer: &Peer) -> Named {
2340 Named { id: peer.id.clone(), name: peer.name.clone(), phone: peer.said.as_ref().is_some_and(|s| s.phone) }
2341}
2342
2343fn differ_sentence(given: &[Given], me: &str, words: &Translator) -> String {
2347 let zone = jiff::tz::TimeZone::system();
2348 let parts: Vec<String> = given
2349 .iter()
2350 .map(|g| {
2351 let when = Timestamp::from_second(g.at).map(|t| when_said(&t.to_zoned(zone.clone()), words)).unwrap_or_default();
2352 let name = device_word(&Named { id: g.device.clone(), name: g.name.clone(), phone: g.kind == sioul_sync::devices::PHONE }, words);
2353 let here = !me.is_empty() && g.device == me;
2354 let id = match (&g.state, g.device.is_empty(), here) {
2355 (State::Taken, true, _) => "dose-answer-taken",
2356 (State::Taken, _, true) => "dose-answer-taken-here",
2357 (State::Taken, _, false) => "dose-answer-taken-on",
2358 (State::Skipped, true, _) => "dose-answer-skipped",
2359 (State::Skipped, _, true) => "dose-answer-skipped-here",
2360 (State::Skipped, _, false) => "dose-answer-skipped-on",
2361 (State::Other(_), _, _) => "dose-answer-other-on",
2362 };
2363 said(words, id, &[("name", name), ("when", when)])
2364 })
2365 .collect();
2366 said(words, "dose-answers", &[("answers", parts.join(", "))])
2367}
2368
2369static STATE: std::sync::Mutex<()> = std::sync::Mutex::new(());
2373
2374fn state_held() -> std::sync::MutexGuard<'static, ()> {
2375 STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
2376}
2377
2378static TICKING: std::sync::Mutex<()> = std::sync::Mutex::new(());
2380
2381pub(crate) fn not_taken(key: &str) -> String {
2383 let now = Timestamp::now().as_second();
2384 let problem = change(|state| {
2385 state.not_taken.insert(key.to_string(), now);
2386 });
2387 if !problem.is_empty() {
2388 return problem;
2389 }
2390 record_answer(key, sioul_core::doses::SKIPPED, now, false)
2392}
2393
2394pub(crate) fn choose(key: &str, taken: bool) -> String {
2400 let _held = state_held();
2401 let now = Timestamp::now().as_second();
2402 let first_taken = match sioul_core::doses::answered(key, &record(), &records()) {
2403 Answered::Taken(given) => Some(given.at),
2404 Answered::Differ(given) => given.iter().find(|g| g.state == State::Taken).map(|g| g.at),
2405 _ => None,
2406 };
2407 let at = if taken { first_taken.unwrap_or(now) } else { now };
2408 let moved = std::sync::Mutex::new(None);
2409 let problem = change(|state| {
2410 if taken {
2411 state.taken.insert(key.to_string(), at);
2412 state.not_taken.remove(key);
2413 } else {
2414 state.taken.remove(key);
2415 state.not_taken.insert(key.to_string(), now);
2416 if let Ok(mut moved) = moved.lock() {
2417 *moved = state.moved.remove(key);
2418 }
2419 }
2420 });
2421 if let Some([before, after]) = moved.into_inner().ok().flatten() {
2422 let _ = change_health(|health| Ok(health.taken_back(key, before, after)));
2423 }
2424 if !problem.is_empty() {
2425 return problem;
2426 }
2427 record_answer(key, if taken { sioul_core::doses::TAKEN } else { sioul_core::doses::SKIPPED }, at, true)
2428}
2429
2430fn due_of(key: &str) -> Option<i64> {
2432 key.rsplit_once('@').and_then(|(_, at)| at.parse().ok())
2433}
2434
2435pub(crate) fn is_late(key: &str) -> bool {
2437 due_of(key).is_some_and(|due| Timestamp::now().as_second() - due > GRACE_MINUTES * 60)
2438}
2439
2440pub(crate) fn taken_late(key: &str, time: &str) -> String {
2444 let now = Zoned::now();
2445 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 {
2446 return tr().text("dose-time-wrong", None);
2447 };
2448 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());
2449 if at > now.timestamp().as_second() {
2450 at -= 86_400;
2451 }
2452 let _held = state_held();
2453 let moved = match change_health(|health| Ok(health.taken_at(key, at))) {
2455 Ok(moved) => moved,
2456 Err(e) => return e,
2457 };
2458 let problem = change(|state| {
2459 state.taken.insert(key.to_string(), at);
2460 state.not_taken.remove(key);
2461 if let Some((before, after)) = moved {
2462 state.moved.insert(key.to_string(), [before, after]);
2463 }
2464 });
2465 if problem.is_empty() {
2467 let _ = record_answer(key, sioul_core::doses::TAKEN, at, false);
2468 }
2469 if !problem.is_empty()
2471 && let Some((before, after)) = moved
2472 {
2473 let _ = change_health(|health| Ok(health.taken_back(key, before, after)));
2474 }
2475 problem
2476}
2477
2478pub(crate) fn dose_info(key: &str) -> String {
2480 let health = load();
2481 let now = Zoned::now();
2482 let (Some((id, _)), Some(due)) = (key.rsplit_once('@'), due_of(key)) else { return "null".into() };
2483 let Some(medicine) = health.medicines.iter().find(|m| m.id == id) else { return "null".into() };
2484 let due = Timestamp::from_second(due).map(|t| t.to_zoned(now.time_zone().clone())).unwrap_or_else(|_| now.clone());
2485 let hours = match medicine.schedule {
2486 Schedule::Hours { hours, .. } => hours,
2487 _ => 0,
2488 };
2489 serde_json::json!({
2490 "name": medicine.short_name(),
2491 "dose": health.amount_of(key, now.time_zone()).unwrap_or_else(|| medicine.dose.clone()),
2492 "due": if due.date() == now.date() { due.strftime("%H:%M").to_string() } else { format!("{} {}", tr().weekday_short(due.date()), due.strftime("%H:%M")) },
2493 "now": now.strftime("%H:%M").to_string(),
2494 "hours": hours,
2496 })
2497 .to_string()
2498}
2499
2500pub(crate) fn set_taken(key: &str, taken: bool) -> String {
2504 let _held = state_held();
2505 let now = Timestamp::now().as_second();
2506 let shift = if taken {
2507 match change_health(|health| Ok(health.taken_at(key, now))) {
2508 Ok(shift) => shift,
2509 Err(e) => return e,
2510 }
2511 } else {
2512 None
2513 };
2514 let moved = std::sync::Mutex::new(None);
2515 let problem = change(|state| {
2516 if taken {
2517 state.taken.insert(key.to_string(), now);
2518 if let Some((before, after)) = shift {
2519 state.moved.insert(key.to_string(), [before, after]);
2520 }
2521 } else {
2522 state.taken.remove(key);
2523 if let Ok(mut moved) = moved.lock() {
2524 *moved = state.moved.remove(key);
2525 }
2526 }
2527 });
2528 if let Some([before, after]) = moved.into_inner().ok().flatten() {
2529 let _ = change_health(|health| Ok(health.taken_back(key, before, after)));
2530 }
2531 if problem.is_empty() {
2534 let _ = if taken { record_answer(key, sioul_core::doses::TAKEN, now, false) } else { change_records(|records| records.take_back(key)) };
2535 }
2536 problem
2537}
2538
2539pub(crate) fn set_setting(key: &str, value: &str) -> String {
2542 let number = || value.trim().parse::<u32>().unwrap_or(0);
2543 if !matches!(key, "movement.enabled" | "movement.minutes" | "chats.enabled" | "chats.minutes" | "chats.locked_minutes" | "errands_list") {
2544 return say("setting-unknown-key", &[("key", key.to_string())]);
2545 }
2546 change_health(|health| {
2547 match key {
2548 "movement.enabled" => health.movement.enabled = value == "true",
2549 "movement.minutes" => health.movement.minutes = number().clamp(10, 240),
2550 "chats.enabled" => health.chats.enabled = value == "true",
2551 "chats.minutes" => health.chats.minutes = number(),
2552 "chats.locked_minutes" => health.chats.locked_minutes = number(),
2553 _ => health.errands_list = value.trim().to_string(),
2554 }
2555 Ok(())
2556 })
2557 .err()
2558 .unwrap_or_default()
2559}
2560
2561pub(crate) fn movement_minutes() -> i32 {
2563 let movement = load().movement;
2564 if movement.enabled { i32::try_from(movement.minutes).unwrap_or(45) } else { 0 }
2565}
2566
2567pub(crate) fn chat_minute() -> bool {
2569 let limit = load().chats;
2570 let _held = state_held();
2571 let now = Zoned::now();
2572 let covered = std::sync::atomic::AtomicBool::new(false);
2573 let _ = change(|state| covered.store(state.chat_minute(&limit, &now), std::sync::atomic::Ordering::Relaxed));
2574 covered.into_inner()
2575}
2576
2577pub(crate) fn chats_covered() -> bool {
2579 record().chats_covered(&Zoned::now())
2580}
2581
2582pub(crate) fn usual_name(kind: &str, index: usize) -> String {
2587 match (kind, index) {
2588 ("meal", 0..=2) => tr().text(&format!("need-meal-{index}"), None),
2589 ("meal", n) => say("need-meal-n", &[("n", (n + 1).to_string())]),
2590 ("nap", _) => tr().text("need-nap", None),
2591 _ => tr().text("need-sleep", None),
2592 }
2593}
2594
2595pub(crate) fn block_name(kind: &str, key: &str, name: &str) -> String {
2598 if !name.trim().is_empty() {
2599 return name.to_string();
2600 }
2601 if sioul_core::needs::is_added(key) {
2602 return tr().text(if kind == "nap" { "need-added-nap" } else { "need-added-meal" }, None);
2603 }
2604 usual_name(kind, key.rsplit_once(':').and_then(|(_, i)| i.parse().ok()).unwrap_or(0))
2605}
2606
2607fn name_of(kept: &Kept) -> String {
2608 block_name(kept.kind, &kept.key, &kept.name)
2609}
2610
2611pub(crate) fn days() -> Days {
2615 let path = Days::default_path();
2616 let now = Zoned::now();
2617 let today_path = sioul_core::needs::Today::default_path();
2618 let mut today = sioul_core::needs::Today::load(&today_path, now.date());
2619 if today.shifts.is_empty() && today.skipped.is_empty() {
2620 return Days::load(&path);
2621 }
2622 let zone = now.time_zone().clone();
2623 let blocks = load().needs.blocks_of(now.date(), &zone, &Days::default(), &|_| 0);
2624 let moved = Days::change_file(&path, now.date(), |days| {
2626 for k in &blocks {
2627 let shift = today.shifts.get(&k.key).copied().unwrap_or(0) * 60;
2628 let skipped = today.skipped.contains(&k.key);
2629 days.change(now.date(), &k.key, |b| {
2630 if shift != 0 && b.at.is_empty() {
2631 b.at = clock(k.at + shift, &zone);
2632 if k.kind == "sleep" {
2633 b.wake = clock(k.end + shift, &zone);
2634 }
2635 }
2636 b.quiet |= skipped;
2637 });
2638 }
2639 });
2640 if moved.is_ok() {
2641 today.shifts.clear();
2642 today.skipped.clear();
2643 let _ = today.save(&today_path);
2644 }
2645 Days::load(&path)
2646}
2647
2648fn held_today(now: &Zoned) -> Vec<(i64, i64)> {
2651 static CACHE: std::sync::Mutex<Option<(i64, Date, Vec<(i64, i64)>)>> = std::sync::Mutex::new(None);
2652 let stamp = now.timestamp().as_second();
2653 let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
2654 if let Some((at, day, held)) = cache.as_ref()
2655 && *day == now.date()
2656 && (0..300).contains(&(stamp - at))
2657 {
2658 return held.clone();
2659 }
2660 let midnight = now.date().to_zoned(now.time_zone().clone()).map_or(stamp, |z| z.timestamp().as_second());
2661 let held = sioul_core::plan::event_spans(&sioul_core::agenda::occurrences(midnight, midnight + 26 * 3600), 0);
2662 *cache = Some((stamp, now.date(), held.clone()));
2663 held
2664}
2665
2666fn held_on(date: Date, now: &Zoned) -> Vec<(i64, i64)> {
2668 if date == now.date() {
2669 return held_today(now);
2670 }
2671 let start = midnight(date, now.time_zone());
2672 sioul_core::plan::event_spans(&sioul_core::agenda::occurrences(start, start + 26 * 3600), 0)
2673}
2674
2675fn blocks_now(needs: &Needs, days: &Days, date: Date, now: &Zoned) -> Vec<Kept> {
2677 let pushed = needs.past_events_on(date, now.time_zone(), days, &held_on(date, now));
2678 needs.blocks_of(date, now.time_zone(), days, &|key: &str| pushed.get(key).copied().unwrap_or(0))
2679}
2680
2681fn needs_tick(qt: &QtThread, shared: &Arc<Shared>, health: &Health, now: &Zoned) {
2687 let needs = &health.needs;
2688 let days = days();
2689 if !(needs.meals_on || needs.naps_on || needs.sleep_on) && !days.0.contains_key(&now.date()) {
2690 return;
2691 }
2692 let path = sioul_core::needs::Today::default_path();
2693 let mut today = sioul_core::needs::Today::load(&path, now.date());
2694 let stamp = now.timestamp().as_second();
2695 let pushed = needs.past_events_on(now.date(), now.time_zone(), &days, &held_today(now));
2696 let kept = needs.kept_with(now.date(), now.time_zone(), &days, &|key: &str| pushed.get(key).copied().unwrap_or(0));
2697 let due = today.due(&kept, needs.heads_up, stamp);
2698 if due.is_empty() {
2699 return;
2700 }
2701 let _ = today.save(&path);
2702 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);
2704 if meeting {
2705 return;
2706 }
2707 let hm = |at: i64| clock(at, now.time_zone());
2708 let attention = crate::hours::attention();
2710 let moment = crate::hours::attention_now();
2711 let needs = sioul_core::attention::Row::Own(sioul_core::attention::Kind::Needs);
2712 let may = attention.level(needs, &moment) == sioul_core::attention::Level::Now;
2713 let mut awake = moment.clone();
2716 awake.times.retain(|c| *c != sioul_core::attention::Column::Sleep);
2717 let starting = attention.level(needs, &awake) == sioul_core::attention::Level::Now;
2718 for (block, heads_up) in due {
2719 let own_start = !heads_up && (block.kind == "sleep" || block.kind == "nap") && block.start <= stamp;
2720 if !(may || (own_start && starting)) {
2721 continue;
2722 }
2723 let name = name_of(block);
2724 let (key, qt_open) = (block.key.clone(), qt.clone());
2727 let open: Box<dyn FnOnce() + Send> = Box::new(move || {
2728 let _ = qt_open.queue(move |mut sioul| sioul.as_mut().reminder_opened(QString::from("need"), QString::default(), QString::from(&key)));
2729 });
2730 let action = Some((tr().text("need-open", None), open));
2731 let shown = if heads_up {
2732 sioul_sync::notify::remind(&tr().text("need-heads-up", None), &say("need-at", &[("name", name), ("time", hm(block.start))]), action)
2733 } else if block.kind == "sleep" && crate::reviews::night_open(block.at, now) {
2734 let qt_review = qt.clone();
2736 let review: Box<dyn FnOnce() + Send> = Box::new(move || {
2737 let _ = qt_review.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("review"), QString::default(), QString::from("night")));
2738 });
2739 let choices = std::iter::once((tr().text("review-close-night", None), review)).chain(action).collect();
2740 sioul_sync::notify::remind_choices(&name, &hm(block.start), choices)
2741 } else {
2742 sioul_sync::notify::remind(&name, &hm(block.start), action)
2743 };
2744 if let Err(e) = shown {
2745 tell(qt, shared, e);
2746 }
2747 }
2748}
2749
2750pub(crate) fn change_need(edit: &str) -> String {
2756 #[derive(Deserialize)]
2757 struct Asked {
2758 #[serde(default)]
2759 date: String,
2760 #[serde(flatten)]
2761 edit: DayEdit,
2762 }
2763 match serde_json::from_str::<Asked>(edit) {
2764 Ok(asked) => change_day(&asked.date, &asked.edit),
2765 Err(e) => e.to_string(),
2766 }
2767}
2768
2769fn change_day(date: &str, edit: &DayEdit) -> String {
2770 let now = Zoned::now();
2771 let date = if date.trim().is_empty() { Some(now.date()) } else { date.trim().parse::<Date>().ok() };
2772 let Some(date) = date else { return tr().text("need-times-wrong", None) };
2773 let _ = days();
2775 let needs = load().needs;
2776 let held = held_on(date, &now);
2777 let changed = match Days::change_file(&Days::default_path(), now.date(), |days| needs.change_day(days, date, edit, &now, &held)) {
2778 Ok(changed) => changed,
2779 Err(e) => return e,
2780 };
2781 match changed {
2782 Ok(()) => {
2783 crate::wake::schedule();
2785 String::new()
2786 }
2787 Err(DayProblem::Past) => tr().text("need-past", None),
2788 Err(DayProblem::Times) => tr().text("need-times-wrong", None),
2789 Err(DayProblem::TooShort) => tr().text("need-too-short", None),
2790 Err(DayProblem::OtherDay) => tr().text("need-other-day", None),
2791 Err(DayProblem::Unknown) => say("setting-unknown-key", &[("key", edit.action.clone())]),
2792 }
2793}
2794
2795pub(crate) fn drag_need(qt: &QtThread, shared: &Arc<Shared>, edit: &str) -> String {
2801 #[derive(Deserialize)]
2802 struct Asked {
2803 #[serde(default)]
2804 date: String,
2805 #[serde(flatten)]
2806 edit: DayEdit,
2807 }
2808 let asked = match serde_json::from_str::<Asked>(edit) {
2809 Ok(asked) => asked,
2810 Err(e) => return e.to_string(),
2811 };
2812 let now = Zoned::now();
2813 let date = if asked.date.trim().is_empty() { Some(now.date()) } else { asked.date.trim().parse::<Date>().ok() };
2814 let Some(date) = date else { return tr().text("need-times-wrong", None) };
2815 let key = asked.edit.key.clone();
2816 let was = days().get(date, &key).cloned();
2817 let problem = change_day(&asked.date, &asked.edit);
2818 if !problem.is_empty() {
2819 return problem;
2820 }
2821 let after = days();
2822 if after.get(date, &key) == was.as_ref() {
2823 return String::new();
2824 }
2825 let zone = now.time_zone().clone();
2827 let line = blocks_now(&load().needs, &after, date, &now)
2828 .iter()
2829 .find(|k| k.key == key)
2830 .map(|k| say("drag-done", &[("what", name_of(k)), ("time", format!("{}–{}", clock(k.start, &zone), clock(k.end, &zone)))]))
2831 .unwrap_or_else(|| tr().text("drag-done-plain", None));
2832 crate::mail::offer_back(qt, shared, line, move |qt, shared| {
2833 let _ = days();
2835 let problem = Days::change_file(&Days::default_path(), Zoned::now().date(), |days| match &was {
2836 Some(block) => days.change(date, &key, |b| *b = block.clone()),
2837 None => days.forget(date, &key),
2838 })
2839 .err()
2840 .unwrap_or_default();
2841 crate::wake::schedule();
2843 crate::work::show_work(qt, shared);
2844 show_health(qt, shared);
2845 problem
2846 });
2847 String::new()
2848}
2849
2850pub(crate) fn move_today(key: &str, minutes: i64, time: &str) -> String {
2854 let action = if time.is_empty() { "later" } else { "move" };
2855 change_day("", &DayEdit { key: key.to_string(), action: action.to_string(), minutes, from: time.to_string(), ..DayEdit::default() })
2856}
2857
2858pub(crate) fn needs_today() -> String {
2861 let needs = load().needs;
2862 let now = Zoned::now();
2863 let days = days();
2864 let zone = now.time_zone().clone();
2865 let stamp = now.timestamp().as_second();
2866 let rows: Vec<serde_json::Value> = blocks_now(&needs, &days, now.date(), &now)
2867 .iter()
2868 .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 }))
2869 .collect();
2870 json(&rows)
2871}
2872
2873pub(crate) fn later_minutes() -> u32 {
2875 load().needs.later
2876}
2877
2878pub(crate) fn skip_today(key: &str, skip: bool) -> String {
2880 change_day("", &DayEdit { key: key.to_string(), action: if skip { "quiet" } else { "loud" }.to_string(), ..DayEdit::default() })
2881}
2882
2883pub(crate) fn needs_page() -> String {
2886 let needs = load().needs;
2887 serde_json::json!({
2888 "needs": needs,
2889 "usual": {
2890 "meals": (0..needs.meals.len().max(3) + 1).map(|i| usual_name("meal", i)).collect::<Vec<_>>(),
2891 "nap": usual_name("nap", 0),
2892 "sleep": usual_name("sleep", 0),
2893 },
2894 "gaps": needs.long_gaps().into_iter().map(|(from, to)| say("need-gap", &[("from", from), ("to", to)])).collect::<Vec<_>>(),
2895 "wake": crate::wake::status(&needs),
2897 })
2898 .to_string()
2899}
2900
2901fn removed_at(old: &[sioul_core::needs::Block], new: &[sioul_core::needs::Block]) -> Option<usize> {
2903 if new.len() + 1 != old.len() {
2904 return None;
2905 }
2906 let at = (0..old.len()).find(|&i| i == new.len() || old[i] != new[i])?;
2907 (old[at + 1..] == new[at..]).then_some(at)
2908}
2909
2910pub(crate) fn save_needs(edit: &str, shown: &str) -> String {
2914 let needs: Needs = match serde_json::from_str(edit) {
2915 Ok(needs) => needs,
2916 Err(e) => return e.to_string(),
2917 };
2918 let shown: Option<Needs> = serde_json::from_str(shown).ok();
2920 let removed = change_health(|health| {
2921 let needs = shown.as_ref().map_or_else(|| needs.clone(), |shown| needs.rebased(shown, &health.needs));
2922 let removed = (removed_at(&health.needs.meals, &needs.meals), removed_at(&health.needs.naps, &needs.naps));
2923 health.needs = needs;
2924 Ok(removed)
2925 });
2926 let (meal, nap) = match removed {
2927 Ok(removed) => removed,
2928 Err(e) => return e,
2929 };
2930 if meal.is_none() && nap.is_none() {
2931 return String::new();
2932 }
2933 Days::change_file(&Days::default_path(), Zoned::now().date(), |days| {
2935 if let Some(at) = meal {
2936 days.removed_usual("meal", at);
2937 }
2938 if let Some(at) = nap {
2939 days.removed_usual("nap", at);
2940 }
2941 })
2942 .err()
2943 .unwrap_or_default()
2944}
2945
2946pub(crate) fn tick(qt: &QtThread, shared: &Arc<Shared>) {
2949 let Some(_ticking) = crate::backend::one_at_a_time(&TICKING) else { return };
2950 let health = load();
2951 if health.medicines.is_empty() && health.prescriptions.is_empty() {
2952 let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
2954 let (keeper, _) = crate::share::keeper("health", sioul_sync::lease::Rule::FollowsYou, active, false);
2955 movement_tick(qt, shared, &health, &Zoned::now(), keeper.mine);
2956 if keeper.mine && keeper.settled {
2957 needs_tick(qt, shared, &health, &Zoned::now());
2958 }
2959 crate::wake::schedule();
2961 return;
2962 }
2963 let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
2967 let (keeper, _) = crate::share::keeper("health", sioul_sync::lease::Rule::FollowsYou, active, false);
2968 let _held = state_held();
2969 let state = record();
2970 let health = if health.clone().mend_shifts(&state) && change_health(|h| Ok(h.mend_shifts(&state))).is_ok() { load() } else { health };
2974 let knowledge = know();
2975 let now = Zoned::now();
2976 let stamp = now.timestamp().as_second();
2977 open_due(&health, &state, &now);
2980 if let Ok(mut there) = REMINDED_THERE.lock() {
2981 *there = if keeper.mine { String::new() } else { say("health-reminded-there", &[("computer", keeper.name.clone())]) };
2982 }
2983 movement_tick(qt, shared, &health, &now, keeper.mine);
2984 if keeper.mine && keeper.settled {
2985 needs_tick(qt, shared, &health, &now);
2986 }
2987 let mut reminded: Vec<String> = Vec::new();
2988 let mut unshown: Vec<String> = Vec::new();
2989 crate::alarms::schedule();
2991 if keeper.mine && keeper.settled && !cfg!(target_os = "android") && !crate::hours::doses_silent() {
2994 let window = crate::hours::woke_from().map_or(GRACE_MINUTES, |since| GRACE_MINUTES.max((stamp - since) / 60 + 1));
2996 for dose in state.to_remind(&health, &now, window) {
2997 if SENT.lock().is_ok_and(|sent| sent.contains(&dose.key)) {
2998 continue;
2999 }
3000 let due = dose.at.timestamp().as_second();
3003 let doubt = doubt_of(&knowledge, due, stamp);
3004 if !doubt.is_empty() && stamp < due + WAIT_FOR_NEWS {
3005 continue;
3006 }
3007 if let Ok(mut sent) = SENT.lock() {
3008 sent.insert(dose.key.clone());
3009 }
3010 let named = named(&dose);
3011 let (title, body) = if doubt.is_empty() {
3012 (named, dose.at.strftime("%H:%M").to_string())
3013 } else {
3014 (say("dose-check-title", &[("dose", named)]), format!("{}. {doubt}", dose.at.strftime("%H:%M")))
3015 };
3016 let key = dose.key.clone();
3017 let (qt_taken, shared_taken) = (qt.clone(), Arc::clone(shared));
3018 let taken: Box<dyn FnOnce() + Send> = Box::new(move || {
3019 if is_late(&key) {
3021 let _ = qt_taken.queue(move |mut sioul| sioul.as_mut().reminder_opened(QString::from("dose"), QString::default(), QString::from(&key)));
3022 return;
3023 }
3024 let problem = set_taken(&key, true);
3025 if !problem.is_empty() {
3026 tell(&qt_taken, &shared_taken, problem);
3027 }
3028 crate::share::exchange(&qt_taken, &shared_taken);
3030 });
3031 match sioul_sync::notify::remind(&title, &body, Some((tr().text("health-taken", None), taken))) {
3035 Ok(()) => reminded.push(dose.key.clone()),
3036 Err(e) => {
3037 unshown.push(dose.key.clone());
3038 tell(qt, shared, e);
3039 }
3040 }
3041 }
3042 }
3043 let heard = !crate::share::on() || crate::share::last_exchange().is_some();
3048 if heard && keeper.mine && !cfg!(target_os = "android") && !crate::hours::doses_silent() && !ASKED.swap(true, std::sync::atomic::Ordering::Relaxed) {
3051 let missed = state.unanswered(&health, &now, MISSED_HOURS, GRACE_MINUTES);
3052 if !missed.is_empty() {
3053 let names: Vec<String> = missed.iter().map(|d| format!("{} {}", d.name, d.at.strftime("%H:%M"))).collect();
3054 let qt_open = qt.clone();
3055 let open: Box<dyn FnOnce() + Send> = Box::new(move || {
3056 let _ = qt_open.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("porch"), QString::default(), QString::default()));
3057 });
3058 let doubts: Vec<String> = missed.iter().map(|d| doubt_of(&knowledge, d.at.timestamp().as_second(), stamp)).filter(|d| !d.is_empty()).collect();
3059 let mut body = say("health-missed-body", &[("doses", names.join(", "))]);
3060 if let Some(doubt) = doubts.first() {
3061 body = format!("{body} {doubt}");
3062 }
3063 if let Err(e) = sioul_sync::notify::remind(&missed_title(&missed, &state, tr()), &body, Some((tr().text("health-missed-open", None), open))) {
3064 tell(qt, shared, e);
3065 }
3066 }
3067 }
3068 let target = errands_list(&health);
3071 let mut made: Vec<(String, String)> = Vec::new();
3072 for errand in health.errands().into_iter().filter(|e| e.day <= now.date().checked_add(Span::new().days(7)).unwrap_or(now.date())) {
3073 if state.errands.contains_key(&errand.key) {
3074 continue;
3075 }
3076 let title = say(if errand.kind == ErrandKind::Refill { "health-errand-refill" } else { "health-errand-renew" }, &[("title", errand.title.clone())]);
3077 let edit = TaskEdit { title, start: errand.day.to_string(), categories: vec![tr().text("health-category", None)], estimate: 30, ..TaskEdit::default() };
3078 let task = match &target {
3079 Some(list) => work::create_task(qt, shared, &edit, list),
3080 None => work::local_task(qt, shared, &tr().text("health-list", None), &edit),
3081 };
3082 if let Ok(uid) = task {
3083 made.push((errand.key, uid));
3084 }
3085 }
3086 if let Some(list) = target.as_ref().filter(|l| !l.starts_with(&format!("{}/", sioul_core::vdir::LOCAL))) {
3087 let loaded = work::loaded(shared);
3088 for uid in state.errands.values().chain(made.iter().map(|(_, uid)| uid)) {
3089 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)));
3090 if here {
3091 let _ = work::move_task(qt, shared, uid, list, true);
3092 }
3093 }
3094 }
3095 let problem = change(|record| note_minute(record, &reminded, &unshown, &made, stamp));
3097 if !problem.is_empty() && (!reminded.is_empty() || !unshown.is_empty() || !made.is_empty()) {
3098 tell(qt, shared, problem);
3099 }
3100}
3101
3102#[cfg(test)]
3103mod tests {
3104 use super::*;
3105 use sioul_sync::lease::Claim;
3106
3107 fn claim(renewed: i64, wrote: Option<(u32, u64)>, closed: bool) -> Claim {
3108 Claim { computer: "laptop-id".into(), name: "laptop".into(), since: 0, renewed, until: renewed + 300, active: renewed, taken: 0, wrote, closed, pad: String::new() }
3109 }
3110
3111 fn heard_up_to(n: u64) -> sioul_sync::share::Heard {
3112 sioul_sync::share::Heard { read: [("laptop-id".to_string(), (1, n))].into(), broken: Default::default() }
3113 }
3114
3115 #[test]
3116 fn what_the_others_claims_teach() {
3117 let mut kept = Peers::default();
3118 learn(&mut kept, &[claim(1_000, Some((1, 5)), false)], &[], &heard_up_to(5), 9_000);
3120 let peer = &kept.peers["laptop-id"].peer;
3121 assert_eq!((peer.known_until, peer.delay, peer.name.as_str()), (1_000, None, "laptop"));
3122 learn(&mut kept, &[claim(9_030, Some((1, 6)), false)], &[], &heard_up_to(6), 9_060);
3124 learn(&mut kept, &[claim(9_090, Some((1, 6)), false)], &[], &heard_up_to(6), 9_120);
3125 assert_eq!(kept.peers["laptop-id"].peer.delay, Some(30));
3126 learn(&mut kept, &[claim(9_150, Some((1, 9)), false)], &[], &heard_up_to(6), 9_180);
3128 assert_eq!(kept.peers["laptop-id"].peer.known_until, 9_090);
3129 learn(&mut kept, &[claim(9_200, Some((1, 9)), true)], &[], &heard_up_to(9), 9_240);
3131 let peer = &kept.peers["laptop-id"].peer;
3132 assert!(peer.closed && peer.known_until == 9_200);
3133 assert!(sioul_core::health::doubts(9_600, 9_700, None, std::slice::from_ref(peer)).is_empty());
3135 let mut old = Peers::default();
3137 learn(&mut old, &[claim(9_000, None, false)], &[], &heard_up_to(99), 9_030);
3138 assert_eq!(old.peers["laptop-id"].peer.known_until, 0);
3139 assert_eq!(sioul_core::health::doubts(9_010, 9_030, None, &[old.peers["laptop-id"].peer.clone()]).len(), 1);
3140 }
3141
3142 #[test]
3146 fn a_claim_from_a_clock_ahead_is_never_known_past_now() {
3147 let mut kept = Peers::default();
3148 learn(&mut kept, &[claim(9_600, Some((1, 5)), false)], &[], &heard_up_to(5), 9_000);
3149 assert_eq!(kept.peers["laptop-id"].peer.known_until, 0, "ten minutes ahead: not known");
3150 assert_eq!(sioul_core::health::doubts(9_000, 9_000, None, &[kept.peers["laptop-id"].peer.clone()]).len(), 1);
3151 learn(&mut kept, &[claim(9_630, Some((1, 5)), false)], &[], &heard_up_to(5), 9_600);
3152 assert_eq!(kept.peers["laptop-id"].peer.known_until, 9_600, "half a minute ahead: until now");
3153 }
3154
3155 #[test]
3160 fn medicines_and_prescriptions_on_the_page() {
3161 let day = |text: &str| text.parse::<Date>().unwrap();
3162 let today = day("2026-10-06");
3163 assert_eq!((hhmm("8:00"), hhmm(" 18:30 "), hhmm("noon"), hhmm("25:00")), ("08:00".into(), "18:30".into(), "noon".into(), "25:00".into()));
3164 assert_eq!(words(&Schedule::Day { times: vec!["20:00".into(), "8:00".into(), "08:00".into()], amounts: Default::default() }), "08:00 · 20:00");
3165 let medicine = |id: &str, prescription: Option<&str>| Medicine {
3166 id: id.into(),
3167 name: id.into(),
3168 dose: String::new(),
3169 schedule: Schedule::Day { times: vec!["07:30".into()], amounts: Default::default() },
3170 prescription: prescription.map(Into::into),
3171 until: None,
3172 paused: false,
3173 generic: String::new(),
3174 strength: String::new(),
3175 since: None,
3176 };
3177 let health = Health {
3178 prescriptions: vec![
3179 Prescription { id: "thyroid".into(), title: "Thyroid".into(), until: Some(day("2027-02-04")), refill_days: Some(28), last_refill: Some(today), ..Default::default() },
3181 Prescription { id: "levo".into(), title: "Levothyroxine 75 µg".into(), until: Some(day("2026-10-01")), ..Default::default() },
3183 Prescription { id: "old".into(), title: "Old".into(), refill_days: Some(30), last_refill: Some(day("2026-06-01")), ..Default::default() },
3185 ],
3186 medicines: vec![medicine("Levothyroxine", Some("levo")), medicine("Magnesium", Some("thyroid")), medicine("Iron", Some("thyroid")), medicine("Zinc", None)],
3187 ..Default::default()
3188 };
3189 let errands = health.errands();
3190 assert_eq!(coming(&errands, &health.prescriptions[0], today), [(Coming::Pharmacy, day("2026-11-01")), (Coming::RenewBy, day("2027-02-04"))]);
3192 assert_eq!(coming(&errands, &health.prescriptions[1], today), [(Coming::ValidUntil, day("2026-10-01"))]);
3193 assert!(coming(&errands, &health.prescriptions[2], today).is_empty());
3194 assert_eq!(covered(&health, &health.prescriptions[0]), ["Magnesium", "Iron"]);
3195 assert!(covered(&health, &health.prescriptions[1]).is_empty() && covered(&health, &health.prescriptions[2]).is_empty());
3196 }
3197
3198 fn take(time: &str, amount: &str) -> Take {
3199 Take { time: time.into(), amount: amount.into() }
3200 }
3201
3202 fn prescription_edit(medicines: serde_json::Value) -> PrescriptionEdit {
3203 serde_json::from_value(serde_json::json!({ "title": "Thyroid and iron", "prescriber": "Dr Martin", "refill_days": 30, "medicines": medicines })).unwrap()
3204 }
3205
3206 #[test]
3209 fn a_prescriptions_medicines_and_their_takes_saved_from_its_form() {
3210 let english = Translator::new("en");
3211 let now: Zoned = "2026-10-08T09:00[Europe/Paris]".parse().unwrap();
3212 let mut health = Health::default();
3213 let levo: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Levothyroxine", "dose": "75 µg", "every": "day", "takes": [{ "time": "07:30", "amount": "" }] })).unwrap();
3215 let levo = apply_medicine(&mut health, "", levo, &now, &english).unwrap();
3216 let rows = serde_json::json!([
3218 { "id": levo, "name": "Levothyroxine", "dose": "75 µg", "takes": [{ "time": "07:30", "amount": "" }] },
3219 { "id": "", "name": "Iron", "dose": "1 tablet", "takes": [{ "time": "08:00", "amount": "" }, { "time": "13:00", "amount": "" }, { "time": "20:00", "amount": "2 tablets" }] },
3220 { "id": "", "name": "Magnesium", "dose": "300 mg", "takes": [{ "time": "21:30", "amount": "" }, { "time": "12:30", "amount": "150 mg" }] },
3221 { "id": "", "name": "", "dose": "", "takes": [{ "time": "08:00", "amount": "" }] },
3222 ]);
3223 let id = apply_prescription(&mut health, "", prescription_edit(rows), &english).unwrap();
3224 assert_eq!(health.prescriptions.len(), 1);
3225 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()))]);
3226 let iron = health.medicines.iter().find(|m| m.name == "Iron").unwrap().clone();
3227 assert_eq!(iron.takes(), [take("08:00", "1 tablet"), take("13:00", "1 tablet"), take("20:00", "2 tablets")]);
3228 let today = now.date();
3230 assert_eq!(tied(&health, &health.prescriptions[0]).map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Levothyroxine", "Iron", "Magnesium"]);
3231 let row = medicine_row(&iron, today);
3232 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"));
3233 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");
3234 let levo_row = medicine_row(&health.medicines[0], today);
3235 assert_eq!((levo_row.when.as_str(), levo_row.amount.as_str()), ("07:30", "75 µg"));
3236 let start = now.date().to_zoned(now.time_zone().clone()).unwrap();
3237 let end = start.checked_add(Span::new().days(1)).unwrap();
3238 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();
3239 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")];
3240 assert_eq!(doses, expected.map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string())));
3241 let before = health.clone();
3243 let twice = serde_json::json!([{ "id": iron.id, "name": "Iron", "dose": "1 tablet", "takes": [{ "time": "08:00", "amount": "" }, { "time": "8:00", "amount": "2 tablets" }] }]);
3244 assert_eq!(apply_prescription(&mut health, &id, prescription_edit(twice), &english), Err("Iron: two takes at 08:00. Keep one, with its amount.".into()));
3245 let unnamed = serde_json::json!([{ "id": "", "name": " ", "dose": "5 mg", "takes": [{ "time": "08:00", "amount": "" }] }]);
3246 assert!(apply_prescription(&mut health, &id, prescription_edit(unnamed), &english).is_err());
3247 let none = serde_json::json!([{ "id": iron.id, "name": "Iron", "dose": "1 tablet", "takes": [] }]);
3248 assert_eq!(apply_prescription(&mut health, &id, prescription_edit(none), &english), Err("Iron: add a take, the time it is taken each day.".into()));
3249 assert_eq!(health, before);
3250 let out = serde_json::json!([{ "id": levo, "name": "Levothyroxine", "dose": "75 µg", "takes": [{ "time": "07:30", "amount": "" }] }, { "id": iron.id, "removed": true }]);
3253 apply_prescription(&mut health, &id, prescription_edit(out), &english).unwrap();
3254 assert_eq!(health.medicines.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Levothyroxine", "Magnesium"]);
3255 assert!(health.doses(&start, &end).iter().all(|d| d.name != "Iron"));
3256 let kept = health.clone();
3257 let plain: PrescriptionEdit = serde_json::from_value(serde_json::json!({ "title": "Thyroid and iron", "refill_days": 30 })).unwrap();
3258 apply_prescription(&mut health, &id, plain, &english).unwrap();
3259 assert_eq!(health.medicines, kept.medicines);
3260 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();
3262 let antibiotic = apply_medicine(&mut health, "", hours, &now, &english).unwrap();
3263 let schedule = health.medicines.iter().find(|m| m.id == antibiotic).unwrap().schedule.clone();
3264 let rename = serde_json::json!([{ "id": antibiotic, "name": "Amoxicillin", "dose": "1 g", "takes": [] }]);
3265 apply_prescription(&mut health, &id, prescription_edit(rename), &english).unwrap();
3266 let changed = health.medicines.iter().find(|m| m.id == antibiotic).unwrap();
3267 assert_eq!((changed.name.as_str(), changed.dose.as_str(), &changed.schedule), ("Amoxicillin", "1 g", &schedule));
3268 let typed: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Zinc", "every": "day", "times": ["12:00", " 18:00"] })).unwrap();
3270 let zinc = apply_medicine(&mut health, "", typed, &now, &english).unwrap();
3271 assert_eq!(health.medicines.iter().find(|m| m.id == zinc).unwrap().takes(), [take("12:00", ""), take("18:00", "")]);
3272 let wrong: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "Zinc", "every": "day", "takes": [{ "time": "noon", "amount": "" }] })).unwrap();
3273 assert_eq!(apply_medicine(&mut health, &zinc, wrong, &now, &english), Err("“noon” is not a time of day.".into()));
3274 }
3275
3276 #[test]
3279 fn a_takes_own_amount_is_said_wherever_its_dose_is() {
3280 let dir = std::env::temp_dir().join(format!("sioul-takes-{}", std::process::id()));
3281 let _ = std::fs::remove_dir_all(&dir);
3282 std::fs::create_dir_all(&dir).unwrap();
3283 let path = dir.join("health.toml");
3284 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 };
3285 iron.set_takes("1 tablet", &[take("08:00", ""), take("20:00", "2 tablets")]).unwrap();
3286 Health { medicines: vec![iron], ..Health::default() }.save(&path).unwrap();
3287 let health = Health::load(&path);
3288 let english = Translator::new("en");
3289 let at = |text: &str| text.parse::<Zoned>().unwrap();
3290 let alone = Knowledge { record_lost: None, peers: Vec::new() };
3291 let state = HealthState::default();
3292 let evening = at("2026-10-08T20:01[Europe/Paris]");
3294 let porch = serde_json::to_value(porch_doses(&health, &state, &DoseRecords::default(), "desk-id", &alone, &evening, false, true, &english)).unwrap();
3295 let due: Vec<(&str, &str)> = porch["due"].as_array().unwrap().iter().map(|d| (d["time"].as_str().unwrap(), d["dose"].as_str().unwrap())).collect();
3296 assert_eq!(due, [("20:00", "2 tablets")]);
3297 let card = doses_for_card(&health, &state, &DoseRecords::default(), true, &alone, &at("2026-10-08T07:00[Europe/Paris]"));
3299 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")]);
3300 let doses = health.doses(&at("2026-10-08T00:00[Europe/Paris]"), &at("2026-10-09T00:00[Europe/Paris]"));
3302 assert_eq!(doses.iter().map(named).collect::<Vec<_>>(), ["Iron · 1 tablet", "Iron · 2 tablets"]);
3303 assert_eq!(health.amount_of(&doses[1].key, evening.time_zone()).as_deref(), Some("2 tablets"));
3305 let _ = std::fs::remove_dir_all(&dir);
3306 }
3307
3308 #[test]
3313 fn a_doctor_or_pharmacist_reads_the_generic_name_first() {
3314 let english = Translator::new("en");
3315 let now: Zoned = "2026-10-08T09:00[Europe/Paris]".parse().unwrap();
3316 let today = now.date();
3317 let mut health = Health::default();
3318 let rows = serde_json::json!([
3319 { "id": "", "name": "Thyrolan", "generic": "levothyroxine", "strength": "75 µg", "dose": "1 tablet", "takes": [{ "time": "07:30", "amount": "" }] },
3320 { "id": "", "name": " ", "generic": "metformin", "strength": "500 mg per tablet", "dose": "1 tablet", "takes": [{ "time": "08:00", "amount": "" }, { "time": "20:00", "amount": "2 tablets" }] },
3321 ]);
3322 let edit: PrescriptionEdit = serde_json::from_value(serde_json::json!({ "title": "Thyroid and sugar", "prescriber": "Dr Martin", "until": "2027-02-04", "medicines": rows })).unwrap();
3323 let id = apply_prescription(&mut health, "", edit, &english).unwrap();
3324 let fields: Vec<(&str, &str, &str)> = health.medicines.iter().map(|m| (m.name.as_str(), m.generic.as_str(), m.strength.as_str())).collect();
3326 assert_eq!(fields, [("Thyrolan", "levothyroxine", "75 µg"), ("metformin", "metformin", "500 mg per tablet")]);
3327 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();
3329 apply_medicine(&mut health, "", antibiotic, &now, &english).unwrap();
3330 let mut paused = health.medicines[0].clone();
3332 (paused.id, paused.name, paused.paused, paused.prescription) = ("zinc".into(), "Zinc".into(), true, None);
3333 let mut over = health.medicines[0].clone();
3334 (over.id, over.name, over.prescription, over.until) = ("iron".into(), "Iron".into(), None, Some("2026-10-01".parse().unwrap()));
3335 health.medicines.extend([paused, over]);
3336 let again: Health = toml::from_str(&toml::to_string(&health).unwrap()).unwrap();
3338 assert_eq!(again, health);
3339 let nameless: MedicineEdit = serde_json::from_value(serde_json::json!({ "name": "", "generic": " ", "every": "day", "takes": [{ "time": "08:00" }] })).unwrap();
3341 assert_eq!(apply_medicine(&mut health.clone(), "", nameless, &now, &english), Err("A name is needed.".into()));
3342 let levo = medicine_row(&health.medicines[0], today);
3344 assert_eq!((levo.precise.as_str(), levo.line.as_str()), ("Thyrolan — levothyroxine 75 µg", "Thyrolan — levothyroxine 75 µg · 07:30 · 1 tablet"));
3345 assert_eq!(medicine_row(&health.medicines[1], today).precise, "metformin 500 mg per tablet");
3346 let start = now.date().to_zoned(now.time_zone().clone()).unwrap();
3347 let end = start.checked_add(Span::new().days(1)).unwrap();
3348 assert_eq!(health.doses(&start, &end).iter().find(|d| d.at.hour() == 7).map(|d| d.name.as_str()), Some("Thyrolan"));
3349 let view = for_professional_of(&health, &id, today, &english);
3351 assert_eq!(view.title, "Thyroid and sugar");
3352 assert_eq!(view.about, ["Prescribed by Dr Martin", "Valid until Thursday 4 February 2027"]);
3353 let levo = &view.medicines[0];
3354 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()], ""));
3355 let metformin = &view.medicines[1];
3356 assert_eq!((metformin.molecule.as_str(), metformin.brand.as_str()), ("metformin 500 mg per tablet", ""), "its name is its generic name: said once");
3357 assert_eq!(metformin.takes, ["08:00 — 1 tablet", "20:00 — 2 tablets"]);
3358 assert_eq!(view.medicines.len(), 2, "the antibiotic is not on it");
3359 let all = for_professional_of(&health, "", today, &english);
3361 assert_eq!((all.title.as_str(), all.about.clone()), ("Current medicines", vec!["As of Thursday 8 October 2026".to_string()]));
3362 assert_eq!(all.medicines.iter().map(|m| m.molecule.as_str()).collect::<Vec<_>>(), ["levothyroxine 75 µg", "metformin 500 mg per tablet", "amoxicillin 500 mg"]);
3363 assert_eq!(all.medicines[0].prescriber, "Prescribed by Dr Martin");
3364 let amoxicillin = &all.medicines[2];
3365 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"));
3366 health.medicines[1].paused = true;
3368 assert_eq!(for_professional_of(&health, &id, today, &english).medicines[1].notes, ["Paused for now"]);
3369 let french = for_professional_of(&health, "", today, &Translator::new("fr"));
3371 assert_eq!(french.title, "Médicaments en cours");
3372 assert_eq!(french.medicines[1].since, "Pris depuis le jeudi 1er octobre, 7 jours");
3373 assert_eq!(french.medicines[0].molecule, "levothyroxine 75 µg");
3374 assert_eq!(for_professional_of(&Health::default(), "", today, &english).empty, "No medicine to show.");
3376 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()));
3377 }
3378
3379 #[test]
3385 fn the_porch_shows_todays_doses_until_marked() {
3386 let dir = std::env::temp_dir().join(format!("sioul-porch-doses-{}", std::process::id()));
3387 let _ = std::fs::remove_dir_all(&dir);
3388 std::fs::create_dir_all(&dir).unwrap();
3389 let path = dir.join("health.toml");
3390 let file = "[[medicine]]\nid = \"levo\"\nname = \"Levothyroxine\"\ndose = \"75 µg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"07:30\"]\n\n\
3391 [[medicine]]\nid = \"iron\"\nname = \"Iron\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"12:00\", \"21:00\"]\n\n\
3392 [[medicine]]\nid = \"zinc\"\nname = \"Zinc\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"09:00\"]\n";
3393 std::fs::write(&path, file).unwrap();
3394 let health = Health::load(&path);
3395 assert_eq!(health.medicines.len(), 3);
3396 let english = Translator::new("en");
3397 let at = |text: &str| text.parse::<Zoned>().unwrap();
3398 let key = |id: &str, when: &str| format!("{id}@{}", at(when).timestamp().as_second());
3399 let alone = Knowledge { record_lost: None, peers: Vec::new() };
3400 let shown = |state: &HealthState, knowledge: &Knowledge, now: &Zoned, silent: bool, heard: bool| serde_json::to_value(porch_doses(&health, state, &DoseRecords::default(), "desk-id", knowledge, now, silent, heard, &english)).unwrap();
3401 let mut state = HealthState::default();
3402 state.reminded.insert(key("levo", "2026-10-06T07:30[Europe/Paris]"), at("2026-10-06T07:31[Europe/Paris]").timestamp().as_second());
3404 let noon = at("2026-10-06T12:00:40[Europe/Paris]");
3405 let view = shown(&state, &alone, &noon, false, true);
3406 let due = view["due"].as_array().unwrap();
3407 assert_eq!(due.len(), 2, "{view}");
3408 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)));
3410 assert_eq!((due[1]["time"].as_str(), due[1]["late"].as_bool(), due[1]["doubt"].as_str()), (Some("12:00"), Some(false), Some("")));
3412 let closed = view["closed"].as_array().unwrap();
3414 assert_eq!(closed.len(), 1);
3415 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")));
3416 let early = shown(&state, &alone, &noon, false, false);
3418 assert_eq!((early["due"].as_array().unwrap().len(), early["closed"].as_array().unwrap().len()), (2, 0));
3419 let asleep = shown(&state, &alone, &noon, true, true);
3421 assert!(asleep["due"].as_array().unwrap().is_empty() && asleep["closed"].as_array().unwrap().is_empty());
3422 let heard = at("2026-10-06T07:35[Europe/Paris]").timestamp().as_second();
3424 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() }] };
3425 let doubted = shown(&state, &unsure, &noon, false, true);
3426 for row in doubted["due"].as_array().unwrap().iter().chain(doubted["closed"].as_array().unwrap()) {
3427 let doubt = row["doubt"].as_str().unwrap();
3428 assert!(doubt.starts_with("Sioul can't tell whether it was taken") && doubt.contains("laptop") && doubt.ends_with("Check before taking it."), "{doubt}");
3429 assert!(!doubt.to_lowercase().contains("not taken"), "{doubt}");
3430 }
3431 state.taken.insert(key("iron", "2026-10-06T12:00[Europe/Paris]"), noon.timestamp().as_second());
3433 state.not_taken.insert(key("levo", "2026-10-06T07:30[Europe/Paris]"), noon.timestamp().as_second());
3434 assert!(shown(&state, &alone, &noon, false, true)["due"].as_array().unwrap().is_empty());
3435 let night = shown(&state, &alone, &at("2026-10-07T00:30[Europe/Paris]"), false, true);
3437 assert!(night["due"].as_array().unwrap().is_empty());
3438 assert_eq!(night["closed"][0]["time"].as_str(), Some("Tue 21:00"));
3439 let _ = std::fs::remove_dir_all(&dir);
3440 }
3441
3442 #[test]
3449 fn a_reminder_not_shown_is_asked_about_under_its_own_question() {
3450 let dir = std::env::temp_dir().join(format!("sioul-unshown-doses-{}", std::process::id()));
3451 let _ = std::fs::remove_dir_all(&dir);
3452 std::fs::create_dir_all(&dir).unwrap();
3453 let path = dir.join("health.toml");
3454 let file = "[[medicine]]\nid = \"demo-a\"\nname = \"Demo A\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\"]\n\n\
3455 [[medicine]]\nid = \"demo-b\"\nname = \"Demo B\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"07:00\"]\n";
3456 std::fs::write(&path, file).unwrap();
3457 let health = Health::load(&path);
3458 let (english, french) = (Translator::new("en"), Translator::new("fr"));
3459 let at = |text: &str| text.parse::<Zoned>().unwrap();
3460 let key = |id: &str, when: &str| format!("{id}@{}", at(when).timestamp().as_second());
3461 let (a, b) = (key("demo-a", "2026-10-06T08:00[Europe/Paris]"), key("demo-b", "2026-10-06T07:00[Europe/Paris]"));
3462 let alone = Knowledge { record_lost: None, peers: Vec::new() };
3463 let noon = at("2026-10-06T12:00[Europe/Paris]");
3464 let mut state = HealthState::default();
3466 note_minute(&mut state, &[], std::slice::from_ref(&a), &[], at("2026-10-06T08:00:30[Europe/Paris]").timestamp().as_second());
3467 assert!(state.reminded.is_empty() && state.reminder_unshown(&a));
3468 let view = serde_json::to_value(porch_doses(&health, &state, &DoseRecords::default(), "desk-id", &alone, &noon, false, true, &english)).unwrap();
3470 let closed = view["closed"].as_array().unwrap();
3471 assert_eq!(closed.len(), 2, "{view}");
3472 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?")));
3473 assert_eq!(closed[1]["key"].as_str(), Some(a.as_str()));
3475 let question = closed[1]["question"].as_str().unwrap();
3476 assert!(question.starts_with("Due while Sioul ran, but their reminder could not be shown") && !question.contains("closed"), "{question}");
3477 assert!(view["due"].as_array().unwrap().iter().all(|row| row["question"] == ""), "today's doses carry no question");
3478 let rows = missed_rows(&health, &state, &alone, &noon, &french);
3480 assert_eq!(rows.iter().map(|r| r.key.as_str()).collect::<Vec<_>>(), [b.as_str(), a.as_str()]);
3481 assert!(rows[1].question.starts_with("Prévus pendant que Sioul tournait, mais leur rappel n’a pas pu s’afficher"), "{}", rows[1].question);
3482 let heard = at("2026-10-06T06:30[Europe/Paris]").timestamp().as_second();
3484 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() }] };
3485 for row in missed_rows(&health, &state, &unsure, &noon, &english) {
3486 assert!(row.doubt.starts_with("Sioul can't tell whether it was taken") && !row.doubt.to_lowercase().contains("not taken"), "{}", row.doubt);
3487 }
3488 let missed = state.unanswered(&health, &noon, MISSED_HOURS, GRACE_MINUTES);
3490 assert_eq!(missed_title(&missed, &state, &english), "Doses due earlier");
3491 assert_eq!(missed_title(&missed[1..], &state, &english), "A reminder could not be shown");
3492 assert_eq!(missed_title(&missed[..1], &state, &english), "While Sioul was closed");
3493 assert_eq!(missed_title(&missed[1..], &state, &french), "Un rappel n’a pas pu s’afficher");
3494 note_minute(&mut state, std::slice::from_ref(&a), &[], &[], at("2026-10-06T08:05[Europe/Paris]").timestamp().as_second());
3496 let rows = missed_rows(&health, &state, &alone, &noon, &english);
3497 assert_eq!(rows.iter().map(|r| r.key.as_str()).collect::<Vec<_>>(), [b.as_str()]);
3498 let _ = std::fs::remove_dir_all(&dir);
3499 }
3500
3501 #[test]
3506 fn the_home_screen_card_says_doses_as_known() {
3507 let dir = std::env::temp_dir().join(format!("sioul-card-doses-{}", std::process::id()));
3508 let _ = std::fs::remove_dir_all(&dir);
3509 std::fs::create_dir_all(&dir).unwrap();
3510 let path = dir.join("health.toml");
3511 std::fs::write(&path, "[[medicine]]\nid = \"mag\"\nname = \"Magnesium\"\ndose = \"300 mg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"12:30\", \"20:00\"]\n").unwrap();
3512 let health = Health::load(&path);
3513 let at = |text: &str| text.parse::<Zoned>().unwrap();
3514 let s = |text: &str| at(text).timestamp().as_second();
3515 let now = at("2026-10-06T13:00[Europe/Paris]");
3516 let alone = Knowledge { record_lost: None, peers: Vec::new() };
3517 let mut state = HealthState::default();
3518 let doses = doses_for_card(&health, &state, &DoseRecords::default(), true, &alone, &now);
3519 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")]);
3521 let midnight = s("2026-10-07T00:00[Europe/Paris]");
3523 assert_eq!((doses[0].at, doses[0].until, doses[0].known_until), (s("2026-10-06T12:30[Europe/Paris]"), midnight, midnight));
3524 state.taken.insert(format!("mag@{}", s("2026-10-06T12:30[Europe/Paris]")), s("2026-10-06T12:35[Europe/Paris]"));
3526 assert_eq!(doses_for_card(&health, &state, &DoseRecords::default(), true, &alone, &now).len(), 1);
3527 let heard = s("2026-10-06T12:58[Europe/Paris]");
3529 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() }] };
3530 let fresh = doses_for_card(&health, &HealthState::default(), &DoseRecords::default(), true, &laptop, &now);
3531 assert_eq!(fresh[0].known_until, heard + 5 * 60 + 1);
3532 assert_eq!(fresh[1].known_until, 0);
3534 let noon = s("2026-10-06T12:00[Europe/Paris]");
3536 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() }] };
3537 assert_eq!(doses_for_card(&health, &HealthState::default(), &DoseRecords::default(), true, &old, &now)[0].known_until, 0);
3538 let unread = doses_for_card(&health, &HealthState::default(), &DoseRecords::default(), false, &alone, &now);
3540 assert_eq!(unread.len(), 2);
3541 assert!(unread.iter().all(|d| d.known_until == 0));
3542 let mut differ = HealthState::default();
3545 let key = format!("mag@{}", s("2026-10-06T12:30[Europe/Paris]"));
3546 differ.taken.insert(key.clone(), s("2026-10-06T12:35[Europe/Paris]"));
3547 differ.not_taken.insert(key, s("2026-10-06T12:40[Europe/Paris]"));
3548 let doses = doses_for_card(&health, &differ, &DoseRecords::default(), true, &alone, &now);
3549 assert_eq!(doses.iter().map(|d| (d.time.as_str(), d.known_until)).collect::<Vec<_>>(), [("12:30", 0), ("20:00", midnight)]);
3550 let key = format!("mag@{}", s("2026-10-06T12:30[Europe/Paris]"));
3553 let given = |state: &str, at: &str, name: &str| Answer { state: state.into(), at: s(at), noted: s(at), name: name.into(), kind: name.into() };
3554 let mut records = DoseRecords::default();
3555 records.answer(&key, "phone-id", given(sioul_core::doses::TAKEN, "2026-10-06T12:35[Europe/Paris]", "phone"));
3556 let doses = doses_for_card(&health, &HealthState::default(), &records, true, &alone, &now);
3557 assert_eq!(doses.iter().map(|d| d.time.as_str()).collect::<Vec<_>>(), ["20:00"]);
3558 records.answer(&key, "desk-id", given(sioul_core::doses::SKIPPED, "2026-10-06T12:40[Europe/Paris]", "computer"));
3559 let doses = doses_for_card(&health, &HealthState::default(), &records, true, &alone, &now);
3560 assert_eq!(doses.iter().map(|d| (d.time.as_str(), d.known_until)).collect::<Vec<_>>(), [("12:30", 0), ("20:00", midnight)]);
3561 let _ = std::fs::remove_dir_all(&dir);
3562 }
3563
3564 #[test]
3569 fn the_card_says_check_while_a_closed_device_may_have_opened() {
3570 let dir = std::env::temp_dir().join(format!("sioul-card-wake-{}", std::process::id()));
3571 let _ = std::fs::remove_dir_all(&dir);
3572 std::fs::create_dir_all(&dir).unwrap();
3573 let path = dir.join("health.toml");
3574 std::fs::write(&path, "[[medicine]]\nid = \"mag\"\nname = \"Magnesium\"\ndose = \"300 mg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"12:30\", \"20:00\"]\n").unwrap();
3575 let health = Health::load(&path);
3576 let s = |text: &str| text.parse::<Zoned>().unwrap().timestamp().as_second();
3577 let now = "2026-10-06T13:00[Europe/Paris]".parse::<Zoned>().unwrap();
3578 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() };
3579 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() };
3580 let knowledge = Knowledge { record_lost: None, peers: vec![desk] };
3581 let doses = doses_for_card(&health, &HealthState::default(), &DoseRecords::default(), true, &knowledge, &now);
3582 let (eight, midnight) = (s("2026-10-06T20:00[Europe/Paris]"), s("2026-10-07T00:00[Europe/Paris]"));
3583 let pieces: Vec<(&str, i64, i64, i64)> = doses.iter().map(|d| (d.time.as_str(), d.at, d.until, d.known_until)).collect();
3584 let woke = eight + 120 + sioul_core::health::SKEW;
3585 assert_eq!(pieces, [("12:30", s("2026-10-06T12:30[Europe/Paris]"), midnight, midnight), ("20:00", eight, woke, 0), ("20:00", woke, midnight, midnight)]);
3586 let _ = std::fs::remove_dir_all(&dir);
3587 }
3588
3589 fn heard_of(id: &str, n: u64) -> sioul_sync::share::Heard {
3590 sioul_sync::share::Heard { read: [(id.to_string(), (1, n))].into(), broken: Default::default() }
3591 }
3592
3593 #[test]
3602 fn an_alarm_that_read_no_news_says_a_closed_device_in_doubt() {
3603 let english = Translator::new("en");
3604 let due = 1_800_000_000;
3605 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() };
3606 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() };
3607 let peers = vec![laptop];
3608 assert!(sioul_core::health::doubts(due, due + 60, None, &peers).is_empty(), "news read: known");
3609 let doubts = sioul_core::health::doubts_unread(due, due + 60, None, &peers, due + 30, &Default::default());
3610 let words = why_in(&doubts, &english);
3611 assert!(words.starts_with("laptop closed") && words.contains("its news can be slow to come") && !words.to_lowercase().contains("not taken"), "{words}");
3612 }
3613
3614 #[test]
3620 fn how_late_a_devices_opening_comes_is_timed_while_looking() {
3621 use sioul_sync::devices::Entry;
3622 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() };
3623 let mut kept = Peers::default();
3624 learn(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), 1_020);
3626 for now in [1_100, 1_200, 1_300] {
3627 learn(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), now);
3628 }
3629 assert_eq!(kept.peers["phone-id"].peer.wake, None);
3630 learn(&mut kept, &[], &[phone(1_150, true, 1_000)], &heard_of("phone-id", 5), 1_390);
3632 assert_eq!(kept.peers["phone-id"].peer.wake, Some(240));
3633 learn(&mut kept, &[], &[phone(1_150, false, 1_500)], &heard_of("phone-id", 5), 1_510);
3635 learn(&mut kept, &[], &[phone(1_600, true, 1_500)], &heard_of("phone-id", 5), 2_200);
3636 assert_eq!(kept.peers["phone-id"].peer.wake, Some(240));
3637 learn(&mut kept, &[], &[phone(1_600, false, 2_250)], &heard_of("phone-id", 5), 2_260);
3639 learn(&mut kept, &[], &[phone(2_300, true, 2_250)], &heard_of("phone-id", 5), 2_330);
3640 assert_eq!(kept.peers["phone-id"].peer.wake, Some(240));
3641 learn(&mut kept, &[], &[phone(2_300, false, 2_900)], &heard_of("phone-id", 5), 2_910);
3643 let peers = vec![kept.peers["phone-id"].peer.clone()];
3644 let skew = sioul_core::health::SKEW;
3645 assert_eq!(sioul_core::health::doubts_now(3_000, 3_239 + skew, None, &peers).len(), 1);
3646 assert!(sioul_core::health::doubts_now(3_000, 3_240 + skew, None, &peers).is_empty());
3647 }
3648
3649 #[test]
3654 fn an_alarm_in_doubt_asks_again_when_the_doubt_may_end() {
3655 let due = 1_800_000_000;
3656 assert_eq!(alarm_again(due, due + 5, Some(due + 300)), due + 300, "a closed device's opening could have come by then");
3657 assert_eq!(alarm_again(due, due + 5, Some(due + 30)), due + 65, "a minute apart at least");
3658 assert_eq!(alarm_again(due, due + 5, Some(due + 6)), due + WAIT_FOR_NEWS, "known by what was read: only the news missed");
3659 assert_eq!(alarm_again(due, due + 5, None), due + WAIT_FOR_NEWS);
3660 assert_eq!(alarm_again(due, due + 5, Some(due + 2 * WAIT_FOR_NEWS)), due + WAIT_FOR_NEWS);
3661 }
3662
3663 #[test]
3669 fn an_opening_is_not_timed_by_this_devices_own_outage() {
3670 use sioul_sync::devices::Entry;
3671 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() };
3672 let opened_late = |news: Option<bool>| {
3673 let mut kept = Peers::default();
3674 learn_with(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), news, 1_020);
3675 learn_with(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), news, 1_080);
3676 for now in (1_140..19_100).step_by(60) {
3678 learn_with(&mut kept, &[], &[phone(900, false, 1_000)], &heard_of("phone-id", 5), news, now);
3679 }
3680 learn_with(&mut kept, &[], &[phone(1_100, true, 1_000)], &heard_of("phone-id", 5), news, 19_100);
3681 kept.peers["phone-id"].peer.wake
3682 };
3683 assert_eq!(opened_late(Some(false)), None, "its own pull stale: its outage, not the phone's");
3684 assert_eq!(opened_late(Some(true)), Some(18_000), "its own pull went through: the phone's news was late");
3685 assert_eq!(opened_late(None), Some(UNSURE_WAKE), "a sync app alone: an hour at most");
3686 }
3687
3688 #[test]
3689 fn what_the_devices_say_of_themselves_teaches() {
3690 use sioul_sync::devices::Entry;
3691 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() };
3692 let mut kept = Peers::default();
3693 learn(&mut kept, &[], &[phone(1_100, true, 0)], &heard_of("phone-id", 5), 1_120);
3694 let peer = &kept.peers["phone-id"].peer;
3695 assert_eq!((peer.seen, peer.complete, peer.said.as_ref().map(|s| (s.working, s.phone))), (1_120, true, Some((true, true))));
3696 learn(&mut kept, &[], &[phone(1_100, true, 0)], &heard_of("phone-id", 5), 1_180);
3698 assert_eq!(kept.peers["phone-id"].peer.seen, 1_120);
3699 learn(&mut kept, &[], &[phone(1_400, true, 0)], &heard_of("phone-id", 5), 1_220);
3701 assert_eq!((kept.peers["phone-id"].peer.seen, kept.peers["phone-id"].peer.ahead), (1_220, 180));
3702 learn(&mut kept, &[], &[Entry { wrote: Some((1, 9)), ..phone(1_460, true, 0) }], &heard_of("phone-id", 5), 1_280);
3704 assert!(!kept.peers["phone-id"].peer.complete);
3705 learn(&mut kept, &[], &[], &heard_of("phone-id", 9), 1_340);
3707 assert_eq!(kept.peers["phone-id"].peer.said, None);
3708 learn(&mut kept, &[], &[phone(1_460, true, 0)], &heard_of("phone-id", 9), 1_400);
3710 kept.peers.get_mut("phone-id").unwrap().off = Some(Mark { started: 1_000, exported: 1_460, renewed: 0, at: 1_400 });
3711 learn(&mut kept, &[], &[phone(1_460, true, 0)], &heard_of("phone-id", 9), 1_460);
3712 assert!(kept.peers["phone-id"].peer.off, "nothing newer: still off");
3713 learn(&mut kept, &[], &[phone(1_520, false, 1_525)], &heard_of("phone-id", 9), 1_530);
3714 assert!(!kept.peers["phone-id"].peer.off, "it spoke again");
3715 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() };
3718 learn(&mut kept, &[open], &[phone(1_520, false, 1_525)], &heard_of("phone-id", 9), 1_710);
3719 assert_eq!(kept.peers["phone-id"].peer.said, None);
3720 }
3721
3722 #[test]
3728 fn the_porch_says_answers_that_differ_and_which_device_may_know() {
3729 use sioul_core::health::Said;
3730 let dir = std::env::temp_dir().join(format!("sioul-porch-registry-{}", std::process::id()));
3731 let _ = std::fs::remove_dir_all(&dir);
3732 std::fs::create_dir_all(&dir).unwrap();
3733 let path = dir.join("health.toml");
3734 std::fs::write(&path, "[[medicine]]\nid = \"levo\"\nname = \"Levothyroxine\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\", \"12:00\"]\n").unwrap();
3735 let health = Health::load(&path);
3736 let english = Translator::new("en");
3737 let s = |text: &str| text.parse::<Zoned>().unwrap().timestamp().as_second();
3738 let eight = format!("levo@{}", s("2026-10-06T08:00[Europe/Paris]"));
3739 let noon = "2026-10-06T12:00:40[Europe/Paris]".parse::<Zoned>().unwrap();
3740 let mut records = DoseRecords::default();
3742 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() });
3743 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() });
3744 let mut state = HealthState::default();
3745 state.taken.insert(eight.clone(), s("2026-10-06T08:02[Europe/Paris]"));
3746 state.not_taken.insert(eight.clone(), s("2026-10-06T08:10[Europe/Paris]"));
3747 let shown = |knowledge: &Knowledge| serde_json::to_value(porch_doses(&health, &state, &records, "desk-id", knowledge, &noon, false, true, &english)).unwrap();
3748 let alone = Knowledge { record_lost: None, peers: Vec::new() };
3749 let view = shown(&alone);
3750 let due = view["due"].as_array().unwrap();
3751 assert_eq!(due.len(), 2, "{view}");
3752 let differ = due[0]["doubt"].as_str().unwrap();
3753 assert!(differ.starts_with("Marked taken on the phone ") && differ.contains(", skipped here ") && differ.ends_with("Check which is right before taking it."), "{differ}");
3754 assert_eq!((due[0]["key"].as_str(), due[0]["choose"].as_bool()), (Some(eight.as_str()), Some(true)));
3755 assert_eq!((due[1]["doubt"].as_str(), due[1]["choose"].as_bool()), (Some(""), Some(false)));
3756 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() };
3758 let view = shown(&Knowledge { record_lost: None, peers: vec![phone.clone()] });
3759 let row = &view["due"][1];
3760 let doubt = row["doubt"].as_str().unwrap();
3761 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}");
3762 assert!(!doubt.to_lowercase().contains("not taken"), "{doubt}");
3763 assert_eq!(row["doubt_off"], serde_json::json!([{ "id": "phone-id", "label": "This device is off" }]));
3764 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() };
3766 let view = shown(&Knowledge { record_lost: None, peers: vec![closed] });
3767 assert_eq!((view["due"][1]["doubt"].as_str(), view["due"][1]["doubt_off"].as_array().map(Vec::len)), (Some(""), Some(0)));
3768 let off = Peer { off: true, ..phone };
3770 let view = shown(&Knowledge { record_lost: None, peers: vec![off] });
3771 assert_eq!((view["due"][1]["doubt"].as_str(), view["due"].as_array().map(Vec::len)), (Some(""), Some(2)));
3772 let view = serde_json::to_value(porch_doses(&health, &HealthState::default(), &records, "desk-id", &alone, &noon, false, true, &english)).unwrap();
3774 let keys: Vec<&str> = view["due"].as_array().unwrap().iter().map(|row| row["key"].as_str().unwrap_or("")).collect();
3775 assert_eq!(keys.iter().filter(|key| **key == eight).count(), 1, "{view}");
3776 assert_eq!(view["due"][0]["choose"].as_bool(), Some(true), "{view}");
3777 let _ = std::fs::remove_dir_all(&dir);
3778 }
3779
3780 #[test]
3783 fn your_devices_listed_in_words() {
3784 use sioul_core::health::Said;
3785 use sioul_sync::devices::Entry;
3786 let english = Translator::new("en");
3787 let now = 1_800_000_000;
3788 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() };
3789 let mut kept = Peers::default();
3790 let mut add = |entry: Entry, seen: i64, ahead: i64| {
3791 let peer = Peer { id: entry.id.clone(), name: entry.name.clone(), said: Some(entry.said()), complete: true, seen, heard: entry.exported, ahead, ..Peer::default() };
3792 kept.peers.insert(entry.id.clone(), PeerSeen { peer, entry: Some(entry), ..PeerSeen::default() });
3793 };
3794 add(entry("a-phone", "phone", "FP3", false, now - 600, true), now - 590, 240);
3795 add(entry("b-desk", "computer", "desk", true, now - 30, true), now - 25, 0);
3796 add(entry("c-laptop", "computer", "laptop", true, now - 4 * 3_600, false), now - 4 * 3_600, 0);
3797 add(entry("d-old", "computer", "old", true, now - 9 * 86_400, true), now - 9 * 86_400, 0);
3798 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() });
3799 kept.peers.get_mut("b-desk").unwrap().peer.off = true;
3800 let peers: Vec<Peer> = kept.peers.values().map(|s| s.peer.clone()).collect();
3801 let rows = device_rows_of(&peers, &kept, now, &english);
3802 let row = |id: &str| rows.iter().find(|r| r.id == id).unwrap();
3803 assert_eq!(row("a-phone").name, "The phone (FP3)");
3804 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"));
3805 assert!(row("b-desk").off && row("b-desk").state == "Counted as off, as you said, until it shares again.");
3806 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"));
3807 assert!(row("d-old").forget && row("d-old").state.starts_with("Silent since "), "{:?}", row("d-old"));
3808 assert!(row("e-older").state.starts_with("An older Sioul: last heard "), "{:?}", row("e-older"));
3809 assert!(rows.iter().all(|r| !r.state.contains("not taken")));
3810 let _ = Said::default();
3811 let mut rows = rows;
3814 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);
3815 assert_eq!(rows.iter().filter(|r| r.id == "e-older").count(), 1);
3816 let row = |id: &str| rows.iter().find(|r| r.id == id).unwrap();
3817 assert!(!row("e-older").forget, "counted for the doses: forgotten once silent a week only");
3818 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"));
3819 assert!(row("h-after").forget && row("h-after").state.starts_with("An older Sioul: last heard "), "{:?}", row("h-after"));
3820 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"));
3821 }
3822}