Skip to main content

sioul_app/
health.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Health and well-being, for the window: the page (a day, or its week, at a
5//! glance: meals, naps, the night and the doses, each day's own changes; the
6//! medicines and the prescriptions), its settings (the usual meals and nights,
7//! the pauses), and the minute tick that reminds a dose once, quietly,
8//! and turns refills and renewals into tasks in a list your phone has. The
9//! rest goes to no server, except sealed to your other computers when you
10//! share with them (docs/database.md).
11
12use 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
30/// The health file read, changed by `change` and written back, all under its
31/// lock, which the sharing takes too: a change is set over the medicines as
32/// the file holds them then (`Health::change`), never over a copy read earlier.
33fn change_health<R>(change: impl FnOnce(&mut Health) -> Result<R, String>) -> Result<R, String> {
34    let out = Health::change(&Health::default_path(), change)?;
35    // A medicine changed: a phone's alarms follow (`alarms`).
36    crate::alarms::schedule();
37    Ok(out)
38}
39
40/// "Levothyroxine and Magnesium", "A, B and C".
41fn 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
49/// "8:00" → "08:00", as the day's list writes its times; what does not read stays as written.
50fn 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
58/// When a medicine is taken: its times each day, "08:00 · 20:00"; every few
59/// days or hours, in words ("every other day at 08:00, from Sunday 4 October").
60fn 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            // Said by its next dose: each dose taken sets the next.
77            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    /// "12:04" when marked taken; "" otherwise.
96    taken: String,
97    past: bool,
98    /// Past its time by more than half an hour, not marked: marked now, it
99    /// asks when it was taken (`DoseTaken.qml`).
100    late: bool,
101    /// Not marked here, but whether it was taken is not known here: why, in
102    /// a sentence; "" when it is known (docs/health.md, "Knowing"). Answers
103    /// that differ on your devices: both, said (`choose`).
104    doubt: String,
105    /// A "This device is off" for each device the doubt names (`device_off`).
106    doubt_off: Vec<OffButton>,
107    /// Answers that differ, said in `doubt`: "Taken" and "Not taken" settle it (`choose`).
108    choose: bool,
109    /// For a dose due earlier and answered nowhere (`missed_rows`), the
110    /// question it is asked under, shown above the first of its kind: due
111    /// while Sioul was closed, or due while it ran but its reminder could not
112    /// be shown. "" for today's doses on the Porch.
113    question: String,
114}
115
116/// "This device is off", under a doubt naming it: the device's id, the button's words.
117#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
118struct OffButton {
119    id: String,
120    label: String,
121}
122
123/// A medicine as the page lists it, and as its form reads it.
124#[derive(Serialize)]
125struct MedicineRow {
126    #[serde(flatten)]
127    medicine: Medicine,
128    /// Its times, "08:00 · 20:00"; each take with its amount when they
129    /// differ, "08:00 · 1 tablet, 20:00 · 2 tablets"; every few days or
130    /// hours, in words.
131    when: String,
132    /// What the page says beside its name: its dose; "" when each take says its own.
133    amount: String,
134    /// Its name, generic name and strength: "Thyrolan — levothyroxine 75 µg" (`Medicine::precise`).
135    precise: String,
136    /// All of it in a line, for a prescription's entry: "Thyrolan — levothyroxine 75 µg · 07:30 · 1 tablet".
137    line: String,
138    /// Its form's dose: the amount most takes have (`Medicine::usual`).
139    usual: String,
140    /// Its takes at set times each day, in time order, for its form: each
141    /// take's own amount only when it differs from `usual` ("" otherwise).
142    takes: Vec<Take>,
143    /// "until Monday 26 October"; "" for as long as it goes.
144    ends: String,
145    /// Paused, or past its last day: listed all the same, quieter.
146    quiet: bool,
147}
148
149/// What a medicine says of its takes: each with its amount when they differ
150/// ("08:00 · 1 tablet, 20:00 · 2 tablets"), else its times, or its schedule in words.
151fn 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
158/// A medicine as the page and its forms read it.
159fn 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/// A prescription as the page lists it, and as its form reads it.
178#[derive(Serialize)]
179struct PrescriptionRow {
180    #[serde(flatten)]
181    prescription: Prescription,
182    /// What comes, in words, the soonest first: "pharmacy from Tuesday 27
183    /// October", "renew by Thursday 4 February 2027" (its last valid day).
184    next: Vec<String>,
185    /// The medicines that come with it, unless its title names them all:
186    /// "for Levothyroxine and Magnesium"; "" when none or named, or when
187    /// `lines` says them.
188    covers: String,
189    /// Its medicines precisely, a line each, when one has a generic name or
190    /// a strength: "Thyrolan — levothyroxine 75 µg · 07:30 · 1 tablet".
191    lines: Vec<String>,
192    /// Fetched at the pharmacy today: its button says so.
193    fetched_today: bool,
194    /// The medicines tied to it, in the order they were added: its form's rows.
195    medicines: Vec<MedicineRow>,
196}
197
198/// The medicines, in the order they were added (a row keeps its place).
199fn medicine_rows(health: &Health, today: Date) -> Vec<MedicineRow> {
200    health.medicines.iter().map(|m| medicine_row(m, today)).collect()
201}
202
203/// What comes for a prescription, on a day.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205enum Coming {
206    /// Its medicines fetched at the pharmacy, from that day.
207    Pharmacy,
208    /// Renewed by that day, its last valid day.
209    RenewBy,
210    /// Valid until that day, now passed: said plainly, never as late.
211    ValidUntil,
212}
213
214/// What comes for a prescription, the soonest first: the next visit to the
215/// pharmacy (a month back at most), and the day to renew it by.
216fn 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
226/// The medicines tied to a prescription (`Medicine::prescription`), in the order they were added.
227fn 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
231/// The medicines tied to a prescription, unless its title already names
232/// them all ("Levothyroxine 75 µg" for Levothyroxine).
233fn 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
239/// The prescriptions, in the order they were added, as the page lists them.
240fn 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/// The page: a week of days, the day shown one of them (`week`), and what
272/// only today says.
273#[derive(Serialize)]
274struct PageView {
275    week: WeekView,
276    /// Doses neither marked nor reminded: due while Sioul ran nowhere, or
277    /// while it ran but could not show their reminder; a question on the past.
278    missed: Vec<DoseRow>,
279    /// Why a dose marked elsewhere may not show here: this computer alone, or
280    /// your other computers not heard from lately; "" when all is known.
281    shared_note: String,
282    /// Reminders come on another computer, the one you are at: said, by its name.
283    reminded_there: String,
284    /// The minutes "Later" moves a block by.
285    later: u32,
286    /// Anything set at all (meals, naps, the night, a medicine): else the page says where to set them.
287    any: bool,
288    /// The medicines and the prescriptions: content of the page, changed there (their forms read them).
289    medicines: Vec<MedicineRow>,
290    prescriptions: Vec<PrescriptionRow>,
291}
292
293/// The page's settings (⚙), what is set once: the pauses, where the errands
294/// go (the usual meals and night: `needs_page`).
295#[derive(Serialize)]
296struct SettingsView {
297    movement: Movement,
298    chats: ChatLimit,
299    /// Where the errands go, and the lists they can go to: {id, name}.
300    errands_list: String,
301    lists: Vec<serde_json::Value>,
302}
303
304/// Monday to Sunday, the hours the timeline shows (minutes from midnight,
305/// whole hours, the same for every day: the lines stay put from day to day).
306#[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/// One day: its list and what its column of the timeline draws.
316#[derive(Serialize)]
317struct DayView {
318    /// "2026-10-07".
319    date: String,
320    /// "Today, Wednesday 7 October", "Tomorrow, …", "Thursday 8 October".
321    title: String,
322    /// "Wed", and its number: a week's column.
323    weekday: String,
324    number: i8,
325    today: bool,
326    /// Before today: shown, never changed.
327    past: bool,
328    /// The list, in time order: the day's meals, naps and night (those taken
329    /// out that day too, to put back), those added that day, its doses.
330    items: Vec<DayItem>,
331    /// What the timeline draws in this day's column, in minutes from its midnight.
332    segments: Vec<Segment>,
333    /// Its events and today's planned steps, faded, for context.
334    context: Vec<ContextItem>,
335    /// Events of the whole day; refills and renewals falling on it.
336    lines: Vec<String>,
337}
338
339/// A row of a day's list: a meal, a nap, the night, or a dose.
340#[derive(Serialize)]
341struct DayItem {
342    /// "meal:1", "nap:0", "sleep", "added-…", or a dose's key.
343    key: String,
344    /// "meal", "nap", "sleep", "dose".
345    kind: &'static str,
346    name: String,
347    /// A dose's amount ("75 µg").
348    dose: String,
349    /// Kept from, to ("12:10", "13:00"): getting it ready, winding down,
350    /// coming back included, as the notices say; a dose: its time.
351    from: String,
352    to: String,
353    /// When it starts proper: eating, the nap, bed.
354    at: String,
355    /// Said under it: "eating from 12:30", "winding down, bed at 23:00".
356    detail: String,
357    /// How that day differs: "changed for this day", "this day only", "moved
358    /// after an event", "no notice that day…", "removed from this day"; "" as usual.
359    note: String,
360    /// Changed that day (its times), added that day, quiet, taken out that day.
361    changed: bool,
362    added: bool,
363    quiet: bool,
364    off: bool,
365    /// Over (or on a past day): shown, never changed.
366    past: bool,
367    /// Its lengths that day, for its form: minutes, getting ready (winding down), coming back.
368    minutes: u32,
369    before: u32,
370    after: u32,
371    /// A dose of today: "12:04" when marked taken; late past half an hour;
372    /// the doubt when another device may know (never "not taken").
373    taken: String,
374    late: bool,
375    doubt: String,
376    /// "This device is off" for each device the doubt names; answers that differ (`DoseRow`).
377    doubt_off: Vec<OffButton>,
378    choose: bool,
379    /// The night: when its alarm rings, the morning it ends ("07:00"; "" when
380    /// none would), and whether "No alarm" was asked for that night (`wake`).
381    alarm: String,
382    alarm_skipped: bool,
383    /// Its alarm not rung yet: "No alarm" can still be asked, even after
384    /// midnight, on the night going on (a past day's row, its menu that item only).
385    alarm_open: bool,
386}
387
388/// A part of the timeline's column: from, to, in minutes from the column's
389/// midnight (a night split at midnight, its morning part the day before's).
390#[derive(Serialize)]
391struct Segment {
392    /// The day it belongs to, and its key: what the list's row is.
393    date: String,
394    key: String,
395    /// "meal", "nap", "sleep", "dose".
396    kind: &'static str,
397    name: String,
398    from_minute: i64,
399    to_minute: i64,
400    /// Its whole span as kept, not cut at the column's midnights (before 0, past
401    /// 1440): a drag knows which of its ends this column holds, and how long it is.
402    start_minute: i64,
403    end_minute: i64,
404    /// Where it starts and ends proper: before and after, lighter (getting ready, winding down, coming back).
405    at_minute: i64,
406    until_minute: i64,
407    quiet: bool,
408    past: bool,
409    /// A dose of today: "taken", "due", "check" (another device may know); "" another day.
410    state: &'static str,
411}
412
413/// An event or a planned step, for context only.
414#[derive(Serialize)]
415struct ContextItem {
416    title: String,
417    /// "event", "task".
418    kind: &'static str,
419    from_minute: i64,
420    to_minute: i64,
421}
422
423/// The list the errands go to: the one chosen, else your usual list, else the
424/// first list on a server (so the phone has them), else one on this computer.
425fn 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
436/// The day the page shows (`show_week`): its week is made. None: today.
437static SHOWN: std::sync::Mutex<Option<Date>> = std::sync::Mutex::new(None);
438
439/// The page shows the week of `day` ("2026-10-07"); one that does not read: today's.
440pub(crate) fn show_week(day: &str) {
441    if let Ok(mut shown) = SHOWN.lock() {
442        *shown = day.trim().parse().ok();
443    }
444}
445
446/// `at` (Unix seconds) on the clock in `zone`: "12:30".
447fn 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
451/// Midnight of `date` in `zone`, Unix seconds.
452fn midnight(date: Date, zone: &TimeZone) -> i64 {
453    date.to_zoned(zone.clone()).map_or(0, |z| z.timestamp().as_second())
454}
455
456/// "Wednesday": a word's first letter in capitals, as a title starts.
457fn 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
462/// The health page, as JSON: the week of the day it shows, and what only today says.
463pub(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
492/// The page's settings (⚙), as JSON.
493pub(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
504/// The events of the week from `monday`, read again five minutes after at most:
505/// the page is made again each minute while it is open.
506fn 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
524/// Which day a night is: the evening it starts, or, its bedtime after
525/// midnight, the evening before (`Needs::kept_with`'s rule).
526fn 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
531/// The week of `day`, Monday to Sunday: each day's list and timeline, and
532/// the hours the timeline shows (the same each day).
533fn 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    // Meals move past the events with their margins, as the plan has them.
539    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    // From half an hour before the first thing (waking, the first block or
543    // event), to half an hour after the last (bedtime, the last one), whole hours.
544    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
568/// What lies around the days: the week's events, their times with margins, today's planned steps.
569struct Around<'a> {
570    events: &'a [sioul_core::agenda::Occurrence],
571    held: &'a [(i64, i64)],
572    steps: &'a [(i64, i64, String)],
573}
574
575/// One day: its list (blocks, those taken out that day, its doses) and what its column draws.
576fn 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    // Minutes from this day's midnight on the clock (a time the next day: past 1440).
583    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        // The night's alarm at waking, said quietly; "No alarm" asked, said as how that day differs.
606        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        // Before and after it proper, lighter: getting it ready, winding down, coming back.
655        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    // Taken out that day: in the list still, to put back; its time is free.
662    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    // The night ending this morning is the day before's: its morning part.
667    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    // The doses: on today with whether they are marked, and the doubt when
686    // another device may know; another day, plainly (no record of the past here).
687    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        // Taken today, at a time the schedule no longer has: a dose taken late
691        // or early moved the next ones (a medicine counted from its last dose).
692        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            // Today, as your devices answered it: taken (the earliest answer),
703            // or answers that differ, both said; else the doubt when another
704            // device may hold an answer (never "not taken").
705            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    // The day's events, and today's planned steps, for context; whole days and errands in words.
749    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
790/// Doses neither marked nor reminded: a question on the past, each saying
791/// when whether it was taken is not known here. Two questions, each its own
792/// heading: those due while Sioul ran nowhere, then those due while it ran
793/// but whose reminder could not be shown (no notification server: never
794/// said "while Sioul was closed"); by their time within each.
795fn 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
820/// The notification's title for the question on doses neither marked nor
821/// reminded (`missed`): "While Sioul was closed" only when it was; "A
822/// reminder could not be shown" when Sioul ran; both kinds, a title true of
823/// both.
824fn 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
833/// What a minute of `tick` did, written in the doses' record: the reminders
834/// shown, those that could not be (`unshown`, so that the question on the
835/// past says Sioul ran), and the errands' tasks made.
836fn 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/// The Porch's doses (docs/health.md, "On the Porch"). `due`: today's, from
849/// their time on, not marked yet, reminded or not (a notification can go
850/// unseen), each with "Taken" (past its half hour, when it was taken is asked)
851/// and the doubt when another device may know; until marked, the day's end,
852/// or twelve hours after its time. `closed`: those due while Sioul ran
853/// nowhere, the question on the past, once your other devices were heard
854/// from (`heard`), so that a dose marked there is not asked about here.
855/// Whatever the hours: doses are not mail. None while you sleep with doses
856/// staying silent (`silent`): they come at waking.
857#[derive(Default, Serialize)]
858struct PorchDoses {
859    due: Vec<DoseRow>,
860    closed: Vec<DoseRow>,
861}
862
863/// `records` and `me` (this device's id): today's doses whose answers differ
864/// on your devices come too, both answers said, until you choose.
865#[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    // Answered in the records, their marks not here (yet): answered all the same; never due twice.
876    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    // Answered on two devices, differently: both said, as far back as today's doses show.
886    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
899/// The Porch's doses, as JSON (`PorchDoses`), made off the window's thread.
900pub(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/// A medicine as its form gives it.
910#[derive(Deserialize)]
911struct MedicineEdit {
912    /// Its brand name, or your own word for it; "" when its generic name says it.
913    #[serde(default)]
914    name: String,
915    /// The generic name of its molecule (INN), and its strength: optional.
916    #[serde(default)]
917    generic: String,
918    #[serde(default)]
919    strength: String,
920    /// "2026-03-02", the day it was first taken; "" when not known.
921    #[serde(default)]
922    since: String,
923    /// Its usual amount: each take's when it says none.
924    #[serde(default)]
925    dose: String,
926    /// "day", "days", "hours".
927    every: String,
928    /// At set times each day: each take, its time and its own amount ("" for the usual).
929    #[serde(default)]
930    takes: Vec<Take>,
931    /// The times alone, as the form gave them before takes had rows.
932    #[serde(default)]
933    times: Vec<String>,
934    #[serde(default)]
935    days: u32,
936    #[serde(default)]
937    hours: u32,
938    /// "2026-10-04"; for hours, "2026-10-03T18:30".
939    #[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
958/// Why takes could not be set, in words; with the medicine's name in a
959/// prescription's form, where several are.
960fn 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
979/// A medicine made (`id` empty) or changed in `health`, as its form gives
980/// it: its id, or what is wrong, nothing changed then.
981fn apply_medicine(health: &mut Health, id: &str, edit: MedicineEdit, now: &Zoned, words: &Translator) -> Result<String, String> {
982    // Its name, else its generic name: reminders always have one, and so does an older Sioul.
983    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
1019/// A medicine made (`id` empty) or changed; returns {"id"} or {"error"}.
1020pub(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/// A prescription as its form gives it.
1028#[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    /// Its medicines, row by row; none given: they stay as they are.
1042    #[serde(default)]
1043    medicines: Option<Vec<PrescribedEdit>>,
1044}
1045
1046/// A medicine as a row of its prescription's form gives it.
1047#[derive(Deserialize)]
1048struct PrescribedEdit {
1049    /// "" for one added in the form.
1050    #[serde(default)]
1051    id: String,
1052    #[serde(default)]
1053    name: String,
1054    /// The generic name of its molecule (INN), and its strength: optional.
1055    #[serde(default)]
1056    generic: String,
1057    #[serde(default)]
1058    strength: String,
1059    /// Its usual amount: each take's when it says none.
1060    #[serde(default)]
1061    dose: String,
1062    /// Its takes, at set times each day (a medicine taken every few days or
1063    /// hours keeps its own schedule: its form changes it).
1064    #[serde(default)]
1065    takes: Vec<Take>,
1066    /// Taken out in the form: the medicine goes when the form is saved.
1067    #[serde(default)]
1068    removed: bool,
1069}
1070
1071/// A prescription made (`id` empty) or changed in `health`, with its
1072/// medicines as its rows give them: made, changed, taken out. Every row is
1073/// read before anything changes: what does not save changes nothing. Its id,
1074/// or what is wrong.
1075fn 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        // Its name, else its generic name.
1088        let name = if row.name.trim().is_empty() { row.generic.trim() } else { row.name.trim() };
1089        // A row added and left as it came: nothing to take.
1090        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
1145/// A prescription made (`id` empty) or changed, with its medicines; returns {"id"} or {"error"}.
1146pub(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/// What a doctor or a pharmacist is shown, read-only, at arm's length
1154/// (`qml/ShowMedicines.qml`): a prescription's medicines, or all those taken now.
1155#[derive(Serialize, Debug, PartialEq)]
1156struct ForProfessional {
1157    /// The prescription's title, or "Current medicines".
1158    title: String,
1159    /// Who prescribed it and until when it is valid; for all, the day shown.
1160    about: Vec<String>,
1161    medicines: Vec<Shown>,
1162    /// No medicine to show, said; "" otherwise.
1163    empty: String,
1164}
1165
1166/// One medicine, for a professional.
1167#[derive(Serialize, Debug, PartialEq)]
1168struct Shown {
1169    /// Its generic name and strength, first: "levothyroxine 75 µg"; its name
1170    /// and strength when no generic name is given.
1171    molecule: String,
1172    /// Its brand name, or your word for it, when it is not the generic name.
1173    brand: String,
1174    /// Each take, its time and amount ("07:30 — 1 tablet"); every few days
1175    /// or hours in words, with the amount.
1176    takes: Vec<String>,
1177    /// "Taken since Monday 2 March, 7 months"; "" when not known.
1178    since: String,
1179    /// "Prescribed by Dr Elena Varga", when the medicine's prescription says
1180    /// who, and the view is not that prescription's (it says so above).
1181    prescriber: String,
1182    /// "Until Wednesday 28 October", "Paused for now".
1183    notes: Vec<String>,
1184}
1185
1186/// How long since `since`, in words: days, then weeks, months, years.
1187fn 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
1203/// A prescription's medicines (`prescription`, its id), or those taken now
1204/// ("": not paused, not past their last day), for a professional, in your
1205/// language; the generic names as typed (international).
1206fn 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
1259/// When a medicine taken every few days or hours is taken, in `words`; at set times, its times.
1260fn words_of(schedule: &Schedule, words: &Translator) -> String {
1261    // Numbers as numbers: "every other day" is chosen by the number 2.
1262    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
1278/// "Show to a doctor or pharmacist": a prescription's medicines (its id), or
1279/// all those taken now (""), as JSON (`ForProfessional`).
1280pub(crate) fn for_professional(prescription: &str) -> String {
1281    json(&for_professional_of(&load(), prescription, Zoned::now().date(), tr()))
1282}
1283
1284/// A medicine or a prescription taken out; returns what went wrong, else "".
1285pub(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
1298/// Fetched at the pharmacy today: the next refill is counted from now.
1299pub(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
1310/// How far back a dose due while Sioul was closed is asked about, and the
1311/// minutes a dose is reminded live before that.
1312const MISSED_HOURS: i64 = 12;
1313const GRACE_MINUTES: i64 = 30;
1314/// When the pause to move was last offered, or Sioul started (Unix seconds).
1315static MOVED: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
1316
1317/// The pause to move and stretch, every so many minutes since Sioul started
1318/// or since the last one, without waiting for a focus session; a session
1319/// running offers its own (`FocusWindow.qml`). A desktop notification: it
1320/// reaches you with the window hidden, from the computer you are at.
1321fn 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    // At a time the matrix of what reaches you drops it (as usual: sleep, the pauses, a slot
1326    // of time for you, do-not-disturb), the pause is counted again from its end.
1327    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
1344/// The question on doses due while Sioul was closed: asked once a session.
1345static ASKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1346/// Doses reminded in this session: never twice, even when the record cannot be written.
1347static SENT: std::sync::Mutex<std::collections::BTreeSet<String>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
1348/// "Reminders come on `<computer>`", when another computer keeps them.
1349static REMINDED_THERE: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
1350
1351/// Why the doses here may not be all: this computer alone, or what is not
1352/// known of your other devices now; "" when all is known.
1353fn 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    // As for a dose due a while ago (the wait for news): a device in use whose
1359    // news is fresh is known by then; what is said here lasts (a device silent
1360    // while in use, an older Sioul, a record that does not read).
1361    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    // Devices you said are off: said plainly, for as long as it lasts.
1367    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
1373/// The Health page, the doses due while closed and today's meals and naps,
1374/// made on a thread (they read the doses' record and the other devices'
1375/// claims, a shared folder that may be slow) and shown.
1376pub(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
1391// ---------------------------------------------------------------- phone alarms
1392
1393/// How far ahead the coming doses are given to Android's alarm clock.
1394const ALARMS_AHEAD_HOURS: i64 = 48;
1395
1396/// A dose as a reminder names it: "Levothyroxine · 75 µg".
1397fn named(dose: &sioul_core::health::Dose) -> String {
1398    if dose.dose.is_empty() { dose.name.clone() } else { format!("{} · {}", dose.name, dose.dose) }
1399}
1400
1401/// The coming doses, as Android's alarm clock is given them (`alarms::schedule`):
1402/// [{key, at (Unix milliseconds), title}], from half an hour ago to two days
1403/// ahead, those marked or reminded left out. A record that does not read
1404/// leaves none out: the decision at their time reads it again.
1405pub(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
1420/// The doses of the last day marked taken or not taken: their reminders, if
1421/// shown on a phone, are taken away (`alarms::schedule`).
1422pub(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
1428/// How long a reminder shown on a phone is checked again (every quarter of an
1429/// hour, the others' news read first), so that a dose marked on another device
1430/// takes it away: three hours after the dose's time.
1431const SHOWN_WATCH: i64 = 3 * 3600;
1432const SHOWN_EVERY: i64 = 15 * 60;
1433
1434/// At a dose's time, Android's alarm asks what to say (`alarms`), maybe with
1435/// nothing on the screen. What your other devices marked is read first
1436/// (`share::exchange_here`: Sioul's own pull from the server while it works,
1437/// else the sync app asked to bring it; an exchange without notes and papers,
1438/// which never holds a dose back); then, as the window would: taken, not taken
1439/// said, or reminded already: nothing; kept by another device you use: its
1440/// turn, asked again after the wait for news; not known: after that wait,
1441/// reminded with the doubt said first; else the dose. Recorded `reminded`
1442/// only when shown, so that another device still reminds what was not.
1443/// JSON {show, title, body, again_at (Unix ms, 0: never)}.
1444pub(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    // None of the others' news read since this alarm began (its pull and the
1448    // sync app bringing nothing): what was read before is said in doubt.
1449    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    // The medicine taken out, or its doses moved: this one is no more, nor its reminder.
1458    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    // Marked, here or on another device: its reminder, if shown, goes.
1464    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    // Reminded already (here, still shown, or on another device): looked at
1469    // again a while, so that a mark made elsewhere takes the reminder away.
1470    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    // Too early (moved later): at its time.
1475    if due > stamp + 60 {
1476        return answer(false, "", "", due);
1477    }
1478    // Asleep, "Doses during sleep: stay silent" chosen: asked again at waking.
1479    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    // A session of its own, Sioul not on the screen (`devices::receiver`): up,
1501    // the reminder and the dose's record (the canary) noted, sent, down. Your
1502    // other devices know it was reminded: they do not remind it again.
1503    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    // Shown: looked at again while it may still be shown (a mark elsewhere takes it away).
1512    answer(true, &title, &body, stamp + SHOWN_EVERY)
1513}
1514
1515/// "Taken", pressed on the phone's reminder. More than half an hour late, when
1516/// it was taken is asked in the window instead (`open`). JSON {done, open, line}.
1517pub(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    // A session of its own, Sioul not on the screen (`devices::receiver`): up,
1522    // the answer noted, sent, down; your other devices show it taken.
1523    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
1534// ---------------------------------------------------------------- the home screen's card
1535
1536/// Today's doses for the card on a phone's home screen (`homecard`): those
1537/// due and not marked, from their time on, as the Porch has them
1538/// (`due_today`: until marked, the day's end, or twelve hours after their
1539/// time), and those still to come today; each with until when it is known
1540/// not marked on another device, the Porch's rule given ahead
1541/// (`doubts_now`: every other device heard within five minutes), 0 when it
1542/// is not known from its time on. A record that does not read is never read
1543/// as empty: every dose is then not known. Whether doses show while you
1544/// sleep or pause is the card's frames' to say.
1545pub(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
1555/// `card_doses`, from what was read. A dose answered differently on two
1556/// devices (`doses::answered`: taken on one, said not taken on another) is
1557/// on the card too, never known: "check before taking it", as the Porch asks you to choose.
1558fn 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    // Answered in the records, their marks not here (yet): answered all the same; never on the card twice.
1565    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            // Not known at once (a device closed before it may open around it,
1576            // `known_from`): "check" from its time until it can be, then the line.
1577            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    // Answered differently, as far back as today's doses show: never known, until one answer is chosen.
1587    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
1600/// Until when a dose due at `due` stays known not marked elsewhere, seen
1601/// from `from` (`doubts_now`: each other device fresh five minutes after it
1602/// was last heard in full): `until` when nothing ends it before; 0 when it is
1603/// not known at `from`.
1604fn 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
1608// ---------------------------------------------------------------- knowing
1609
1610/// How long a dose not known here waits for news from your other devices
1611/// before it is reminded all the same, the doubt said.
1612const WAIT_FOR_NEWS: i64 = 10 * 60;
1613
1614/// When a dose's alarm in doubt within the wait for news asks again: as soon
1615/// as it may be known (`known`: a device closed before the dose, once the
1616/// news of an opening had the time to come), a minute apart at least; known
1617/// already by what was read (`known` now), in doubt only because no news came
1618/// since the alarm began, nothing tells when news comes: at the end of the
1619/// wait, as for a doubt that has no end in view, then reminded with the doubt
1620/// said if none came (until 10 October 2026 it asked each minute, a pull each
1621/// time: R9 of that day's review).
1622fn 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/// The doses' record found broken or gone here, kept on this computer only
1630/// (`$XDG_STATE_HOME/sioul/health-doubt.toml`): the doses due before stay
1631/// not known, for a day.
1632#[derive(Debug, Default, Serialize, Deserialize)]
1633struct RecordDoubt {
1634    since: i64,
1635    /// Where the broken record was kept aside; "" when it was gone.
1636    #[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
1656/// The doses' record. One that cannot be trusted is never read as empty: it
1657/// is repaired (`repair`), and the doses due before are said not known.
1658fn 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
1669/// A record that cannot be trusted: nothing it held may be taken out on your
1670/// other devices, so the sharing forgets it first and reads it again from
1671/// every device's records at the next exchange; then it is kept aside and a
1672/// new one starts. Sharing failing that, it is left as it is: never started
1673/// again empty where the sharing could take marks out elsewhere.
1674fn 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
1684/// The record changed by `change`, under its lock; one that cannot be trusted
1685/// is repaired first, then changed. Returns what went wrong, else "".
1686fn change(change: impl Fn(&mut HealthState)) -> String {
1687    let path = HealthState::default_path();
1688    let now = Timestamp::now().as_second();
1689    // What changed is owed to your other devices until an export reads it,
1690    // said once written (`devices::recorded`); a minute that changed nothing owes nothing.
1691    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            // A dose marked: a phone's alarm for it goes (`alarms`).
1702            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
1720/// The doses' records (`sioul_core::doses`: each dose that fell due, and the
1721/// answers your devices captured), as `record` reads the marks: records that
1722/// cannot be trusted are never read as empty; they are repaired, and the
1723/// doses due before are said not known for a day.
1724fn 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
1735/// Records that cannot be trusted, as `repair` does the marks: the sharing
1736/// first forgets holding them and reads them again from every device's
1737/// records; then they are kept aside and new ones start.
1738fn 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
1748/// The records changed by `change`, under their lock; ones that cannot be
1749/// trusted repaired first, then changed. Returns what went wrong, else "".
1750fn change_records(change: impl Fn(&mut DoseRecords)) -> String {
1751    let path = DoseRecords::default_path();
1752    let now = Timestamp::now().as_second();
1753    // Owed to your other devices until an export reads it, as the marks (`change`).
1754    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
1779/// Your answer for a dose, captured here (its time `at`), in the doses'
1780/// records, beside the marks: `settle` takes every other answer saying
1781/// otherwise out (you chose, seeing them). Returns what went wrong, else "".
1782fn 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
1791/// The doses due in the last hours (as far back as a dose is asked about),
1792/// answered nowhere this device knows: their records opened, not taken yet
1793/// (the canary), where no device opened them yet.
1794fn 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
1801/// Each of `keys` opened here, not taken yet, unless a device opened or answered it already.
1802fn open_canaries(keys: &[String], now: i64) {
1803    let me = this_device();
1804    if keys.is_empty() || me.is_empty() {
1805        return;
1806    }
1807    // Records that do not read are repaired where they are read (`records`), not here.
1808    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
1819/// This device's id in the sharing ("" before it has one).
1820fn this_device() -> String {
1821    crate::share::vault().map(|(id, _)| id).unwrap_or_default()
1822}
1823
1824/// What this computer saw of your other devices, for the doses: kept here
1825/// only (`$XDG_STATE_HOME/sioul/share/peers.json`), never shared.
1826#[derive(Debug, Default, Serialize, Deserialize)]
1827struct Peers {
1828    #[serde(default)]
1829    peers: std::collections::BTreeMap<String, PeerSeen>,
1830    /// When this device last looked (its clock): an opening seen now is timed
1831    /// only when it looked a moment before, else its own absence would count.
1832    #[serde(default)]
1833    looked: i64,
1834}
1835
1836#[derive(Debug, Default, Clone, Serialize, Deserialize)]
1837struct PeerSeen {
1838    peer: Peer,
1839    /// Its last claim seen here: when it renewed it (its clock).
1840    #[serde(default)]
1841    renewed: i64,
1842    /// When this computer last looked (its clock): news is timed only while it watches.
1843    #[serde(default)]
1844    looked: i64,
1845    /// How late its last claims came here, in seconds.
1846    #[serde(default)]
1847    delays: Vec<i64>,
1848    /// Its entry in the devices' registry as last seen changing here (`peer.seen`: when).
1849    #[serde(default)]
1850    entry: Option<sioul_sync::devices::Entry>,
1851    /// Its export's date (its clock) less when it was seen here (this one's),
1852    /// the last sixty: how far its clock is ahead, at least, is the largest.
1853    #[serde(default)]
1854    aheads: Vec<i64>,
1855    /// You said it is off: what it had said then. Counted again once it says anything newer.
1856    #[serde(default)]
1857    off: Option<Mark>,
1858    /// You forgot it, silent: not listed until it says anything newer.
1859    #[serde(default)]
1860    forgotten: Option<Mark>,
1861    /// How late the news of its last openings came here, in seconds
1862    /// (`Peer::wake`: the longest), the last `WAKES`.
1863    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1864    wakes: Vec<i64>,
1865    /// Since when its claim on the doses ran, as last seen: an older Sioul's
1866    /// openings, timed as an entry's start is.
1867    #[serde(default, skip_serializing_if = "is_zero")]
1868    since: i64,
1869}
1870
1871fn is_zero(n: &i64) -> bool {
1872    *n == 0
1873}
1874
1875/// How many of a device's openings are kept to time the next: an opening
1876/// whose news came late once (a phone offline when picked up) holds the wait
1877/// for this many more, then goes.
1878const WAKES: usize = 10;
1879
1880/// The longest an opening's news counts as late when this device cannot
1881/// tell whether its own news came in time (a sync app carries the folder:
1882/// paused, or stopped, unseen from here): a longer wait is as likely this
1883/// device's own outage, and would hold the doses of a closed device in doubt
1884/// that long, for its next `WAKES` openings (review of 10 October 2026, R8).
1885const UNSURE_WAKE: i64 = 60 * 60;
1886
1887/// How late the news of a device's opening came here, kept (`PeerSeen::wakes`),
1888/// when this device's own news was flowing then (`news`: its own pull went
1889/// through lately; none when a sync app alone carries the folder, the delay
1890/// then counted up to `UNSURE_WAKE`). Its own pull stale or failing: the
1891/// delay is its own outage, not timed.
1892fn 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/// What a device had said when you said it was off, or forgot it.
1904#[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    /// When you said so (this device's clock).
1913    #[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
1921/// What is known here of the doses marked elsewhere: this computer's record,
1922/// and each other device sharing with it (`sioul_core::health::doubts`).
1923pub(crate) struct Knowledge {
1924    record_lost: Option<i64>,
1925    peers: Vec<Peer>,
1926}
1927
1928/// One look at a time at the others' claims.
1929static KNOWING: std::sync::Mutex<()> = std::sync::Mutex::new(());
1930
1931/// What this computer knows now: the others' claims on the doses read (each
1932/// says how far it wrote its records, and whether it closed), set against
1933/// what was read of their records here.
1934fn 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    // Read, learned, written under its lock: a phone's background service learns too, in its own process.
1940    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    // Two phones or more: each said by its name, never both "the phone".
1948    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    // A device sharing through the folder this last week whose claim and entry
1954    // do not read here (its seal not arrived yet, a damaged file): a peer all
1955    // the same, never heard from, so that its doses are said not known.
1956    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/// What the others' claims say, learned: each one's name, how late its news
1980/// comes, and until when everything it wrote is read here.
1981#[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
1986/// The same, `news` saying whether this device's own news was flowing now
1987/// (`share::Others::news`), for timing the others' openings.
1988fn 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    // How late a device's opening comes is timed only while this device
1990    // looked a moment before (review of 5 October 2026, F21), and its own
1991    // news came in time (`note_wake`).
1992    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        // Its claim begun again, open (an older Sioul's opening): how late its news came.
1999        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            // How late its news comes, timed only while watching: a claim
2007            // found after this computer was closed is old, not late.
2008            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        // Everything it wrote until its claim, read here: known until then,
2019        // never past this device's clock: a claim dated ahead (its clock
2020        // fast) by more than a minute is known not at all (review of 5
2021        // October 2026, F13), one a little ahead until now.
2022        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    // What each says of itself in the registry (`sioul_sync::devices`), read
2033    // now: only what reads now counts; a device whose entry no longer reads is
2034    // known by its claims, as an older Sioul is, never as it last said.
2035    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 changing now (this device's clock); its export dated later
2048            // than now, its clock is ahead of this one's by that much at least.
2049            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            // Started again since last seen: how late the news of its opening
2056            // came, its clock's lead taken off (a clock behind only adds to it).
2057            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        // Its entry says it closed, but its claim, renewed open more than a
2065        // minute after that close (both on its own clock), says it runs: an
2066        // older Sioul there now (a version put back), which updates no entry.
2067        // The entry is not trusted: known by its claims, as an older Sioul is.
2068        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        // Everything it wrote up to its last export, read here.
2074        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    // Said off, or forgotten, until it says anything newer: a new start, a new
2079    // export, a claim renewed since.
2080    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
2094/// A device you say is off (`off`), or not any more: not counted for the
2095/// doses until it says anything newer (a new start, a new export, a claim),
2096/// said plainly meanwhile (`shared_note`, Settings). It never answers a dose
2097/// for you. Returns what went wrong, else "".
2098pub(crate) fn device_off(id: &str, off: bool) -> String {
2099    mark_device(id, |seen, mark| seen.off = off.then_some(mark))
2100}
2101
2102/// A device silent for a week, forgotten: not listed until it says anything newer.
2103pub(crate) fn device_forget(id: &str) -> String {
2104    // Forgotten by the sharing too: it no longer holds its newer form back (docs/database.md).
2105    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    // What it says now, learned first: the mark is lifted by anything newer than that only.
2115    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/// A device as Settings ▸ Your folder and sharing lists it (`share::status`).
2128#[derive(Serialize, Debug)]
2129pub(crate) struct DeviceRow {
2130    id: String,
2131    name: String,
2132    /// How it is, in words: in use now, closed at 22:14, silent since…, off as you said, an older Sioul.
2133    state: String,
2134    /// Said under it, quietly: it does not share its doses; its clock ahead.
2135    notes: Vec<String>,
2136    /// Counted as off, as you said: "Count it again".
2137    off: bool,
2138    /// Silent for a week: "Forget this device".
2139    forget: bool,
2140    /// The Sioul it runs, as its entry says it: "Sioul 0.0.3 (eff8661abcde)", said
2141    /// calmly when older than this device's; "" when its entry does not say.
2142    build: String,
2143}
2144
2145/// Your other devices, as this one knows them, for Settings: nothing red, no
2146/// counts. `holders`: the devices that hold the newer form of what travels
2147/// back, or run an older Sioul once it travels (`share::formats`), with when
2148/// each was last heard (seconds, 0 when nothing says it) and whether it holds
2149/// it back; each is listed (`holder_rows`).
2150pub(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
2158/// Every device holding the newer form back has its line, so that the window
2159/// says which (docs/database.md, "The format of what travels"): one the rows
2160/// do not list (known to the sharing alone, by what it shared before, or
2161/// forgotten here while the sharing heard it since) is added, with "Forget
2162/// this device": it is not counted for your doses, so forgetting it makes no
2163/// dose look known. One listed keeps its own line: forgotten only once silent
2164/// for a week, as any device, since a device counted for your doses and
2165/// forgotten could make a dose it took look not taken.
2166fn 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            // The build it runs (docs/database.md, "Devices"): an older one said, calmly.
2223            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
2236/// Why a dose is not known, in words: "your laptop: last heard at 07:52".
2237fn why(doubts: &[Doubt]) -> String {
2238    why_in(doubts, tr())
2239}
2240
2241/// The same, in `words`' language (the tests give theirs).
2242fn 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
2263/// A moment as a doubt says it: "at 07:45" today, "on Monday 5 October at 07:45" before.
2264fn 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
2268/// A device as a doubt names it: "the phone", else its name; one whose name
2269/// is not known (an older Sioul on a phone says "localhost"), "another device of yours".
2270fn 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
2280/// A message with its words given, in `words`' language (`say`, for any translator).
2281fn 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
2289/// A dose's doubt as its reminder says it, at its time; "" when it is known.
2290fn doubt_of(knowledge: &Knowledge, due: i64, now: i64) -> String {
2291    doubt_unread(knowledge, due, now, None)
2292}
2293
2294/// The same, at an alarm whose pull did not go through since it began
2295/// (`share::news_missed`: when, and the devices whose entry or health claim
2296/// says more since): any other known closed by what was read before is said in doubt,
2297/// as last heard (`sioul_core::health::doubts_unread`).
2298fn 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
2306/// A dose's doubt as what stays in view says it (the Porch, the Health page,
2307/// where a dose shows due hours after its time: as of now,
2308/// `health::doubts_now`), and a "This device is off" for each device it names
2309/// (one: "This device is off"; several: each by its name). None for a line
2310/// that could not be read, nor a device that does not share its doses: its
2311/// being off would lift neither.
2312fn 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
2332/// A device named at a sentence's start: "The phone", "Another device of yours", else its name as it is.
2333fn 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
2338/// A device as a doubt names it, from what is known of it.
2339fn 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
2343/// Answers that differ, each said, the earliest first: "Marked taken on the
2344/// phone at 08:02, skipped here at 08:10. Check which is right before taking
2345/// it." `me`: this device's id, said "here".
2346fn 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
2369/// The doses' state is read, changed and written by the minute's tick, the
2370/// page and the notifications' buttons: one at a time, so that none writes
2371/// back an older state over another's mark (a dose shown untaken could be taken twice).
2372static 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
2378/// One tick at a time: a slow one (a shared folder) is not overtaken by the next.
2379static TICKING: std::sync::Mutex<()> = std::sync::Mutex::new(());
2380
2381/// A dose not taken, said afterwards: asked no more. Returns what went wrong, else "".
2382pub(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    // Your answer, in the doses' records too (`sioul_core::doses`).
2391    record_answer(key, sioul_core::doses::SKIPPED, now, false)
2392}
2393
2394/// You chose, seeing answers that differ (`DoseRow::choose`): taken (at the
2395/// time the earliest "taken" said, else now), or not taken. Your answer here,
2396/// every answer saying otherwise taken out, on purpose; the marks beside, for
2397/// an older Sioul, as you chose; not taken after all, the doses its take moved
2398/// go back. Returns what went wrong, else "".
2399pub(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
2430/// When a dose is due, from its key (`<medicine>@<Unix seconds>`).
2431fn due_of(key: &str) -> Option<i64> {
2432    key.rsplit_once('@').and_then(|(_, at)| at.parse().ok())
2433}
2434
2435/// Whether a dose marked now is late: more than half an hour past its time.
2436pub(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
2440/// A dose taken late, at `time` ("09:30", the last such time before now),
2441/// as you say: marked then; for a medicine taken every few hours, the next
2442/// doses that many hours after it. Returns what went wrong, else "".
2443pub(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    // The doses it moves, moved in the medicines as the file holds them now, under its lock.
2454    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    // Your answer, in the doses' records too, at the time you said.
2466    if problem.is_empty() {
2467        let _ = record_answer(key, sioul_core::doses::TAKEN, at, false);
2468    }
2469    // Not marked: the doses it moved go back, in the medicines as the file holds them then.
2470    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
2478/// What the late dose's question shows, as JSON: {name, dose, due, now, hours}.
2479pub(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        // Every few hours: the next dose comes that many hours after the one taken.
2495        "hours": hours,
2496    })
2497    .to_string()
2498}
2499
2500/// A dose marked taken, or not, as it happens: taken every few hours, the
2501/// next doses come that many hours after now; a mark taken back puts them
2502/// back. Returns what went wrong, else "".
2503pub(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    // Your answer, in the doses' records too: taken now; taken back, every
2532    // "taken" goes, on purpose (one click takes it back, wherever it was marked).
2533    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
2539/// The pause to move and the chats' limit: "movement.enabled", "movement.minutes",
2540/// "chats.enabled", "chats.minutes", "chats.locked_minutes".
2541pub(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
2561/// Minutes of focus before the pause to move; 0 when off.
2562pub(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
2567/// One more minute in a chat; returns whether chats are covered now.
2568pub(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
2577/// Whether chats are covered now.
2578pub(crate) fn chats_covered() -> bool {
2579    record().chats_covered(&Zoned::now())
2580}
2581
2582// ---------------------------------------------------------------- meals, rest and sleep
2583
2584/// A block's usual name, in your language: breakfast, lunch, dinner, then
2585/// "Meal 4"; a nap; winding down for the night.
2586pub(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
2595/// A block's name: its own, else its usual one by its key ("meal:1": lunch);
2596/// one added for a day without a name of its own: "Meal", "Rest".
2597pub(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
2611/// Each day's own meals, naps and nights (`sioul_core::needs::Days`). The
2612/// moves and skips of today an older Sioul kept on this device
2613/// (`needs-today.toml`) are moved into them once, as times.
2614pub(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    // Moved in once, over the days as the file holds them then, under its lock.
2625    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
2648/// Today's events' held times (their margins counted), read again five
2649/// minutes after at most: the needs' tick runs every minute.
2650fn 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
2666/// `date`'s events' held times: today's from the minute's cache, another day's read now.
2667fn 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
2675/// A day's own blocks as they are: its changes, its meals pushed past its events.
2676fn 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
2681/// Each minute, from the computer you are at: a block's heads-up about the
2682/// work, `heads_up` minutes before it ("No new big task"), with "Later";
2683/// then one at its time. Two at most, each once, only near its time; none
2684/// while an event goes on, none for a block quiet or taken out that day. Its
2685/// name and time only: safe to be read by someone else (docs/health.md).
2686fn 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    // In a meeting, nothing is said: the time stays kept.
2703    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    // As the matrix of what reaches you says (as usual, nothing while you sleep, in a pause or in Free time).
2709    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    // The night's or a nap's own notice as it begins says that sleep begins:
2714    // sleep's column does not hold it (docs/health.md); a pause does.
2715    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        // One button: the window's question (later, at another time, not
2725        // today; a line on where you stopped).
2726        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            // The night's own notice: closing the day is offered beside its options (docs/reviews.md).
2735            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
2750/// A day's block changed, that day only, as the page asks (JSON: its `date`,
2751/// "2026-10-07", today when none, and `sioul_core::needs::DayEdit`: key,
2752/// action, from, to…; `Needs::change_day`). The plan and the page follow;
2753/// a block moved is not said again: moving it never brings a nag. Returns
2754/// what went wrong, else "".
2755pub(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    // Changed over the days as the file holds them now, under its lock, which the sharing takes too.
2774    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            // A night changed, or its alarm: a phone's alarm at waking follows (`wake`).
2784            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
2795/// A day's block moved by a drag on the page's timeline (`change_need`'s
2796/// JSON: "move", or "times" for one of its ends), changed as "Move to…" and
2797/// "Change its times…" change it, then "Undo" offered for ten seconds, which
2798/// puts that day's block back as it was. Dropped where it was: nothing
2799/// changes, nothing to undo. Returns what went wrong, else "".
2800pub(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    // Said as the day has it now: its name, its time kept.
2826    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        // Put back over the days as the file holds them now, under its lock, which the sharing takes too.
2834        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        // A night put back: a phone's alarm at waking follows (`wake`).
2842        crate::wake::schedule();
2843        crate::work::show_work(qt, shared);
2844        show_health(qt, shared);
2845        problem
2846    });
2847    String::new()
2848}
2849
2850/// A block moved today only (the question, a notice's "Later"): by `minutes`
2851/// more ("Later": by the minutes set, again and again), or to start at `time`
2852/// ("13:30"). Returns what went wrong, else "".
2853pub(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
2858/// Today's meals, naps and night as they are now, as JSON: [{key, kind,
2859/// name, from, to, skipped, past}], for the question (`Interruption.qml`).
2860pub(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
2873/// The minutes "Later" moves a block by.
2874pub(crate) fn later_minutes() -> u32 {
2875    load().needs.later
2876}
2877
2878/// A block without notices today, or with them again: kept free all the same.
2879pub(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
2883/// The usual meals, naps and night as the page's settings set them, as
2884/// JSON: the settings, the usual names, the long gaps between meals.
2885pub(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        // The alarm at waking: its weekdays' names, the next ring, what Android refuses it.
2896        "wake": crate::wake::status(&needs),
2897    })
2898    .to_string()
2899}
2900
2901/// Where a list lost one block: the place taken out, when the rest is the same.
2902fn 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
2910/// The usual meals, naps and night saved as the settings give them; a meal
2911/// or a nap taken out, the days' changes of those after it follow them to
2912/// their new place. Returns what went wrong, else "".
2913pub(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    // What the page showed: its change set over the settings as the file holds them now.
2919    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    // A meal or a nap taken out: the days' changes of those after it move up a place.
2934    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
2946/// Each minute: a dose due is reminded once, quietly, with "Taken"; a refill
2947/// or a renewal coming becomes a task in a list kept on this computer.
2948pub(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        // No medicines: the pause to move, meals and rest, from the computer you are at.
2953        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        // On a phone, the alarm at waking (`wake`; with medicines, `alarms::schedule` below hands it).
2960        crate::wake::schedule();
2961        return;
2962    }
2963    // One computer reminds: the one you are at, once it has been so long
2964    // enough for the others to know (`sioul_sync::lease`). A dose reminded
2965    // twice could be taken twice.
2966    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    // A dose taken late moved the next ones; the medicine, changed meanwhile
2971    // on another device, came back with its start as it was: moved again,
2972    // and sent (review of 5 October 2026, F23).
2973    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    // Each dose due lately, answered nowhere this device knows: its record
2978    // opened, not taken yet (the canary), for your devices to answer.
2979    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    // On a phone, Android's alarm clock reminds, Sioul shown or not (`alarms`).
2990    crate::alarms::schedule();
2991    // Asleep with "Doses during sleep: stay silent" (Settings ▸ Reminders and
2992    // notifications), they wait for waking; by default they come, asleep or not.
2993    if keeper.mine && keeper.settled && !cfg!(target_os = "android") && !crate::hours::doses_silent() {
2994        // Those that waited come at waking, once (more than half an hour late, Taken asks when).
2995        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            // Not known whether it was taken on another device: news is waited
3001            // for a while; then it is reminded all the same, the doubt said first.
3002            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                // Pressed more than half an hour late: when it was taken is asked, in the window.
3020                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                // Your other computers know at once.
3029                crate::share::exchange(&qt_taken, &shared_taken);
3030            });
3031            // Recorded reminded only when shown: else another device reminds it,
3032            // and the question on the past still asks about it, saying that its
3033            // reminder could not be shown (`unshown`), never that Sioul was closed.
3034            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    // Doses due while Sioul ran nowhere, or whose reminder could not be shown:
3044    // asked about once, as a question on the past, after your other computers
3045    // were heard from (a dose marked there comes first), and only where you
3046    // are; its title says which (`missed_title`); what is not known, said.
3047    let heard = !crate::share::on() || crate::share::last_exchange().is_some();
3048    // On a phone, without notifications, the Porch asks it (`missed`).
3049    // Asleep with doses staying silent, it waits for waking too.
3050    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    // Errands: a task each, the day it comes into view, made once, in the list
3069    // the phone has; those made on this computer before move there, once.
3070    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    // What this minute did, written over nothing anyone else wrote meanwhile.
3096    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        // Seen for the first time, an old claim: known until then, but no delay measured from it.
3119        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        // Watching, each new claim times how late it came.
3123        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        // A record it wrote not read here yet: known only until the claim before.
3127        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        // Read, and it says it closed: known until then, closed.
3130        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        // Its news comes within a minute: a dose due after it closed is known.
3134        assert!(sioul_core::health::doubts(9_600, 9_700, None, std::slice::from_ref(peer)).is_empty());
3135        // An older Sioul that never says how far it wrote: heard, never known.
3136        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    /// F13: an older Sioul's claim dated ahead of this device's clock (its
3143    /// clock ten minutes fast) is never known past now: what it may mark in
3144    /// those ten minutes is not known here yet. A little ahead: until now.
3145    #[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    /// The page's medicines and prescriptions, without their words (those
3156    /// need the translator, which reads the configuration): times as the
3157    /// day's list writes them, what comes for each prescription and when,
3158    /// the medicines it covers when its title does not name them.
3159    #[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                // Fetched today, 28 days at a time, valid until February.
3180                Prescription { id: "thyroid".into(), title: "Thyroid".into(), until: Some(day("2027-02-04")), refill_days: Some(28), last_refill: Some(today), ..Default::default() },
3181                // Its title names its medicine; its last valid day passed.
3182                Prescription { id: "levo".into(), title: "Levothyroxine 75 µg".into(), until: Some(day("2026-10-01")), ..Default::default() },
3183                // Last fetched long ago: no pharmacy visit said from three months back.
3184                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        // Two days before they run out (6 October + 28 days), then the renewal.
3191        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    /// The prescription's form: its medicines as rows, each with its takes;
3207    /// a row added, changed, taken out; what cannot be saved changes nothing.
3208    #[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        // Levothyroxine made on its own form first, every morning.
3214        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        // A new prescription with three medicines: Levothyroxine tied to it, two added there; and a row left empty.
3217        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        // A medicine like any other: on the page, its prescription form's rows, the doses.
3229        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        // Two takes at one time: said with the medicine's name, and nothing changes.
3242        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        // Iron taken out of the form: gone, with its doses; the others as they were. A form
3251        // without its rows (none given) leaves the medicines alone.
3252        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        // A medicine every few hours, in a row: its name and dose change, its schedule stays.
3261        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        // Its own form: takes as rows, a time that does not read said; the times as typed before still read.
3269        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    /// A take's own amount wherever a dose is said: the Porch, the home
3277    /// screen's card, the reminder (desktop and phone: `named`), the day's list.
3278    #[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        // The Porch at 20:01: the evening's take, 2 tablets.
3293        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        // The home screen's card at 07:00: both, each with its amount.
3298        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        // The reminder's words, the phone's alarms' and the day's list: each dose its take's amount.
3301        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        // The late dose's question: its take's amount.
3304        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    /// A medicine's generic name and strength, saved from both forms and read
3309    /// back; said precisely on the page; and the view a doctor or a pharmacist
3310    /// reads: the generic name and strength first, then the brand, the takes,
3311    /// how long, who prescribed it; a prescription's, or all those taken now.
3312    #[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        // No name given: the generic name is its name, for reminders and an older Sioul.
3325        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        // Its own form: since a week, every eight hours.
3328        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        // Paused, and past its last day: not taken now.
3331        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        // Written and read back, as they were.
3337        let again: Health = toml::from_str(&toml::to_string(&health).unwrap()).unwrap();
3338        assert_eq!(again, health);
3339        // Nothing given, nothing to call it: said.
3340        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        // On the page, precisely; the reminders keep the short name.
3343        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        // The prescription's view: who and until when above, the generic names first.
3350        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        // All those taken now: neither the paused one nor the one past its last day.
3360        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        // A paused medicine in its prescription's view: said.
3367        health.medicines[1].paused = true;
3368        assert_eq!(for_professional_of(&health, &id, today, &english).medicines[1].notes, ["Paused for now"]);
3369        // In French, the generic names as typed.
3370        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        // Nothing to show: said.
3375        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    /// The Porch's doses at a desktop, on a health file written for the test
3380    /// (never yours) and fake times: due now while Sioul runs; due earlier
3381    /// today, reminded by its notification but not answered; due while Sioul
3382    /// ran nowhere; another device not heard lately; asleep with doses silent;
3383    /// marked; the day over.
3384    #[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        // 07:30, reminded by its notification while Sioul ran, never answered.
3403        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        // Due earlier, reminded, not answered: on the Porch, "Taken…" asking when (late).
3409        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        // Due now, Sioul running, its notification given or not yet: "Taken", marked now; nothing in doubt.
3411        assert_eq!((due[1]["time"].as_str(), due[1]["late"].as_bool(), due[1]["doubt"].as_str()), (Some("12:00"), Some(false), Some("")));
3412        // Due while Sioul ran nowhere (09:00, reminded nowhere): the question, apart.
3413        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        // Before your other devices were heard from, the question waits; today's doses do not.
3417        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        // Asleep, doses staying silent: nothing until waking.
3420        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        // The laptop heard in full at 07:35, not since: it may have marked them. Check first, never "not taken".
3423        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        // Marked, taken or said not taken: gone.
3432        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        // The day over: today's are gone at midnight; the evening's iron, reminded nowhere, is the question's.
3436        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    /// A dose whose reminder could not be shown (no notification server) while
3443    /// Sioul ran, on a health file written for the test: recorded apart from a
3444    /// reminder (`note_minute`), still asked about, but under its own question,
3445    /// never "Due while Sioul was closed"; a dose due while Sioul ran nowhere
3446    /// keeps that one. Both say the doubt, never "not taken"; the
3447    /// notification's title says which, in English and French.
3448    #[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        // The minute at 08:00: Demo A's notification failed. Not reminded: noted apart.
3465        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        // Demo B, at 07:00, due while Sioul ran nowhere: the usual question, first.
3469        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        // Demo A, Sioul running: still asked about, under what happened.
3474        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        // The Health page's list says the same.
3479        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        // Another device not heard since: the doubt said under both, never "not taken".
3483        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        // The notification's title: one kind, the other, both.
3489        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        // Reminded later (another device, or notifications back): no question at all.
3495        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    /// The phone's home screen card (`homecard`), on a health file written
3502    /// for the test: today's doses due and not marked, and those to come
3503    /// today; each known not marked only while the other devices were heard
3504    /// within five minutes; a record that does not read, never as empty.
3505    #[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        // 12:30 due and not marked; 20:00 to come; tomorrow's not given.
3520        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        // This device alone: known until the card lets it go, at midnight.
3522        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        // Marked: gone.
3525        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        // The laptop heard in full at 12:58: known until five minutes after, then not.
3528        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        // 20:00, still to come: what the laptop marks by then is not here now; not known from its time on.
3533        assert_eq!(fresh[1].known_until, 0);
3534        // Heard before the dose was due: not known at all.
3535        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        // A record that does not read: every dose not known, none left out.
3539        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        // Taken here, said not taken on another device: answers that differ, on the card all the
3543        // same, never known (the Porch asks to choose); never left out as if marked.
3544        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        // Answered in the records only, their marks not here yet: the answer counts, and a dose
3551        // answered differently there is on the card once, never known.
3552        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    /// F21 on the phone's home screen card: a dose to come, a computer closed
3565    /// before it whose opening's news comes within two minutes: "check" from
3566    /// its time for those two minutes and `SKEW` more, then said as known; a dose due an hour
3567    /// ago, known at once. The card's Java shows each piece in its own time.
3568    #[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    /// The devices' registry, learned (`learn`): each entry seen changing here
3594    /// is timed by this device's clock; an export dated later than it was seen
3595    /// tells how far the other clock is ahead, at least; what no longer reads
3596    /// is not said; a device said off counts again once it says anything newer.
3597    /// An alarm that read none of the others' news since it went off
3598    /// (`share::news_missed`): the laptop, known closed before the dose by
3599    /// what was read before, is said in doubt with when it closed; with news
3600    /// read, it is known, as before (docs/health.md, "No news at the alarm").
3601    #[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    /// F21 of the review of 5 October 2026: how late the news of a device's
3615    /// opening comes here, timed on its starts seen while this device looked
3616    /// a moment before (its own absence never counts), its clock's lead taken
3617    /// off; the longest of the last ones is the wait for a device closed
3618    /// before a dose (`sioul_core::health::doubts_now`).
3619    #[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        // Closed at 1 000; seen first at 1 020: nothing timed.
3625        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        // Opened at 1 150 (its clock as this one), its news seen here at 1 390, this device looking each minute or two: 240 s.
3631        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        // Closed again, then opened while this device was away for ten minutes: its absence not counted.
3634        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        // A quicker one: the longest of the last ones stays the wait.
3638        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        // A dose due at 3 000, the phone closed at 2 900: in doubt for those 240 s and `SKEW`, then known.
3642        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    /// R9 of the review of 10 October 2026: a dose's alarm in doubt asks again
3650    /// when the doubt may end, a minute apart at least; in doubt only for the
3651    /// news missed since it began, at the end of the wait for news, not each
3652    /// minute.
3653    #[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    /// R8 of the review of 10 October 2026: an opening is timed only while
3664    /// this device's own news flows. Its own pull stale (a desktop whose
3665    /// pulls failed for hours): the delay is its outage, not timed. A sync
3666    /// app alone carrying the folder (nothing tells here whether it paused):
3667    /// timed up to an hour, never more.
3668    #[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            // Opened at 1 100, its news seen here five hours later, this device looking each minute.
3677            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        // The same entry a minute later: not seen changing.
3697        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        // An export dated three minutes after it was seen here: its clock three minutes ahead, at least.
3700        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        // Its records not all read here: not complete.
3703        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        // Its entry no longer reads: nothing said of it, never as it last said.
3706        learn(&mut kept, &[], &[], &heard_of("phone-id", 9), 1_340);
3707        assert_eq!(kept.peers["phone-id"].peer.said, None);
3708        // Said off: not counted; counted again once it says anything newer.
3709        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        // Closed at 1 525 by its entry, but its claim renewed open at 1 700 (an older Sioul put back
3716        // there, which updates no entry): the entry is not trusted, its claims are, as an older Sioul's.
3717        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    /// The Porch's doses with the devices' registry, on a health file written
3723    /// for the test: a dose your devices answered differently shows both
3724    /// answers, the earliest first, and lets you choose; one the phone may
3725    /// hold (in use, silent since the dose) names it and offers "This device
3726    /// is off"; the phone closed cleanly, nothing in doubt. Never "not taken".
3727    #[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        // Taken on the phone at 08:02 (its sync slow); said not taken here at 08:10.
3741        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        // The phone in use, its last export at 11:45, before the 12:00 dose: named, and "This device is off" offered.
3757        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        // Closed cleanly at 11:50, after its last export, read in full: known; nothing to say off.
3765        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        // You said it is off: not counted, the dose shown due, never answered for you.
3769        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        // The same answers in the records only, their marks not here yet: the 08:00 dose once, to choose.
3773        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    /// Settings ▸ Your folder and sharing lists your other devices: how each
3781    /// is, in words, with what can be done; nothing red, no counts.
3782    #[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        // The devices holding the newer form of what travels back: each has its
3812        // line; one not listed is added, with Forget; one listed keeps its own.
3813        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}